aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorleeeanh <44962286+leeeanh@users.noreply.github.com>2026-05-17 19:05:30 +0700
committerGitHub <noreply@github.com>2026-05-17 15:05:30 +0300
commit45a4318bbbcb2c91d95faee6f31e5846ae3f9ec0 (patch)
tree0b5f9b8f5b3dfb81a5f2fe4672e148fcbd58473b /src/app/input
parentf4008dc97a60fb6ce9e75b8b521b21ab38f6ad61 (diff)
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 <mac@macs-MacBook-Pro.local>
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs9
-rw-r--r--src/app/input/mouse.rs16
-rw-r--r--src/app/input/navigate.rs1
-rw-r--r--src/app/input/selection.rs230
-rw-r--r--src/app/input/terminal.rs1
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();