fix(tui): correct wheel/PageUp scroll direction and line-step

The scroll offset semantics were inverted for the wheel and PageUp:
is the content offset (larger = newer), so scrolling up must DECREASE it, but
ScrollUp and PageUp both INCREASED it — every wheel event (either direction)
moved toward the bottom, and at the bottom (follow=true) an up-notch added 3
then got clamped back to max, making the wheel dead until keyboard-scrolling
away. Now:

- wheel ScrollUp/ScrollLeft: offset -1, follow cleared (works from the bottom)
- wheel ScrollDown/ScrollRight: offset +1, follow restored at the bottom
- PageUp: offset - view_height; PageDown: offset + view_height
- one line per wheel notch (was 3) for line-by-line scrolling

regression test drives handle_mouse with synthetic events (direction,
line-step, no underflow, follow transitions).
This commit is contained in:
DaiChaoXiong 2026-08-09 22:34:51 +08:00
parent 4b5f4cf52b
commit acddf7be1b
2 changed files with 69 additions and 8 deletions

View File

@ -707,9 +707,10 @@ impl App {
self.caret = self.line_end(self.caret); self.caret = self.line_end(self.caret);
} }
KeyCode::PageUp => { KeyCode::PageUp => {
// 向上翻页减小内容偏移scroll 为内容偏移量)。
// Page up: decrease the content offset.
self.follow = false; self.follow = false;
self.scroll = self.scroll.saturating_add(self.view_height.max(1)); self.scroll = self.scroll.saturating_sub(self.view_height.max(1));
self.scroll = self.scroll.min(self.max_scroll());
} }
KeyCode::PageDown => { KeyCode::PageDown => {
self.scroll = (self.scroll + self.view_height.max(1)).min(self.max_scroll()); self.scroll = (self.scroll + self.view_height.max(1)).min(self.max_scroll());
@ -863,17 +864,20 @@ impl App {
} }
} }
// crossterm 把 Linuxxterm 协议)与 WindowsConPTY的滚轮事件 // crossterm 把 Linuxxterm 协议)与 WindowsConPTY的滚轮事件
// 都归一化为 ScrollUp/ScrollDown向上滚必须取消 follow // 都归一化为 ScrollUp/ScrollDown。注意方向scroll 是内容偏移量,
// 否则每次绘制会被吸回底部。 // 向上滚(看更早内容)要减小偏移,向下滚才增大;每格 1 行,逐行滚动。
// 向上滚必须取消 follow否则每次绘制会被吸回底部。
// crossterm normalizes wheel events from both Linux (xterm) and // crossterm normalizes wheel events from both Linux (xterm) and
// Windows (ConPTY) to ScrollUp/ScrollDown; scrolling up must clear // Windows (ConPTY) to ScrollUp/ScrollDown. Direction: `scroll` is
// follow, otherwise every draw snaps back to the bottom. // the content offset, so scrolling up (older content) decreases
// it and scrolling down increases it; one line per notch.
// Scrolling up must clear follow, otherwise every draw snaps back.
MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => { MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => {
self.follow = false; self.follow = false;
self.scroll = self.scroll.saturating_add(3); self.scroll = self.scroll.saturating_sub(1);
} }
MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => { MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => {
self.scroll = (self.scroll + 3).min(self.max_scroll()); self.scroll = (self.scroll + 1).min(self.max_scroll());
self.follow = self.scroll >= self.max_scroll(); self.follow = self.scroll >= self.max_scroll();
} }
_ => {} _ => {}

View File

@ -1,6 +1,7 @@
//! App 级会话持久化的测试(临时数据目录,零网络)。 //! App 级会话持久化的测试(临时数据目录,零网络)。
//! App-level session persistence tests (temp data dir, zero network). //! App-level session persistence tests (temp data dir, zero network).
use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
use focus_core::model::Message; use focus_core::model::Message;
use focus_harness::session::SessionStore; use focus_harness::session::SessionStore;
use focus_tui::app::App; use focus_tui::app::App;
@ -84,3 +85,59 @@ fn new_session_resets_state() {
let sid2 = app.session_id.clone().unwrap(); let sid2 = app.session_id.clone().unwrap();
assert_ne!(sid1, sid2); assert_ne!(sid1, sid2);
} }
/// 滚轮方向与逐行滚动(回归:此前 ScrollUp/ScrollDown 方向写反,且贴底时
/// 上滚被钳回底部导致滚轮失灵)。
/// Wheel direction and line-by-line scrolling (regression: both wheel
/// directions used to scroll down, and scrolling up at the bottom was clamped
/// back, killing the wheel).
#[test]
fn wheel_scrolls_line_by_line_in_correct_direction() {
use focus_tui::app::BlockGeometry;
use focus_tui::state::BlockId;
let mut app = app_on(&temp_data_dir("wheel"));
// 模拟内容 100 行、视口 10 行的几何。
// Simulate geometry: 100 content lines, 10-line viewport.
app.geometry = vec![BlockGeometry {
id: BlockId("x".into()),
start: 0,
end: 100,
}];
app.view_height = 10;
// 贴底时向上滚:偏移减 1、取消 follow。
// At the bottom, scrolling up: offset -1, follow cleared.
app.scroll = 90;
app.follow = true;
app.handle_mouse(scroll_event(MouseEventKind::ScrollUp));
assert_eq!(app.scroll, 89, "wheel up must move up one line");
assert!(!app.follow);
// 向下滚:偏移 +1逐行。
// Wheel down: offset +1, line by line.
app.handle_mouse(scroll_event(MouseEventKind::ScrollDown));
assert_eq!(app.scroll, 90);
assert!(app.follow, "reaching the bottom restores follow");
// 从顶部向下滚一次只动一行(不是直接跳到底部)。
// From the top, one notch moves exactly one line (no jump to the bottom).
app.scroll = 0;
app.follow = false;
app.handle_mouse(scroll_event(MouseEventKind::ScrollDown));
assert_eq!(app.scroll, 1);
// 顶部再向上滚不会下溢。
// Scrolling up at the top does not underflow.
app.handle_mouse(scroll_event(MouseEventKind::ScrollUp));
assert_eq!(app.scroll, 0);
}
fn scroll_event(kind: MouseEventKind) -> MouseEvent {
MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
}
}