diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 70 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 9 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 16 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 1 | ||||
| -rw-r--r-- | src/app/input/selection.rs | 230 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 1 | ||||
| -rw-r--r-- | src/app/mod.rs | 49 | ||||
| -rw-r--r-- | src/app/runtime.rs | 269 | ||||
| -rw-r--r-- | src/app/state.rs | 29 |
9 files changed, 655 insertions, 19 deletions
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 = { @@ -1340,6 +1365,51 @@ mod tests { } #[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"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); 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<crate::pane::ScrollMetrics> { 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(); + } + } + } + + 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<PreparedPaneInput> { 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<Instant>, pub(crate) next_auto_update_check: Option<Instant>, + pub(crate) selection_autoscroll_deadline: Option<Instant>, pub(crate) session_save_deadline: Option<Instant>, pub(crate) last_render_at: Option<Instant>, 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<WorkspacePressState>, pub(crate) tab_press: Option<TabPressState>, pub selection: Option<Selection>, + pub selection_autoscroll: Option<SelectionAutoscroll>, pub context_menu: Option<ContextMenuState>, // Notifications pub update_available: Option<String>, @@ -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(), |
