diff options
Diffstat (limited to 'src/client/attach.rs')
| -rw-r--r-- | src/client/attach.rs | 582 |
1 files changed, 582 insertions, 0 deletions
diff --git a/src/client/attach.rs b/src/client/attach.rs new file mode 100644 index 00000000..e385775c --- /dev/null +++ b/src/client/attach.rs @@ -0,0 +1,582 @@ +//! Direct terminal attach input parsing and semantic actions. + +#[cfg(unix)] +use std::io; + +#[cfg(unix)] +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; + +#[cfg(unix)] +use super::write_to_server; +#[cfg(unix)] +use crate::ipc::LocalStream; +#[cfg(unix)] +use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientMessage}; + +#[derive(Debug, Default)] +#[cfg(windows)] +pub(super) struct AttachEscapeState; + +#[derive(Debug, Default)] +#[cfg(unix)] +pub(super) struct AttachEscapeState { + pending_prefix: Option<Vec<u8>>, +} + +#[derive(Debug)] +#[cfg(unix)] +pub(super) enum AttachInputAction { + Forward(Vec<u8>), + ForwardPair(Vec<u8>, Vec<u8>), + Semantic(AttachSemanticAction), + ForwardThenSemantic(Vec<u8>, AttachSemanticAction), + Detach, + None, +} + +#[derive(Debug)] +#[cfg(unix)] +pub(super) enum AttachSemanticAction { + Scroll { + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option<u16>, + row: Option<u16>, + modifiers: u8, + }, + Mouse { + kind: crate::protocol::ClientMouseKind, + position: crate::protocol::ClientMousePosition, + modifiers: u8, + }, + Ignore, +} + +impl AttachEscapeState { + #[cfg(unix)] + pub(super) fn filter_input( + &mut self, + data: Vec<u8>, + viewport_rows: u16, + mouse_scroll_lines: usize, + ) -> AttachInputAction { + const PREFIX: u8 = 0x02; // Ctrl+B + + if crate::raw_input::is_complete_text_bracketed_paste(&data) { + return if let Some(prefix) = self.pending_prefix.take() { + AttachInputAction::ForwardPair(prefix, data) + } else { + AttachInputAction::Forward(data) + }; + } + + if let Some(key) = single_attach_key(&data) { + let is_prefix = key.code == crossterm::event::KeyCode::Char('b') + && key.modifiers == crossterm::event::KeyModifiers::CONTROL; + let is_quit = key.code == crossterm::event::KeyCode::Char('q') + && key.modifiers.is_empty() + && key.kind == crossterm::event::KeyEventKind::Press; + + if let Some(mut prefix) = self.pending_prefix.take() { + if is_prefix && key.kind != crossterm::event::KeyEventKind::Press { + prefix.extend(data); + self.pending_prefix = Some(prefix); + return AttachInputAction::None; + } + if is_quit { + return AttachInputAction::Detach; + } + if is_prefix { + return AttachInputAction::Forward(data); + } + if let Some(action) = attach_scroll_action(&data, viewport_rows, mouse_scroll_lines) + { + return AttachInputAction::ForwardThenSemantic(prefix, action); + } + prefix.extend(data); + return AttachInputAction::Forward(prefix); + } + + if is_prefix && key.kind == crossterm::event::KeyEventKind::Press { + self.pending_prefix = Some(data); + return AttachInputAction::None; + } + } + + if let Some(action) = attach_scroll_action(&data, viewport_rows, mouse_scroll_lines) { + return if let Some(prefix) = self.pending_prefix.take() { + AttachInputAction::ForwardThenSemantic(prefix, action) + } else { + AttachInputAction::Semantic(action) + }; + } + + // The host framer normally supplies one complete event. Preserve the legacy + // byte path for coalesced plain input used by older terminals. + let mut output = Vec::with_capacity(data.len()); + for byte in data { + if let Some(mut prefix) = self.pending_prefix.take() { + match byte { + b'q' => return AttachInputAction::Detach, + PREFIX => output.extend(prefix), + other => { + prefix.push(other); + output.extend(prefix); + } + } + continue; + } + + if byte == PREFIX { + self.pending_prefix = Some(vec![PREFIX]); + } else { + output.push(byte); + } + } + + if output.is_empty() { + AttachInputAction::None + } else if let Some(action) = + attach_scroll_action(&output, viewport_rows, mouse_scroll_lines) + { + AttachInputAction::Semantic(action) + } else { + AttachInputAction::Forward(output) + } + } + + #[cfg(unix)] + pub(super) fn take_pending_prefix(&mut self) -> Option<Vec<u8>> { + self.pending_prefix.take() + } +} + +#[cfg(unix)] +fn single_attach_key(data: &[u8]) -> Option<crate::input::TerminalKey> { + let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + if events.len() != 1 { + return None; + } + match events.pop()? { + crate::raw_input::RawInputEvent::Key(key) => Some(key), + _ => None, + } +} + +#[cfg(unix)] +pub(super) fn direct_attach_pixel_mouse( + data: &[u8], + geometry: crate::input::mouse::HostGeometry, +) -> Option<( + crate::protocol::ClientMouseKind, + crate::protocol::ClientMousePosition, + u8, +)> { + let (x, y) = crate::input::mouse::parse_report(data)?; + let (column, row) = geometry.cell(x, y)?; + let cell_report = crate::input::mouse::report_at_cell(data, column, row)?; + let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); + if events.len() != 1 { + return None; + } + let crate::raw_input::RawInputEvent::Mouse(mouse) = events.pop()? else { + return None; + }; + Some(( + crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?, + crate::protocol::ClientMousePosition::Pixels { x, y, column, row }, + mouse.modifiers.bits(), + )) +} + +#[cfg(unix)] +fn attach_scroll_action( + data: &[u8], + viewport_rows: u16, + mouse_scroll_lines: usize, +) -> Option<AttachSemanticAction> { + let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + if events.len() != 1 { + return None; + } + + match events.pop()? { + crate::raw_input::RawInputEvent::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + let direction = if mouse.kind == MouseEventKind::ScrollUp { + AttachScrollDirection::Up + } else { + AttachScrollDirection::Down + }; + Some(AttachSemanticAction::Scroll { + source: AttachScrollSource::Wheel, + direction, + lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, + column: Some(mouse.column), + row: Some(mouse.row), + modifiers: mouse.modifiers.bits(), + }) + } + kind => Some(AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::from_crossterm(kind)?, + position: crate::protocol::ClientMousePosition::Cell { + column: mouse.column, + row: mouse.row, + }, + modifiers: mouse.modifiers.bits(), + }), + }, + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + let direction = match key.code { + KeyCode::PageUp => AttachScrollDirection::Up, + KeyCode::PageDown => AttachScrollDirection::Down, + _ => return None, + }; + Some(AttachSemanticAction::Scroll { + source: AttachScrollSource::PageKey { + input: data.to_vec(), + }, + direction, + lines: viewport_rows.saturating_sub(1).max(1), + column: None, + row: None, + modifiers: KeyModifiers::empty().bits(), + }) + } + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && key.kind == KeyEventKind::Release + && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) => + { + Some(AttachSemanticAction::Ignore) + } + _ => None, + } +} + +#[cfg(unix)] +pub(super) fn write_attach_semantic_action( + stream: &mut LocalStream, + action: AttachSemanticAction, +) -> io::Result<()> { + let message = match action { + AttachSemanticAction::Scroll { + source, + direction, + lines, + column, + row, + modifiers, + } => ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + }, + AttachSemanticAction::Mouse { + kind, + position, + modifiers, + } => ClientMessage::AttachMouse { + kind, + position, + geometry: None, + modifiers, + lines: 1, + }, + AttachSemanticAction::Ignore => return Ok(()), + }; + write_to_server(stream, &message) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::protocol::{AttachScrollDirection, AttachScrollSource}; + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_prefix_q() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(vec![b'q'], 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_sends_literal_prefix_on_double_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + match escape.filter_input(vec![0x02], 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), + other => panic!("expected forwarded prefix, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_kitty_encoded_prefix_q() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[98;5:3u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[113u".to_vec(), 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_modify_other_keys_encoded_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[27;5;98~".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"q".to_vec(), 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_kitty_encoded_literal_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[98;5:3u".to_vec(), 24, 3), + AttachInputAction::None + )); + match escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[98;5u"), + other => panic!("expected Kitty-encoded prefix, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_does_not_interpret_bracketed_paste_contents() { + let mut escape = AttachEscapeState::default(); + let paste = b"\x1b[200~one\x02q\ntwo\x1b[201~".to_vec(); + + match escape.filter_input(paste.clone(), 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, paste), + other => panic!("expected opaque paste, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_flushes_pending_prefix_before_bracketed_paste() { + let mut escape = AttachEscapeState::default(); + let paste = b"\x1b[200~one\ntwo\x1b[201~".to_vec(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert!(matches!( + escape.filter_input(paste.clone(), 24, 3), + AttachInputAction::ForwardPair(prefix, bytes) + if prefix == vec![0x02] && bytes == paste + )); + assert!(matches!( + escape.filter_input(vec![b'q'], 24, 3), + AttachInputAction::Forward(bytes) if bytes == b"q" + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_prefix_before_non_escape_key() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![b'a', 0x02], 24, 3), + AttachInputAction::Forward(bytes) if bytes == b"a" + )); + match escape.filter_input(vec![b'x'], 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), + other => panic!("expected forwarded bytes, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_turns_wheel_into_scroll_action() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + column, + row, + .. + }) => { + assert_eq!(source, AttachScrollSource::Wheel); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 7); + assert_eq!(column, Some(10)); + assert_eq!(row, Some(5)); + } + other => panic!("expected scroll action, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_routes_non_wheel_mouse_reports_semantically() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), + AttachInputAction::Semantic(AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: crate::protocol::ClientMousePosition::Cell { column: 10, row: 5 }, + modifiers: 0, + }) + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_flushes_pending_prefix_before_cell_mouse() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert!(matches!( + escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), + AttachInputAction::ForwardThenSemantic( + prefix, + AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: crate::protocol::ClientMousePosition::Cell { + column: 10, + row: 5 + }, + modifiers: 0, + } + ) if prefix == vec![0x02] + )); + } + + #[cfg(unix)] + #[test] + fn direct_attach_pixel_mouse_keeps_pixels_and_semantic_kind() { + let geometry = crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(); + let (kind, position, modifiers) = + direct_attach_pixel_mouse(b"\x1b[<0;21;22M", geometry).expect("pixel mouse"); + + assert_eq!( + kind, + crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left) + ); + assert_eq!( + position, + crate::protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + } + ); + assert_eq!(modifiers, 0); + } + + #[cfg(unix)] + #[test] + fn pixel_mouse_flushes_pending_attach_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert_eq!(escape.take_pending_prefix(), Some(vec![0x02])); + assert_eq!(escape.take_pending_prefix(), None); + } + + #[cfg(unix)] + #[test] + fn attach_escape_turns_plain_page_keys_into_scroll_actions() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + .. + }) => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 11); + } + other => panic!("expected page-up scroll action, got {other:?}"), + } + + match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + .. + }) => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[6~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Down); + assert_eq!(lines, 11); + } + other => panic!("expected page-down scroll action, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_modified_page_key() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"), + other => panic!("expected modified page key to forward, got {other:?}"), + } + } +} |
