From 45a4318bbbcb2c91d95faee6f31e5846ae3f9ec0 Mon Sep 17 00:00:00 2001 From: leeeanh <44962286+leeeanh@users.noreply.github.com> Date: Sun, 17 May 2026 19:05:30 +0700 Subject: feat: add selection autoscroll and fix stale selection on transitions (#129) When dragging to select text near pane edges, a 30ms recurring tick now continues scrolling and extending the selection while the mouse is idle. The autoscroll stops on MouseUp, key input, navigate action, cursor leaving the hot zone, scrollback boundary, missing metrics, or pane rect change. Also fixes pre-existing bugs where workspace/tab/pane transitions failed to clear selection state, which could cause stale selections to persist across views. Selection clearing on pane death is scoped to only the dying pane. Also fixes anchor_screen_pos returning pane-relative coordinates instead of screen coordinates, which caused false drag detection and spurious edge autoscroll on same-cell events when the pane has a non-zero origin. refs #128 Co-authored-by: leeanh --- src/app/input/selection.rs | 230 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 217 insertions(+), 13 deletions(-) (limited to 'src/app/input/selection.rs') diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs index 953e9b26..ad1dd33f 100644 --- a/src/app/input/selection.rs +++ b/src/app/input/selection.rs @@ -1,9 +1,9 @@ use crossterm::event::{MouseEvent, MouseEventKind}; -use crate::app::state::AppState; +use crate::app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection}; impl AppState { - fn update_selection_cursor( + pub(crate) fn update_selection_cursor( &mut self, pane_id: crate::layout::PaneId, screen_col: u16, @@ -30,20 +30,97 @@ impl AppState { return; }; + let top = info.inner_rect.y; let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1); - if screen_row < info.inner_rect.y { - self.scroll_pane_up( - pane_id, - Self::selection_edge_scroll_lines(info.inner_rect.y - screen_row), - ); - } else if screen_row > bottom { - self.scroll_pane_down( - pane_id, - Self::selection_edge_scroll_lines(screen_row - bottom), - ); - } + // Only activate autoscroll when the user is actively dragging. + // An anchored click in the hot zone should not start the timer. + // Check before advancing the cursor: if already Dragging from a prior + // event, it stays true. If Anchored, the mouse must have moved away + // from the anchor cell for this to count as a real drag. + let was_dragging = self.selection.as_ref().is_some_and(|s| s.is_dragging()); + let anchor_differs_from_mouse = self.selection.as_ref().is_some_and(|s| { + // Convert anchor to screen coords for comparison. + // Anchor is stored in absolute row; for a simple screen + // comparison, check whether the mouse is on a different + // cell than the anchor's screen position. + let (ar, ac) = + s.anchor_screen_pos(info.inner_rect, self.pane_scroll_metrics(s.pane_id)); + ar != screen_row || ac != screen_col + }); + let is_dragging = was_dragging || anchor_differs_from_mouse; + + // Advance the selection cursor. self.update_selection_cursor(pane_id, screen_col, screen_row); + + // If the mouse is on a different cell than the anchor but drag() + // didn't transition (cursor clamped to edge == anchor), force + // Dragging so the selection becomes visible and autoscroll can run. + if is_dragging { + if let Some(sel) = self.selection.as_mut() { + if sel.is_just_click() { + sel.force_dragging(); + } + } + } + + if screen_row < top { + // Cursor above pane — immediate scroll + set autoscroll state + if is_dragging { + self.scroll_pane_up(pane_id, Self::selection_edge_scroll_lines(top - screen_row)); + // Re-advance cursor after scroll so it reflects the new viewport position + self.update_selection_cursor(pane_id, screen_col, screen_row); + self.selection_autoscroll = Some(SelectionAutoscroll { + direction: SelectionAutoscrollDirection::Up, + last_mouse_screen_col: screen_col, + last_mouse_screen_row: screen_row, + inner_rect: info.inner_rect, + }); + } + } else if screen_row > bottom { + // Cursor below pane — immediate scroll + set autoscroll state + if is_dragging { + self.scroll_pane_down( + pane_id, + Self::selection_edge_scroll_lines(screen_row - bottom), + ); + // Re-advance cursor after scroll so it reflects the new viewport position + self.update_selection_cursor(pane_id, screen_col, screen_row); + self.selection_autoscroll = Some(SelectionAutoscroll { + direction: SelectionAutoscrollDirection::Down, + last_mouse_screen_col: screen_col, + last_mouse_screen_row: screen_row, + inner_rect: info.inner_rect, + }); + } + } else if screen_row == top { + // Hot zone: top edge row — no immediate scroll, set autoscroll state + if is_dragging { + self.selection_autoscroll = Some(SelectionAutoscroll { + direction: SelectionAutoscrollDirection::Up, + last_mouse_screen_col: screen_col, + last_mouse_screen_row: screen_row, + inner_rect: info.inner_rect, + }); + } else { + self.selection_autoscroll = None; + } + } else if screen_row == bottom { + // Hot zone: bottom edge row — no immediate scroll, set autoscroll state + if is_dragging { + self.selection_autoscroll = Some(SelectionAutoscroll { + direction: SelectionAutoscrollDirection::Down, + last_mouse_screen_col: screen_col, + last_mouse_screen_row: screen_row, + inner_rect: info.inner_rect, + }); + } else { + self.selection_autoscroll = None; + } + } else { + // Safe zone: inside pane, not on edge rows — clear autoscroll + self.selection_autoscroll = None; + } } pub(super) fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool { @@ -66,3 +143,130 @@ impl AppState { true } } + +#[cfg(test)] +mod autoscroll_tests { + use super::*; + use crate::layout::PaneInfo; + use crate::workspace::Workspace; + use ratatui::layout::Rect; + + /// Build an AppState with one workspace/pane and pane_infos populated + /// so pane_info_by_id works. Returns (state, pane_id). + fn make_state_with_pane() -> (AppState, crate::layout::PaneId) { + let mut state = AppState::test_new(); + let ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + state.workspaces.push(ws); + state.active = Some(0); + state.view.pane_infos.push(PaneInfo { + id: pane_id, + rect: Rect::new(0, 0, 80, 24), + inner_rect: Rect::new(0, 0, 80, 24), + scrollbar_rect: None, + is_focused: true, + }); + (state, pane_id) + } + + #[test] + fn above_pane_sets_autoscroll_up() { + // Build state with pane starting at row 5 so we can drag above it + let mut state = AppState::test_new(); + let ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + state.workspaces.push(ws); + state.active = Some(0); + state.view.pane_infos.push(PaneInfo { + id: pane_id, + rect: Rect::new(0, 5, 80, 24), + inner_rect: Rect::new(0, 5, 80, 24), + scrollbar_rect: None, + is_focused: true, + }); + // Anchor at (5, 10), drag to different cell above pane + let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); + sel.drag(4, 5, Rect::new(0, 5, 80, 24), None); + state.selection = Some(sel); + state.update_selection_drag(5, 4); + let autoscroll = state.selection_autoscroll.as_ref().unwrap(); + assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); + } + + #[test] + fn top_hot_zone_sets_autoscroll_up_on_drag() { + let (mut state, pane_id) = make_state_with_pane(); + // Anchor at (5, 10), drag to top edge row (row 0) — different cell + let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); + sel.drag(0, 0, Rect::new(0, 0, 80, 24), None); + state.selection = Some(sel); + state.update_selection_drag(0, 0); + let autoscroll = state.selection_autoscroll.as_ref().unwrap(); + assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); + } + + #[test] + fn top_hot_zone_clears_autoscroll_on_click() { + // An anchored click on the top edge row should NOT start autoscroll. + let (mut state, pane_id) = make_state_with_pane(); + state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); + // Same-cell drag on top edge row — still anchored + state.update_selection_drag(0, 0); + assert!(state.selection_autoscroll.is_none()); + } + + #[test] + fn bottom_hot_zone_sets_autoscroll_down_on_drag() { + let (mut state, pane_id) = make_state_with_pane(); + // Anchor at (0, 0), drag to bottom edge row (row 23) — different cell + let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); + sel.drag(23, 0, Rect::new(0, 0, 80, 24), None); + state.selection = Some(sel); + state.update_selection_drag(0, 23); + let autoscroll = state.selection_autoscroll.as_ref().unwrap(); + assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); + } + + #[test] + fn bottom_hot_zone_clears_autoscroll_on_click() { + // An anchored click on the bottom edge row should NOT start autoscroll. + let (mut state, pane_id) = make_state_with_pane(); + // Anchor at bottom edge row + state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None)); + // Same-cell drag — still anchored + state.update_selection_drag(0, 23); + assert!(state.selection_autoscroll.is_none()); + } + + #[test] + fn below_pane_sets_autoscroll_down_on_drag() { + let (mut state, pane_id) = make_state_with_pane(); + // Anchor at (0, 0), drag to different cell below pane + let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); + sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); + state.selection = Some(sel); + // Drag cursor one row below the pane bottom + state.update_selection_drag(0, 24); + let autoscroll = state.selection_autoscroll.as_ref().unwrap(); + assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); + } + + #[test] + fn safe_zone_clears_autoscroll() { + let (mut state, pane_id) = make_state_with_pane(); + // Anchor at (0, 0), drag to (5, 5) so it's truly dragging + let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); + sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); + state.selection = Some(sel); + // Set autoscroll first + state.selection_autoscroll = Some(SelectionAutoscroll { + direction: SelectionAutoscrollDirection::Down, + last_mouse_screen_col: 5, + last_mouse_screen_row: 23, + inner_rect: Rect::new(0, 0, 80, 24), + }); + // Move cursor into safe zone (middle of pane, not on edge rows) + state.update_selection_drag(5, 12); + assert!(state.selection_autoscroll.is_none()); + } +} -- cgit v1.2.3