brewery
notashelf /
219f0a3c949f23c060840f3e81fd2ace20c027cf

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 219f0a3c949f

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified4 files changed+343-8
input: report mouse and focus events to the application

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7136e2ae2c833ff581ea14287c876a3a6a6a6964
diff --git a/src/grid.rs b/src/grid.rsindex 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<bool> {@@ -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.rsindex 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<Vec<u8>> {     let seq = match event.keysym {@@ -51,6 +55,58 @@ pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec     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> {     if mods.alt {         let mut out = Vec::with_capacity(bytes.len() + 1);@@ -165,6 +221,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!(             encode(&key(Keysym::Return, None), NONE, false),diff --git a/src/vt.rs b/src/vt.rsindex 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<Vec<u8>> { #[cfg(test)] mod tests {     use super::*;+    use crate::grid::{MouseEncoding, MouseProtocol};      fn feed(term: &mut Term, bytes: &[u8]) {         let mut parser = vte::Parser::new();@@ -731,6 +758,22 @@ mod tests {     }      #[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);         feed(&mut t, b"\x1b]0;first\x07");diff --git a/src/wayland.rs b/src/wayland.rsindex 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<ExitCode> {         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<u8>,+    /// 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<String> {         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<u8> {+    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);