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>
This commit is contained in:
parent
f4008dc97a
commit
45a4318bbb
|
|
@ -113,6 +113,8 @@ impl AppState {
|
|||
|
||||
pub fn switch_workspace(&mut self, idx: usize) {
|
||||
if idx < self.workspaces.len() {
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
self.active = Some(idx);
|
||||
self.selected = idx;
|
||||
let workspace_id = self.workspaces[idx].id.clone();
|
||||
|
|
@ -196,6 +198,8 @@ impl AppState {
|
|||
|
||||
pub fn switch_tab(&mut self, idx: usize) {
|
||||
if let Some(ws_idx) = self.active {
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
let Some(ws) = self.workspaces.get_mut(ws_idx) else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -470,6 +474,8 @@ impl AppState {
|
|||
if self.workspaces.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
self.mark_session_dirty();
|
||||
let terminal_ids = self.terminal_ids_for_workspace(self.selected);
|
||||
let workspace_id = self.workspaces[self.selected].id.clone();
|
||||
|
|
@ -590,6 +596,8 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub fn close_pane(&mut self) {
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
self.mark_session_dirty();
|
||||
let active = self.active;
|
||||
let terminal_ids = active
|
||||
|
|
@ -615,6 +623,8 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub fn close_tab(&mut self) {
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
self.mark_session_dirty();
|
||||
let should_close_workspace = self
|
||||
.active
|
||||
|
|
@ -654,6 +664,11 @@ impl AppState {
|
|||
impl AppState {
|
||||
pub fn clear_selection(&mut self) {
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
}
|
||||
|
||||
pub(crate) fn stop_selection_autoscroll_state(&mut self) {
|
||||
self.selection_autoscroll = None;
|
||||
}
|
||||
|
||||
pub fn copy_selection(&mut self) {
|
||||
|
|
@ -682,6 +697,7 @@ impl AppState {
|
|||
}
|
||||
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -918,6 +934,15 @@ impl AppState {
|
|||
return;
|
||||
};
|
||||
|
||||
if self
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.pane_id == pane_id)
|
||||
{
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
}
|
||||
|
||||
let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id);
|
||||
let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx);
|
||||
let should_close_workspace = {
|
||||
|
|
@ -1339,6 +1364,51 @@ mod tests {
|
|||
assert_eq!(state.workspaces.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_died_unrelated_pane_preserves_selection() {
|
||||
// Two workspaces; user is selecting text in workspace 0.
|
||||
// A pane in workspace 1 dies — selection must be preserved.
|
||||
let mut state = app_with_workspaces(&["active", "bg"]);
|
||||
let active_pane = *state.workspaces[0].panes.keys().next().unwrap();
|
||||
let bg_pane = *state.workspaces[1].panes.keys().next().unwrap();
|
||||
|
||||
state.selection = Some(crate::selection::Selection::anchor(active_pane, 0, 0, None));
|
||||
state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll {
|
||||
direction: crate::app::state::SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: 0,
|
||||
last_mouse_screen_row: 23,
|
||||
inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
|
||||
});
|
||||
|
||||
state.handle_pane_died(bg_pane);
|
||||
|
||||
assert!(state.selection.is_some());
|
||||
assert!(state.selection_autoscroll.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_died_same_pane_clears_selection() {
|
||||
let mut state = app_with_workspaces(&["test"]);
|
||||
let first_id = state.workspaces[0].tabs[0].root_pane;
|
||||
let second_id = state.workspaces[0].test_split(Direction::Horizontal);
|
||||
|
||||
state.selection = Some(crate::selection::Selection::anchor(second_id, 0, 0, None));
|
||||
state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll {
|
||||
direction: crate::app::state::SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: 0,
|
||||
last_mouse_screen_row: 23,
|
||||
inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
|
||||
});
|
||||
|
||||
state.handle_pane_died(second_id);
|
||||
|
||||
// first_id still alive, workspace stays, but selection was on the dying pane
|
||||
assert!(state.selection.is_none());
|
||||
assert!(state.selection_autoscroll.is_none());
|
||||
assert_eq!(state.workspaces[0].panes.len(), 1);
|
||||
assert_eq!(state.workspaces[0].panes.keys().next().unwrap(), &first_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_changed_updates_pane() {
|
||||
let mut state = app_with_workspaces(&["test"]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ impl App {
|
|||
} else {
|
||||
execute_navigate_action(&mut self.state, action);
|
||||
}
|
||||
self.selection_autoscroll_deadline = None;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use crossterm::event::{MouseEvent, MouseEventKind};
|
||||
|
||||
use crate::app::state::AppState;
|
||||
use crate::app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection};
|
||||
|
||||
impl AppState {
|
||||
fn update_selection_cursor(
|
||||
pub(crate) fn update_selection_cursor(
|
||||
&mut self,
|
||||
pane_id: crate::layout::PaneId,
|
||||
screen_col: u16,
|
||||
|
|
@ -30,20 +30,97 @@ impl AppState {
|
|||
return;
|
||||
};
|
||||
|
||||
let top = info.inner_rect.y;
|
||||
let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1);
|
||||
if screen_row < info.inner_rect.y {
|
||||
self.scroll_pane_up(
|
||||
pane_id,
|
||||
Self::selection_edge_scroll_lines(info.inner_rect.y - screen_row),
|
||||
);
|
||||
} else if screen_row > bottom {
|
||||
self.scroll_pane_down(
|
||||
pane_id,
|
||||
Self::selection_edge_scroll_lines(screen_row - bottom),
|
||||
);
|
||||
|
||||
// Only activate autoscroll when the user is actively dragging.
|
||||
// An anchored click in the hot zone should not start the timer.
|
||||
// Check before advancing the cursor: if already Dragging from a prior
|
||||
// event, it stays true. If Anchored, the mouse must have moved away
|
||||
// from the anchor cell for this to count as a real drag.
|
||||
let was_dragging = self.selection.as_ref().is_some_and(|s| s.is_dragging());
|
||||
let anchor_differs_from_mouse = self.selection.as_ref().is_some_and(|s| {
|
||||
// Convert anchor to screen coords for comparison.
|
||||
// Anchor is stored in absolute row; for a simple screen
|
||||
// comparison, check whether the mouse is on a different
|
||||
// cell than the anchor's screen position.
|
||||
let (ar, ac) =
|
||||
s.anchor_screen_pos(info.inner_rect, self.pane_scroll_metrics(s.pane_id));
|
||||
ar != screen_row || ac != screen_col
|
||||
});
|
||||
let is_dragging = was_dragging || anchor_differs_from_mouse;
|
||||
|
||||
// Advance the selection cursor.
|
||||
self.update_selection_cursor(pane_id, screen_col, screen_row);
|
||||
|
||||
// If the mouse is on a different cell than the anchor but drag()
|
||||
// didn't transition (cursor clamped to edge == anchor), force
|
||||
// Dragging so the selection becomes visible and autoscroll can run.
|
||||
if is_dragging {
|
||||
if let Some(sel) = self.selection.as_mut() {
|
||||
if sel.is_just_click() {
|
||||
sel.force_dragging();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.update_selection_cursor(pane_id, screen_col, screen_row);
|
||||
if screen_row < top {
|
||||
// Cursor above pane — immediate scroll + set autoscroll state
|
||||
if is_dragging {
|
||||
self.scroll_pane_up(pane_id, Self::selection_edge_scroll_lines(top - screen_row));
|
||||
// Re-advance cursor after scroll so it reflects the new viewport position
|
||||
self.update_selection_cursor(pane_id, screen_col, screen_row);
|
||||
self.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: SelectionAutoscrollDirection::Up,
|
||||
last_mouse_screen_col: screen_col,
|
||||
last_mouse_screen_row: screen_row,
|
||||
inner_rect: info.inner_rect,
|
||||
});
|
||||
}
|
||||
} else if screen_row > bottom {
|
||||
// Cursor below pane — immediate scroll + set autoscroll state
|
||||
if is_dragging {
|
||||
self.scroll_pane_down(
|
||||
pane_id,
|
||||
Self::selection_edge_scroll_lines(screen_row - bottom),
|
||||
);
|
||||
// Re-advance cursor after scroll so it reflects the new viewport position
|
||||
self.update_selection_cursor(pane_id, screen_col, screen_row);
|
||||
self.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: screen_col,
|
||||
last_mouse_screen_row: screen_row,
|
||||
inner_rect: info.inner_rect,
|
||||
});
|
||||
}
|
||||
} else if screen_row == top {
|
||||
// Hot zone: top edge row — no immediate scroll, set autoscroll state
|
||||
if is_dragging {
|
||||
self.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: SelectionAutoscrollDirection::Up,
|
||||
last_mouse_screen_col: screen_col,
|
||||
last_mouse_screen_row: screen_row,
|
||||
inner_rect: info.inner_rect,
|
||||
});
|
||||
} else {
|
||||
self.selection_autoscroll = None;
|
||||
}
|
||||
} else if screen_row == bottom {
|
||||
// Hot zone: bottom edge row — no immediate scroll, set autoscroll state
|
||||
if is_dragging {
|
||||
self.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: screen_col,
|
||||
last_mouse_screen_row: screen_row,
|
||||
inner_rect: info.inner_rect,
|
||||
});
|
||||
} else {
|
||||
self.selection_autoscroll = None;
|
||||
}
|
||||
} else {
|
||||
// Safe zone: inside pane, not on edge rows — clear autoscroll
|
||||
self.selection_autoscroll = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool {
|
||||
|
|
@ -66,3 +143,130 @@ impl AppState {
|
|||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod autoscroll_tests {
|
||||
use super::*;
|
||||
use crate::layout::PaneInfo;
|
||||
use crate::workspace::Workspace;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
/// Build an AppState with one workspace/pane and pane_infos populated
|
||||
/// so pane_info_by_id works. Returns (state, pane_id).
|
||||
fn make_state_with_pane() -> (AppState, crate::layout::PaneId) {
|
||||
let mut state = AppState::test_new();
|
||||
let ws = Workspace::test_new("test");
|
||||
let pane_id = ws.tabs[0].root_pane;
|
||||
state.workspaces.push(ws);
|
||||
state.active = Some(0);
|
||||
state.view.pane_infos.push(PaneInfo {
|
||||
id: pane_id,
|
||||
rect: Rect::new(0, 0, 80, 24),
|
||||
inner_rect: Rect::new(0, 0, 80, 24),
|
||||
scrollbar_rect: None,
|
||||
is_focused: true,
|
||||
});
|
||||
(state, pane_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn above_pane_sets_autoscroll_up() {
|
||||
// Build state with pane starting at row 5 so we can drag above it
|
||||
let mut state = AppState::test_new();
|
||||
let ws = Workspace::test_new("test");
|
||||
let pane_id = ws.tabs[0].root_pane;
|
||||
state.workspaces.push(ws);
|
||||
state.active = Some(0);
|
||||
state.view.pane_infos.push(PaneInfo {
|
||||
id: pane_id,
|
||||
rect: Rect::new(0, 5, 80, 24),
|
||||
inner_rect: Rect::new(0, 5, 80, 24),
|
||||
scrollbar_rect: None,
|
||||
is_focused: true,
|
||||
});
|
||||
// Anchor at (5, 10), drag to different cell above pane
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None);
|
||||
sel.drag(4, 5, Rect::new(0, 5, 80, 24), None);
|
||||
state.selection = Some(sel);
|
||||
state.update_selection_drag(5, 4);
|
||||
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
|
||||
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_hot_zone_sets_autoscroll_up_on_drag() {
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
// Anchor at (5, 10), drag to top edge row (row 0) — different cell
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None);
|
||||
sel.drag(0, 0, Rect::new(0, 0, 80, 24), None);
|
||||
state.selection = Some(sel);
|
||||
state.update_selection_drag(0, 0);
|
||||
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
|
||||
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_hot_zone_clears_autoscroll_on_click() {
|
||||
// An anchored click on the top edge row should NOT start autoscroll.
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None));
|
||||
// Same-cell drag on top edge row — still anchored
|
||||
state.update_selection_drag(0, 0);
|
||||
assert!(state.selection_autoscroll.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_hot_zone_sets_autoscroll_down_on_drag() {
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
// Anchor at (0, 0), drag to bottom edge row (row 23) — different cell
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
|
||||
sel.drag(23, 0, Rect::new(0, 0, 80, 24), None);
|
||||
state.selection = Some(sel);
|
||||
state.update_selection_drag(0, 23);
|
||||
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
|
||||
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_hot_zone_clears_autoscroll_on_click() {
|
||||
// An anchored click on the bottom edge row should NOT start autoscroll.
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
// Anchor at bottom edge row
|
||||
state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None));
|
||||
// Same-cell drag — still anchored
|
||||
state.update_selection_drag(0, 23);
|
||||
assert!(state.selection_autoscroll.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_pane_sets_autoscroll_down_on_drag() {
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
// Anchor at (0, 0), drag to different cell below pane
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
|
||||
sel.drag(5, 5, Rect::new(0, 0, 80, 24), None);
|
||||
state.selection = Some(sel);
|
||||
// Drag cursor one row below the pane bottom
|
||||
state.update_selection_drag(0, 24);
|
||||
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
|
||||
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_zone_clears_autoscroll() {
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
// Anchor at (0, 0), drag to (5, 5) so it's truly dragging
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
|
||||
sel.drag(5, 5, Rect::new(0, 0, 80, 24), None);
|
||||
state.selection = Some(sel);
|
||||
// Set autoscroll first
|
||||
state.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: 5,
|
||||
last_mouse_screen_row: 23,
|
||||
inner_rect: Rect::new(0, 0, 80, 24),
|
||||
});
|
||||
// Move cursor into safe zone (middle of pane, not on edge rows)
|
||||
state.update_selection_drag(5, 12);
|
||||
assert!(state.selection_autoscroll.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue