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::>(); let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed { results }); }); } pub(crate) fn git_refresh_deadline(&self) -> Option { (!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 { 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 { 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 { 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, 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, 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()); } }