diff --git a/README.md b/README.md index 7368ce1..7748946 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,50 @@ specifying new width (`cols`) and height (`rows`). This command triggers `resize` event. +#### mouse + +`mouse` command allows sending mouse events to the application running in the +virtual terminal. + +```json +{ "type": "mouse", "event": "click", "button": "left", "row": 10, "col": 25 } +{ "type": "mouse", "event": "press", "button": "right", "row": 5, "col": 15, "control": true } +{ "type": "mouse", "event": "drag", "button": "left", "row": 12, "col": 30 } +{ "type": "mouse", "event": "release", "button": "left", "row": 12, "col": 30 } +``` + +Event types: +- `press` - mouse button pressed down +- `release` - mouse button released +- `drag` - mouse motion while button is held down +- `click` - convenience shorthand that sends both press and release events + +Supported buttons: +- `left`, `middle`, `right` - standard mouse buttons +- `wheel_up`, `wheel_down` - scroll wheel events + +Coordinates are 1-indexed, meaning row 1, col 1 represents the top-left cell of +the terminal. Coordinates exceeding the current terminal size will trigger a +warning but are still sent to the application. + +Optional modifier keys can be specified as boolean fields (default `false`): +- `shift` - Shift key held during mouse event +- `alt` - Alt/Option key held during mouse event +- `control` - Control key held during mouse event + +Example with modifiers: +```json +{ "type": "mouse", "event": "click", "button": "left", "row": 10, "col": 25, "shift": true, "control": true } +``` + +**Important**: Mouse events use the SGR extended mouse protocol (`\x1b[<` format). +The application running in the terminal must enable mouse tracking for these +events to have any effect. Most modern TUI applications (vim with `:set mouse=a`, +tmux, less, emacs, etc.) support mouse tracking and will enable it automatically +when needed. + +This command doesn't trigger any event. + ### WebSocket API The WebSocket API currently provides 2 endpoints: diff --git a/src/api/stdio.rs b/src/api/stdio.rs index 2aa2bf3..a50192a 100644 --- a/src/api/stdio.rs +++ b/src/api/stdio.rs @@ -24,6 +24,20 @@ struct ResizeArgs { rows: usize, } +#[derive(Debug, Deserialize)] +struct MouseArgs { + event: String, + button: String, + row: usize, + col: usize, + #[serde(default)] + shift: bool, + #[serde(default)] + alt: bool, + #[serde(default)] + control: bool, +} + pub async fn start( command_tx: mpsc::Sender, clients_tx: mpsc::Sender, @@ -106,6 +120,55 @@ fn build_command(value: serde_json::Value) -> Result { Ok(Command::Input(seqs)) } + Some("mouse") => { + let args: MouseArgs = args_from_json_value(value)?; + + let is_click = args.event == "click"; + + let event_type = match args.event.as_str() { + "press" | "click" => command::MouseEventType::Press, + "release" => command::MouseEventType::Release, + "drag" => command::MouseEventType::Drag, + e => return Err(format!("invalid mouse event type: {}", e)), + }; + + let button = match args.button.as_str() { + "left" => command::MouseButton::Left, + "middle" => command::MouseButton::Middle, + "right" => command::MouseButton::Right, + "wheel_up" => command::MouseButton::WheelUp, + "wheel_down" => command::MouseButton::WheelDown, + b => return Err(format!("invalid mouse button: {}", b)), + }; + + // Validate coordinates (1-indexed) + if args.row == 0 || args.col == 0 { + return Err( + "mouse coordinates must be 1-indexed (row >= 1, col >= 1)".to_string() + ); + } + + let modifiers = command::MouseModifiers { + shift: args.shift, + alt: args.alt, + control: args.control, + }; + + let mouse_event = command::MouseEvent { + event_type, + button, + row: args.row, + col: args.col, + modifiers, + }; + + if is_click { + Ok(Command::MouseClick(mouse_event)) + } else { + Ok(Command::Mouse(mouse_event)) + } + } + Some("resize") => { let args: ResizeArgs = args_from_json_value(value)?; Ok(Command::Resize(args.cols, args.rows)) @@ -281,7 +344,7 @@ fn parse_key(key: String) -> InputSeq { #[cfg(test)] mod test { use super::{cursor_key, parse_line, standard_key, Command}; - use crate::command::InputSeq; + use crate::command::{InputSeq, MouseButton, MouseEventType}; #[test] fn parse_input() { @@ -487,4 +550,140 @@ mod test { fn parse_invalid_json() { parse_line("{").expect_err("should fail"); } + + #[test] + fn parse_mouse_click() { + let command = parse_line( + r#"{ "type": "mouse", "event": "click", "button": "left", "row": 10, "col": 25 }"#, + ) + .unwrap(); + assert!(matches!(command, Command::MouseClick(_))); + } + + #[test] + fn parse_mouse_press() { + let command = parse_line( + r#"{ "type": "mouse", "event": "press", "button": "right", "row": 5, "col": 15 }"#, + ) + .unwrap(); + + if let Command::Mouse(event) = command { + assert!(matches!(event.event_type, MouseEventType::Press)); + assert!(matches!(event.button, MouseButton::Right)); + assert_eq!(event.row, 5); + assert_eq!(event.col, 15); + assert!(!event.modifiers.shift); + assert!(!event.modifiers.alt); + assert!(!event.modifiers.control); + } else { + panic!("expected Command::Mouse"); + } + } + + #[test] + fn parse_mouse_release() { + let command = parse_line( + r#"{ "type": "mouse", "event": "release", "button": "middle", "row": 1, "col": 1 }"#, + ) + .unwrap(); + + if let Command::Mouse(event) = command { + assert!(matches!(event.event_type, MouseEventType::Release)); + assert!(matches!(event.button, MouseButton::Middle)); + } else { + panic!("expected Command::Mouse"); + } + } + + #[test] + fn parse_mouse_drag() { + let command = parse_line( + r#"{ "type": "mouse", "event": "drag", "button": "left", "row": 20, "col": 30 }"#, + ) + .unwrap(); + + if let Command::Mouse(event) = command { + assert!(matches!(event.event_type, MouseEventType::Drag)); + } else { + panic!("expected Command::Mouse"); + } + } + + #[test] + fn parse_mouse_with_modifiers() { + let command = parse_line( + r#"{ "type": "mouse", "event": "click", "button": "left", "row": 10, "col": 25, "shift": true, "control": true }"#, + ) + .unwrap(); + + if let Command::MouseClick(event) = command { + assert!(event.modifiers.shift); + assert!(event.modifiers.control); + assert!(!event.modifiers.alt); + } else { + panic!("expected Command::MouseClick"); + } + } + + #[test] + fn parse_mouse_wheel() { + let command = parse_line( + r#"{ "type": "mouse", "event": "press", "button": "wheel_up", "row": 10, "col": 25 }"#, + ) + .unwrap(); + + if let Command::Mouse(event) = command { + assert!(matches!(event.button, MouseButton::WheelUp)); + } else { + panic!("expected Command::Mouse"); + } + + let command = parse_line( + r#"{ "type": "mouse", "event": "press", "button": "wheel_down", "row": 10, "col": 25 }"#, + ) + .unwrap(); + + if let Command::Mouse(event) = command { + assert!(matches!(event.button, MouseButton::WheelDown)); + } else { + panic!("expected Command::Mouse"); + } + } + + #[test] + fn parse_mouse_invalid_event() { + parse_line( + r#"{ "type": "mouse", "event": "invalid", "button": "left", "row": 10, "col": 25 }"#, + ) + .expect_err("should fail"); + } + + #[test] + fn parse_mouse_invalid_button() { + parse_line( + r#"{ "type": "mouse", "event": "click", "button": "invalid", "row": 10, "col": 25 }"#, + ) + .expect_err("should fail"); + } + + #[test] + fn parse_mouse_zero_row() { + parse_line( + r#"{ "type": "mouse", "event": "click", "button": "left", "row": 0, "col": 25 }"#, + ) + .expect_err("should fail"); + } + + #[test] + fn parse_mouse_zero_col() { + parse_line( + r#"{ "type": "mouse", "event": "click", "button": "left", "row": 10, "col": 0 }"#, + ) + .expect_err("should fail"); + } + + #[test] + fn parse_mouse_missing_args() { + parse_line(r#"{ "type": "mouse" }"#).expect_err("should fail"); + } } diff --git a/src/command.rs b/src/command.rs index a9d29a7..8ed037b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,6 +1,8 @@ #[derive(Debug)] pub enum Command { Input(Vec), + Mouse(MouseEvent), + MouseClick(MouseEvent), // Convenience: sends press then release Snapshot, Resize(usize, usize), } @@ -28,3 +30,70 @@ fn seq_as_bytes(seq: &InputSeq, app_mode: bool) -> &[u8] { (InputSeq::Cursor(_seq1, seq2), true) => seq2.as_bytes(), } } + +#[derive(Debug, Clone)] +pub struct MouseEvent { + pub event_type: MouseEventType, + pub button: MouseButton, + pub row: usize, + pub col: usize, + pub modifiers: MouseModifiers, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MouseEventType { + Press, + Release, + Drag, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MouseButton { + Left, + Middle, + Right, + WheelUp, + WheelDown, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MouseModifiers { + pub shift: bool, + pub alt: bool, + pub control: bool, +} + +pub fn mouse_to_bytes(event: &MouseEvent) -> Vec { + // Base button encoding per SGR protocol + let mut btn = match event.button { + MouseButton::Left => 0, + MouseButton::Middle => 1, + MouseButton::Right => 2, + MouseButton::WheelUp => 64, + MouseButton::WheelDown => 65, + }; + + // Add modifier bits + if event.modifiers.shift { + btn += 4; + } + if event.modifiers.alt { + btn += 8; + } + if event.modifiers.control { + btn += 16; + } + + // Add motion bit for drag events + if matches!(event.event_type, MouseEventType::Drag) { + btn += 32; + } + + // SGR format: ESC[ 'M', + MouseEventType::Release => 'm', + }; + + format!("\x1b[<{};{};{}{}", btn, event.col, event.row, suffix).into_bytes() +} diff --git a/src/main.rs b/src/main.rs index e844e95..e1f043a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,16 @@ async fn start_http_api( Ok(()) } +fn validate_mouse_coordinates(mouse_event: &command::MouseEvent, session: &Session) { + let (cols, rows) = session.size(); + if mouse_event.row > rows || mouse_event.col > cols { + eprintln!( + "warning: mouse coordinates ({},{}) exceed terminal size ({}x{})", + mouse_event.col, mouse_event.row, cols, rows + ); + } +} + async fn run_event_loop( mut output_rx: mpsc::Receiver>, input_tx: mpsc::Sender>, @@ -98,6 +108,28 @@ async fn run_event_loop( input_tx.send(data).await?; } + Some(Command::Mouse(mouse_event)) => { + validate_mouse_coordinates(&mouse_event, &session); + let data = command::mouse_to_bytes(&mouse_event); + input_tx.send(data).await?; + } + + Some(Command::MouseClick(mouse_event)) => { + validate_mouse_coordinates(&mouse_event, &session); + + // Send press event + let mut press_event = mouse_event.clone(); + press_event.event_type = command::MouseEventType::Press; + let press_data = command::mouse_to_bytes(&press_event); + input_tx.send(press_data).await?; + + // Send release event + let mut release_event = mouse_event; + release_event.event_type = command::MouseEventType::Release; + let release_data = command::mouse_to_bytes(&release_event); + input_tx.send(release_data).await?; + } + Some(Command::Snapshot) => { session.snapshot(); } diff --git a/src/session.rs b/src/session.rs index 5dc59dd..01dfb14 100644 --- a/src/session.rs +++ b/src/session.rs @@ -76,6 +76,10 @@ impl Session { self.vt.cursor_key_app_mode() } + pub fn size(&self) -> (usize, usize) { + self.vt.size() + } + pub fn subscribe(&self) -> Subscription { let (cols, rows) = self.vt.size();