tastty-core 0.1.0

Sans-IO core of the tastty terminal session library: VT parser, screen buffer, and byte encoders.
//! Mouse-event byte encoding: legacy X10 (`\x1b[M` + three raw bytes), SGR
//! cell coordinates (DECSET `?1006`), and SGR pixel coordinates (`?1016`).
//! Button codes and modifier bits follow X11; see [xterm ctlseqs] for the
//! encoding tables.
//!
//! [xterm ctlseqs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html

use crate::input::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use crate::screen::CellPixelSize;

/// Coordinate encoding for an outbound mouse event.
///
/// SGR (`?1006`) and SGR-pixel (`?1016`) share a wire format
/// (`\x1b[<Pb;Px;Py;{M|m}`) and differ only in coordinate space; legacy X10
/// stays separate because it packs three raw bytes after `\x1b[M`.
#[derive(Clone, Copy, Debug)]
pub(crate) enum CoordEncoding {
    /// Legacy `\x1b[M` + three raw bytes; coordinates clamped at 223.
    X10,
    /// SGR cell coordinates: 1-based `(col, row)`.
    Sgr,
    /// SGR pixel coordinates: 1-based `(col * width + 1, row * height + 1)`.
    SgrPixel(CellPixelSize),
}

const MOUSE_BUTTON_LEFT: u32 = 0;
const MOUSE_BUTTON_MIDDLE: u32 = 1;
const MOUSE_BUTTON_RIGHT: u32 = 2;
const MOUSE_BUTTON_RELEASE: u32 = 3;
const MOUSE_DRAG_OFFSET: u32 = 32;
const MOUSE_MOTION: u32 = 35;
const MOUSE_SCROLL_UP: u32 = 64;
const MOUSE_SCROLL_DOWN: u32 = 65;
const MOUSE_SCROLL_LEFT: u32 = 66;
const MOUSE_SCROLL_RIGHT: u32 = 67;

const MOUSE_MOD_SHIFT: u32 = 4;
const MOUSE_MOD_ALT: u32 = 8;
const MOUSE_MOD_CTRL: u32 = 16;

/// X10/normal encoding offset added to coordinates and button codes.
const X10_OFFSET: u32 = 32;

