From 219f0a3c949f23c060840f3e81fd2ace20c027cf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 09:13:43 +0300 Subject: [PATCH] input: report mouse and focus events to the application Signed-off-by: NotAShelf Change-Id: I7136e2ae2c833ff581ea14287c876a3a6a6a6964 --- src/grid.rs | 61 ++++++++++++++++++++ src/input.rs | 88 ++++++++++++++++++++++++++++ src/vt.rs | 49 +++++++++++++++- src/wayland.rs | 153 +++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 343 insertions(+), 8 deletions(-) diff --git a/src/grid.rs b/src/grid.rs index 764b73d..6707c39 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -75,6 +75,34 @@ pub enum CursorShape { Beam, } +/// Which mouse events the application has asked to receive (DECSET 9/1000-1003). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum MouseProtocol { + /// No reporting; the pointer drives local selection/scroll. + #[default] + Off, + /// X10 (9): button presses only. + X10, + /// Normal (1000): button press and release. + Normal, + /// Button-event (1002): press, release, and motion while a button is held. + Button, + /// Any-event (1003): press, release, and all pointer motion. + Any, +} + +/// How mouse events are framed on the wire (default byte form, UTF-8, or SGR). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum MouseEncoding { + /// Legacy `CSI M Cb Cx Cy`, each value a byte offset by 32 (≤ 223). + #[default] + X10, + /// As X10 but coordinates above 95 are UTF-8 encoded (DECSET 1005). + Utf8, + /// `CSI < Cb ; Cx ; Cy M/m`, decimal and unbounded (DECSET 1006). + Sgr, +} + /// One grid cell: a character plus its rendering style. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Cell { @@ -154,6 +182,12 @@ pub struct Grid { /// Synchronized output (DECSET 2026): hold presentation while a frame is /// being assembled, so the screen never shows a half-drawn update. sync: bool, + /// Which mouse events the application wants reported. + mouse_protocol: MouseProtocol, + /// Wire framing for those reports. + mouse_encoding: MouseEncoding, + /// Focus in/out reporting (DECSET 1004). + focus_events: bool, } fn default_tabs(cols: usize) -> Vec { @@ -189,6 +223,9 @@ impl Grid { selection: None, bracketed_paste: false, sync: false, + mouse_protocol: MouseProtocol::Off, + mouse_encoding: MouseEncoding::X10, + focus_events: false, } } @@ -894,6 +931,30 @@ impl Grid { self.sync } + pub fn set_mouse_protocol(&mut self, protocol: MouseProtocol) { + self.mouse_protocol = protocol; + } + + pub fn mouse_protocol(&self) -> MouseProtocol { + self.mouse_protocol + } + + pub fn set_mouse_encoding(&mut self, encoding: MouseEncoding) { + self.mouse_encoding = encoding; + } + + pub fn mouse_encoding(&self) -> MouseEncoding { + self.mouse_encoding + } + + pub fn set_focus_events(&mut self, on: bool) { + self.focus_events = on; + } + + pub fn focus_events(&self) -> bool { + self.focus_events + } + // --- inspection (logging + tests) --- /// The visible text of one row, trailing blanks trimmed. diff --git a/src/input.rs b/src/input.rs index e5755a1..1603492 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,8 +1,12 @@ //! Keyboard encoding: translate decoded key events into the byte sequences a //! terminal application expects (xterm/VT-style). +use std::io::Write as _; + use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers}; +use crate::grid::MouseEncoding; + /// Encode a key press into bytes for the PTY, or `None` if it produces no input. pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option> { let seq = match event.keysym { @@ -51,6 +55,58 @@ pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option Vec { + let mod_bits = + (u8::from(mods.shift) << 2) | (u8::from(mods.alt) << 3) | (u8::from(mods.ctrl) << 4); + let motion_bit = if motion { 32 } else { 0 }; + let mut out = Vec::new(); + match encoding { + MouseEncoding::Sgr => { + let cb = u32::from(button) + u32::from(motion_bit) + u32::from(mod_bits); + let final_byte = if pressed { 'M' } else { 'm' }; + let _ = write!(out, "\x1b[<{cb};{};{}{final_byte}", col + 1, row + 1); + } + enc => { + // Legacy form collapses every button release to code 3. + let base = if !pressed && button <= 2 { 3 } else { button }; + let cb = 32 + base + motion_bit + mod_bits; + out.extend_from_slice(b"\x1b[M"); + push_coord(&mut out, u32::from(cb), false); + let utf8 = enc == MouseEncoding::Utf8; + push_coord(&mut out, col as u32 + 33, utf8); + push_coord(&mut out, row as u32 + 33, utf8); + } + } + out +} + +/// Push one legacy mouse coordinate byte, UTF-8 encoding values above 127 when +/// the extended (1005) mode is active, and clamping otherwise. +fn push_coord(out: &mut Vec, value: u32, utf8: bool) { + if utf8 { + let v = value.min(0x7ff); + if v >= 0x80 { + out.push(0xc0 | (v >> 6) as u8); + out.push(0x80 | (v & 0x3f) as u8); + } else { + out.push(v as u8); + } + } else { + out.push(value.min(255) as u8); + } +} + fn prefix_alt(bytes: Vec, mods: Modifiers) -> Vec { if mods.alt { let mut out = Vec::with_capacity(bytes.len() + 1); @@ -164,6 +220,38 @@ mod tests { ); } + #[test] + fn mouse_sgr_press_and_release() { + // Left press at column 3, row 5 (0-based) → SGR with 1-based coords. + assert_eq!( + encode_mouse(MouseEncoding::Sgr, 0, 3, 5, true, false, NONE), + b"\x1b[<0;4;6M".to_vec() + ); + assert_eq!( + encode_mouse(MouseEncoding::Sgr, 0, 3, 5, false, false, NONE), + b"\x1b[<0;4;6m".to_vec() + ); + } + + #[test] + fn mouse_legacy_release_collapses_to_three() { + // Legacy release: button bits become 3; values offset by 32. + assert_eq!( + encode_mouse(MouseEncoding::X10, 0, 0, 0, false, false, NONE), + vec![0x1b, b'[', b'M', 32 + 3, 33, 33] + ); + } + + #[test] + fn mouse_motion_and_modifiers_set_bits() { + let mods = Modifiers { ctrl: true, ..NONE }; + // Drag with the left button and ctrl held: 0 + 32(motion) + 16(ctrl). + assert_eq!( + encode_mouse(MouseEncoding::Sgr, 0, 0, 0, true, true, mods), + b"\x1b[<48;1;1M".to_vec() + ); + } + #[test] fn special_keys() { assert_eq!( diff --git a/src/vt.rs b/src/vt.rs index 7b35a04..0c3f5b8 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -4,7 +4,7 @@ use std::io::Write as _; use vte::{Params, Perform}; -use crate::grid::{Color, CursorShape, Flags, Grid, Underline}; +use crate::grid::{Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, Underline}; /// G0/G1 character set designation. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -26,6 +26,16 @@ fn set_reset(on: bool) -> u8 { if on { 1 } else { 2 } } +/// Select `protocol` when a mouse mode is set, else turn reporting off. +fn proto(on: bool, protocol: MouseProtocol) -> MouseProtocol { + if on { protocol } else { MouseProtocol::Off } +} + +/// Select `encoding` when its mode is set, else fall back to the default form. +fn enc(on: bool, encoding: MouseEncoding) -> MouseEncoding { + if on { encoding } else { MouseEncoding::X10 } +} + /// Parse an X11 colour spec from an OSC string: `rgb:rr/gg/bb` (1-4 hex /// digits per channel) or `#rrggbb`. fn parse_color(spec: &[u8]) -> Option<(u8, u8, u8)> { @@ -179,10 +189,19 @@ impl Term { (false, 4) => self.grid.set_insert(on), (true, 1) => self.grid.set_app_cursor(on), (true, 25) => self.grid.set_cursor_visible(on), + (true, 9) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::X10)), + (true, 1000) => self + .grid + .set_mouse_protocol(proto(on, MouseProtocol::Normal)), + (true, 1002) => self + .grid + .set_mouse_protocol(proto(on, MouseProtocol::Button)), + (true, 1003) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::Any)), + (true, 1004) => self.grid.set_focus_events(on), + (true, 1005) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Utf8)), + (true, 1006) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Sgr)), (true, 2004) => self.grid.set_bracketed_paste(on), (true, 2026) => self.grid.set_sync(on), - // App-cursor/bracketed-paste/mouse/sync modes affect input and - // rendering, which arrive with the keyboard and renderer. _ => tracing::trace!("unhandled mode {code} private={private} on={on}"), } } @@ -282,6 +301,13 @@ impl Term { (true, 6) => set_reset(self.grid.origin()), (true, 7) => set_reset(self.grid.autowrap()), (true, 47 | 1047 | 1049) => set_reset(self.grid.alt_active()), + (true, 9) => set_reset(self.grid.mouse_protocol() == MouseProtocol::X10), + (true, 1000) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Normal), + (true, 1002) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Button), + (true, 1003) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Any), + (true, 1004) => set_reset(self.grid.focus_events()), + (true, 1005) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Utf8), + (true, 1006) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Sgr), (true, 2004) => set_reset(self.grid.bracketed_paste()), (true, 2026) => set_reset(self.grid.sync_active()), (false, 4) => set_reset(self.grid.insert()), @@ -584,6 +610,7 @@ fn decode_hex(s: &[u8]) -> Option> { #[cfg(test)] mod tests { use super::*; + use crate::grid::{MouseEncoding, MouseProtocol}; fn feed(term: &mut Term, bytes: &[u8]) { let mut parser = vte::Parser::new(); @@ -730,6 +757,22 @@ mod tests { assert_eq!(t.take_response(), b"\x1b[?2026;2$y"); } + #[test] + fn mouse_modes_track_protocol_and_encoding() { + let mut t = Term::new(20, 4); + feed(&mut t, b"\x1b[?1002h\x1b[?1006h"); + assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Button); + assert_eq!(t.grid().mouse_encoding(), MouseEncoding::Sgr); + feed(&mut t, b"\x1b[?1002$p"); + assert_eq!(t.take_response(), b"\x1b[?1002;1$y"); + feed(&mut t, b"\x1b[?1003h"); // any-event supersedes button-event + assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Any); + feed(&mut t, b"\x1b[?1000l"); // turning a mouse mode off clears reporting + assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Off); + feed(&mut t, b"\x1b[?1004h"); + assert!(t.grid().focus_events()); + } + #[test] fn title_stack_push_pop() { let mut t = Term::new(20, 4); diff --git a/src/wayland.rs b/src/wayland.rs index 25d730b..018514b 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -18,7 +18,7 @@ use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction, RegistrationTok use calloop_wayland_source::WaylandSource; use crate::font::Fonts; -use crate::grid::{Cell, CursorShape, Grid}; +use crate::grid::{Cell, CursorShape, Grid, MouseProtocol}; use crate::pty::Pty; use crate::render::Renderer; use crate::vt::Term; @@ -52,7 +52,7 @@ use smithay_client_toolkit::{ Capability, SeatHandler, SeatState, keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo}, pointer::{ - BTN_LEFT, BTN_MIDDLE, PointerEvent, PointerEventKind, PointerHandler, + BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, PointerEvent, PointerEventKind, PointerHandler, cursor_shape::CursorShapeManager, }, }, @@ -215,6 +215,8 @@ pub fn run() -> anyhow::Result { clipboard: String::new(), primary_clip: String::new(), selecting: false, + pressed_button: None, + last_report_cell: None, pointer_pos: (0.0, 0.0), last_click: None, serial: 0, @@ -318,6 +320,10 @@ struct App { primary_clip: String, /// A left-button drag is in progress. selecting: bool, + /// Button base code held down while mouse reporting, for drag reports. + pressed_button: Option, + /// Last cell a motion report was emitted for, to suppress duplicates. + last_report_cell: Option<(usize, usize)>, pointer_pos: (f64, f64), /// Last click (time ms, abs row, col, count) for double/triple detection. last_click: Option<(u32, usize, usize, u32)>, @@ -520,6 +526,101 @@ impl App { self.set_primary(qh); } + /// Whether the application wants mouse reports and the user is not holding + /// Shift (which forces local selection regardless of mode). + fn mouse_reporting(&self) -> bool { + self.session + .as_ref() + .is_some_and(|s| s.term.grid().mouse_protocol() != MouseProtocol::Off) + && !self.modifiers.shift + } + + /// The viewport cell `(col, row)` under the pointer, clamped to the screen. + fn report_screen_cell(&self) -> Option<(usize, usize)> { + let session = self.session.as_ref()?; + let m = self.renderer.metrics(); + let grid = session.term.grid(); + let col = (self.pointer_pos.0.max(0.0) as usize / m.width as usize) + .min(grid.cols().saturating_sub(1)); + let row = (self.pointer_pos.1.max(0.0) as usize / m.height as usize) + .min(grid.rows().saturating_sub(1)); + Some((col, row)) + } + + /// Report a button press/release to the application, if reporting is active. + /// Returns whether the event was consumed (so local handling is skipped). + fn try_report_button(&mut self, code: u8, pressed: bool) -> bool { + let Some(session) = self.session.as_ref() else { + return false; + }; + let grid = session.term.grid(); + let proto = grid.mouse_protocol(); + if proto == MouseProtocol::Off || self.modifiers.shift { + return false; + } + let enc = grid.mouse_encoding(); + // X10 (mode 9) reports presses only; a release is swallowed, not sent. + if (pressed || proto != MouseProtocol::X10) + && let Some((col, row)) = self.report_screen_cell() + { + let bytes = + crate::input::encode_mouse(enc, code, col, row, pressed, false, self.modifiers); + self.write_to_pty(&bytes); + self.last_report_cell = Some((col, row)); + } + true + } + + /// Report pointer motion to the application when the active mode wants it. + /// Returns whether reporting consumed the motion (suppressing local drag). + fn try_report_motion(&mut self) -> bool { + let Some(session) = self.session.as_ref() else { + return false; + }; + let grid = session.term.grid(); + let proto = grid.mouse_protocol(); + if proto == MouseProtocol::Off || self.modifiers.shift { + return false; + } + let enc = grid.mouse_encoding(); + let wants = match proto { + MouseProtocol::Any => true, + MouseProtocol::Button => self.pressed_button.is_some(), + _ => false, + }; + if wants + && let Some((col, row)) = self.report_screen_cell() + && self.last_report_cell != Some((col, row)) + { + // Any-event motion with no button held uses the "no button" code 3. + let code = self.pressed_button.unwrap_or(3); + let bytes = crate::input::encode_mouse(enc, code, col, row, true, true, self.modifiers); + self.write_to_pty(&bytes); + self.last_report_cell = Some((col, row)); + } + true + } + + /// Send focus in/out (DECSET 1004) to the application when it asked for it. + fn report_focus(&mut self, focused: bool) { + if self + .session + .as_ref() + .is_some_and(|s| s.term.grid().focus_events()) + { + self.write_to_pty(if focused { b"\x1b[I" } else { b"\x1b[O" }); + } + } + + /// Write bytes to the PTY master, logging on failure. + fn write_to_pty(&mut self, bytes: &[u8]) { + if let Some(session) = self.session.as_mut() + && let Err(err) = write_all(session.pty.master(), bytes) + { + tracing::warn!("write to pty: {err}"); + } + } + /// The current selection text, if any and non-empty. fn selection_text(&self) -> Option { let text = self.session.as_ref()?.term.grid().selection_text()?; @@ -1010,6 +1111,7 @@ impl KeyboardHandler for App { ) { self.serial = serial; self.focused = true; + self.report_focus(true); self.needs_draw = true; } @@ -1022,6 +1124,7 @@ impl KeyboardHandler for App { _: u32, ) { self.focused = false; + self.report_focus(false); self.needs_draw = true; } @@ -1082,6 +1185,16 @@ impl KeyboardHandler for App { } } +/// Map a Wayland button code to the terminal mouse base code, if reportable. +fn button_code(button: u32) -> Option { + match button { + BTN_LEFT => Some(0), + BTN_MIDDLE => Some(1), + BTN_RIGHT => Some(2), + _ => None, + } +} + impl PointerHandler for App { fn pointer_frame( &mut self, @@ -1102,6 +1215,9 @@ impl PointerHandler for App { } PointerEventKind::Motion { .. } => { self.pointer_pos = event.position; + if self.try_report_motion() { + continue; + } self.pointer_drag(); } PointerEventKind::Press { @@ -1112,14 +1228,32 @@ impl PointerHandler for App { } => { self.serial = *serial; self.pointer_pos = event.position; + if let Some(code) = button_code(*button) + && self.try_report_button(code, true) + { + self.pressed_button = Some(code); + continue; + } match *button { BTN_LEFT => self.pointer_press(*time), BTN_MIDDLE => self.paste_primary(), _ => {} } } - PointerEventKind::Release { button, .. } if *button == BTN_LEFT => { - self.pointer_release(qh); + PointerEventKind::Release { button, .. } => { + self.pointer_pos = event.position; + let code = button_code(*button); + if let Some(code) = code + && self.try_report_button(code, false) + { + if self.pressed_button == Some(code) { + self.pressed_button = None; + } + continue; + } + if *button == BTN_LEFT { + self.pointer_release(qh); + } } PointerEventKind::Axis { vertical, .. } => { // Wheel notches arrive as value120 (÷120) or legacy discrete @@ -1136,9 +1270,18 @@ impl PointerHandler for App { if raw == 0.0 { continue; } + let lines = (raw.abs() * scale).ceil().max(1.0) as isize; + // Reporting apps get wheel buttons (64 up / 65 down) as + // presses, one per line, capped so a flick cannot flood. + if self.mouse_reporting() { + let code = if raw < 0.0 { 64 } else { 65 }; + for _ in 0..lines.clamp(1, 8) { + self.try_report_button(code, true); + } + continue; + } // Positive axis = scroll down (toward live); the viewport // scrolls the opposite way (negative offset delta). - let lines = (raw.abs() * scale).ceil().max(1.0) as isize; let delta = if raw < 0.0 { lines } else { -lines }; if let Some(session) = self.session.as_mut() { session.term.scroll_view(delta);