diff options
Diffstat (limited to 'src/app/runtime.rs')
| -rw-r--r-- | src/app/runtime.rs | 269 |
1 files changed, 268 insertions, 1 deletions
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()); + } +} |
