From acddf7be1b0bb438c9085c773a7e46d075baede7 Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Sun, 9 Aug 2026 22:34:51 +0800 Subject: [PATCH] fix(tui): correct wheel/PageUp scroll direction and line-step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- crates/focus-tui/src/app.rs | 20 +++++--- crates/focus-tui/tests/app_session_tests.rs | 57 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/crates/focus-tui/src/app.rs b/crates/focus-tui/src/app.rs index 864f2c3..ed9bd2e 100644 --- a/crates/focus-tui/src/app.rs +++ b/crates/focus-tui/src/app.rs @@ -707,9 +707,10 @@ impl App { self.caret = self.line_end(self.caret); } KeyCode::PageUp => { + // 向上翻页:减小内容偏移(scroll 为内容偏移量)。 + // Page up: decrease the content offset. self.follow = false; - self.scroll = self.scroll.saturating_add(self.view_height.max(1)); - self.scroll = self.scroll.min(self.max_scroll()); + self.scroll = self.scroll.saturating_sub(self.view_height.max(1)); } KeyCode::PageDown => { self.scroll = (self.scroll + self.view_height.max(1)).min(self.max_scroll()); @@ -863,17 +864,20 @@ impl App { } } // crossterm 把 Linux(xterm 协议)与 Windows(ConPTY)的滚轮事件 - // 都归一化为 ScrollUp/ScrollDown;向上滚必须取消 follow, - // 否则每次绘制会被吸回底部。 + // 都归一化为 ScrollUp/ScrollDown。注意方向:scroll 是内容偏移量, + // 向上滚(看更早内容)要减小偏移,向下滚才增大;每格 1 行,逐行滚动。 + // 向上滚必须取消 follow,否则每次绘制会被吸回底部。 // crossterm normalizes wheel events from both Linux (xterm) and - // Windows (ConPTY) to ScrollUp/ScrollDown; scrolling up must clear - // follow, otherwise every draw snaps back to the bottom. + // Windows (ConPTY) to ScrollUp/ScrollDown. Direction: `scroll` is + // 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 => { self.follow = false; - self.scroll = self.scroll.saturating_add(3); + self.scroll = self.scroll.saturating_sub(1); } 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(); } _ => {} diff --git a/crates/focus-tui/tests/app_session_tests.rs b/crates/focus-tui/tests/app_session_tests.rs index d499121..38af816 100644 --- a/crates/focus-tui/tests/app_session_tests.rs +++ b/crates/focus-tui/tests/app_session_tests.rs @@ -1,6 +1,7 @@ //! App 级会话持久化的测试(临时数据目录,零网络)。 //! App-level session persistence tests (temp data dir, zero network). +use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind}; use focus_core::model::Message; use focus_harness::session::SessionStore; use focus_tui::app::App; @@ -84,3 +85,59 @@ fn new_session_resets_state() { let sid2 = app.session_id.clone().unwrap(); 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, + } +}