diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-07-26 00:28:11 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-26 00:28:11 +0300 |
| commit | 547aba8e0ab310623f1986d3026658f708d432bd (patch) | |
| tree | 8b05ec9feec9a3c3c04f21aa308bf22faad3772a /src | |
| parent | b01fc37e66aac45c7fa9eccc826d793d68608a0a (diff) | |
fix: avoid renders for passive mouse motion (#1872)
refs #1865
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/mod.rs | 4 | ||||
| -rw-r--r-- | src/app/mod.rs | 39 | ||||
| -rw-r--r-- | src/app/runtime.rs | 22 | ||||
| -rw-r--r-- | src/app/state.rs | 4 | ||||
| -rw-r--r-- | src/server/headless.rs | 113 |
5 files changed, 178 insertions, 4 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index a2e6597b..3d20adcd 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -464,7 +464,9 @@ impl App { let Some(bytes) = bytes else { return; }; - rt.scroll_reset(); + if !matches!(mouse.kind, MouseEventKind::Moved) { + rt.scroll_reset(); + } if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { warn!(err = %err, kind = ?mouse.kind, "failed to forward popup mouse event"); } diff --git a/src/app/mod.rs b/src/app/mod.rs index c2d479b3..e9aa24b6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5762,6 +5762,45 @@ last_pane = "prefix+tab" } #[tokio::test] + async fn popup_mouse_motion_preserves_scrollback() { + let mut app = test_app(); + app.state.mode = Mode::Terminal; + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); + let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( + 40, + 2, + 1024, + b"one\r\ntwo\r\nthree\r\n\x1b[?1003h\x1b[?1006h", + 4, + ); + popup_runtime.scroll_up(1); + assert!(popup_runtime + .scroll_metrics() + .is_some_and(|metrics| metrics.offset_from_bottom > 0)); + app.install_test_popup_runtime(popup_runtime); + let (_, inner) = + crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap(); + + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Mouse( + crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::Moved, + column: inner.x + 1, + row: inner.y, + modifiers: crossterm::event::KeyModifiers::NONE, + }, + )], + true, + ); + + assert!(popup_rx.try_recv().is_ok()); + assert!(app + .popup_runtime() + .and_then(TerminalRuntime::scroll_metrics) + .is_some_and(|metrics| metrics.offset_from_bottom > 0)); + } + + #[tokio::test] async fn route_client_events_routes_popup_mouse_when_global_capture_is_disabled() { let mut app = test_app(); let mut workspace = Workspace::test_new("tiled"); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 180c3cef..bd7830b9 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -167,13 +167,15 @@ impl App { true } crate::raw_input::RawInputEvent::Mouse(mouse) => { + let changes_view = !matches!(mouse.kind, crossterm::event::MouseEventKind::Moved) + || self.state.mode.mouse_motion_changes_view(); if self.state.popup_pane.is_some() || self.state.mouse_capture { self.handle_mouse(mouse); } else { self.state .handle_pane_mouse_only(&self.terminal_runtimes, mouse); } - true + changes_view } crate::raw_input::RawInputEvent::OuterFocusGained => { self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); @@ -809,6 +811,24 @@ mod tests { } #[tokio::test] + async fn passive_mouse_motion_does_not_request_monolithic_render() { + let (mut app, _) = test_app_with_pane(); + app.state.mode = crate::app::Mode::Terminal; + let motion = || { + crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::Moved, + column: 10, + row: 5, + modifiers: crossterm::event::KeyModifiers::empty(), + }) + }; + + assert!(!app.handle_raw_input_event(motion()).await); + app.state.mode = crate::app::Mode::GlobalMenu; + assert!(app.handle_raw_input_event(motion()).await); + } + + #[tokio::test] async fn raw_input_batch_does_not_start_pending_agent_resume_before_render() { let (mut app, pane_id) = test_app_with_pane(); app.state.ensure_test_terminals(); diff --git a/src/app/state.rs b/src/app/state.rs index a3d15a8b..6069b8a6 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -814,6 +814,10 @@ pub enum Mode { } impl Mode { + pub(crate) fn mouse_motion_changes_view(self) -> bool { + matches!(self, Self::GlobalMenu | Self::ContextMenu | Self::Navigator) + } + /// Whether keys in this mode are commands/navigation (an ASCII input source is wanted) rather /// than free text. This is an explicit **allowlist** of the prefix command/navigation realm: /// any mode NOT listed defaults to leaving the user's IME alone (the safe default), so adding a diff --git a/src/server/headless.rs b/src/server/headless.rs index 94721c38..0b25dd97 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2610,11 +2610,13 @@ impl HeadlessServer { &events, self.app.state.redraw_on_focus_gained, ); + let render_neutral_mouse_motion = + events_are_render_neutral_mouse_motion(&events, self.app.state.mode); if let Some(client) = self.clients.get_mut(&client_id) { if host_surface_redraw { client.request_repaint(); client.defer_full_render(); - } else { + } else if !render_neutral_mouse_motion { // Ensure semantic clients receive one post-input frame even if the // semantic buffer compares equal. Terminal-ANSI clients must keep their // server-side blit baseline; resetting it here forces a full redraw on @@ -2669,7 +2671,7 @@ impl HeadlessServer { false } else { - foreground_changed || theme_changed || interaction + foreground_changed || theme_changed || (interaction && !render_neutral_mouse_motion) } } @@ -4203,6 +4205,25 @@ impl HeadlessServer { } } +// Pane applications render their own motion responses through PTY output. Only Herdr modes with +// hover selection mutate the current frame directly from a plain mouse-move event. +fn events_are_render_neutral_mouse_motion( + events: &[crate::raw_input::RawInputEvent], + mode: crate::app::Mode, +) -> bool { + !events.is_empty() + && !mode.mouse_motion_changes_view() + && events.iter().all(|event| { + matches!( + event, + crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + .. + }) + ) + }) +} + fn events_for_app_routing( events: Vec<crate::raw_input::RawInputEvent>, mut source_is_foreground: bool, @@ -7297,6 +7318,94 @@ next_tab = "" assert_eq!(server.foreground_client_id, Some(3)); } + #[tokio::test] + async fn passive_mouse_motion_forwards_without_requesting_render() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h"); + server.clients.insert(1, test_app_client(Some(true), 1)); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + let baseline = FrameData { + cells: Vec::new(), + width: 0, + height: 0, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let client = server.clients.get_mut(&1).unwrap(); + let prepared = client + .render_state + .prepare_frame(baseline.clone()) + .expect("new semantic baseline"); + client.render_state.commit_sent_frame(prepared); + let pane = server.app.state.view.pane_infos[0].clone(); + let column = pane.inner_rect.x + 2; + let row = pane.inner_rect.y + 3; + let input = format!("\x1b[<35;{};{}M", column + 1, row + 1).into_bytes(); + + assert!(!server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: input, + })); + assert_eq!( + input_rx.try_recv().expect("forwarded mouse motion"), + Bytes::from_static(b"\x1b[<35;3;4M") + ); + assert_eq!( + server.clients[&1].render_state.last_frame(), + Some(&baseline) + ); + } + + #[test] + fn background_mouse_motion_promotes_once_then_becomes_render_neutral() { + let mut server = test_headless_server(); + server.app.state.mode = crate::app::Mode::Terminal; + server.clients.insert(1, test_app_client(Some(true), 1)); + server.clients.insert(2, test_app_client(Some(true), 2)); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + let motion = || ServerEvent::ClientInputEvents { + client_id: 2, + events: vec![crate::protocol::ClientInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + column: 10, + row: 5, + modifiers: 0, + }], + }; + + assert!(server.handle_server_event(motion())); + assert_eq!(server.foreground_client_id, Some(2)); + assert!(!server.handle_server_event(motion())); + } + + #[test] + fn mouse_motion_in_hover_modes_requires_render() { + let events = [crate::raw_input::RawInputEvent::Mouse( + crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: 10, + row: 5, + modifiers: KeyModifiers::empty(), + }, + )]; + + assert!(events_are_render_neutral_mouse_motion( + &events, + crate::app::Mode::Terminal + )); + for mode in [ + crate::app::Mode::GlobalMenu, + crate::app::Mode::ContextMenu, + crate::app::Mode::Navigator, + ] { + assert!(!events_are_render_neutral_mouse_motion(&events, mode)); + } + } + fn install_focused_test_runtime( server: &mut HeadlessServer, terminal_bytes: &[u8], |
