input: report mouse and focus events to the application

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7136e2ae2c833ff581ea14287c876a3a6a6a6964
This commit is contained in:
raf 2026-06-25 09:13:43 +03:00
commit 219f0a3c94
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
4 changed files with 343 additions and 8 deletions

View file

@ -75,6 +75,34 @@ pub enum CursorShape {
Beam, 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. /// One grid cell: a character plus its rendering style.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct Cell { pub struct Cell {
@ -154,6 +182,12 @@ pub struct Grid {
/// Synchronized output (DECSET 2026): hold presentation while a frame is /// Synchronized output (DECSET 2026): hold presentation while a frame is
/// being assembled, so the screen never shows a half-drawn update. /// being assembled, so the screen never shows a half-drawn update.
sync: bool, 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<bool> { fn default_tabs(cols: usize) -> Vec<bool> {
@ -189,6 +223,9 @@ impl Grid {
selection: None, selection: None,
bracketed_paste: false, bracketed_paste: false,
sync: false, sync: false,
mouse_protocol: MouseProtocol::Off,
mouse_encoding: MouseEncoding::X10,
focus_events: false,
} }
} }
@ -894,6 +931,30 @@ impl Grid {
self.sync 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) --- // --- inspection (logging + tests) ---
/// The visible text of one row, trailing blanks trimmed. /// The visible text of one row, trailing blanks trimmed.

View file

@ -1,8 +1,12 @@
//! Keyboard encoding: translate decoded key events into the byte sequences a //! Keyboard encoding: translate decoded key events into the byte sequences a
//! terminal application expects (xterm/VT-style). //! terminal application expects (xterm/VT-style).
use std::io::Write as _;
use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers}; 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. /// 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<Vec<u8>> { pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {
let seq = match event.keysym { let seq = match event.keysym {
@ -51,6 +55,58 @@ pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec
Some(seq) Some(seq)
} }
/// Encode a mouse event for the application. `button` is the base code (0/1/2
/// for left/middle/right, 64/65 for wheel up/down); `col`/`row` are 0-based
/// screen cells; `motion` marks a drag/move report. Returns the bytes to send.
pub fn encode_mouse(
encoding: MouseEncoding,
button: u8,
col: usize,
row: usize,
pressed: bool,
motion: bool,
mods: Modifiers,
) -> Vec<u8> {
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<u8>, 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<u8>, mods: Modifiers) -> Vec<u8> { fn prefix_alt(bytes: Vec<u8>, mods: Modifiers) -> Vec<u8> {
if mods.alt { if mods.alt {
let mut out = Vec::with_capacity(bytes.len() + 1); 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] #[test]
fn special_keys() { fn special_keys() {
assert_eq!( assert_eq!(

View file

@ -4,7 +4,7 @@ use std::io::Write as _;
use vte::{Params, Perform}; 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. /// G0/G1 character set designation.
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
@ -26,6 +26,16 @@ fn set_reset(on: bool) -> u8 {
if on { 1 } else { 2 } 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 /// Parse an X11 colour spec from an OSC string: `rgb:rr/gg/bb` (1-4 hex
/// digits per channel) or `#rrggbb`. /// digits per channel) or `#rrggbb`.
fn parse_color(spec: &[u8]) -> Option<(u8, u8, u8)> { fn parse_color(spec: &[u8]) -> Option<(u8, u8, u8)> {
@ -179,10 +189,19 @@ impl Term {
(false, 4) => self.grid.set_insert(on), (false, 4) => self.grid.set_insert(on),
(true, 1) => self.grid.set_app_cursor(on), (true, 1) => self.grid.set_app_cursor(on),
(true, 25) => self.grid.set_cursor_visible(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, 2004) => self.grid.set_bracketed_paste(on),
(true, 2026) => self.grid.set_sync(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}"), _ => tracing::trace!("unhandled mode {code} private={private} on={on}"),
} }
} }
@ -282,6 +301,13 @@ impl Term {
(true, 6) => set_reset(self.grid.origin()), (true, 6) => set_reset(self.grid.origin()),
(true, 7) => set_reset(self.grid.autowrap()), (true, 7) => set_reset(self.grid.autowrap()),
(true, 47 | 1047 | 1049) => set_reset(self.grid.alt_active()), (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, 2004) => set_reset(self.grid.bracketed_paste()),
(true, 2026) => set_reset(self.grid.sync_active()), (true, 2026) => set_reset(self.grid.sync_active()),
(false, 4) => set_reset(self.grid.insert()), (false, 4) => set_reset(self.grid.insert()),
@ -584,6 +610,7 @@ fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::grid::{MouseEncoding, MouseProtocol};
fn feed(term: &mut Term, bytes: &[u8]) { fn feed(term: &mut Term, bytes: &[u8]) {
let mut parser = vte::Parser::new(); let mut parser = vte::Parser::new();
@ -730,6 +757,22 @@ mod tests {
assert_eq!(t.take_response(), b"\x1b[?2026;2$y"); 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] #[test]
fn title_stack_push_pop() { fn title_stack_push_pop() {
let mut t = Term::new(20, 4); let mut t = Term::new(20, 4);

View file

@ -18,7 +18,7 @@ use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction, RegistrationTok
use calloop_wayland_source::WaylandSource; use calloop_wayland_source::WaylandSource;
use crate::font::Fonts; use crate::font::Fonts;
use crate::grid::{Cell, CursorShape, Grid}; use crate::grid::{Cell, CursorShape, Grid, MouseProtocol};
use crate::pty::Pty; use crate::pty::Pty;
use crate::render::Renderer; use crate::render::Renderer;
use crate::vt::Term; use crate::vt::Term;
@ -52,7 +52,7 @@ use smithay_client_toolkit::{
Capability, SeatHandler, SeatState, Capability, SeatHandler, SeatState,
keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo}, keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},
pointer::{ pointer::{
BTN_LEFT, BTN_MIDDLE, PointerEvent, PointerEventKind, PointerHandler, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, PointerEvent, PointerEventKind, PointerHandler,
cursor_shape::CursorShapeManager, cursor_shape::CursorShapeManager,
}, },
}, },
@ -215,6 +215,8 @@ pub fn run() -> anyhow::Result<ExitCode> {
clipboard: String::new(), clipboard: String::new(),
primary_clip: String::new(), primary_clip: String::new(),
selecting: false, selecting: false,
pressed_button: None,
last_report_cell: None,
pointer_pos: (0.0, 0.0), pointer_pos: (0.0, 0.0),
last_click: None, last_click: None,
serial: 0, serial: 0,
@ -318,6 +320,10 @@ struct App {
primary_clip: String, primary_clip: String,
/// A left-button drag is in progress. /// A left-button drag is in progress.
selecting: bool, selecting: bool,
/// Button base code held down while mouse reporting, for drag reports.
pressed_button: Option<u8>,
/// Last cell a motion report was emitted for, to suppress duplicates.
last_report_cell: Option<(usize, usize)>,
pointer_pos: (f64, f64), pointer_pos: (f64, f64),
/// Last click (time ms, abs row, col, count) for double/triple detection. /// Last click (time ms, abs row, col, count) for double/triple detection.
last_click: Option<(u32, usize, usize, u32)>, last_click: Option<(u32, usize, usize, u32)>,
@ -520,6 +526,101 @@ impl App {
self.set_primary(qh); 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. /// The current selection text, if any and non-empty.
fn selection_text(&self) -> Option<String> { fn selection_text(&self) -> Option<String> {
let text = self.session.as_ref()?.term.grid().selection_text()?; let text = self.session.as_ref()?.term.grid().selection_text()?;
@ -1010,6 +1111,7 @@ impl KeyboardHandler for App {
) { ) {
self.serial = serial; self.serial = serial;
self.focused = true; self.focused = true;
self.report_focus(true);
self.needs_draw = true; self.needs_draw = true;
} }
@ -1022,6 +1124,7 @@ impl KeyboardHandler for App {
_: u32, _: u32,
) { ) {
self.focused = false; self.focused = false;
self.report_focus(false);
self.needs_draw = true; 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<u8> {
match button {
BTN_LEFT => Some(0),
BTN_MIDDLE => Some(1),
BTN_RIGHT => Some(2),
_ => None,
}
}
impl PointerHandler for App { impl PointerHandler for App {
fn pointer_frame( fn pointer_frame(
&mut self, &mut self,
@ -1102,6 +1215,9 @@ impl PointerHandler for App {
} }
PointerEventKind::Motion { .. } => { PointerEventKind::Motion { .. } => {
self.pointer_pos = event.position; self.pointer_pos = event.position;
if self.try_report_motion() {
continue;
}
self.pointer_drag(); self.pointer_drag();
} }
PointerEventKind::Press { PointerEventKind::Press {
@ -1112,14 +1228,32 @@ impl PointerHandler for App {
} => { } => {
self.serial = *serial; self.serial = *serial;
self.pointer_pos = event.position; 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 { match *button {
BTN_LEFT => self.pointer_press(*time), BTN_LEFT => self.pointer_press(*time),
BTN_MIDDLE => self.paste_primary(), BTN_MIDDLE => self.paste_primary(),
_ => {} _ => {}
} }
} }
PointerEventKind::Release { button, .. } if *button == BTN_LEFT => { PointerEventKind::Release { button, .. } => {
self.pointer_release(qh); 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, .. } => { PointerEventKind::Axis { vertical, .. } => {
// Wheel notches arrive as value120 (÷120) or legacy discrete // Wheel notches arrive as value120 (÷120) or legacy discrete
@ -1136,9 +1270,18 @@ impl PointerHandler for App {
if raw == 0.0 { if raw == 0.0 {
continue; 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 // Positive axis = scroll down (toward live); the viewport
// scrolls the opposite way (negative offset delta). // 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 }; let delta = if raw < 0.0 { lines } else { -lines };
if let Some(session) = self.session.as_mut() { if let Some(session) = self.session.as_mut() {
session.term.scroll_view(delta); session.term.scroll_view(delta);