471 lines
14 KiB
Rust
471 lines
14 KiB
Rust
// SPDX-License-Identifier: GPL-2.0-only
|
||
//
|
||
// rs-mrxvt — a modernized, distro-agnostic mrxvt-inspired terminal emulator.
|
||
//
|
||
// Copyright (C) 2024 rs-mrxvt contributors
|
||
//
|
||
// This program is free software; you can redistribute it and/or modify
|
||
// it under the terms of the GNU General Public License as published by
|
||
// the Free Software Foundation; either version 2 of the License, or
|
||
// (at your option) any later version.
|
||
//
|
||
// This program is distributed in the hope that it will be useful,
|
||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
// GNU General Public License for more details.
|
||
//
|
||
// You should have received a copy of the GNU General Public License along
|
||
// with this program; if not, see <https://www.gnu.org/licenses/>.
|
||
|
||
|
||
//! Mouse event types and SGR mouse encoding.
|
||
//!
|
||
//! The classic mrxvt supported several mouse modes (X10, X11 normal, X11
|
||
//! SGR-1006). This module defines the backend-agnostic mouse event type and
|
||
//! the SGR encoder that translates mouse events into escape sequences for
|
||
//! the child process.
|
||
//!
|
||
//! ## Mouse modes
|
||
//!
|
||
//! Programs request mouse reporting via DECSET escape sequences:
|
||
//! - `?9h` — X10 (click only, no modifiers, no release)
|
||
//! - `?1000h` — X11 normal (press/release + motion-with-button)
|
||
//! - `?1002h` — X11 motion (all motion events, even with no button)
|
||
//! - `?1003h` — all motion (no button needed)
|
||
//! - `?1006h` — SGR-1006 encoding (extends the above with bigger coords
|
||
//! and explicit press/release markers)
|
||
//!
|
||
//! The terminal keeps a `MouseMode` bitfield; the renderer translates raw
|
||
//! mouse events into [`MouseEvent`]s and asks the encoder whether to send
|
||
//! them to the child.
|
||
//!
|
||
//! ## Selection
|
||
//!
|
||
//! When mouse reporting is OFF, mouse events are interpreted locally as
|
||
//! text selection: click-drag selects, release copies to clipboard. This
|
||
//! module exposes a [`Selection`] state machine that renderers can drive.
|
||
|
||
use std::fmt;
|
||
|
||
/// Bitflags for active mouse modes.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||
pub struct MouseMode {
|
||
/// X10 (button press only).
|
||
pub x10: bool,
|
||
/// X11 normal (press + release + motion-with-button).
|
||
pub x11: bool,
|
||
/// Motion reporting (even with no button held).
|
||
pub motion: bool,
|
||
/// SGR-1006 encoding.
|
||
pub sgr: bool,
|
||
}
|
||
|
||
impl MouseMode {
|
||
/// Is any mouse reporting active?
|
||
pub fn any_reporting(self) -> bool {
|
||
self.x10 || self.x11 || self.motion
|
||
}
|
||
|
||
/// Should a button-press event be reported?
|
||
pub fn reports_press(self) -> bool {
|
||
self.x10 || self.x11 || self.motion
|
||
}
|
||
|
||
/// Should a button-release event be reported?
|
||
pub fn reports_release(self) -> bool {
|
||
self.x11 || self.motion
|
||
}
|
||
|
||
/// Should a motion event be reported?
|
||
pub fn reports_motion(self, button_held: bool) -> bool {
|
||
self.motion || (self.x11 && button_held)
|
||
}
|
||
}
|
||
|
||
/// A mouse button.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum MouseButton {
|
||
Left,
|
||
Middle,
|
||
Right,
|
||
/// Wheel up (one notch).
|
||
WheelUp,
|
||
/// Wheel down (one notch).
|
||
WheelDown,
|
||
/// No button (used for motion events with no button held).
|
||
None,
|
||
}
|
||
|
||
/// A mouse event.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct MouseEvent {
|
||
pub button: MouseButton,
|
||
/// Cell column (0-indexed).
|
||
pub col: u32,
|
||
/// Cell row (0-indexed).
|
||
pub row: u32,
|
||
pub mods: MouseMods,
|
||
pub kind: MouseEventKind,
|
||
}
|
||
|
||
/// Modifier flags on a mouse event.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||
pub struct MouseMods {
|
||
pub shift: bool,
|
||
pub ctrl: bool,
|
||
pub alt: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum MouseEventKind {
|
||
Press,
|
||
Release,
|
||
Motion,
|
||
}
|
||
|
||
/// Encode a mouse event using SGR-1006 format.
|
||
///
|
||
/// Returns `None` if the event shouldn't be reported (e.g. release in X10 mode).
|
||
///
|
||
/// SGR-1006 format:
|
||
/// - Press: `ESC [ < button ; col ; row M`
|
||
/// - Release: `ESC [ < button ; col ; row m`
|
||
///
|
||
/// Where `button` is the button code + modifier bits (lower 3 bits = button,
|
||
/// bit 2 = shift, bit 3 = meta, bit 4 = ctrl, bit 5 = wheel, bit 6 = motion
|
||
/// flag — actually for SGR we just encode button+mods, motion is a separate
|
||
/// M vs m signal).
|
||
pub fn encode_sgr(ev: MouseEvent, mode: MouseMode) -> Option<Vec<u8>> {
|
||
let code = button_code(ev.button, ev.mods);
|
||
let suffix = match ev.kind {
|
||
MouseEventKind::Press => {
|
||
if !mode.reports_press() {
|
||
return None;
|
||
}
|
||
'M'
|
||
}
|
||
MouseEventKind::Release => {
|
||
if !mode.reports_release() {
|
||
return None;
|
||
}
|
||
'm'
|
||
}
|
||
MouseEventKind::Motion => {
|
||
if !mode.reports_motion(ev.button != MouseButton::None) {
|
||
return None;
|
||
}
|
||
// SGR-1006 uses M for motion-with-button, m for motion-without.
|
||
// Convention: motion events always use M; the button field
|
||
// encodes which button (or 35 = no button) is held.
|
||
'M'
|
||
}
|
||
};
|
||
// SGR is 1-indexed.
|
||
let col = ev.col + 1;
|
||
let row = ev.row + 1;
|
||
let s = format!("\x1b[<{code};{col};{row}{suffix}");
|
||
Some(s.into_bytes())
|
||
}
|
||
|
||
/// Encode a mouse event using legacy X11 format (for programs that don't
|
||
/// support SGR-1006).
|
||
///
|
||
/// Returns `None` if the event shouldn't be reported.
|
||
///
|
||
/// Legacy format: `ESC [ M <button-byte> <col-byte> <row-byte>`
|
||
/// where each byte is the value + 32 (to keep it in the printable range).
|
||
/// Coordinates are clamped to 1..227 (bytes 33..255).
|
||
pub fn encode_x11(ev: MouseEvent, mode: MouseMode) -> Option<Vec<u8>> {
|
||
let _should = match ev.kind {
|
||
MouseEventKind::Press => mode.reports_press(),
|
||
MouseEventKind::Release => mode.reports_release(),
|
||
MouseEventKind::Motion => mode.reports_motion(ev.button != MouseButton::None),
|
||
};
|
||
if !_should {
|
||
return None;
|
||
}
|
||
let mut code = button_code(ev.button, ev.mods);
|
||
if ev.kind == MouseEventKind::Motion {
|
||
code |= 32; // motion flag
|
||
}
|
||
let b = (code.min(255 - 32) + 32) as u8;
|
||
// Coords: 1-indexed, clamped to 1..223 (bytes 33..255 after +32).
|
||
// Clamp to 223 so that 223 + 32 = 255 = u8::MAX (no overflow).
|
||
let col_clamped = (ev.col.saturating_add(1)).clamp(1, 223);
|
||
let row_clamped = (ev.row.saturating_add(1)).clamp(1, 223);
|
||
let c = (col_clamped + 32) as u8;
|
||
let r = (row_clamped + 32) as u8;
|
||
Some(vec![0x1b, b'[', b'M', b, c, r])
|
||
}
|
||
|
||
/// Compute the button code (lower 3 bits + modifier bits).
|
||
fn button_code(button: MouseButton, mods: MouseMods) -> u32 {
|
||
let base = match button {
|
||
MouseButton::Left => 0,
|
||
MouseButton::Middle => 1,
|
||
MouseButton::Right => 2,
|
||
MouseButton::WheelUp => 64,
|
||
MouseButton::WheelDown => 65,
|
||
MouseButton::None => 3,
|
||
};
|
||
let mod_bits: [(bool, u32); 3] =
|
||
[(mods.shift, 4), (mods.alt, 8), (mods.ctrl, 16)];
|
||
base | mod_bits.iter().filter(|(f, _)| *f).map(|(_, b)| b).fold(0, |a, b| a | b)
|
||
}
|
||
|
||
// ─── Selection state machine ─────────────────────────────────────────────────
|
||
|
||
/// A text selection. Used by renderers for click-drag-to-select.
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||
pub struct Selection {
|
||
/// Start point (cell coords). `None` = no active selection.
|
||
pub start: Option<(u32, u32)>,
|
||
/// End point (cell coords). `None` = single-click.
|
||
pub end: Option<(u32, u32)>,
|
||
}
|
||
|
||
impl Selection {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
/// Begin a selection at the given cell.
|
||
pub fn begin(&mut self, col: u32, row: u32) {
|
||
self.start = Some((col, row));
|
||
self.end = Some((col, row));
|
||
}
|
||
|
||
/// Extend the selection to the given cell.
|
||
pub fn extend(&mut self, col: u32, row: u32) {
|
||
if self.start.is_some() {
|
||
self.end = Some((col, row));
|
||
}
|
||
}
|
||
|
||
/// Clear the selection.
|
||
pub fn clear(&mut self) {
|
||
self.start = None;
|
||
self.end = None;
|
||
}
|
||
|
||
/// Is there an active selection?
|
||
pub fn is_active(&self) -> bool {
|
||
self.start.is_some() && self.end.is_some()
|
||
}
|
||
|
||
/// Iterate over the selected cells in row-major order.
|
||
pub fn cells(&self) -> Vec<(u32, u32)> {
|
||
let Some((sx, sy)) = self.start else { return Vec::new(); };
|
||
let Some((ex, ey)) = self.end else { return Vec::new(); };
|
||
// Normalize: top-left to bottom-right.
|
||
let (x1, y1) = (sx.min(ex), sy.min(ey));
|
||
let (x2, y2) = (sx.max(ex), sy.max(ey));
|
||
(y1..=y2).flat_map(|y| (x1..=x2).map(move |x| (x, y))).collect()
|
||
}
|
||
|
||
/// True if the selection spans more than one cell.
|
||
pub fn is_multi_cell(&self) -> bool {
|
||
if let (Some(s), Some(e)) = (self.start, self.end) {
|
||
s != e
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for MouseButton {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
write!(f, "{:?}", self)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn mouse_mode_no_reporting_by_default() {
|
||
let m = MouseMode::default();
|
||
assert!(!m.any_reporting());
|
||
assert!(!m.reports_press());
|
||
assert!(!m.reports_release());
|
||
assert!(!m.reports_motion(false));
|
||
}
|
||
|
||
#[test]
|
||
fn x11_mode_reports_press_and_release() {
|
||
let m = MouseMode { x11: true, ..Default::default() };
|
||
assert!(m.reports_press());
|
||
assert!(m.reports_release());
|
||
assert!(!m.reports_motion(false));
|
||
assert!(m.reports_motion(true));
|
||
}
|
||
|
||
#[test]
|
||
fn motion_mode_reports_all() {
|
||
let m = MouseMode { motion: true, ..Default::default() };
|
||
assert!(m.reports_motion(false));
|
||
assert!(m.reports_motion(true));
|
||
}
|
||
|
||
#[test]
|
||
fn x10_only_reports_press() {
|
||
let m = MouseMode { x10: true, ..Default::default() };
|
||
assert!(m.reports_press());
|
||
assert!(!m.reports_release());
|
||
assert!(!m.reports_motion(true));
|
||
}
|
||
|
||
#[test]
|
||
fn sgr_press_encoding() {
|
||
let ev = MouseEvent {
|
||
button: MouseButton::Left,
|
||
col: 5,
|
||
row: 10,
|
||
mods: MouseMods::default(),
|
||
kind: MouseEventKind::Press,
|
||
};
|
||
let mode = MouseMode { x11: true, sgr: true, ..Default::default() };
|
||
let bytes = encode_sgr(ev, mode).unwrap();
|
||
let s = String::from_utf8(bytes).unwrap();
|
||
// SGR is 1-indexed: col 5 → 6, row 10 → 11.
|
||
assert_eq!(s, "\x1b[<0;6;11M");
|
||
}
|
||
|
||
#[test]
|
||
fn sgr_release_encoding() {
|
||
let ev = MouseEvent {
|
||
button: MouseButton::Right,
|
||
col: 0,
|
||
row: 0,
|
||
mods: MouseMods::default(),
|
||
kind: MouseEventKind::Release,
|
||
};
|
||
let mode = MouseMode { x11: true, sgr: true, ..Default::default() };
|
||
let bytes = encode_sgr(ev, mode).unwrap();
|
||
let s = String::from_utf8(bytes).unwrap();
|
||
assert_eq!(s, "\x1b[<2;1;1m");
|
||
}
|
||
|
||
#[test]
|
||
fn sgr_with_modifiers() {
|
||
let ev = MouseEvent {
|
||
button: MouseButton::Left,
|
||
col: 0,
|
||
row: 0,
|
||
mods: MouseMods { shift: true, ctrl: true, alt: true },
|
||
kind: MouseEventKind::Press,
|
||
};
|
||
let mode = MouseMode { x11: true, sgr: true, ..Default::default() };
|
||
let bytes = encode_sgr(ev, mode).unwrap();
|
||
let s = String::from_utf8(bytes).unwrap();
|
||
// shift=4, alt=8, ctrl=16, left=0 → 28
|
||
assert_eq!(s, "\x1b[<28;1;1M");
|
||
}
|
||
|
||
#[test]
|
||
fn sgr_wheel_events() {
|
||
let up = MouseEvent {
|
||
button: MouseButton::WheelUp,
|
||
col: 3, row: 4,
|
||
mods: MouseMods::default(),
|
||
kind: MouseEventKind::Press,
|
||
};
|
||
let mode = MouseMode { x11: true, sgr: true, ..Default::default() };
|
||
let s = String::from_utf8(encode_sgr(up, mode).unwrap()).unwrap();
|
||
// WheelUp = 64
|
||
assert_eq!(s, "\x1b[<64;4;5M");
|
||
}
|
||
|
||
#[test]
|
||
fn sgr_skipped_when_mode_off() {
|
||
let ev = MouseEvent {
|
||
button: MouseButton::Left,
|
||
col: 0, row: 0,
|
||
mods: MouseMods::default(),
|
||
kind: MouseEventKind::Press,
|
||
};
|
||
let mode = MouseMode::default();
|
||
assert!(encode_sgr(ev, mode).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn x11_legacy_encoding() {
|
||
let ev = MouseEvent {
|
||
button: MouseButton::Left,
|
||
col: 0, row: 0,
|
||
mods: MouseMods::default(),
|
||
kind: MouseEventKind::Press,
|
||
};
|
||
let mode = MouseMode { x11: true, ..Default::default() };
|
||
let bytes = encode_x11(ev, mode).unwrap();
|
||
// ESC [ M <32> <33> <33>
|
||
assert_eq!(bytes, vec![0x1b, b'[', b'M', 32, 33, 33]);
|
||
}
|
||
|
||
#[test]
|
||
fn x11_legacy_clamps_coords() {
|
||
let ev = MouseEvent {
|
||
button: MouseButton::Left,
|
||
col: 500, row: 500,
|
||
mods: MouseMods::default(),
|
||
kind: MouseEventKind::Press,
|
||
};
|
||
let mode = MouseMode { x11: true, ..Default::default() };
|
||
let bytes = encode_x11(ev, mode).unwrap();
|
||
// Coords clamped to 223 before +32, so byte = 255 (no u8 overflow).
|
||
assert_eq!(bytes.len(), 6);
|
||
assert_eq!(bytes[4], 255); // col byte
|
||
assert_eq!(bytes[5], 255); // row byte
|
||
}
|
||
|
||
#[test]
|
||
fn selection_begin_and_extend() {
|
||
let mut s = Selection::new();
|
||
assert!(!s.is_active());
|
||
s.begin(0, 0);
|
||
assert!(s.is_active());
|
||
s.extend(5, 2);
|
||
assert!(s.is_multi_cell());
|
||
}
|
||
|
||
#[test]
|
||
fn selection_cells_row_major() {
|
||
let mut s = Selection::new();
|
||
s.begin(0, 0);
|
||
s.extend(2, 1);
|
||
let cells = s.cells();
|
||
// 3 cols × 2 rows = 6 cells.
|
||
assert_eq!(cells.len(), 6);
|
||
assert!(cells.contains(&(0, 0)));
|
||
assert!(cells.contains(&(2, 1)));
|
||
}
|
||
|
||
#[test]
|
||
fn selection_normalizes_swapped_points() {
|
||
let mut s = Selection::new();
|
||
s.begin(5, 5);
|
||
s.extend(1, 1);
|
||
let cells = s.cells();
|
||
// Should still cover the same rectangle regardless of direction.
|
||
assert_eq!(cells.len(), 5 * 5);
|
||
assert!(cells.contains(&(1, 1)));
|
||
assert!(cells.contains(&(5, 5)));
|
||
}
|
||
|
||
#[test]
|
||
fn selection_clear() {
|
||
let mut s = Selection::new();
|
||
s.begin(0, 0);
|
||
s.clear();
|
||
assert!(!s.is_active());
|
||
}
|
||
|
||
#[test]
|
||
fn selection_single_cell_not_multi() {
|
||
let mut s = Selection::new();
|
||
s.begin(3, 3);
|
||
assert!(!s.is_multi_cell());
|
||
}
|
||
}
|