diff options
Diffstat (limited to 'src/client/input.rs')
| -rw-r--r-- | src/client/input.rs | 310 |
1 files changed, 298 insertions, 12 deletions
diff --git a/src/client/input.rs b/src/client/input.rs index e9592db8..bbc37a58 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -1,22 +1,24 @@ //! Stdin input reading for the thin client. //! -//! Reads stdin bytes and forwards framed input to the main event loop. -//! Unlike the monolithic herdr, the thin client does NOT parse input into -//! key/mouse/paste events. It keeps enough byte-framing state to avoid splitting -//! terminal control strings, then sends bytes to the server as `ClientMessage::Input`. -//! The server handles semantic parsing. +//! On Unix, reads stdin bytes and forwards framed input to the main event loop. +//! The server handles semantic parsing. On Windows, crossterm may surface +//! terminal control strings as character key events, so the reader re-frames +//! those control bytes before forwarding semantic client input events. //! //! This is simpler and more reliable because: //! - The server has the same input parsing code //! - We avoid duplicating parsing logic in the client //! - Host terminal control replies can be buffered or discarded before they leak -use std::io::{self, Read}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; #[cfg(unix)] +use std::io::{self, Read}; +#[cfg(unix)] use std::os::fd::AsRawFd; +#[cfg(windows)] +use std::time::Duration; use tokio::sync::mpsc; use super::ClientLoopEvent; @@ -31,6 +33,15 @@ use super::ClientLoopEvent; /// The main loop receives the raw bytes and forwards them as /// `ClientMessage::Input` to the server. pub fn stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>) { + #[cfg(windows)] + return windows_stdin_reader_loop(event_tx, should_quit); + + #[cfg(unix)] + unix_stdin_reader_loop(event_tx, should_quit); +} + +#[cfg(unix)] +fn unix_stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>) { let stdin = io::stdin(); let mut reader = stdin.lock(); let mut scratch = [0u8; 4096]; @@ -70,16 +81,179 @@ pub fn stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: & } } +#[cfg(windows)] +fn windows_stdin_reader_loop( + event_tx: mpsc::Sender<ClientLoopEvent>, + should_quit: &Arc<AtomicBool>, +) { + let mut framer = crate::raw_input::RawInputFramer::default(); + let mut raw_sequence_pending = false; + + while !should_quit.load(Ordering::Acquire) { + match crossterm::event::poll(Duration::from_millis(10)) { + Ok(true) => {} + Ok(false) => { + if raw_sequence_pending { + tracing::debug!("windows input raw sequence timed out; flushing"); + if !send_windows_raw_events(framer.flush_timeout(), &event_tx) { + return; + } + raw_sequence_pending = false; + } + continue; + } + Err(_) => break, + } + + let event = match crossterm::event::read() { + Ok(event) => event, + Err(_) => break, + }; + + if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) { + tracing::debug!( + bytes = ?bytes, + pending_before = raw_sequence_pending, + "windows input routed through raw framer" + ); + let events = framer.push(&bytes); + raw_sequence_pending = events.is_empty(); + if !send_windows_raw_events(events, &event_tx) { + return; + } + continue; + } + + if raw_sequence_pending { + tracing::debug!("windows input raw sequence interrupted by semantic event; flushing"); + if !send_windows_raw_events(framer.flush_timeout(), &event_tx) { + return; + } + raw_sequence_pending = false; + } + + if windows_event_is_control_key(&event) { + tracing::debug!(event = ?event, "windows control key forwarded as semantic input"); + } + + let Some(event) = crate::protocol::ClientInputEvent::from_crossterm(event) else { + continue; + }; + if event_tx + .blocking_send(ClientLoopEvent::StdinEvents(vec![event])) + .is_err() + { + return; + } + } + + if raw_sequence_pending { + let _ = send_windows_raw_events(framer.flush_timeout(), &event_tx); + } +} + +#[cfg(windows)] +fn windows_event_is_control_key(event: &crossterm::event::Event) -> bool { + use crossterm::event::{Event, KeyModifiers}; + + matches!( + event, + Event::Key(key) + if key.modifiers.contains(KeyModifiers::CONTROL) + || matches!(key.code, crossterm::event::KeyCode::Char(ch) if ch.is_control()) + ) +} + +#[cfg(windows)] +fn windows_key_raw_bytes( + event: &crossterm::event::Event, + raw_sequence_pending: bool, +) -> Option<Vec<u8>> { + use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; + + let Event::Key(key) = event else { + return None; + }; + if key.kind == KeyEventKind::Release { + return None; + } + + match key.code { + KeyCode::Esc if key.modifiers.is_empty() => Some(vec![0x1b]), + KeyCode::Char(ch) if raw_sequence_pending || ch.is_control() => { + let mut bytes = Vec::new(); + if key.modifiers.contains(KeyModifiers::ALT) { + bytes.push(0x1b); + } + let mut buf = [0; 4]; + bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); + Some(bytes) + } + _ => None, + } +} + +#[cfg(windows)] +fn send_windows_raw_events( + events: Vec<crate::raw_input::RawInputEvent>, + event_tx: &mpsc::Sender<ClientLoopEvent>, +) -> bool { + let raw_event_count = events.len(); + let events = events + .into_iter() + .filter_map(windows_client_input_event_from_raw) + .collect::<Vec<_>>(); + if events.is_empty() { + return true; + } + + tracing::debug!( + raw_event_count, + forwarded_event_count = events.len(), + "windows raw-framed input events forwarded" + ); + event_tx + .blocking_send(ClientLoopEvent::StdinEvents(events)) + .is_ok() +} + +#[cfg(windows)] +fn windows_client_input_event_from_raw( + event: crate::raw_input::RawInputEvent, +) -> Option<crate::protocol::ClientInputEvent> { + match event { + crate::raw_input::RawInputEvent::Key(key) => Some(crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::from_crossterm(key.code)?, + modifiers: key.modifiers.bits(), + kind: crate::protocol::ClientKeyKind::from_crossterm(key.kind), + }), + crate::raw_input::RawInputEvent::Mouse(mouse) => { + Some(crate::protocol::ClientInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?, + column: mouse.column, + row: mouse.row, + modifiers: mouse.modifiers.bits(), + }) + } + crate::raw_input::RawInputEvent::Paste(text) => { + Some(crate::protocol::ClientInputEvent::Paste { text }) + } + crate::raw_input::RawInputEvent::OuterFocusGained => { + Some(crate::protocol::ClientInputEvent::FocusGained) + } + crate::raw_input::RawInputEvent::OuterFocusLost => { + Some(crate::protocol::ClientInputEvent::FocusLost) + } + crate::raw_input::RawInputEvent::HostDefaultColor { .. } + | crate::raw_input::RawInputEvent::Unsupported => None, + } +} + #[cfg(unix)] fn stdin_read_ready<R: AsRawFd>(reader: &R, timeout_ms: i32) -> Option<bool> { poll_read_ready(reader.as_raw_fd(), timeout_ms) } -#[cfg(not(unix))] -fn stdin_read_ready<R>(_reader: &R, _timeout_ms: i32) -> Option<bool> { - None -} - #[cfg(unix)] fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> { #[repr(C)] @@ -113,7 +287,7 @@ fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> { // Tests // --------------------------------------------------------------------------- -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { // The stdin reader thread is hard to unit test since it reads from actual stdin. // Integration tests will verify the full client→server input flow. @@ -121,6 +295,7 @@ mod tests { use super::*; + #[cfg(unix)] #[test] fn stdin_input_event_carries_raw_bytes() { let data = vec![0x1b, b'[', b'A']; // Up arrow escape sequence @@ -131,3 +306,114 @@ mod tests { } } } + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + + #[test] + fn windows_control_chars_are_reframed_as_raw_bytes() { + let escape = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); + assert_eq!( + windows_key_raw_bytes(&escape, false).as_deref(), + Some(b"\x1b".as_slice()) + ); + + let enter = Event::Key(KeyEvent::new(KeyCode::Char('\r'), KeyModifiers::empty())); + assert_eq!( + windows_key_raw_bytes(&enter, false).as_deref(), + Some(b"\r".as_slice()) + ); + + let printable = Event::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty())); + assert_eq!(windows_key_raw_bytes(&printable, false), None); + + let pending_arrow_tail = + Event::Key(KeyEvent::new(KeyCode::Char('['), KeyModifiers::empty())); + assert_eq!( + windows_key_raw_bytes(&pending_arrow_tail, true).as_deref(), + Some(b"[".as_slice()) + ); + } + + #[test] + fn windows_ctrl_d_semantic_event_encodes_to_eot() { + let event = Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); + assert_eq!(windows_key_raw_bytes(&event, false), None); + + let event = + crate::protocol::ClientInputEvent::from_crossterm(event).expect("ctrl-d converts"); + let raw = event.to_raw_input_event(); + let crate::raw_input::RawInputEvent::Key(key) = raw else { + panic!("expected key"); + }; + assert_eq!(key.code, KeyCode::Char('d')); + assert_eq!(key.modifiers, KeyModifiers::CONTROL); + assert_eq!( + crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), + b"\x04" + ); + } + + #[test] + fn windows_eot_control_char_normalizes_to_ctrl_d() { + let event = Event::Key(KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::empty())); + let bytes = windows_key_raw_bytes(&event, false).expect("eot routes through raw framer"); + assert_eq!(bytes, b"\x04"); + + let mut framer = crate::raw_input::RawInputFramer::default(); + let events = framer.push(&bytes); + assert_eq!(events.len(), 1); + + let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap()) + .expect("raw eot converts"); + assert_eq!( + event, + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('d'), + modifiers: KeyModifiers::CONTROL.bits(), + kind: crate::protocol::ClientKeyKind::Press, + } + ); + } + + #[test] + fn windows_pending_escape_sequence_converts_to_semantic_arrow() { + let mut framer = crate::raw_input::RawInputFramer::default(); + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.push(b"[").is_empty()); + let events = framer.push(b"A"); + assert_eq!(events.len(), 1); + + let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap()) + .expect("raw arrow converts"); + assert_eq!( + event, + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Up, + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + } + ); + } + + #[test] + fn windows_bare_escape_flushes_to_semantic_escape() { + let mut framer = crate::raw_input::RawInputFramer::default(); + assert!(framer.push(b"\x1b").is_empty()); + let events = framer.flush_timeout(); + assert_eq!(events.len(), 1); + + let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap()) + .expect("raw escape converts"); + assert_eq!( + event, + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Esc, + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + } + ); + } +} |
