diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-03 02:03:14 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-03 02:03:44 +0300 |
| commit | b695d7cbc0d29540993642c23b3d5798cbf9f7dc (patch) | |
| tree | d7243c891dca6c8991583edd7666ec9fe8cc6f94 /src | |
| parent | daf636fef7fd68bcb1e30cfb155883c3b831513c (diff) | |
fix: forward any-motion pane mouse events
refs #419
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/mouse.rs | 181 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 13 | ||||
| -rw-r--r-- | src/pane.rs | 11 | ||||
| -rw-r--r-- | src/pane/input.rs | 26 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 67 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 10 |
6 files changed, 305 insertions, 3 deletions
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 7bd68330..51a9763e 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -49,7 +49,9 @@ impl AppState { MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); } - MouseEventKind::Moved => {} + MouseEventKind::Moved => { + self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse); + } } } @@ -883,6 +885,12 @@ impl AppState { } } + MouseEventKind::Moved if self.mode == Mode::Terminal && !in_sidebar => { + if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { + let _ = self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse); + } + } + MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => { self.workspace_press = None; self.tab_press = None; @@ -1490,6 +1498,30 @@ impl AppState { true } + pub(super) fn forward_pane_mouse_motion( + &self, + terminal_runtimes: &TerminalRuntimeRegistry, + info: &PaneInfo, + mouse: MouseEvent, + ) -> bool { + let Some(ws_idx) = self.active else { + return false; + }; + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) + else { + return false; + }; + let column = mouse.column.saturating_sub(info.inner_rect.x); + let row = mouse.row.saturating_sub(info.inner_rect.y); + let Some(bytes) = rt.encode_mouse_motion(mouse.kind, column, row, mouse.modifiers) else { + return false; + }; + if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { + warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse motion event"); + } + true + } + fn forward_pane_reported_wheel( &self, terminal_runtimes: &TerminalRuntimeRegistry, @@ -1779,6 +1811,153 @@ mod tests { } #[tokio::test] + async fn pane_mouse_only_forwards_moved_events_for_any_motion_apps() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 0, + b"\x1b[?1003h\x1b[?1006h", + 4, + ); + ws.insert_test_runtime(pane_id, runtime); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + app.state.handle_pane_mouse_only( + &app.terminal_runtimes, + mouse( + MouseEventKind::Moved, + info.inner_rect.x + 2, + info.inner_rect.y + 3, + ), + ); + + assert_eq!( + input_rx.try_recv().expect("forwarded mouse motion"), + Bytes::from_static(b"\x1b[<35;3;4M") + ); + assert!(input_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn pane_mouse_motion_uses_computed_inner_rect_offsets() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 18, + 0, + b"\x1b[?1003h\x1b[?1006h", + 4, + ); + ws.insert_test_runtime(pane_id, runtime); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let info = app.state.view.pane_infos[0].clone(); + assert!(info.inner_rect.x > 0, "sidebar offset should be present"); + assert!(info.inner_rect.y > 0, "tab bar offset should be present"); + + app.state.handle_pane_mouse_only( + &app.terminal_runtimes, + mouse( + MouseEventKind::Moved, + info.inner_rect.x + 2, + info.inner_rect.y + 3, + ), + ); + + assert_eq!( + input_rx.try_recv().expect("forwarded mouse motion"), + Bytes::from_static(b"\x1b[<35;3;4M") + ); + assert!(input_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn mouse_dispatcher_downgrades_sgr_pixel_motion_to_cell_coordinates() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 18, + 0, + b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", + 4, + ); + ws.insert_test_runtime(pane_id, runtime); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let info = app.state.view.pane_infos[0].clone(); + assert!(info.inner_rect.x > 0, "sidebar offset should be present"); + assert!(info.inner_rect.y > 0, "tab bar offset should be present"); + + app.handle_mouse(mouse( + MouseEventKind::Moved, + info.inner_rect.x + 2, + info.inner_rect.y + 3, + )); + + assert_eq!( + input_rx.try_recv().expect("forwarded mouse motion"), + Bytes::from_static(b"\x1b[<35;3;4M") + ); + assert!(input_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn mouse_dispatcher_does_not_forward_motion_behind_herdr_modes() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 18, + 0, + b"\x1b[?1003h\x1b[?1006h", + 4, + ); + ws.insert_test_runtime(pane_id, runtime); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Navigate; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let info = app.state.view.pane_infos[0].clone(); + + app.handle_mouse(mouse( + MouseEventKind::Moved, + info.inner_rect.x + 2, + info.inner_rect.y + 3, + )); + + assert!(input_rx.try_recv().is_err()); + } + + #[tokio::test] async fn unset_right_click_passthrough_keeps_modified_right_click_as_herdr_menu() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 0576f340..20161d80 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -150,12 +150,15 @@ pub const MOUSE_BUTTON_WHEEL_LEFT: ffi::GhosttyMouseButton = ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_SIX; pub const MOUSE_BUTTON_WHEEL_RIGHT: ffi::GhosttyMouseButton = ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_SEVEN; +pub const MOUSE_FORMAT_SGR: ffi::GhosttyMouseFormat = + ffi::GhosttyMouseFormat_GHOSTTY_MOUSE_FORMAT_SGR; pub const MODE_APPLICATION_CURSOR_KEYS: u16 = 1; pub const MODE_FOCUS_EVENT: u16 = 1004; pub const MODE_MOUSE_UTF8: u16 = 1005; pub const MODE_MOUSE_SGR: u16 = 1006; pub const MODE_MOUSE_ALTERNATE_SCROLL: u16 = 1007; +pub const MODE_MOUSE_SGR_PIXELS: u16 = 1016; pub const MODE_BRACKETED_PASTE: u16 = 2004; pub const MODE_SYNCHRONIZED_OUTPUT: u16 = 2026; @@ -2114,6 +2117,16 @@ impl MouseEncoder { } } + pub fn set_format(&mut self, format: ffi::GhosttyMouseFormat) { + unsafe { + ffi::ghostty_mouse_encoder_setopt( + self.raw, + ffi::GhosttyMouseEncoderOption_GHOSTTY_MOUSE_ENCODER_OPT_FORMAT, + (&format as *const ffi::GhosttyMouseFormat).cast(), + ) + } + } + pub fn encode(&mut self, event: &MouseEvent) -> Result<Vec<u8>, Error> { encode_with_retry(|buf, len, out_len| unsafe { ffi::ghostty_mouse_encoder_encode(self.raw, event.raw, buf, len, out_len) diff --git a/src/pane.rs b/src/pane.rs index 7ab069bc..4611b315 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1900,6 +1900,17 @@ impl PaneRuntime { .encode_mouse_button(kind, column, row, modifiers) } + pub fn encode_mouse_motion( + &self, + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, + ) -> Option<Vec<u8>> { + self.terminal + .encode_mouse_motion(kind, column, row, modifiers) + } + pub fn encode_mouse_wheel( &self, kind: crossterm::event::MouseEventKind, diff --git a/src/pane/input.rs b/src/pane/input.rs index 262e036d..6906e77d 100644 --- a/src/pane/input.rs +++ b/src/pane/input.rs @@ -58,6 +58,14 @@ pub(super) fn ghostty_mouse_encoder_for_terminal( ) -> Option<crate::ghostty::MouseEncoder> { let mut encoder = crate::ghostty::MouseEncoder::new().ok()?; encoder.set_from_terminal(terminal); + if terminal + .mode_get(crate::ghostty::MODE_MOUSE_SGR_PIXELS) + .ok()? + { + // Herdr receives host mouse positions in terminal cells. Downgrade + // SGR-pixels to normal SGR so forwarded coordinates stay cell-local. + encoder.set_format(crate::ghostty::MOUSE_FORMAT_SGR); + } let cols = terminal.cols().ok()? as u32; let rows = terminal.rows().ok()? as u32; encoder.set_size(cols, rows, 1, 1); @@ -121,6 +129,24 @@ pub(super) fn ghostty_mouse_event_from_button_kind( Some(event) } +pub(super) fn ghostty_mouse_event_from_motion_kind( + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, +) -> Option<crate::ghostty::MouseEvent> { + if kind != crossterm::event::MouseEventKind::Moved { + return None; + } + + let mut event = crate::ghostty::MouseEvent::new().ok()?; + event.set_action(crate::ghostty::MOUSE_ACTION_MOTION); + event.clear_button(); + event.set_mods(ghostty_mods_from_key_modifiers(modifiers)); + event.set_position(column as f32, row as f32); + Some(event) +} + pub(super) fn ghostty_mouse_event_from_wheel_kind( kind: crossterm::event::MouseEventKind, column: u16, diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index be1200e6..0a67f11a 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -16,8 +16,8 @@ use crate::protocol::CellData; use super::{ input::{ ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal, - ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_wheel_kind, - ghostty_prefers_herdr_text_encoding, + ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_motion_kind, + ghostty_mouse_event_from_wheel_kind, ghostty_prefers_herdr_text_encoding, }, kitty_keyboard::KittyKeyboardTracker, osc::{ @@ -283,6 +283,17 @@ impl PaneTerminal { .encode_mouse_button(kind, column, row, modifiers) } + pub fn encode_mouse_motion( + &self, + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, + ) -> Option<Vec<u8>> { + self.ghostty + .encode_mouse_motion(kind, column, row, modifiers) + } + pub fn encode_mouse_wheel( &self, kind: crossterm::event::MouseEventKind, @@ -887,6 +898,24 @@ impl GhosttyPaneTerminal { encoder.encode(&event).ok() } + pub fn encode_mouse_motion( + &self, + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, + ) -> Option<Vec<u8>> { + let Ok(core) = self.core.lock() else { + return None; + }; + if !core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()? { + return None; + } + let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?; + let event = ghostty_mouse_event_from_motion_kind(kind, column, row, modifiers)?; + encoder.encode(&event).ok() + } + pub fn encode_mouse_wheel( &self, kind: crossterm::event::MouseEventKind, @@ -2351,6 +2380,40 @@ mod tests { } #[test] + fn ghostty_mouse_moved_encoding_uses_any_motion_state() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.write(b"\x1b[?1003h\x1b[?1006h"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let encoded = pane.encode_mouse_motion( + crossterm::event::MouseEventKind::Moved, + 4, + 6, + crossterm::event::KeyModifiers::empty(), + ); + + assert_eq!(encoded.as_deref(), Some(&b"\x1b[<35;5;7M"[..])); + } + + #[test] + fn ghostty_mouse_sgr_pixels_downgrades_to_cell_coordinates() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.write(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let encoded = pane.encode_mouse_motion( + crossterm::event::MouseEventKind::Moved, + 4, + 6, + crossterm::event::KeyModifiers::empty(), + ); + + assert_eq!(encoded.as_deref(), Some(&b"\x1b[<35;5;7M"[..])); + } + + #[test] fn ghostty_normalize_buffer_symbol_prefers_grapheme_width_when_metadata_disagrees() { const WIDE_GRAPHEME: &str = "🙂"; const VS16_GRAPHEME: &str = "⚠️"; diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index bfc8f3c6..6d761b11 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -333,6 +333,16 @@ impl TerminalRuntime { self.0.encode_mouse_button(kind, column, row, modifiers) } + pub fn encode_mouse_motion( + &self, + kind: crossterm::event::MouseEventKind, + column: u16, + row: u16, + modifiers: crossterm::event::KeyModifiers, + ) -> Option<Vec<u8>> { + self.0.encode_mouse_motion(kind, column, row, modifiers) + } + pub fn encode_mouse_wheel( &self, kind: crossterm::event::MouseEventKind, |
