Full remote input parity: forward mouse and structured key events over IPC

- New key_event/mouse_event client messages (crossterm serde) that run
  through the complete WM event handling on the server — remote clients
  get window focus, drag/resize and mouse forwarding into virtual
  terminals (SGR/UTF-8/X10) identical to standalone mode
- tui-wm -c now forwards mouse events; previously only keyboard worked
  remotely
- IPC unit tests + end-to-end integration test (tests/integration.py):
  daemon, frames, click/scroll SGR forwarding, popups, TUI-FM in a
  window — 12/12 passing
- Document the new messages in SOCKET_API.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:50:27 +02:00
parent 75aa7033a4
commit f0bed7a14e
8 changed files with 373 additions and 63 deletions

View File

@@ -117,12 +117,14 @@ fn client_loop(
{
break;
}
if let Some(bytes) = key_to_bytes(key) {
ipc::write_message(
writer,
&ClientMessage::Input { data: ipc::to_hex(&bytes) },
)?;
}
// Skicka strukturerat så att servern kör eventet genom
// hela WM-hanteringen (globala keybinds, dialoger, PTY).
ipc::write_message(writer, &ClientMessage::KeyEvent { event: key })?;
}
Event::Mouse(mouse) => {
// Full musforwarding: fokus, drag/resize och vidare-
// befordran in i de virtuella terminalerna.
ipc::write_message(writer, &ClientMessage::MouseEvent { event: mouse })?;
}
Event::Resize(w, h) => {
term_width = w;
@@ -144,58 +146,3 @@ fn draw_boundary(out: &mut impl Write, at_row: u16, width: u16) -> io::Result<()
Ok(())
}
pub fn key_to_bytes(key: crossterm::event::KeyEvent) -> Option<Vec<u8>> {
use crossterm::event::KeyCode::*;
let bytes: Vec<u8> = match key.code {
Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match c {
'a'..='z' => vec![c as u8 - b'a' + 1],
'A'..='Z' => vec![c as u8 - b'A' + 1],
'[' => vec![0x1b],
'\\' => vec![0x1c],
']' => vec![0x1d],
_ => return None,
}
} else {
let mut buf = [0u8; 4];
c.encode_utf8(&mut buf).as_bytes().to_vec()
}
}
Enter => vec![b'\r'],
Backspace => vec![0x7f],
Tab => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
b"\x1b[Z".to_vec()
} else {
vec![b'\t']
}
}
Esc => vec![0x1b],
Insert => b"\x1b[2~".to_vec(),
Up => b"\x1b[A".to_vec(),
Down => b"\x1b[B".to_vec(),
Right => b"\x1b[C".to_vec(),
Left => b"\x1b[D".to_vec(),
Home => b"\x1b[H".to_vec(),
End => b"\x1b[F".to_vec(),
PageUp => b"\x1b[5~".to_vec(),
PageDown => b"\x1b[6~".to_vec(),
Delete => b"\x1b[3~".to_vec(),
F(1) => b"\x1bOP".to_vec(),
F(2) => b"\x1bOQ".to_vec(),
F(3) => b"\x1bOR".to_vec(),
F(4) => b"\x1bOS".to_vec(),
F(5) => b"\x1b[15~".to_vec(),
F(6) => b"\x1b[17~".to_vec(),
F(7) => b"\x1b[18~".to_vec(),
F(8) => b"\x1b[19~".to_vec(),
F(9) => b"\x1b[20~".to_vec(),
F(10) => b"\x1b[21~".to_vec(),
F(11) => b"\x1b[23~".to_vec(),
F(12) => b"\x1b[24~".to_vec(),
BackTab => b"\x1b[Z".to_vec(),
_ => return None,
};
Some(bytes)
}

View File

@@ -53,6 +53,15 @@ pub enum ClientMessage {
window_id: Option<usize>,
},
Input { data: String }, // hex-encoded raw bytes
/// Strukturerat tangentbordsevent från en display-klient.
/// Körs genom WM:ns fulla event-hantering (globala keybinds,
/// fokus, PTY-forwarding) — till skillnad från Input som skrivs
/// rakt in i den fokuserade terminalen.
KeyEvent { event: crossterm::event::KeyEvent },
/// Strukturerat musevent från en display-klient. Ger fönsterfokus,
/// drag/resize och vidarebefordran in i virtuella terminaler
/// (SGR/UTF-8/X10-kodning) precis som i standalone-läget.
MouseEvent { event: crossterm::event::MouseEvent },
Resize { width: u16, height: u16 },
SpawnWindow {
command: String,
@@ -245,6 +254,78 @@ fn color_fg(c: ratatui::style::Color) -> String {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_event_roundtrip() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let msg = ClientMessage::KeyEvent {
event: KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
};
let json = serde_json::to_string(&msg).unwrap();
println!("key_event JSON: {}", json);
let back: ClientMessage = serde_json::from_str(&json).unwrap();
match back {
ClientMessage::KeyEvent { event } => {
assert_eq!(event.code, KeyCode::Char('a'));
assert_eq!(event.modifiers, KeyModifiers::CONTROL);
}
_ => panic!("fel variant"),
}
}
#[test]
fn mouse_event_roundtrip() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind, KeyModifiers};
let msg = ClientMessage::MouseEvent {
event: MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 5,
modifiers: KeyModifiers::NONE,
},
};
let json = serde_json::to_string(&msg).unwrap();
println!("mouse_event JSON: {}", json);
let back: ClientMessage = serde_json::from_str(&json).unwrap();
match back {
ClientMessage::MouseEvent { event } => {
assert_eq!(event.kind, MouseEventKind::Down(MouseButton::Left));
assert_eq!(event.column, 10);
assert_eq!(event.row, 5);
}
_ => panic!("fel variant"),
}
}
#[test]
fn message_framing_roundtrip() {
let msg = ClientMessage::SpawnWindow {
command: "htop".to_string(),
request_id: Some("t1".to_string()),
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
let mut cursor = std::io::Cursor::new(buf);
let back: ClientMessage = read_message(&mut cursor).unwrap();
match back {
ClientMessage::SpawnWindow { command, request_id } => {
assert_eq!(command, "htop");
assert_eq!(request_id.as_deref(), Some("t1"));
}
_ => panic!("fel variant"),
}
}
#[test]
fn hex_roundtrip() {
let data = vec![0x00, 0x1b, 0xff, 0x41];
assert_eq!(from_hex(&to_hex(&data)), data);
}
}
fn color_bg(c: ratatui::style::Color) -> String {
use ratatui::style::Color::*;
match c {

View File

@@ -383,6 +383,12 @@ fn handle_ipc_event(
}
}
}
ClientMessage::KeyEvent { event } => {
app.handle_event(crossterm::event::Event::Key(event));
}
ClientMessage::MouseEvent { event } => {
app.handle_event(crossterm::event::Event::Mouse(event));
}
ClientMessage::Resize { width, height } => {
if let Some(client) = all_clients.get_mut(&client_id) {
client.1 = width;