diff options
Diffstat (limited to 'src/app/input')
| -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 |
5 files changed, 240 insertions, 17 deletions
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(); |
