aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs70
-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
-rw-r--r--src/app/mod.rs49
-rw-r--r--src/app/runtime.rs269
-rw-r--r--src/app/state.rs29
-rw-r--r--src/selection.rs72
-rw-r--r--src/server/headless.rs9
11 files changed, 736 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(),
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<ScrollMetrics>,
+ ) -> (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<ScrollMetric
viewport_top_row(metrics) + u32::from(viewport_row)
}
+fn viewport_row_for_absolute_row(absolute_row: u32, metrics: Option<ScrollMetrics>) -> 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