Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
201 changes: 200 additions & 1 deletion src/api/stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Command>,
clients_tx: mpsc::Sender<session::Client>,
Expand Down Expand Up @@ -106,6 +120,55 @@ fn build_command(value: serde_json::Value) -> Result<Command, String> {
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))
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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");
}
}
69 changes: 69 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#[derive(Debug)]
pub enum Command {
Input(Vec<InputSeq>),
Mouse(MouseEvent),
MouseClick(MouseEvent), // Convenience: sends press then release
Snapshot,
Resize(usize, usize),
}
Expand Down Expand Up @@ -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<u8> {
// 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[<btn;col;rowM (press/drag) or m (release)
let suffix = match event.event_type {
MouseEventType::Press | MouseEventType::Drag => 'M',
MouseEventType::Release => 'm',
};

format!("\x1b[<{};{};{}{}", btn, event.col, event.row, suffix).into_bytes()
}
Loading