aboutsummaryrefslogtreecommitdiff
path: root/src/app/runtime.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/runtime.rs')
-rw-r--r--src/app/runtime.rs269
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());
+ }
+}