aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-03 02:03:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-03 02:03:44 +0300
commitb695d7cbc0d29540993642c23b3d5798cbf9f7dc (patch)
treed7243c891dca6c8991583edd7666ec9fe8cc6f94 /src
parentdaf636fef7fd68bcb1e30cfb155883c3b831513c (diff)
fix: forward any-motion pane mouse events
refs #419
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mouse.rs181
-rw-r--r--src/ghostty/mod.rs13
-rw-r--r--src/pane.rs11
-rw-r--r--src/pane/input.rs26
-rw-r--r--src/pane/terminal.rs67
-rw-r--r--src/terminal/runtime.rs10
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,