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/runtime.rs | 269 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 268 insertions(+), 1 deletion(-) (limited to 'src/app/runtime.rs') diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 55c89e73..739d3548 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -5,7 +5,7 @@ use crossterm::terminal; use super::{ auto_updates_enabled, repeat_key_identity, App, Mode, ANIMATION_INTERVAL, AUTO_UPDATE_CHECK_INTERVAL, GIT_REMOTE_STATUS_REFRESH_INTERVAL, MIN_RENDER_INTERVAL, - RESIZE_POLL_INTERVAL, + RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL, }; use crate::events::AppEvent; use crate::workspace::{Workspace, WorkspaceGitStatus}; @@ -156,6 +156,14 @@ impl App { changed = true; } + if self + .selection_autoscroll_deadline + .is_some_and(|deadline| now >= deadline) + { + self.tick_selection_autoscroll(now); + changed = true; + } + self.start_git_status_refresh_if_due(now); if self @@ -207,6 +215,78 @@ impl App { } } + pub(crate) fn tick_selection_autoscroll(&mut self, now: Instant) { + let Some(autoscroll) = self.state.selection_autoscroll.clone() else { + // Self-heal: state cleared but deadline leaked + self.selection_autoscroll_deadline = None; + return; + }; + + // Selection must still be in progress for autoscroll to continue + let Some(pane_id) = self.state.selection.as_ref().map(|s| s.pane_id) else { + self.stop_selection_autoscroll(); + return; + }; + if !self + .state + .selection + .as_ref() + .is_some_and(|s| s.is_dragging()) + { + self.stop_selection_autoscroll(); + return; + } + + // Rect-change detection: if inner_rect changed since drag, stop + let current_rect = self + .state + .pane_info_by_id(pane_id) + .map(|info| info.inner_rect); + if current_rect != Some(autoscroll.inner_rect) { + self.stop_selection_autoscroll(); + return; + } + + // Scrollback boundary detection via ScrollMetrics — fail-closed if unavailable + let Some(metrics) = self.state.pane_scroll_metrics(pane_id) else { + self.stop_selection_autoscroll(); + return; + }; + match autoscroll.direction { + crate::app::state::SelectionAutoscrollDirection::Up => { + let at_top = metrics.offset_from_bottom >= metrics.max_offset_from_bottom; + if at_top { + self.stop_selection_autoscroll(); + return; + } + self.state.scroll_pane_up(pane_id, 1); + } + crate::app::state::SelectionAutoscrollDirection::Down => { + let at_bottom = metrics.offset_from_bottom == 0; + if at_bottom { + self.stop_selection_autoscroll(); + return; + } + self.state.scroll_pane_down(pane_id, 1); + } + } + + // Extend selection cursor to last known mouse position + self.state.update_selection_cursor( + pane_id, + autoscroll.last_mouse_screen_col, + autoscroll.last_mouse_screen_row, + ); + + // Reschedule + self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL); + } + + pub(crate) fn stop_selection_autoscroll(&mut self) { + self.state.stop_selection_autoscroll_state(); + self.selection_autoscroll_deadline = None; + } + pub(crate) fn can_render_now(&self, now: Instant) -> bool { match self.last_render_at { Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL, @@ -310,6 +390,7 @@ impl App { self.git_refresh_deadline(), self.next_auto_update_check, self.session_save_deadline, + self.selection_autoscroll_deadline, render_deadline, ] .into_iter() @@ -326,3 +407,189 @@ impl App { had_event } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::state; + use crate::workspace::Workspace; + + fn test_app_with_pane() -> (super::super::App, crate::layout::PaneId) { + let mut app = super::super::App::new( + &crate::config::Config::default(), + true, + None, + None, + tokio::sync::mpsc::unbounded_channel().1, + crate::api::EventHub::default(), + ); + let ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + app.state.workspaces.push(ws); + app.state.active = Some(0); + app.state.view.pane_infos.push(crate::layout::PaneInfo { + id: pane_id, + rect: ratatui::layout::Rect::new(0, 0, 80, 24), + inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), + scrollbar_rect: None, + is_focused: true, + }); + (app, pane_id) + } + + #[test] + fn tick_selection_autoscroll_stops_when_metrics_unavailable() { + // Without a runtime, pane_scroll_metrics returns None. + // Fail-closed: stop autoscroll instead of rescheduling forever. + let (mut app, pane_id) = test_app_with_pane(); + let now = Instant::now(); + let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); + // Drag to a different cell so it becomes Dragging + sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); + app.state.selection = Some(sel); + app.state.selection_autoscroll = Some(state::SelectionAutoscroll { + direction: state::SelectionAutoscrollDirection::Down, + last_mouse_screen_col: 5, + last_mouse_screen_row: 23, + inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), + }); + app.selection_autoscroll_deadline = Some(now); + app.tick_selection_autoscroll(now); + // Should stop because no runtime metrics available + assert!(app.state.selection_autoscroll.is_none()); + assert!(app.selection_autoscroll_deadline.is_none()); + } + + #[test] + fn tick_selection_autoscroll_stops_when_selection_done() { + let (mut app, pane_id) = test_app_with_pane(); + let now = Instant::now(); + // Create a selection that is already finished (not in progress) + let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); + // Drag to a different cell so it becomes visible, then finish + sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); + sel.finish(); // now it's Done, not in progress + app.state.selection = Some(sel); + app.state.selection_autoscroll = Some(state::SelectionAutoscroll { + direction: state::SelectionAutoscrollDirection::Down, + last_mouse_screen_col: 0, + last_mouse_screen_row: 23, + inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), + }); + app.selection_autoscroll_deadline = Some(now); + app.tick_selection_autoscroll(now); + assert!(app.state.selection_autoscroll.is_none()); + assert!(app.selection_autoscroll_deadline.is_none()); + } + + #[test] + fn tick_selection_autoscroll_stops_when_selection_cleared() { + let (mut app, _pane_id) = test_app_with_pane(); + let now = Instant::now(); + app.state.selection = None; + app.state.selection_autoscroll = Some(state::SelectionAutoscroll { + direction: state::SelectionAutoscrollDirection::Down, + last_mouse_screen_col: 0, + last_mouse_screen_row: 23, + inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), + }); + app.selection_autoscroll_deadline = Some(now); + app.tick_selection_autoscroll(now); + assert!(app.state.selection_autoscroll.is_none()); + assert!(app.selection_autoscroll_deadline.is_none()); + } + + #[test] + fn tick_selection_autoscroll_stops_when_selection_anchored() { + // Anchored (click, no drag) should not keep the timer running. + let (mut app, pane_id) = test_app_with_pane(); + let now = Instant::now(); + app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); + app.state.selection_autoscroll = Some(state::SelectionAutoscroll { + direction: state::SelectionAutoscrollDirection::Down, + last_mouse_screen_col: 0, + last_mouse_screen_row: 23, + inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), + }); + app.selection_autoscroll_deadline = Some(now); + app.tick_selection_autoscroll(now); + assert!(app.state.selection_autoscroll.is_none()); + assert!(app.selection_autoscroll_deadline.is_none()); + } + + /// Creates an app with a real TerminalRuntime (no PTY) so scroll_metrics + /// returns meaningful data. Uses test_with_scrollback_bytes. + fn test_app_with_runtime( + cols: u16, + rows: u16, + bytes: &[u8], + ) -> (super::super::App, crate::layout::PaneId) { + let mut app = super::super::App::new( + &crate::config::Config::default(), + true, + None, + None, + tokio::sync::mpsc::unbounded_channel().1, + crate::api::EventHub::default(), + ); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let runtime = + crate::terminal::TerminalRuntime::test_with_scrollback_bytes(cols, rows, 0, bytes); + ws.tabs[0].runtimes.insert(pane_id, runtime); + app.state.workspaces.push(ws); + app.state.active = Some(0); + app.state.view.pane_infos.push(crate::layout::PaneInfo { + id: pane_id, + rect: ratatui::layout::Rect::new(0, 0, cols, rows), + inner_rect: ratatui::layout::Rect::new(0, 0, cols, rows), + scrollbar_rect: None, + is_focused: true, + }); + (app, pane_id) + } + + #[tokio::test] + async fn tick_selection_autoscroll_stops_at_scrollback_top() { + // Create a runtime with no scrollback content — we're already at + // the top (offset_from_bottom == max_offset_from_bottom). + let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]); + let now = Instant::now(); + let mut sel = crate::selection::Selection::anchor(pane_id, 5, 5, None); + sel.drag(0, 0, ratatui::layout::Rect::new(0, 0, 80, 24), None); + app.state.selection = Some(sel); + app.state.selection_autoscroll = Some(state::SelectionAutoscroll { + direction: state::SelectionAutoscrollDirection::Up, + last_mouse_screen_col: 0, + last_mouse_screen_row: 0, + inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), + }); + app.selection_autoscroll_deadline = Some(now); + app.tick_selection_autoscroll(now); + // At scrollback top, can't scroll further up — should stop + assert!(app.state.selection_autoscroll.is_none()); + assert!(app.selection_autoscroll_deadline.is_none()); + } + + #[tokio::test] + async fn tick_selection_autoscroll_stops_at_scrollback_bottom() { + // Create a runtime with no scrollback content — we're already at + // the bottom (offset_from_bottom == 0). + let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]); + let now = Instant::now(); + let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); + sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); + app.state.selection = Some(sel); + app.state.selection_autoscroll = Some(state::SelectionAutoscroll { + direction: state::SelectionAutoscrollDirection::Down, + last_mouse_screen_col: 5, + last_mouse_screen_row: 23, + inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), + }); + app.selection_autoscroll_deadline = Some(now); + app.tick_selection_autoscroll(now); + // At scrollback bottom, can't scroll further down — should stop + assert!(app.state.selection_autoscroll.is_none()); + assert!(app.selection_autoscroll_deadline.is_none()); + } +} -- cgit v1.2.3