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:
@@ -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)
|
||||
}
|
||||
|
||||
81
src/ipc.rs
81
src/ipc.rs
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user