diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 16:37:50 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 16:37:50 +0300 |
| commit | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (patch) | |
| tree | 773c5e6e45031a55b6a32cc731944c6e65e19f33 /src/client | |
| parent | bf76cef889257a56b29f892816cff827b34c23da (diff) | |
fix: bound mouse selection repaint cadence (#3901)
refs #3895
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/shell/composition.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/mouse.rs | 20 | ||||
| -rw-r--r-- | src/client/shell/state.rs | 9 | ||||
| -rw-r--r-- | src/client/shell/tests/copy.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/tests/mouse_selection.rs | 71 |
5 files changed, 101 insertions, 3 deletions
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index 1d5e583d..3266e54d 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -131,6 +131,8 @@ impl ClientShellState { } pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> { + self.last_composed_at = Some(std::time::Instant::now()); + self.selection_repaint_deadline = None; if self.last_composed_size != Some((cols, rows)) && self.mode == ClientShellMode::Navigate { self.reveal_navigation_workspace = true; self.reveal_mobile_workspace = true; diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 24310907..af324174 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -2,6 +2,7 @@ use super::*; use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; const SELECTION_AUTOSCROLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(30); +const SELECTION_REPAINT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(16); impl ClientShellState { fn set_sidebar_width_from_column(&mut self, column: u16, outcome: &mut ClientShellInput) { @@ -313,12 +314,27 @@ impl ClientShellState { true } + pub(super) fn request_selection_drag_repaint(&mut self, now: std::time::Instant) -> bool { + let deadline = self + .last_composed_at + .map(|last| last + SELECTION_REPAINT_INTERVAL); + self.selection_repaint_deadline = deadline.filter(|deadline| now < *deadline); + self.selection_repaint_deadline.is_none() + } + pub(crate) fn tick_selection_autoscroll( &mut self, now: std::time::Instant, ) -> ClientShellInput { let mut outcome = ClientShellInput::default(); if self + .selection_repaint_deadline + .is_some_and(|deadline| now >= deadline) + { + self.selection_repaint_deadline = None; + outcome.repaint = true; + } + if self .selection_autoscroll_deadline .is_none_or(|deadline| now < deadline) { @@ -1652,7 +1668,9 @@ impl ClientShellState { }); if let Some(hit) = selection_hit { self.update_selection_drag(&hit, mouse.column, mouse.row, outcome); - outcome.repaint = true; + // Consume every motion, but do not rebuild a frame for every intermediate position. + outcome.repaint |= !outcome.actions.is_empty() + || self.request_selection_drag_repaint(std::time::Instant::now()); return; } } diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index add297e2..60afc3df 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -922,6 +922,8 @@ pub(crate) struct ClientShellState { pub(super) reveal_focused_tab: bool, pub(super) last_tab_bar_width: Option<u16>, pub(super) last_composed_size: Option<(u16, u16)>, + pub(super) last_composed_at: Option<std::time::Instant>, + pub(super) selection_repaint_deadline: Option<std::time::Instant>, pub(super) hits: ShellHitMap, pub(super) endpoints: Vec<ClientShellEndpoint>, pub(super) active_endpoint_id: ClientEndpointId, @@ -1078,6 +1080,8 @@ impl ClientShellState { reveal_focused_tab: true, last_tab_bar_width: None, last_composed_size: None, + last_composed_at: None, + selection_repaint_deadline: None, hits: ShellHitMap::default(), endpoints: vec![local_endpoint()], active_endpoint_id: ClientEndpointId::Local, @@ -1258,6 +1262,8 @@ impl ClientShellState { self.reveal_focused_tab = true; self.last_tab_bar_width = None; self.last_composed_size = None; + self.last_composed_at = None; + self.selection_repaint_deadline = None; self.pending_requests.clear(); self.pane_scroll_in_flight.clear(); self.pane_scroll_queued.clear(); @@ -1823,6 +1829,9 @@ impl ClientShellState { pub(crate) fn timer_delay(&self, now: std::time::Instant) -> std::time::Duration { let default = std::time::Duration::from_millis(100); self.selection_autoscroll_deadline + .into_iter() + .chain(self.selection_repaint_deadline) + .min() .map(|deadline| deadline.saturating_duration_since(now).min(default)) .unwrap_or(default) } diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index 2e0ca28d..c83f7fc6 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -162,7 +162,7 @@ fn client_mouse_selection_highlights_and_copies_through_endpoint_extraction() { row: pane.inner_rect.y, modifiers: KeyModifiers::empty(), })]); - assert!(drag.repaint); + assert!(drag.repaint || state.selection_repaint_deadline.is_some()); assert!(state .selection .as_ref() diff --git a/src/client/shell/tests/mouse_selection.rs b/src/client/shell/tests/mouse_selection.rs index a23ff45f..ea78705a 100644 --- a/src/client/shell/tests/mouse_selection.rs +++ b/src/client/shell/tests/mouse_selection.rs @@ -1,6 +1,75 @@ use super::*; #[test] +fn selection_repaint_cadence_keeps_one_deadline_and_flushes_when_input_stops() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let now = std::time::Instant::now(); + let ms = std::time::Duration::from_millis; + state.last_composed_at = Some(now); + for elapsed in [1, 4, 8, 12, 15] { + assert!(!state.request_selection_drag_repaint(now + ms(elapsed))); + assert_eq!(state.selection_repaint_deadline, Some(now + ms(16))); + } + assert_eq!(state.timer_delay(now + ms(8)), ms(8)); + assert!(!state.tick_selection_autoscroll(now + ms(15)).repaint); + assert!(state.tick_selection_autoscroll(now + ms(16)).repaint); + assert!(state.selection_repaint_deadline.is_none()); + assert!(!state.tick_selection_autoscroll(now + ms(17)).repaint); +} + +#[test] +fn selection_repaint_cadence_allows_immediate_paint_when_due() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let now = std::time::Instant::now(); + assert!(state.request_selection_drag_repaint(now)); + state.last_composed_at = Some(now); + assert!(state.request_selection_drag_repaint(now + std::time::Duration::from_millis(16))); + assert!(state.selection_repaint_deadline.is_none()); +} + +#[test] +fn selection_repaint_cadence_does_not_leave_work_after_another_composition() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let now = std::time::Instant::now(); + state.selection_repaint_deadline = Some(now); + state.compose(106, 20).expect("frame"); + assert!(state.selection_repaint_deadline.is_none()); + assert!(!state.tick_selection_autoscroll(now).repaint); +} + +#[test] +fn selection_release_copies_latest_position_before_deferred_paint() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let mut mouse = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }; + state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + mouse.kind = MouseEventKind::Drag(MouseButton::Left); + mouse.column += 2; + state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + state.selection_repaint_deadline = Some(std::time::Instant::now()); + mouse.kind = MouseEventKind::Up(MouseButton::Left); + let release = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + assert!(release.repaint); + assert!(matches!( + &release.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 2 }) + )); + state.compose(106, 20).expect("release frame"); + assert!(state.selection_repaint_deadline.is_none()); +} + +#[test] fn ctrl_click_routes_link_activation_through_endpoint_then_client_host() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot())); @@ -397,7 +466,7 @@ fn pane_content_updates_preserve_active_selection_only_when_selected_cells_stay_ pane.inner_rect.y + 1, )]); - assert!(drag.repaint); + assert!(drag.repaint || state.selection_repaint_deadline.is_some()); let selection = state.selection.as_ref().expect("visible selection"); assert!(selection.is_visible()); assert_eq!(selection.ordered_cells(), ((12, 0), (12, 1))); |