/// Encode a mouse event as bytes for the child process.
///
/// `encoding` selects between legacy X10, SGR cell coordinates (`?1006`),
/// and SGR pixel coordinates (`?1016`). The X10 variant returns `None` when
/// the coordinates overflow the 223-column protocol limit; SGR variants emit
/// decimal integers and never overflow.
pub(crate) fn mouse_to_bytes(event: &MouseEvent, encoding: CoordEncoding) -> Option<Vec<u8>> {
    let button = match event.kind {
        MouseEventKind::Down(MouseButton::Left) => MOUSE_BUTTON_LEFT,
        MouseEventKind::Down(MouseButton::Middle) => MOUSE_BUTTON_MIDDLE,
        MouseEventKind::Down(MouseButton::Right) => MOUSE_BUTTON_RIGHT,
        MouseEventKind::Up(MouseButton::Left) => MOUSE_BUTTON_LEFT,
        MouseEventKind::Up(MouseButton::Middle) => MOUSE_BUTTON_MIDDLE,
        MouseEventKind::Up(MouseButton::Right) => MOUSE_BUTTON_RIGHT,
        MouseEventKind::Drag(MouseButton::Left) => MOUSE_DRAG_OFFSET + MOUSE_BUTTON_LEFT,
        MouseEventKind::Drag(MouseButton::Middle) => MOUSE_DRAG_OFFSET + MOUSE_BUTTON_MIDDLE,
        MouseEventKind::Drag(MouseButton::Right) => MOUSE_DRAG_OFFSET + MOUSE_BUTTON_RIGHT,
        MouseEventKind::Moved => MOUSE_MOTION,
        MouseEventKind::ScrollUp => MOUSE_SCROLL_UP,
        MouseEventKind::ScrollDown => MOUSE_SCROLL_DOWN,
        MouseEventKind::ScrollLeft => MOUSE_SCROLL_LEFT,
        MouseEventKind::ScrollRight => MOUSE_SCROLL_RIGHT,
    };

    let mut cb: u32 = button;
    if event.modifiers.contains(KeyModifiers::SHIFT) {
        cb |= MOUSE_MOD_SHIFT;
    }
    if event.modifiers.contains(KeyModifiers::ALT) {
        cb |= MOUSE_MOD_ALT;
    }
    if event.modifiers.contains(KeyModifiers::CONTROL) {
        cb |= MOUSE_MOD_CTRL;
    }

    let final_char = match event.kind {
        MouseEventKind::Up(_) => 'm',
        _ => 'M',
    };

    match encoding {
        CoordEncoding::Sgr => {
            let col = u32::from(event.col) + 1;
            let row = u32::from(event.row) + 1;
            Some(format!("\x1b[<{cb};{col};{row}{final_char}").into_bytes())
        }
        CoordEncoding::SgrPixel(cell) => {
            // xterm convention: 1-based, left-edge of the cell. The input
            // event has no sub-cell offset, so the pixel is the cell origin.
            let x = u32::from(event.col) * u32::from(cell.width) + 1;
            let y = u32::from(event.row) * u32::from(cell.height) + 1;
            Some(format!("\x1b[<{cb};{x};{y}{final_char}").into_bytes())
        }
        CoordEncoding::X10 => {
            let col = u32::from(event.col) + 1;
            let row = u32::from(event.row) + 1;
            let cx = u8::try_from(col.saturating_add(X10_OFFSET)).ok()?;
            let cy = u8::try_from(row.saturating_add(X10_OFFSET)).ok()?;
            let cb_byte = u8::try_from(cb.saturating_add(X10_OFFSET)).ok()?;
            // X10 release reports button code 3 regardless of which button.
            let cb_byte = match event.kind {
                MouseEventKind::Up(_) => (X10_OFFSET + MOUSE_BUTTON_RELEASE) as u8,
                _ => cb_byte,
            };
            Some(vec![0x1b, b'[', b'M', cb_byte, cx, cy])
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mouse_sgr_left_click() {
        let event = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            row: 4,
            col: 9,
            modifiers: KeyModifiers::NONE,
        };
        assert_eq!(
            mouse_to_bytes(&event, CoordEncoding::Sgr),
            Some(b"\x1b[<0;10;5M".to_vec())
        );
    }

    #[test]
    fn mouse_sgr_left_release() {
        let event = MouseEvent {
            kind: MouseEventKind::Up(MouseButton::Left),
            row: 4,
            col: 9,
            modifiers: KeyModifiers::NONE,
        };
        assert_eq!(
            mouse_to_bytes(&event, CoordEncoding::Sgr),
            Some(b"\x1b[<0;10;5m".to_vec())
        );
    }

    #[test]
    fn mouse_sgr_scroll_up() {
        let event = MouseEvent {
            kind: MouseEventKind::ScrollUp,
            row: 0,
            col: 0,
            modifiers: KeyModifiers::NONE,
        };
        assert_eq!(
            mouse_to_bytes(&event, CoordEncoding::Sgr),
            Some(b"\x1b[<64;1;1M".to_vec())
        );
    }

    #[test]
    fn mouse_sgr_shift_click() {
        let event = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            row: 0,
            col: 0,
            modifiers: KeyModifiers::SHIFT,
        };
        assert_eq!(
            mouse_to_bytes(&event, CoordEncoding::Sgr),
            Some(b"\x1b[<4;1;1M".to_vec())
        );
    }

    #[test]
    fn mouse_x10_left_click() {
        let event = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            row: 0,
            col: 0,
            modifiers: KeyModifiers::NONE,
        };
        // X10: ESC [ M cb cx cy, all + 32
        // button 0 + 32 = 32, col 1 + 32 = 33, row 1 + 32 = 33
        assert_eq!(
            mouse_to_bytes(&event, CoordEncoding::X10),
            Some(vec![0x1b, b'[', b'M', 32, 33, 33])
        );
    }

    #[test]
    fn mouse_x10_release() {
        let event = MouseEvent {
            kind: MouseEventKind::Up(MouseButton::Left),
            row: 0,
            col: 0,
            modifiers: KeyModifiers::NONE,
        };
        // X10 release uses button code 3
        assert_eq!(
            mouse_to_bytes(&event, CoordEncoding::X10),
            Some(vec![0x1b, b'[', b'M', 35, 33, 33])
        );
    }

    #[test]
    fn mouse_drag() {
        let event = MouseEvent {
            kind: MouseEventKind::Drag(MouseButton::Left),
            row: 10,
            col: 5,
            modifiers: KeyModifiers::NONE,
        };
        // Drag left = button 32, col 6, row 11
        assert_eq!(
            mouse_to_bytes(&event, CoordEncoding::Sgr),
            Some(b"\x1b[<32;6;11M".to_vec())
        );
    }
}