diff --git a/src/app/actions.rs b/src/app/actions.rs index b7634c3c..f8ae511f 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -113,6 +113,8 @@ impl AppState { pub fn switch_workspace(&mut self, idx: usize) { if idx < self.workspaces.len() { + self.selection = None; + self.selection_autoscroll = None; self.active = Some(idx); self.selected = idx; let workspace_id = self.workspaces[idx].id.clone(); @@ -196,6 +198,8 @@ impl AppState { pub fn switch_tab(&mut self, idx: usize) { if let Some(ws_idx) = self.active { + self.selection = None; + self.selection_autoscroll = None; let Some(ws) = self.workspaces.get_mut(ws_idx) else { return; }; @@ -470,6 +474,8 @@ impl AppState { if self.workspaces.is_empty() { return; } + self.selection = None; + self.selection_autoscroll = None; self.mark_session_dirty(); let terminal_ids = self.terminal_ids_for_workspace(self.selected); let workspace_id = self.workspaces[self.selected].id.clone(); @@ -590,6 +596,8 @@ impl AppState { } pub fn close_pane(&mut self) { + self.selection = None; + self.selection_autoscroll = None; self.mark_session_dirty(); let active = self.active; let terminal_ids = active @@ -615,6 +623,8 @@ impl AppState { } pub fn close_tab(&mut self) { + self.selection = None; + self.selection_autoscroll = None; self.mark_session_dirty(); let should_close_workspace = self .active @@ -654,6 +664,11 @@ impl AppState { impl AppState { pub fn clear_selection(&mut self) { self.selection = None; + self.selection_autoscroll = None; + } + + pub(crate) fn stop_selection_autoscroll_state(&mut self) { + self.selection_autoscroll = None; } pub fn copy_selection(&mut self) { @@ -682,6 +697,7 @@ impl AppState { } self.selection = None; + self.selection_autoscroll = None; } } @@ -918,6 +934,15 @@ impl AppState { return; }; + if self + .selection + .as_ref() + .is_some_and(|s| s.pane_id == pane_id) + { + self.selection = None; + self.selection_autoscroll = None; + } + let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id); let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx); let should_close_workspace = { @@ -1339,6 +1364,51 @@ mod tests { assert_eq!(state.workspaces.len(), 1); } + #[test] + fn pane_died_unrelated_pane_preserves_selection() { + // Two workspaces; user is selecting text in workspace 0. + // A pane in workspace 1 dies — selection must be preserved. + let mut state = app_with_workspaces(&["active", "bg"]); + let active_pane = *state.workspaces[0].panes.keys().next().unwrap(); + let bg_pane = *state.workspaces[1].panes.keys().next().unwrap(); + + state.selection = Some(crate::selection::Selection::anchor(active_pane, 0, 0, None)); + state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { + direction: crate::app::state::SelectionAutoscrollDirection::Down, + last_mouse_screen_col: 0, + last_mouse_screen_row: 23, + inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), + }); + + state.handle_pane_died(bg_pane); + + assert!(state.selection.is_some()); + assert!(state.selection_autoscroll.is_some()); + } + + #[test] + fn pane_died_same_pane_clears_selection() { + let mut state = app_with_workspaces(&["test"]); + let first_id = state.workspaces[0].tabs[0].root_pane; + let second_id = state.workspaces[0].test_split(Direction::Horizontal); + + state.selection = Some(crate::selection::Selection::anchor(second_id, 0, 0, None)); + state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { + direction: crate::app::state::SelectionAutoscrollDirection::Down, + last_mouse_screen_col: 0, + last_mouse_screen_row: 23, + inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), + }); + + state.handle_pane_died(second_id); + + // first_id still alive, workspace stays, but selection was on the dying pane + assert!(state.selection.is_none()); + assert!(state.selection_autoscroll.is_none()); + assert_eq!(state.workspaces[0].panes.len(), 1); + assert_eq!(state.workspaces[0].panes.keys().next().unwrap(), &first_id); + } + #[test] fn state_changed_updates_pane() { let mut state = app_with_workspaces(&["test"]); diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 7823de31..93152e55 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -177,6 +177,15 @@ impl App { tracing::warn!("failed to queue clipboard write event"); } } + + // Sync autoscroll deadline with state (mouse handler may have + // set or cleared selection_autoscroll during handle_mouse). + if self.state.selection_autoscroll.is_none() { + self.selection_autoscroll_deadline = None; + } else if self.selection_autoscroll_deadline.is_none() { + self.selection_autoscroll_deadline = + Some(std::time::Instant::now() + super::SELECTION_AUTOSCROLL_INTERVAL); + } } } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 827e37e3..561aa3c4 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -133,6 +133,7 @@ impl AppState { match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { self.selection = None; + self.selection_autoscroll = None; self.workspace_press = None; if self.mode == Mode::ConfirmClose { @@ -371,6 +372,7 @@ impl AppState { if self.forward_pane_mouse_button(&info, mouse) { self.selection = None; + self.selection_autoscroll = None; return None; } @@ -408,6 +410,7 @@ impl AppState { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { if self.forward_pane_mouse_button(&info, mouse) { self.selection = None; + self.selection_autoscroll = None; return None; } } @@ -525,9 +528,11 @@ impl AppState { self.workspace_press = None; self.tab_press = None; self.drag = None; + self.selection_autoscroll = None; let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click()); if was_click { self.selection = None; + self.selection_autoscroll = None; } else { self.copy_selection(); } @@ -538,6 +543,7 @@ impl AppState { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { if self.forward_pane_mouse_button(&info, mouse) { self.selection = None; + self.selection_autoscroll = None; self.workspace_press = None; self.tab_press = None; self.drag = None; @@ -588,6 +594,7 @@ impl AppState { let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click()); if was_click { self.selection = None; + self.selection_autoscroll = None; } else { self.copy_selection(); } @@ -618,6 +625,7 @@ impl AppState { MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { self.selection = None; + self.selection_autoscroll = None; self.handle_terminal_wheel(mouse); } @@ -1010,7 +1018,7 @@ impl AppState { .or_else(|| self.pane_frame_at(col, row)) } - pub(super) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { + pub(crate) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { self.view.pane_infos.iter().find(|info| info.id == pane_id) } @@ -1061,7 +1069,7 @@ impl AppState { self.mode = Mode::Terminal; } - pub(super) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { + pub(crate) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { if let Some(ws_idx) = self.active { if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { rt.scroll_up(lines); @@ -1069,7 +1077,7 @@ impl AppState { } } - pub(super) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { + pub(crate) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { if let Some(ws_idx) = self.active { if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { rt.scroll_down(lines); @@ -1077,7 +1085,7 @@ impl AppState { } } - pub(super) fn pane_scroll_metrics( + pub(crate) fn pane_scroll_metrics( &self, pane_id: crate::layout::PaneId, ) -> Option { diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 9ab3de28..29a71c2e 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -113,6 +113,7 @@ impl App { } else { execute_navigate_action(&mut self.state, action); } + self.selection_autoscroll_deadline = None; return; } 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(); + } + } } - self.update_selection_cursor(pane_id, screen_col, screen_row); + 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()); + } +} diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 90a1a0e2..a9af902a 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -29,6 +29,7 @@ impl App { fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option { self.state.clear_selection(); + self.selection_autoscroll_deadline = None; self.state.update_dismissed = true; let key_event = key.as_key_event(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 1c818a9c..13b8ecc0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -29,6 +29,7 @@ const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); pub(crate) const ANIMATION_INTERVAL: Duration = Duration::from_millis(16); pub(crate) const HEADLESS_ANIMATION_INTERVAL: Duration = Duration::from_millis(128); pub(crate) const HEADLESS_ANIMATION_TICK_STEP: u32 = 8; +pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30); const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); @@ -77,6 +78,7 @@ pub struct App { pub(crate) next_resize_poll: Instant, pub(crate) next_animation_tick: Option, pub(crate) next_auto_update_check: Option, + pub(crate) selection_autoscroll_deadline: Option, pub(crate) session_save_deadline: Option, pub(crate) last_render_at: Option, pub(crate) suppressed_repeat_keys: @@ -363,6 +365,7 @@ impl App { workspace_press: None, tab_press: None, selection: None, + selection_autoscroll: None, context_menu: None, update_available, update_install_command, @@ -451,6 +454,7 @@ impl App { next_auto_update_check: auto_updates_enabled(no_session) .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), session_save_deadline: None, + selection_autoscroll_deadline: None, last_render_at: None, suppressed_repeat_keys: HashSet::new(), api_rx, @@ -2043,6 +2047,51 @@ mod tests { assert!(app.session_save_deadline.is_none()); } + #[test] + fn next_loop_deadline_includes_selection_autoscroll_deadline() { + let mut app = test_app(); + let now = Instant::now(); + app.selection_autoscroll_deadline = Some(now + Duration::from_millis(5)); + app.next_animation_tick = Some(now + Duration::from_millis(100)); + app.session_save_deadline = Some(now + Duration::from_millis(200)); + assert_eq!( + app.next_loop_deadline(now, false), + app.selection_autoscroll_deadline + ); + } + + #[test] + fn tick_selection_autoscroll_self_heals_when_state_cleared() { + let mut app = test_app(); + let now = Instant::now(); + app.state.selection_autoscroll = None; + app.selection_autoscroll_deadline = Some(now); + app.tick_selection_autoscroll(now); + assert!(app.selection_autoscroll_deadline.is_none()); + } + + #[test] + fn tick_selection_autoscroll_stops_on_rect_change() { + let mut app = test_app(); + let now = Instant::now(); + 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.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); + // Set autoscroll with a stale inner_rect that doesn't match pane_infos + app.state.selection_autoscroll = Some(state::SelectionAutoscroll { + direction: state::SelectionAutoscrollDirection::Down, + last_mouse_screen_col: 0, + last_mouse_screen_row: 999, + inner_rect: ratatui::layout::Rect::new(0, 0, 1, 1), // wrong rect + }); + 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()); + } + #[tokio::test] async fn full_internal_event_queue_eventually_applies_working_to_idle_transition() { let mut app = test_app(); 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()); + } +} diff --git a/src/app/state.rs b/src/app/state.rs index 10631157..1a2bbddf 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -5,6 +5,31 @@ use ratatui::style::Color; use crate::layout::{PaneId, PaneInfo, SplitBorder}; use crate::selection::Selection; + +// --------------------------------------------------------------------------- +// Selection autoscroll types +// --------------------------------------------------------------------------- + +/// Direction of automatic scrolling during text selection drag. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SelectionAutoscrollDirection { + Up, + Down, +} + +/// State for automatic scrolling during text selection drag. +/// +/// When the cursor hovers in the 1-row hot zone at the top or bottom edge +/// of a pane (or outside the pane), this struct captures the direction and +/// last known mouse position so a recurring 30ms tick can continue scrolling +/// and extending the selection even when the mouse is not moving. +#[derive(Clone, Debug)] +pub(crate) struct SelectionAutoscroll { + pub direction: SelectionAutoscrollDirection, + pub last_mouse_screen_col: u16, + pub last_mouse_screen_row: u16, + pub inner_rect: Rect, +} use crate::terminal_theme::TerminalTheme; use crate::workspace::Workspace; @@ -862,6 +887,7 @@ pub struct AppState { pub(crate) workspace_press: Option, pub(crate) tab_press: Option, pub selection: Option, + pub selection_autoscroll: Option, pub context_menu: Option, // Notifications pub update_available: Option, @@ -936,7 +962,7 @@ impl AppState { && self .active .and_then(|idx| self.focused_runtime_in_workspace(idx)) - .and_then(crate::pane::PaneRuntime::input_state) + .and_then(crate::terminal::TerminalRuntime::input_state) .is_some_and(crate::pane::InputState::mouse_reporting_enabled) } @@ -1107,6 +1133,7 @@ impl AppState { workspace_press: None, tab_press: None, selection: None, + selection_autoscroll: None, context_menu: None, update_available: None, update_install_command: "herdr update".into(), diff --git a/src/selection.rs b/src/selection.rs index 6b2fc093..016cf649 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -59,6 +59,28 @@ impl Selection { } } + /// Convert the anchor's absolute row and pane-relative column back to + /// screen coordinates. Adds the pane origin before clamping so the + /// returned (screen_row, screen_col) can be compared directly against + /// mouse screen positions. + pub fn anchor_screen_pos( + &self, + pane_inner: Rect, + metrics: Option, + ) -> (u16, u16) { + let viewport_row = viewport_row_for_absolute_row(self.anchor.0, metrics); + // Convert pane-relative to screen coordinates, then clamp. + let row = (viewport_row.saturating_add(pane_inner.y)).clamp( + pane_inner.y, + pane_inner.y + pane_inner.height.saturating_sub(1), + ); + let col = (self.anchor.1.saturating_add(pane_inner.x)).clamp( + pane_inner.x, + pane_inner.x + pane_inner.width.saturating_sub(1), + ); + (row, col) + } + /// Extend the selection as the mouse drags. Activates highlighting /// once the cursor moves to a different cell than the anchor. /// Screen coordinates are clamped to the pane boundary. @@ -97,11 +119,30 @@ impl Selection { self.phase == Phase::Anchored } + /// Whether the user just clicked without dragging (not a selection). + pub fn is_just_click(&self) -> bool { + self.phase == Phase::Anchored + } + + /// Force the selection into Dragging phase, used when the mouse + /// has moved off the anchor cell but drag() couldn't transition + /// because the cursor was clamped to the same cell as the anchor. + pub fn force_dragging(&mut self) { + if self.phase == Phase::Anchored { + self.phase = Phase::Dragging; + } + } + /// Whether the pointer is still down and the selection can keep extending. pub fn is_in_progress(&self) -> bool { matches!(self.phase, Phase::Anchored | Phase::Dragging) } + /// Whether the user is actively dragging (cursor moved from anchor). + pub fn is_dragging(&self) -> bool { + self.phase == Phase::Dragging + } + /// Returns (start, end) in reading order (top-left to bottom-right). fn ordered(&self) -> ((u32, u16), (u32, u16)) { let (ar, ac) = self.anchor; @@ -153,6 +194,13 @@ fn absolute_row_for_viewport_row(viewport_row: u16, metrics: Option) -> u16 { + absolute_row + .saturating_sub(viewport_top_row(metrics)) + .try_into() + .unwrap_or(0) +} + fn clamp_to_pane(screen_col: u16, screen_row: u16, pane_inner: Rect) -> (u16, u16) { let clamped_col = screen_col.clamp( pane_inner.x, @@ -342,4 +390,28 @@ mod tests { assert_eq!(row, 0); assert_eq!(col, 0); } + + #[test] + fn anchor_screen_pos_adds_pane_origin() { + // Pane offset by sidebar (x=10) and tab bar (y=5). + // Anchor at viewport_row=3, col=5 (pane-relative). + let sel = Selection::anchor(PaneId::from_raw(0), 3, 5, None); + let pane_inner = Rect::new(10, 5, 80, 24); + let (row, col) = sel.anchor_screen_pos(pane_inner, None); + // Screen row = 3 + 5 = 8, screen col = 5 + 10 = 15 + assert_eq!(row, 8); + assert_eq!(col, 15); + } + + #[test] + fn anchor_screen_pos_same_cell_as_mouse_with_offset() { + // When the pane has a non-zero origin, anchor and mouse on the same + // screen cell must compare equal — no false drag detection. + let pane_inner = Rect::new(10, 5, 80, 24); + // Mouse clicked at screen (15, 8) → anchor stored as (viewport_row=3, col=5) + let sel = Selection::anchor(PaneId::from_raw(0), 3, 5, None); + let (ar, ac) = sel.anchor_screen_pos(pane_inner, None); + // Screen position of the anchor must match the mouse position + assert_eq!((ar, ac), (8, 15)); + } } diff --git a/src/server/headless.rs b/src/server/headless.rs index d1c8d144..5f9b62b9 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2024,6 +2024,15 @@ impl HeadlessServer { changed = true; } + if self + .app + .selection_autoscroll_deadline + .is_some_and(|deadline| now >= deadline) + { + self.app.tick_selection_autoscroll(now); + changed = true; + } + self.app.start_git_status_refresh_if_due(now); if self