Files
herdr/src/app/runtime.rs
T
leeeanhandleeanh 45a4318bbb 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>
2026-05-17 15:05:30 +03:00

596 lines
21 KiB
Rust

use std::time::{Duration, Instant};
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, SELECTION_AUTOSCROLL_INTERVAL,
};
use crate::events::AppEvent;
use crate::workspace::{Workspace, WorkspaceGitStatus};
impl App {
pub(crate) fn drain_api_requests(&mut self) -> bool {
let mut changed = false;
while let Ok(msg) = self.api_rx.try_recv() {
changed |= self.handle_api_request_message(msg);
}
changed
}
pub(super) fn handle_api_request_message(
&mut self,
msg: crate::api::ApiRequestMessage,
) -> bool {
let changed = crate::api::request_changes_ui(&msg.request);
let response = self.handle_api_request(msg.request);
let _ = msg.respond_to.send(response);
changed
}
pub(super) async fn handle_raw_input_batch(
&mut self,
first: crate::raw_input::RawInputEvent,
) -> bool {
let mut changed = self.handle_raw_input_event(first).await;
while let Some(rx) = self.input_rx.as_mut() {
match rx.try_recv() {
Ok(event) => changed |= self.handle_raw_input_event(event).await,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
self.input_rx = None;
break;
}
}
}
changed
}
pub(super) async fn handle_raw_input_event(
&mut self,
event: crate::raw_input::RawInputEvent,
) -> bool {
match event {
crate::raw_input::RawInputEvent::Key(key) => {
let key_id = repeat_key_identity(&key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
if self.state.mode == Mode::Terminal {
self.suppressed_repeat_keys.remove(&key_id);
} else {
self.suppressed_repeat_keys.insert(key_id);
}
self.handle_key(key).await;
true
}
crossterm::event::KeyEventKind::Repeat => {
if self.state.mode == Mode::Terminal
&& !self.suppressed_repeat_keys.contains(&key_id)
{
self.handle_key(key).await;
true
} else {
false
}
}
crossterm::event::KeyEventKind::Release => {
self.suppressed_repeat_keys.remove(&key_id);
false
}
}
}
crate::raw_input::RawInputEvent::Paste(text) => {
self.handle_paste(text).await;
true
}
crate::raw_input::RawInputEvent::Mouse(mouse) => {
if self.state.mouse_capture {
self.handle_mouse(mouse);
} else {
self.state.handle_pane_mouse_only(mouse);
}
true
}
crate::raw_input::RawInputEvent::OuterFocusGained => {
self.request_full_redraw();
self.state.outer_terminal_focus = Some(true);
self.state.mark_active_tab_seen();
true
}
crate::raw_input::RawInputEvent::OuterFocusLost => {
self.state.outer_terminal_focus = Some(false);
false
}
crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
self.update_host_terminal_theme(kind, color)
}
crate::raw_input::RawInputEvent::Unsupported => false,
}
}
fn handle_resize_poll(&mut self) -> bool {
let Ok(size) = terminal::size() else {
return false;
};
if self.last_terminal_size != Some(size) {
self.last_terminal_size = Some(size);
return true;
}
false
}
pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant) -> bool {
let mut changed = false;
self.sync_animation_timer(now);
if now >= self.next_resize_poll {
changed |= self.handle_resize_poll();
self.next_resize_poll = now + RESIZE_POLL_INTERVAL;
}
if self
.config_diagnostic_deadline
.is_some_and(|deadline| now >= deadline)
{
self.config_diagnostic_deadline = None;
self.state.config_diagnostic = None;
changed = true;
}
if self.toast_deadline.is_some_and(|deadline| now >= deadline) {
self.toast_deadline = None;
self.state.toast = None;
changed = true;
}
if self
.next_animation_tick
.is_some_and(|deadline| now >= deadline)
{
self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1);
self.next_animation_tick = Some(now + ANIMATION_INTERVAL);
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
.next_auto_update_check
.is_some_and(|deadline| now >= deadline)
{
self.run_auto_update_check();
}
if self
.session_save_deadline
.is_some_and(|deadline| now >= deadline)
{
self.save_session_now();
}
self.sync_animation_timer(now);
changed
}
pub(crate) fn sync_animation_timer(&mut self, now: Instant) {
self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL);
}
pub(crate) fn sync_headless_animation_timer(&mut self, now: Instant) {
self.sync_animation_timer_with_interval(now, crate::app::HEADLESS_ANIMATION_INTERVAL);
}
fn sync_animation_timer_with_interval(&mut self, now: Instant, interval: Duration) {
if self.agent_panel_has_animation() {
self.next_animation_tick.get_or_insert(now + interval);
} else {
self.next_animation_tick = None;
}
}
fn agent_panel_has_animation(&self) -> bool {
match self.state.agent_panel_scope {
crate::app::state::AgentPanelScope::CurrentWorkspace => self
.state
.active
.and_then(|idx| self.state.workspaces.get(idx))
.is_some_and(|ws| ws.has_working_pane(&self.state.terminals)),
crate::app::state::AgentPanelScope::AllWorkspaces => self
.state
.workspaces
.iter()
.any(|ws| ws.has_working_pane(&self.state.terminals)),
}
}
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,
None => true,
}
}
pub(crate) fn run_auto_update_check(&mut self) {
if !auto_updates_enabled(self.no_session) {
self.next_auto_update_check = None;
return;
}
self.next_auto_update_check = self
.state
.update_available
.is_none()
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL);
if self.state.update_available.is_some() {
return;
}
let update_tx = self.event_tx.clone();
std::thread::spawn(move || crate::update::auto_update(update_tx));
}
pub(crate) fn start_git_status_refresh_if_due(&mut self, now: Instant) {
let Some(deadline) = self.git_refresh_deadline() else {
return;
};
if now < deadline {
return;
}
let workspaces: Vec<_> = self
.state
.workspaces
.iter()
.filter_map(|ws| {
ws.resolved_identity_cwd_from(&self.state.terminals, &self.state.terminal_runtimes)
.map(|cwd| (ws.id.clone(), cwd))
})
.collect();
if workspaces.is_empty() {
self.last_git_remote_status_refresh = now;
return;
}
self.git_refresh_in_flight = true;
let event_tx = self.event_tx.clone();
std::thread::spawn(move || {
let results = workspaces
.into_iter()
.map(|(workspace_id, resolved_identity_cwd)| {
Workspace::git_status_for_cwd(workspace_id, resolved_identity_cwd)
})
.collect::<Vec<WorkspaceGitStatus>>();
let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed { results });
});
}
pub(crate) fn git_refresh_deadline(&self) -> Option<Instant> {
(!self.git_refresh_in_flight && !self.state.workspaces.is_empty())
.then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL)
}
pub(crate) fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> {
self.next_loop_deadline_with_resize_poll(now, needs_render, true)
}
pub(crate) fn next_headless_loop_deadline(
&self,
now: Instant,
needs_render: bool,
) -> Option<Instant> {
self.next_loop_deadline_with_resize_poll(now, needs_render, false)
}
fn next_loop_deadline_with_resize_poll(
&self,
now: Instant,
needs_render: bool,
include_resize_poll: bool,
) -> Option<Instant> {
let render_deadline = if needs_render {
self.last_render_at
.map(|last_render_at| last_render_at + MIN_RENDER_INTERVAL)
.filter(|deadline| *deadline > now)
} else {
None
};
[
include_resize_poll.then_some(self.next_resize_poll),
self.config_diagnostic_deadline,
self.toast_deadline,
self.next_animation_tick,
self.git_refresh_deadline(),
self.next_auto_update_check,
self.session_save_deadline,
self.selection_autoscroll_deadline,
render_deadline,
]
.into_iter()
.flatten()
.min()
}
pub(crate) fn drain_internal_events(&mut self) -> bool {
let mut had_event = false;
while let Ok(ev) = self.event_rx.try_recv() {
had_event = true;
self.handle_internal_event(ev);
}
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());
}
}