Skip to main content

uncurses/event/
mouse.rs

1//! Mouse payloads and mouse-protocol helpers.
2//!
3//! ## Purpose
4//!
5//! Mouse events report a button, coordinates, and keyboard modifiers in a
6//! compact payload shared by click, release, wheel, and motion events. This
7//! module also contains the decoder helpers for the mouse wire formats accepted
8//! by the event decoder.
9//!
10//! ```text
11//! CSI mouse bytes ──▶ button bitfield + x/y ──▶ Mouse ──▶ Event::Mouse*
12//!                         │
13//!                         └─ SGR-pixel mode: x/y are pixel offsets
14//! ```
15//!
16//! ## Key types
17//!
18//! * [`MouseButton`] distinguishes ordinary buttons, wheels, extra buttons,
19//!   and legacy no-button release records.
20//! * [`Mouse`] carries zero-based coordinates plus [`KeyModifiers`].
21//! * [`mouse_pixel_to_cell`] converts SGR-Pixel coordinates to cell positions
22//!   when the caller knows both terminal pixel and cell dimensions.
23//!
24//! ## Supported encodings
25//!
26//! The decoder accepts SGR (1006), SGR-Pixel (1016, same wire shape), X10,
27//! UTF-8 mouse mode (1005), and URxvt decimal mouse mode (1015). Coordinates
28//! are normalized from one-based terminal wire values to zero-based payload
29//! values.
30//!
31//! ## Gotchas
32//!
33//! The parser cannot tell SGR and SGR-Pixel apart from bytes alone. If mode
34//! 1016 is enabled, treat [`Mouse::x`] and [`Mouse::y`] as pixel offsets until
35//! you convert them. Wheel events do not have matching release events.
36use super::Event;
37use super::key::KeyModifiers;
38use crate::ansi::params::Params;
39
40/// Mouse button or wheel direction associated with a [`Mouse`] event.
41///
42/// Button names describe the decoded terminal bitfield, not a physical device
43/// guarantee. Some terminals cannot identify the released button and report
44/// [`MouseButton::None`] instead.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub enum MouseButton {
47    /// Primary (left) mouse button.
48    Left,
49    /// Middle mouse button, often the wheel button.
50    Middle,
51    /// Secondary (right) mouse button.
52    Right,
53    /// Vertical wheel scrolled up.
54    WheelUp,
55    /// Vertical wheel scrolled down.
56    WheelDown,
57    /// Horizontal wheel scrolled left.
58    WheelLeft,
59    /// Horizontal wheel scrolled right.
60    WheelRight,
61    /// Additional mouse button 4 when encoded by a terminal extension.
62    Button4,
63    /// Additional mouse button 5 when encoded by a terminal extension.
64    Button5,
65    /// Additional mouse button 6 when encoded by a terminal extension.
66    Button6,
67    /// Additional mouse button 7 when encoded by a terminal extension.
68    Button7,
69    /// Additional mouse button 8 when encoded by a terminal extension.
70    Button8,
71    /// Additional mouse button 9 when encoded by a terminal extension.
72    Button9,
73    /// Additional mouse button 10 when encoded by a terminal extension.
74    Button10,
75    /// Additional mouse button 11 when encoded by a terminal extension.
76    Button11,
77    /// No button was reported.
78    ///
79    /// This appears for legacy release records and hover-style motion where the
80    /// protocol does not attach a specific held button.
81    None,
82}
83
84/// Mouse-event payload with position, button, and modifier state.
85///
86/// Coordinates are zero-based; `(0, 0)` is the upper-left corner of the
87/// terminal. In normal mouse modes, `x` and `y` are cell coordinates. When the
88/// application has enabled SGR-Pixel encoding (DEC mode 1016), the same fields
89/// contain pixel offsets instead; call [`mouse_pixel_to_cell`] if cell
90/// coordinates are needed.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub struct Mouse {
93    /// Horizontal coordinate.
94    ///
95    /// Interpreted as a column in normal mouse modes and a pixel offset in
96    /// SGR-Pixel mode.
97    pub x: u16,
98    /// Vertical coordinate.
99    ///
100    /// Interpreted as a row in normal mouse modes and a pixel offset in
101    /// SGR-Pixel mode.
102    pub y: u16,
103    /// Button, wheel direction, or no-button marker associated with the event.
104    pub button: MouseButton,
105    /// Keyboard modifiers that were active when the mouse event was reported.
106    pub modifiers: KeyModifiers,
107}
108
109impl Mouse {
110    /// Construct a mouse payload from decoded coordinates, button, and modifiers.
111    ///
112    /// The constructor stores its arguments unchanged. Pass zero-based cell
113    /// coordinates for ordinary mouse modes, or zero-based pixel offsets for
114    /// SGR-Pixel mode. It never allocates or panics.
115    pub fn new(x: u16, y: u16, button: MouseButton, modifiers: KeyModifiers) -> Self {
116        Self {
117            x,
118            y,
119            button,
120            modifiers,
121        }
122    }
123}
124
125/// Convert an SGR-Pixel mouse payload to cell coordinates.
126///
127/// `pixel_width` and `pixel_height` are the terminal's pixel dimensions (for
128/// example from [`Event::WindowPixelSize`]); `cols` and `rows` are the terminal
129/// grid dimensions. The returned [`Mouse`] keeps the original button and
130/// modifiers with `x`/`y` scaled into cell coordinates using integer division.
131///
132/// If either pixel dimension is zero, the corresponding output coordinate is
133/// zero. The function does not clamp to `cols - 1` / `rows - 1` and never
134/// panics.
135pub fn mouse_pixel_to_cell(
136    m: Mouse,
137    pixel_width: u16,
138    pixel_height: u16,
139    cols: u16,
140    rows: u16,
141) -> Mouse {
142    let x = if pixel_width > 0 {
143        (m.x as u32 * cols as u32 / pixel_width as u32) as u16
144    } else {
145        0
146    };
147    let y = if pixel_height > 0 {
148        (m.y as u32 * rows as u32 / pixel_height as u32) as u16
149    } else {
150        0
151    };
152    Mouse::new(x, y, m.button, m.modifiers)
153}
154
155/// Decode a SGR mouse event from CSI < params.
156///
157/// Format: `CSI < Cb ; Cx ; Cy M` (press) or `CSI < Cb ; Cx ; Cy m` (release).
158/// Coordinates are normalized to be zero-based regardless of encoding; callers
159/// using SGR-Pixel (mode 1016) should treat the resulting `Mouse.x/y` as pixel
160/// offsets (and use [`mouse_pixel_to_cell`] if cell coordinates are needed).
161pub(crate) fn decode_sgr_mouse(params: Params<'_>, is_release: bool) -> Option<Event> {
162    if params.len() < 3 {
163        return None;
164    }
165
166    let cb = params.get_or(0, 0) as u16;
167    let cx = (params.get_or(1, 0).saturating_sub(1)) as u16;
168    let cy = (params.get_or(2, 0).saturating_sub(1)) as u16;
169
170    Some(build_mouse_event(cb, cx, cy, Some(is_release)))
171}
172
173/// Decode an X10 mouse event.
174///
175/// Format: `CSI M Cb Cx Cy` (all bytes are raw + 32 offset)
176pub(crate) fn decode_x10_mouse(cb: u8, cx: u8, cy: u8) -> Option<Event> {
177    let cb = cb.wrapping_sub(32) as u16;
178    let cx = cx.wrapping_sub(33) as u16;
179    let cy = cy.wrapping_sub(33) as u16;
180    Some(build_mouse_event(cb, cx, cy, None))
181}
182
183/// Decode an URxvt mouse event (mode 1015).
184///
185/// Format: `CSI Cb ; Cx ; Cy M`. Same semantics as X10 but with decimal
186/// parameters; the release bit is encoded inside `Cb & 3 == 3`.
187pub(crate) fn decode_urxvt_mouse(params: Params<'_>) -> Option<Event> {
188    if params.len() < 3 {
189        return None;
190    }
191    let cb = (params.get_or(0, 0).wrapping_sub(32)) as u16;
192    let cx = (params.get_or(1, 0).saturating_sub(1)) as u16;
193    let cy = (params.get_or(2, 0).saturating_sub(1)) as u16;
194    Some(build_mouse_event(cb, cx, cy, None))
195}
196
197/// Decode a UTF-8 mouse event (mode 1005).
198///
199/// Format: `CSI M` followed by three UTF-8 codepoints, each = value + 32.
200/// Returns `None` if `data` doesn't start with a complete sequence; returns
201/// `Some((event, consumed))` on success.
202pub(crate) fn decode_utf8_mouse(data: &[u8]) -> Option<(Event, usize)> {
203    // Decode three codepoints
204    let mut idx = 0;
205    let (cb, n) = decode_utf8_codepoint(&data[idx..])?;
206    idx += n;
207    let (cx, n) = decode_utf8_codepoint(&data[idx..])?;
208    idx += n;
209    let (cy, n) = decode_utf8_codepoint(&data[idx..])?;
210    idx += n;
211
212    let cb = cb.wrapping_sub(32);
213    let cx = cx.wrapping_sub(33);
214    let cy = cy.wrapping_sub(33);
215    Some((build_mouse_event(cb, cx, cy, None), idx))
216}
217
218fn decode_utf8_codepoint(data: &[u8]) -> Option<(u16, usize)> {
219    if data.is_empty() {
220        return None;
221    }
222    let b0 = data[0];
223    let (cp, len) = if b0 < 0x80 {
224        (b0 as u32, 1)
225    } else if b0 < 0xc0 {
226        return None;
227    } else if b0 < 0xe0 {
228        if data.len() < 2 {
229            return None;
230        }
231        (((b0 as u32 & 0x1f) << 6) | (data[1] as u32 & 0x3f), 2)
232    } else if b0 < 0xf0 {
233        if data.len() < 3 {
234            return None;
235        }
236        (
237            ((b0 as u32 & 0x0f) << 12) | ((data[1] as u32 & 0x3f) << 6) | (data[2] as u32 & 0x3f),
238            3,
239        )
240    } else {
241        return None;
242    };
243    Some((cp as u16, len))
244}
245
246/// Common SGR/X10/URxvt/UTF-8 mouse cb decoder. `is_release` is `Some` only for
247/// SGR mode where release is signalled by the `m` final byte; otherwise it's
248/// derived from `cb & 3 == 3`.
249fn build_mouse_event(cb: u16, cx: u16, cy: u16, is_release: Option<bool>) -> Event {
250    let modifiers = decode_mouse_modifiers(cb);
251    let is_move = cb & 32 != 0;
252    let is_wheel = cb & 64 != 0;
253    let is_extra = cb & 128 != 0;
254
255    let button = if is_wheel {
256        match cb & 3 {
257            0 => MouseButton::WheelUp,
258            1 => MouseButton::WheelDown,
259            2 => MouseButton::WheelLeft,
260            3 => MouseButton::WheelRight,
261            _ => MouseButton::None,
262        }
263    } else if is_extra {
264        // Extra buttons (8..=11) via bit 7
265        match cb & 3 {
266            0 => MouseButton::Button8,
267            1 => MouseButton::Button9,
268            2 => MouseButton::Button10,
269            3 => MouseButton::Button11,
270            _ => MouseButton::None,
271        }
272    } else {
273        match cb & 3 {
274            0 => MouseButton::Left,
275            1 => MouseButton::Middle,
276            2 => MouseButton::Right,
277            3 => MouseButton::None, // legacy release
278            _ => MouseButton::None,
279        }
280    };
281
282    let mouse = Mouse::new(cx, cy, button, modifiers);
283
284    let release = is_release.unwrap_or(cb & 3 == 3 && !is_wheel && !is_extra);
285    if is_wheel {
286        Event::MouseWheel(mouse)
287    } else if release {
288        Event::MouseRelease(mouse)
289    } else if is_move {
290        Event::MouseMove(mouse)
291    } else {
292        Event::MouseClick(mouse)
293    }
294}
295
296/// Encode a mouse button + modifiers into the xterm bitfield for SGR encoding.
297///
298/// Returns `None` if `event` is not a mouse variant.
299#[allow(dead_code)]
300pub(crate) fn encode_sgr_mouse(event: &Event) -> Option<(u16, u16, u16, bool)> {
301    let m = match event {
302        Event::MouseClick(m)
303        | Event::MouseRelease(m)
304        | Event::MouseWheel(m)
305        | Event::MouseMove(m) => m,
306        _ => return None,
307    };
308    let mut cb: u16 = match m.button {
309        MouseButton::Left => 0,
310        MouseButton::Middle => 1,
311        MouseButton::Right => 2,
312        MouseButton::None => 3,
313        MouseButton::WheelUp => 64,
314        MouseButton::WheelDown => 65,
315        MouseButton::WheelLeft => 66,
316        MouseButton::WheelRight => 67,
317        MouseButton::Button4 => 128,
318        MouseButton::Button5 => 129,
319        MouseButton::Button6 => 130,
320        MouseButton::Button7 => 131,
321        MouseButton::Button8 => 256,
322        MouseButton::Button9 => 257,
323        MouseButton::Button10 => 258,
324        MouseButton::Button11 => 259,
325    };
326
327    if m.modifiers.contains(KeyModifiers::SHIFT) {
328        cb |= 4;
329    }
330    if m.modifiers.contains(KeyModifiers::ALT) {
331        cb |= 8;
332    }
333    if m.modifiers.contains(KeyModifiers::CTRL) {
334        cb |= 16;
335    }
336
337    if matches!(event, Event::MouseMove(_)) {
338        cb |= 32;
339    }
340
341    let is_release = matches!(event, Event::MouseRelease(_));
342    Some((cb, m.x + 1, m.y + 1, is_release))
343}
344
345/// Write a SGR mouse event sequence. Returns `Ok(())` without writing if
346/// `event` is not a mouse variant.
347#[allow(dead_code)]
348pub(crate) fn write_sgr_mouse<W: std::io::Write>(w: &mut W, event: &Event) -> std::io::Result<()> {
349    let Some((cb, cx, cy, is_release)) = encode_sgr_mouse(event) else {
350        return Ok(());
351    };
352    let final_char = if is_release { 'm' } else { 'M' };
353    write!(w, "\x1b[<{cb};{cx};{cy}{final_char}")
354}
355
356fn decode_mouse_modifiers(cb: u16) -> KeyModifiers {
357    let mut mods = KeyModifiers::empty();
358    if cb & 4 != 0 {
359        mods |= KeyModifiers::SHIFT;
360    }
361    if cb & 8 != 0 {
362        mods |= KeyModifiers::ALT;
363    }
364    if cb & 16 != 0 {
365        mods |= KeyModifiers::CTRL;
366    }
367    mods
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_decode_sgr_left_click() {
376        let event = decode_sgr_mouse(Params::from_raw(b"0;10;20"), false).unwrap();
377        match event {
378            Event::MouseClick(m) => {
379                assert_eq!(m.button, MouseButton::Left);
380                assert_eq!(m.x, 9);
381                assert_eq!(m.y, 19);
382            }
383            _ => panic!("Expected MouseClick"),
384        }
385    }
386
387    #[test]
388    fn test_decode_sgr_release() {
389        let event = decode_sgr_mouse(Params::from_raw(b"0;5;5"), true).unwrap();
390        assert!(matches!(event, Event::MouseRelease(_)));
391    }
392
393    #[test]
394    fn test_decode_sgr_wheel() {
395        let event = decode_sgr_mouse(Params::from_raw(b"64;1;1"), false).unwrap();
396        match event {
397            Event::MouseWheel(m) => {
398                assert_eq!(m.button, MouseButton::WheelUp);
399            }
400            _ => panic!("Expected MouseWheel"),
401        }
402    }
403
404    #[test]
405    fn test_mouse_pixel_to_cell() {
406        // 800x600 pixels, 80x24 cells → each cell is 10px wide, 25px tall
407        let m = Mouse::new(100, 200, MouseButton::Left, KeyModifiers::empty());
408        let c = mouse_pixel_to_cell(m, 800, 600, 80, 24);
409        assert_eq!(c.x, 10);
410        assert_eq!(c.y, 8);
411    }
412
413    #[test]
414    fn test_decode_urxvt_click() {
415        // cb = 32 (left, no mods), cx=11 (10), cy=21 (20)
416        let event = decode_urxvt_mouse(Params::from_raw(b"32;11;21")).unwrap();
417        match event {
418            Event::MouseClick(m) => {
419                assert_eq!(m.button, MouseButton::Left);
420                assert_eq!(m.x, 10);
421                assert_eq!(m.y, 20);
422            }
423            _ => panic!("Expected MouseClick"),
424        }
425    }
426
427    #[test]
428    fn test_decode_utf8_mouse() {
429        // cb=32, cx=33, cy=33 (all single-byte ASCII)
430        let (event, n) = decode_utf8_mouse(b" !!").unwrap();
431        assert_eq!(n, 3);
432        match event {
433            Event::MouseClick(m) => {
434                assert_eq!(m.x, 0);
435                assert_eq!(m.y, 0);
436            }
437            _ => panic!("Expected MouseClick"),
438        }
439    }
440
441    #[test]
442    fn test_sgr_roundtrip() {
443        let original =
444            Event::MouseClick(Mouse::new(10, 20, MouseButton::Left, KeyModifiers::empty()));
445
446        let mut buf = Vec::new();
447        write_sgr_mouse(&mut buf, &original).unwrap();
448        assert_eq!(buf, b"\x1b[<0;11;21M");
449    }
450}