aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 16:37:50 +0300
committerGitHub <noreply@github.com>2026-09-10 16:37:50 +0300
commit90e947a6e26e02f10ec7f1694c4eb5b78a86406b (patch)
tree773c5e6e45031a55b6a32cc731944c6e65e19f33 /src/client
parentbf76cef889257a56b29f892816cff827b34c23da (diff)
fix: bound mouse selection repaint cadence (#3901)
refs #3895
Diffstat (limited to 'src/client')
-rw-r--r--src/client/shell/composition.rs2
-rw-r--r--src/client/shell/mouse.rs20
-rw-r--r--src/client/shell/state.rs9
-rw-r--r--src/client/shell/tests/copy.rs2
-rw-r--r--src/client/shell/tests/mouse_selection.rs71
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)));