aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-07-26 00:28:11 +0300
committerGitHub <noreply@github.com>2026-07-26 00:28:11 +0300
commit547aba8e0ab310623f1986d3026658f708d432bd (patch)
tree8b05ec9feec9a3c3c04f21aa308bf22faad3772a /src
parentb01fc37e66aac45c7fa9eccc826d793d68608a0a (diff)
fix: avoid renders for passive mouse motion (#1872)
refs #1865
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mod.rs4
-rw-r--r--src/app/mod.rs39
-rw-r--r--src/app/runtime.rs22
-rw-r--r--src/app/state.rs4
-rw-r--r--src/server/headless.rs113
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],