diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 41 | ||||
| -rw-r--r-- | src/app/mod.rs | 60 | ||||
| -rw-r--r-- | src/input.rs | 882 | ||||
| -rw-r--r-- | src/main.rs | 8 | ||||
| -rw-r--r-- | src/pane.rs | 8 | ||||
| -rw-r--r-- | src/pty_callbacks.rs | 18 | ||||
| -rw-r--r-- | src/raw_input.rs | 633 |
7 files changed, 1584 insertions, 66 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index 0c901be5..68fbe260 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -2,6 +2,8 @@ use bytes::Bytes; use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; + +use crate::input::TerminalKey; use ratatui::layout::{Direction, Rect}; use tracing::warn; @@ -18,16 +20,22 @@ use super::App; // --------------------------------------------------------------------------- impl App { - pub(super) async fn handle_key(&mut self, key: KeyEvent) { + pub(super) async fn handle_key(&mut self, key: TerminalKey) { match self.state.mode { - Mode::Onboarding => self.handle_onboarding_key(key), - Mode::Navigate => handle_navigate_key(&mut self.state, key), Mode::Terminal => self.handle_terminal_key(key).await, - Mode::RenameSession => handle_rename_key(&mut self.state, key), - Mode::Resize => handle_resize_key(&mut self.state, key), - Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key), - Mode::ContextMenu => handle_context_menu_key(&mut self.state, key), - Mode::Settings => self.handle_settings_key(key), + _ => { + let key = key.as_key_event(); + match self.state.mode { + Mode::Onboarding => self.handle_onboarding_key(key), + Mode::Navigate => handle_navigate_key(&mut self.state, key), + Mode::RenameSession => handle_rename_key(&mut self.state, key), + Mode::Resize => handle_resize_key(&mut self.state, key), + Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key), + Mode::ContextMenu => handle_context_menu_key(&mut self.state, key), + Mode::Settings => self.handle_settings_key(key), + Mode::Terminal => unreachable!(), + } + } } } @@ -122,11 +130,13 @@ impl App { } } - async fn handle_terminal_key(&mut self, key: KeyEvent) { + async fn handle_terminal_key(&mut self, key: TerminalKey) { self.state.clear_selection(); self.state.update_dismissed = true; - if self.state.is_prefix(&key) { + let key_event = key.as_key_event(); + + if self.state.is_prefix(&key_event) { self.state.mode = Mode::Navigate; return; } @@ -134,10 +144,15 @@ impl App { if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) { if let Some(rt) = ws.focused_runtime() { rt.scroll_reset(); - let kitty = rt.kitty_keyboard.load(std::sync::atomic::Ordering::Relaxed); - let bytes = crate::input::encode_key(key, kitty); + let flags = rt + .kitty_keyboard_flags + .load(std::sync::atomic::Ordering::Relaxed); + let bytes = crate::input::encode_terminal_key( + key, + crate::input::KeyboardProtocol::from_kitty_flags(flags), + ); if bytes.is_empty() { - warn!(code = ?key.code, mods = ?key.modifiers, state = ?key.state, "key produced empty encoding"); + warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding"); } else { let _ = rt.sender.send(Bytes::from(bytes)).await; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 15ae2b9b..5c5467c5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,7 +14,7 @@ use std::time::{Duration, Instant}; const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -use crossterm::event::{self, Event, KeyEventKind}; +use crossterm::terminal; use ratatui::layout::Rect; use ratatui::DefaultTerminal; use tokio::sync::mpsc; @@ -35,6 +35,8 @@ pub struct App { event_hub: crate::api::EventHub, last_focus: Option<(usize, crate::layout::PaneId)>, no_session: bool, + input_rx: Option<mpsc::Receiver<crate::raw_input::RawInputEvent>>, + last_terminal_size: Option<(u16, u16)>, config_diagnostic_deadline: Option<Instant>, toast_deadline: Option<Instant>, last_git_remote_status_refresh: Instant, @@ -192,10 +194,16 @@ impl App { event_hub, last_focus, no_session, + input_rx: None, + last_terminal_size: terminal::size().ok(), } } pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { + if self.input_rx.is_none() { + self.input_rx = Some(crate::raw_input::spawn_input_reader()); + } + while !self.state.should_quit { if self .config_diagnostic_deadline @@ -236,18 +244,10 @@ impl App { } self.sync_focus_events(); + self.handle_resize_poll(); + self.drain_raw_input().await; - if event::poll(Duration::from_millis(16))? { - match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => { - self.handle_key(key).await; - } - Event::Paste(text) => self.handle_paste(text).await, - Event::Mouse(mouse) => self.handle_mouse(mouse), - Event::Resize(_, _) => {} - _ => {} - } - } + tokio::time::sleep(Duration::from_millis(16)).await; if self.state.request_complete_onboarding { self.state.request_complete_onboarding = false; @@ -273,6 +273,33 @@ impl App { Ok(()) } + async fn drain_raw_input(&mut self) { + while let Some(rx) = self.input_rx.as_mut() { + match rx.try_recv() { + Ok(crate::raw_input::RawInputEvent::Key(key)) => { + if key.kind == crossterm::event::KeyEventKind::Press { + self.handle_key(key).await; + } + } + Ok(crate::raw_input::RawInputEvent::Paste(text)) => self.handle_paste(text).await, + Ok(crate::raw_input::RawInputEvent::Mouse(mouse)) => self.handle_mouse(mouse), + Ok(crate::raw_input::RawInputEvent::Unsupported) => {} + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + self.input_rx = None; + break; + } + } + } + } + + fn handle_resize_poll(&mut self) { + let Ok(size) = terminal::size() else { return }; + if self.last_terminal_size != Some(size) { + self.last_terminal_size = Some(size); + } + } + fn drain_internal_events(&mut self) { while let Ok(ev) = self.event_rx.try_recv() { match &ev { @@ -871,11 +898,14 @@ impl App { }) .unwrap(); }; - let kitty = runtime + let flags = runtime .1 - .kitty_keyboard + .kitty_keyboard_flags .load(std::sync::atomic::Ordering::Relaxed); - let bytes = crate::input::encode_key(key_event, kitty); + let bytes = crate::input::encode_key( + key_event, + crate::input::KeyboardProtocol::from_kitty_flags(flags), + ); if let Err(err) = runtime.0.try_send(Bytes::from(bytes)) { return serde_json::to_string(&ErrorResponse { id: request.id, diff --git a/src/input.rs b/src/input.rs index f7c1f5c2..8d11e335 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,20 +1,304 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -/// Encode a key event for a PTY child. -/// If `kitty_mode` is true, modified keys use CSI u format. -pub fn encode_key(key: KeyEvent, kitty_mode: bool) -> Vec<u8> { - if kitty_mode { - if let Some(bytes) = try_encode_csi_u(&key) { +const KITTY_FLAG_REPORT_EVENT_TYPES: u16 = 0b0000_0010; +const KITTY_FLAG_REPORT_ALTERNATE_KEYS: u16 = 0b0000_0100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TerminalKey { + pub code: KeyCode, + pub modifiers: KeyModifiers, + pub kind: crossterm::event::KeyEventKind, + pub shifted_codepoint: Option<u32>, +} + +impl TerminalKey { + pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self { + Self { + code, + modifiers, + kind: crossterm::event::KeyEventKind::Press, + shifted_codepoint: None, + } + } + + pub fn with_kind(mut self, kind: crossterm::event::KeyEventKind) -> Self { + self.kind = kind; + self + } + + #[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs. + pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self { + self.shifted_codepoint = Some(shifted_codepoint); + self + } + + pub fn as_key_event(self) -> KeyEvent { + KeyEvent::new_with_kind(self.code, self.modifiers, self.kind) + } +} + +impl From<KeyEvent> for TerminalKey { + fn from(value: KeyEvent) -> Self { + Self::new(value.code, value.modifiers).with_kind(value.kind) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyboardProtocol { + Legacy, + Kitty { flags: u16 }, +} + +impl KeyboardProtocol { + pub fn from_kitty_flags(flags: u16) -> Self { + if flags == 0 { + Self::Legacy + } else { + Self::Kitty { flags } + } + } +} + +#[allow(dead_code)] // Next step: raw stdin parser will feed TerminalKey directly through this path. +pub fn parse_terminal_key_sequence(data: &str) -> Option<TerminalKey> { + parse_kitty_key_sequence(data) + .or_else(|| parse_modify_other_keys_sequence(data)) + .or_else(|| parse_legacy_key_sequence(data)) +} + +#[allow(dead_code)] // Reserved for the upcoming raw stdin parser. +fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> { + let body = data.strip_prefix("\x1b[")?.strip_suffix('u')?; + + let (main, event_type) = match body.rsplit_once(':') { + Some((head, tail)) if tail.chars().all(|ch| ch.is_ascii_digit()) && head.contains(';') => { + (head, Some(tail)) + } + _ => (body, None), + }; + + let (key_part, modifier_part) = main.rsplit_once(';').unwrap_or((main, "1")); + let modifier = modifier_part.parse::<u8>().ok()?.checked_sub(1)?; + + let mut key_fields = key_part.split(':'); + let codepoint = key_fields.next()?.parse::<u32>().ok()?; + let shifted_codepoint = key_fields + .next() + .filter(|field| !field.is_empty()) + .and_then(|field| field.parse::<u32>().ok()); + + let code = kitty_codepoint_to_keycode(codepoint)?; + let kind = parse_kitty_event_type(event_type)?; + + Some(TerminalKey { + code, + modifiers: key_modifiers_from_u8(modifier), + kind, + shifted_codepoint, + }) +} + +#[allow(dead_code)] // Reserved for the upcoming raw stdin parser. +fn parse_modify_other_keys_sequence(data: &str) -> Option<TerminalKey> { + let body = data.strip_prefix("\x1b[27;")?.strip_suffix('~')?; + let (modifier_part, codepoint_part) = body.split_once(';')?; + let modifier = modifier_part.parse::<u8>().ok()?.checked_sub(1)?; + let codepoint = codepoint_part.parse::<u32>().ok()?; + + Some(TerminalKey::new( + kitty_codepoint_to_keycode(codepoint)?, + key_modifiers_from_u8(modifier), + )) +} + +#[allow(dead_code)] // Reserved for the upcoming raw stdin parser. +fn parse_legacy_key_sequence(data: &str) -> Option<TerminalKey> { + if let Some(key) = parse_legacy_special_sequence(data) { + return Some(key); + } + + match data { + "\r" | "\n" => Some(TerminalKey::new(KeyCode::Enter, KeyModifiers::empty())), + "\t" => Some(TerminalKey::new(KeyCode::Tab, KeyModifiers::empty())), + "\x1b" => Some(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())), + "\x7f" => Some(TerminalKey::new(KeyCode::Backspace, KeyModifiers::empty())), + _ if data.starts_with('\x1b') => { + let rest = data.strip_prefix('\x1b')?; + if rest.chars().count() == 1 { + let ch = rest.chars().next()?; + Some(TerminalKey::new(KeyCode::Char(ch), KeyModifiers::ALT)) + } else { + None + } + } + _ if data.chars().count() == 1 => { + let ch = data.chars().next()?; + + if let Some(ctrl_key) = parse_legacy_ctrl_char(ch) { + return Some(ctrl_key); + } + + let mut modifiers = KeyModifiers::empty(); + let code = if ch.is_ascii_uppercase() { + modifiers |= KeyModifiers::SHIFT; + KeyCode::Char(ch) + } else { + KeyCode::Char(ch) + }; + Some(TerminalKey::new(code, modifiers)) + } + _ => None, + } +} + +fn parse_legacy_ctrl_char(ch: char) -> Option<TerminalKey> { + match ch as u32 { + 0 => Some(TerminalKey::new(KeyCode::Char(' '), KeyModifiers::CONTROL)), + 1..=26 => Some(TerminalKey::new( + KeyCode::Char(char::from_u32((ch as u32) + 96)?), + KeyModifiers::CONTROL, + )), + 27 => Some(TerminalKey::new(KeyCode::Char('['), KeyModifiers::CONTROL)), + 28 => Some(TerminalKey::new(KeyCode::Char('\\'), KeyModifiers::CONTROL)), + 29 => Some(TerminalKey::new(KeyCode::Char(']'), KeyModifiers::CONTROL)), + 30 => Some(TerminalKey::new(KeyCode::Char('^'), KeyModifiers::CONTROL)), + 31 => Some(TerminalKey::new(KeyCode::Char('-'), KeyModifiers::CONTROL)), + _ => None, + } +} + +fn parse_legacy_special_sequence(data: &str) -> Option<TerminalKey> { + match data { + "\x1b\x1b[A" => Some(TerminalKey::new(KeyCode::Up, KeyModifiers::ALT)), + "\x1b\x1b[B" => Some(TerminalKey::new(KeyCode::Down, KeyModifiers::ALT)), + "\x1b\x1b[C" => Some(TerminalKey::new(KeyCode::Right, KeyModifiers::ALT)), + "\x1b\x1b[D" => Some(TerminalKey::new(KeyCode::Left, KeyModifiers::ALT)), + "\x1b[A" => Some(TerminalKey::new(KeyCode::Up, KeyModifiers::empty())), + "\x1b[B" => Some(TerminalKey::new(KeyCode::Down, KeyModifiers::empty())), + "\x1b[C" => Some(TerminalKey::new(KeyCode::Right, KeyModifiers::empty())), + "\x1b[D" => Some(TerminalKey::new(KeyCode::Left, KeyModifiers::empty())), + "\x1b[H" | "\x1bOH" | "\x1b[1~" | "\x1b[7~" => { + Some(TerminalKey::new(KeyCode::Home, KeyModifiers::empty())) + } + "\x1b[F" | "\x1bOF" | "\x1b[4~" | "\x1b[8~" => { + Some(TerminalKey::new(KeyCode::End, KeyModifiers::empty())) + } + "\x1b[5~" => Some(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())), + "\x1b[6~" => Some(TerminalKey::new(KeyCode::PageDown, KeyModifiers::empty())), + "\x1b[2~" => Some(TerminalKey::new(KeyCode::Insert, KeyModifiers::empty())), + "\x1b[3~" => Some(TerminalKey::new(KeyCode::Delete, KeyModifiers::empty())), + "\x1b[Z" => Some(TerminalKey::new(KeyCode::BackTab, KeyModifiers::SHIFT)), + _ => parse_xterm_modified_special_sequence(data), + } +} + +fn parse_xterm_modified_special_sequence(data: &str) -> Option<TerminalKey> { + let body = data.strip_prefix("\x1b[")?; + + if let Some(body) = body.strip_prefix("1;") { + let suffix_char = body.chars().last()?; + if suffix_char.is_ascii_alphabetic() { + let modifier = body.strip_suffix(suffix_char)?; + let mod_value = modifier.parse::<u8>().ok()?.checked_sub(1)?; + let code = match suffix_char { + 'A' => KeyCode::Up, + 'B' => KeyCode::Down, + 'C' => KeyCode::Right, + 'D' => KeyCode::Left, + 'H' => KeyCode::Home, + 'F' => KeyCode::End, + _ => return None, + }; + return Some(TerminalKey::new(code, key_modifiers_from_u8(mod_value))); + } + } + + let tilde_body = body.strip_suffix('~')?; + let (code_part, modifier_part) = tilde_body.split_once(';')?; + let mod_value = modifier_part.parse::<u8>().ok()?.checked_sub(1)?; + let code = match code_part { + "2" => KeyCode::Insert, + "3" => KeyCode::Delete, + "5" => KeyCode::PageUp, + "6" => KeyCode::PageDown, + _ => return None, + }; + Some(TerminalKey::new(code, key_modifiers_from_u8(mod_value))) +} + +#[allow(dead_code)] // Reserved for the upcoming raw stdin parser. +fn parse_kitty_event_type(value: Option<&str>) -> Option<crossterm::event::KeyEventKind> { + match value.unwrap_or("1") { + "1" => Some(crossterm::event::KeyEventKind::Press), + "2" => Some(crossterm::event::KeyEventKind::Repeat), + "3" => Some(crossterm::event::KeyEventKind::Release), + _ => None, + } +} + +#[allow(dead_code)] // Reserved for the upcoming raw stdin parser. +fn kitty_codepoint_to_keycode(codepoint: u32) -> Option<KeyCode> { + match codepoint { + 8 | 127 => Some(KeyCode::Backspace), + 9 => Some(KeyCode::Tab), + 13 | 57414 => Some(KeyCode::Enter), + 27 => Some(KeyCode::Esc), + 57417 => Some(KeyCode::Left), + 57418 => Some(KeyCode::Right), + 57419 => Some(KeyCode::Up), + 57420 => Some(KeyCode::Down), + 57421 => Some(KeyCode::PageUp), + 57422 => Some(KeyCode::PageDown), + 57423 => Some(KeyCode::Home), + 57424 => Some(KeyCode::End), + 57425 => Some(KeyCode::Insert), + 57426 => Some(KeyCode::Delete), + value => char::from_u32(value).map(KeyCode::Char), + } +} + +#[allow(dead_code)] // Reserved for the upcoming raw stdin parser. +fn key_modifiers_from_u8(modifier: u8) -> KeyModifiers { + let mut mods = KeyModifiers::empty(); + if modifier & 0b0000_0001 != 0 { + mods |= KeyModifiers::SHIFT; + } + if modifier & 0b0000_0010 != 0 { + mods |= KeyModifiers::ALT; + } + if modifier & 0b0000_0100 != 0 { + mods |= KeyModifiers::CONTROL; + } + if modifier & 0b0000_1000 != 0 { + mods |= KeyModifiers::SUPER; + } + if modifier & 0b0001_0000 != 0 { + mods |= KeyModifiers::HYPER; + } + if modifier & 0b0010_0000 != 0 { + mods |= KeyModifiers::META; + } + mods +} + +/// Encode a key event for a PTY child using the pane's negotiated keyboard protocol. +pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec<u8> { + encode_terminal_key(key.into(), protocol) +} + +pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<u8> { + if let KeyboardProtocol::Kitty { flags } = protocol { + if let Some(bytes) = try_encode_csi_u(&key, flags) { return bytes; } } - encode_legacy(key) + encode_legacy(key.as_key_event()) } /// CSI u encoding: \e[{codepoint};{modifiers}u /// Used when the child has pushed Kitty keyboard enhancement. /// Returns None if the key doesn't need CSI u (unmodified basic keys). -fn try_encode_csi_u(key: &KeyEvent) -> Option<Vec<u8>> { +fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> { let mods = key.modifiers; // Unmodified keys use legacy encoding (more compatible) @@ -52,18 +336,30 @@ fn try_encode_csi_u(key: &KeyEvent) -> Option<Vec<u8>> { _ => {} } - let codepoint = match key.code { - KeyCode::Char(c) => c as u32, - KeyCode::Enter => 13, - KeyCode::Tab => 9, - KeyCode::Backspace => 127, - KeyCode::Esc => 27, + let (codepoint, alternate_shifted) = match key.code { + KeyCode::Char(c) => { + let base = canonical_kitty_char(c, mods); + let shifted = alternate_shifted_codepoint(key, flags); + (base as u32, shifted) + } + KeyCode::Enter => (13, None), + KeyCode::Tab => (9, None), + KeyCode::Backspace => (127, None), + KeyCode::Esc => (27, None), _ => return None, // fall back to legacy for unhandled keys }; let modifier = kitty_modifier(mods); + let event_suffix = kitty_event_suffix(key, flags); - Some(format!("\x1b[{codepoint};{modifier}u").into_bytes()) + let sequence = match (alternate_shifted, event_suffix) { + (Some(shifted), Some(event)) => format!("\x1b[{codepoint}:{shifted};{modifier}:{event}u"), + (Some(shifted), None) => format!("\x1b[{codepoint}:{shifted};{modifier}u"), + (None, Some(event)) => format!("\x1b[{codepoint};{modifier}:{event}u"), + (None, None) => format!("\x1b[{codepoint};{modifier}u"), + }; + + Some(sequence.into_bytes()) } /// Legacy terminal encoding (standard escape sequences). @@ -167,6 +463,45 @@ fn kitty_modifier(mods: KeyModifiers) -> u32 { m } +fn canonical_kitty_char(ch: char, mods: KeyModifiers) -> char { + if mods.contains(KeyModifiers::SHIFT) && ch.is_ascii_uppercase() { + ch.to_ascii_lowercase() + } else { + ch + } +} + +fn alternate_shifted_codepoint(key: &TerminalKey, flags: u16) -> Option<u32> { + if flags & KITTY_FLAG_REPORT_ALTERNATE_KEYS == 0 { + return None; + } + + if let Some(shifted) = key.shifted_codepoint { + return Some(shifted); + } + + match key.code { + KeyCode::Char(ch) + if key.modifiers.contains(KeyModifiers::SHIFT) && ch.is_ascii_uppercase() => + { + Some(ch as u32) + } + _ => None, + } +} + +fn kitty_event_suffix(key: &TerminalKey, flags: u16) -> Option<u8> { + if flags & KITTY_FLAG_REPORT_EVENT_TYPES == 0 { + return None; + } + + Some(match key.kind { + crossterm::event::KeyEventKind::Press => 1, + crossterm::event::KeyEventKind::Repeat => 2, + crossterm::event::KeyEventKind::Release => 3, + }) +} + fn encode_legacy_inner(key: KeyEvent) -> Vec<u8> { match key.code { KeyCode::Char(ch) => { @@ -229,16 +564,90 @@ fn encode_f_key(n: u8) -> Vec<u8> { mod tests { use super::*; + fn assert_terminal_key_eq( + actual: TerminalKey, + code: KeyCode, + modifiers: KeyModifiers, + kind: crossterm::event::KeyEventKind, + shifted_codepoint: Option<u32>, + ) { + assert_eq!(actual.code, code); + assert_eq!(actual.modifiers, modifiers); + assert_eq!(actual.kind, kind); + assert_eq!(actual.shifted_codepoint, shifted_codepoint); + } + + fn decode_hex(hex: &str) -> Vec<u8> { + let hex = hex.trim(); + assert_eq!(hex.len() % 2, 0, "hex string must have even length"); + (0..hex.len()) + .step_by(2) + .map(|idx| u8::from_str_radix(&hex[idx..idx + 2], 16).unwrap()) + .collect() + } + + fn parse_fixture_key_code(value: &str) -> KeyCode { + match value { + "enter" => KeyCode::Enter, + "tab" => KeyCode::Tab, + "backspace" => KeyCode::Backspace, + "esc" => KeyCode::Esc, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "insert" => KeyCode::Insert, + "delete" => KeyCode::Delete, + value if value.starts_with("char:") => { + KeyCode::Char(value.trim_start_matches("char:").chars().next().unwrap()) + } + other => panic!("unsupported fixture key code: {other}"), + } + } + + fn parse_fixture_modifiers(value: &str) -> KeyModifiers { + if value == "-" || value.is_empty() { + return KeyModifiers::empty(); + } + + let mut modifiers = KeyModifiers::empty(); + for part in value.split('+') { + match part { + "shift" => modifiers |= KeyModifiers::SHIFT, + "alt" => modifiers |= KeyModifiers::ALT, + "control" => modifiers |= KeyModifiers::CONTROL, + "super" => modifiers |= KeyModifiers::SUPER, + "hyper" => modifiers |= KeyModifiers::HYPER, + "meta" => modifiers |= KeyModifiers::META, + other => panic!("unsupported fixture modifier: {other}"), + } + } + modifiers + } + + fn parse_fixture_kind(value: &str) -> crossterm::event::KeyEventKind { + match value { + "press" => crossterm::event::KeyEventKind::Press, + "repeat" => crossterm::event::KeyEventKind::Repeat, + "release" => crossterm::event::KeyEventKind::Release, + other => panic!("unsupported fixture kind: {other}"), + } + } + #[test] fn legacy_enter() { let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); - assert_eq!(encode_key(key, false), vec![b'\r']); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']); } #[test] fn legacy_ctrl_c() { let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - assert_eq!(encode_key(key, false), vec![3]); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![3]); } #[test] @@ -246,57 +655,57 @@ mod tests { let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); // Enter/Tab/Backspace/Esc aren't special keys with xterm modifier encoding, // so Shift+Enter falls through to legacy which just sends CR - assert_eq!(encode_key(key, false), vec![b'\r']); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']); } #[test] fn legacy_alt_up() { let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT); // xterm modified key format: CSI 1;3A (3 = 1 + Alt) - assert_eq!(encode_key(key, false), b"\x1b[1;3A"); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;3A"); } #[test] fn legacy_shift_right() { let key = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT); - assert_eq!(encode_key(key, false), b"\x1b[1;2C"); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;2C"); } #[test] fn legacy_ctrl_left() { let key = KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL); - assert_eq!(encode_key(key, false), b"\x1b[1;5D"); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;5D"); } #[test] fn legacy_ctrl_shift_end() { let key = KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT); - assert_eq!(encode_key(key, false), b"\x1b[1;6F"); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;6F"); } #[test] fn legacy_alt_delete() { let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT); - assert_eq!(encode_key(key, false), b"\x1b[3;3~"); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[3;3~"); } #[test] fn legacy_shift_f5() { let key = KeyEvent::new(KeyCode::F(5), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, false), b"\x1b[15;2~"); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[15;2~"); } #[test] fn legacy_alt_char_still_esc_prefix() { // Alt+a on character keys still uses ESC prefix (not xterm modified) let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT); - assert_eq!(encode_key(key, false), b"\x1ba"); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1ba"); } #[test] fn kitty_shift_enter() { let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); - assert_eq!(encode_key(key, true), b"\x1b[13;2u"); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"\x1b[13;2u"); } #[test] @@ -305,37 +714,448 @@ mod tests { KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT, ); - assert_eq!(encode_key(key, true), b"\x1b[97;6u"); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"\x1b[97;6u"); + } + + #[test] + fn kitty_shift_uppercase_letter_uses_base_codepoint() { + let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"\x1b[108;2u"); + } + + #[test] + fn kitty_shift_uppercase_letter_with_alternate_keys_reports_shifted_codepoint() { + let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"\x1b[108:76;2:1u"); + } + + #[test] + fn kitty_shift_lowercase_letter_normalizes_to_base_codepoint() { + let key = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::SHIFT); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"\x1b[108;2u"); + } + + #[test] + fn kitty_alt_shift_uppercase_letter_uses_base_codepoint() { + let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::ALT | KeyModifiers::SHIFT); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"\x1b[108;4u"); + } + + #[test] + fn kitty_ctrl_shift_uppercase_letter_uses_base_codepoint() { + let key = KeyEvent::new( + KeyCode::Char('L'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"\x1b[108;6u"); + } + + #[test] + fn legacy_shift_uppercase_letter_stays_uppercase() { + let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"L"); } #[test] fn kitty_alt_enter() { let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT); - assert_eq!(encode_key(key, true), b"\x1b[13;3u"); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"\x1b[13;3u"); } #[test] fn kitty_plain_ctrl_c_uses_legacy() { // Plain Ctrl+letter is well-represented in legacy let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - assert_eq!(encode_key(key, true), vec![3]); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), vec![3]); } #[test] fn kitty_unmodified_uses_legacy() { let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()); - assert_eq!(encode_key(key, true), b"a"); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"a"); } #[test] fn kitty_shift_tab() { let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); - assert_eq!(encode_key(key, true), b"\x1b[9;2u"); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"\x1b[9;2u"); } #[test] fn kitty_ctrl_shift_enter() { let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT); - assert_eq!(encode_key(key, true), b"\x1b[13;6u"); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"\x1b[13;6u"); + } + + #[test] + fn kitty_repeat_event_type_is_encoded_when_requested() { + let key = KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::SHIFT, + crossterm::event::KeyEventKind::Repeat, + ); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 3 }), b"\x1b[13;2:2u"); + } + + #[test] + fn kitty_release_event_type_and_alternate_keys_are_encoded_together() { + let key = KeyEvent::new_with_kind( + KeyCode::Char('L'), + KeyModifiers::SHIFT, + crossterm::event::KeyEventKind::Release, + ); + assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"\x1b[108:76;2:3u"); + } + + #[test] + fn kitty_rich_key_data_can_preserve_shifted_symbol_pairs() { + let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) + .with_shifted_codepoint('!' as u32); + assert_eq!( + encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), + b"\x1b[49:33;2:1u" + ); + } + + #[test] + fn parse_kitty_sequence_preserves_shifted_symbol_pair() { + let key = parse_terminal_key_sequence("\x1b[49:33;2:1u").unwrap(); + assert_eq!(key.code, KeyCode::Char('1')); + assert_eq!(key.modifiers, KeyModifiers::SHIFT); + assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); + assert_eq!(key.shifted_codepoint, Some('!' as u32)); + } + + #[test] + fn parse_kitty_sequence_preserves_shifted_letter_pair_and_release() { + let key = parse_terminal_key_sequence("\x1b[108:76;2:3u").unwrap(); + assert_eq!(key.code, KeyCode::Char('l')); + assert_eq!(key.modifiers, KeyModifiers::SHIFT); + assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); + assert_eq!(key.shifted_codepoint, Some('L' as u32)); + } + + #[test] + fn parse_modify_other_keys_sequence() { + let key = parse_terminal_key_sequence("\x1b[27;6;108~").unwrap(); + assert_eq!(key.code, KeyCode::Char('l')); + assert_eq!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SHIFT); + assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); + assert_eq!(key.shifted_codepoint, None); + } + + #[test] + fn parse_legacy_uppercase_letter_as_shifted_char() { + let key = parse_terminal_key_sequence("L").unwrap(); + assert_eq!(key.code, KeyCode::Char('L')); + assert_eq!(key.modifiers, KeyModifiers::SHIFT); + } + + #[test] + fn parse_legacy_up_arrow_sequence() { + let key = parse_terminal_key_sequence("\x1b[A").unwrap(); + assert_eq!(key.code, KeyCode::Up); + assert_eq!(key.modifiers, KeyModifiers::empty()); + } + + #[test] + fn parse_xterm_alt_up_arrow_sequence() { + let key = parse_terminal_key_sequence("\x1b[1;3A").unwrap(); + assert_eq!(key.code, KeyCode::Up); + assert_eq!(key.modifiers, KeyModifiers::ALT); + } + + #[test] + fn parse_xterm_alt_down_arrow_sequence() { + let key = parse_terminal_key_sequence("\x1b[1;3B").unwrap(); + assert_eq!(key.code, KeyCode::Down); + assert_eq!(key.modifiers, KeyModifiers::ALT); + } + + #[test] + fn parse_kitty_functional_up_arrow_sequence() { + let key = parse_terminal_key_sequence("\x1b[57419;1u").unwrap(); + assert_eq!(key.code, KeyCode::Up); + assert_eq!(key.modifiers, KeyModifiers::empty()); + } + + #[test] + fn parse_legacy_ctrl_b_sequence() { + let key = parse_terminal_key_sequence("\x02").unwrap(); + assert_eq!(key.code, KeyCode::Char('b')); + assert_eq!(key.modifiers, KeyModifiers::CONTROL); + } + + #[test] + fn parse_legacy_ctrl_c_sequence() { + let key = parse_terminal_key_sequence("\x03").unwrap(); + assert_eq!(key.code, KeyCode::Char('c')); + assert_eq!(key.modifiers, KeyModifiers::CONTROL); + } + + #[test] + fn legacy_ctrl_byte_matrix_is_covered() { + for (byte, expected) in [ + (b'\x01', 'a'), + (b'\x02', 'b'), + (b'\x03', 'c'), + (b'\x1a', 'z'), + ] { + let key = parse_terminal_key_sequence(std::str::from_utf8(&[byte]).unwrap()).unwrap(); + assert_terminal_key_eq( + key, + KeyCode::Char(expected), + KeyModifiers::CONTROL, + crossterm::event::KeyEventKind::Press, + None, + ); + } + + // Ctrl+[ is byte-identical to Escape in legacy terminals, so the parser + // intentionally treats 0x1b as Escape and only disambiguates the other + // legacy control-symbol bytes here. + for (byte, expected) in [ + (b'\x1c', '\\'), + (b'\x1d', ']'), + (b'\x1e', '^'), + (b'\x1f', '-'), + ] { + let key = parse_terminal_key_sequence(std::str::from_utf8(&[byte]).unwrap()).unwrap(); + assert_terminal_key_eq( + key, + KeyCode::Char(expected), + KeyModifiers::CONTROL, + crossterm::event::KeyEventKind::Press, + None, + ); + } + } + + #[test] + fn legacy_modified_special_roundtrip_matrix() { + let cases = [ + KeyEvent::new(KeyCode::Up, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Down, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::PageUp, KeyModifiers::ALT), + KeyEvent::new(KeyCode::PageDown, KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT), + ]; + + for key in cases { + let encoded = encode_key(key, KeyboardProtocol::Legacy); + let parsed = parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); + assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None); + } + } + + #[test] + fn kitty_shifted_symbol_roundtrip_preserves_alternate_key() { + let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) + .with_shifted_codepoint('!' as u32); + let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); + let parsed = parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); + assert_terminal_key_eq( + parsed, + KeyCode::Char('1'), + KeyModifiers::SHIFT, + crossterm::event::KeyEventKind::Press, + Some('!' as u32), + ); + } + + #[test] + fn legacy_basic_special_roundtrip_matrix() { + let cases = [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Up, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Left, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Home, KeyModifiers::empty()), + KeyEvent::new(KeyCode::End, KeyModifiers::empty()), + KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()), + KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Insert, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Delete, KeyModifiers::empty()), + ]; + + for key in cases { + let encoded = encode_key(key, KeyboardProtocol::Legacy); + let parsed = parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); + assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None); + } + } + + #[test] + fn kitty_functional_key_matrix_is_covered() { + let cases = [ + ("\x1b[57417;1u", KeyCode::Left), + ("\x1b[57418;1u", KeyCode::Right), + ("\x1b[57419;1u", KeyCode::Up), + ("\x1b[57420;1u", KeyCode::Down), + ("\x1b[57421;1u", KeyCode::PageUp), + ("\x1b[57422;1u", KeyCode::PageDown), + ("\x1b[57423;1u", KeyCode::Home), + ("\x1b[57424;1u", KeyCode::End), + ("\x1b[57425;1u", KeyCode::Insert), + ("\x1b[57426;1u", KeyCode::Delete), + ]; + + for (sequence, code) in cases { + let parsed = parse_terminal_key_sequence(sequence).unwrap(); + assert_terminal_key_eq( + parsed, + code, + KeyModifiers::empty(), + crossterm::event::KeyEventKind::Press, + None, + ); + } + } + + #[test] + fn kitty_shifted_symbol_pair_matrix_is_covered() { + let cases = [('1', '!'), ('/', '?'), ('[', '{')]; + + for (base, shifted) in cases { + let key = TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT) + .with_shifted_codepoint(shifted as u32); + let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); + let parsed = parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); + assert_terminal_key_eq( + parsed, + KeyCode::Char(base), + KeyModifiers::SHIFT, + crossterm::event::KeyEventKind::Press, + Some(shifted as u32), + ); + } + } + + fn assert_fixture_corpus_parses(corpus: &str) { + for line in corpus.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let mut columns: Vec<_> = line.split('\t').collect(); + if columns.len() == 5 { + columns.push(""); + } + + let (family, bytes_hex, code, modifiers, kind, shifted) = match columns.len() { + 6 => { + if columns[1].chars().all(|ch| ch.is_ascii_hexdigit()) { + ( + columns[0], + columns[1], + columns[2], + columns[3], + columns[4], + columns[5], + ) + } else { + ( + columns[0], + columns[2], + columns[3], + columns[4], + columns[5], + "", + ) + } + } + 7 => ( + columns[0], + columns[2], + columns[3], + columns[4], + columns[5], + columns[6], + ), + _ => panic!("fixture row must have 6 or 7 columns: {line}"), + }; + + assert!(bytes_hex.chars().all(|ch| ch.is_ascii_hexdigit()), "non-hex fixture bytes for {family}: {bytes_hex}"); + let bytes = decode_hex(bytes_hex); + let text = std::str::from_utf8(&bytes).unwrap(); + let parsed = parse_terminal_key_sequence(text) + .unwrap_or_else(|| panic!("fixture failed to parse: {family}")); + + assert_terminal_key_eq( + parsed, + parse_fixture_key_code(code), + parse_fixture_modifiers(modifiers), + parse_fixture_kind(kind), + if shifted.is_empty() { + None + } else { + Some(shifted.parse::<u32>().unwrap()) + }, + ); + } + } + + #[test] + fn keyboard_protocol_corpus_fixture_parses() { + let corpus = include_str!("../tests/fixtures/keyboard_protocol_corpus.tsv"); + assert_fixture_corpus_parses(corpus); + } + + #[test] + fn macos_terminal_variants_fixture_parses() { + let corpus = include_str!("../tests/fixtures/macos_terminal_variants.tsv"); + for line in corpus.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let mut columns: Vec<_> = line.split('\t').collect(); + if columns.len() == 6 { + columns.push(""); + } + assert_eq!(columns.len(), 7, "macOS fixture row must have 7 columns: {line}"); + + let source = format!("{}:{}", columns[0], columns[1]); + let transformed = [ + source.as_str(), + columns[2], + columns[3], + columns[4], + columns[5], + columns[6], + ] + .join("\t"); + assert_fixture_corpus_parses(&transformed); + } + } + + #[test] + fn linux_terminal_variants_fixture_parses() { + let corpus = include_str!("../tests/fixtures/linux_terminal_variants.tsv"); + assert_fixture_corpus_parses(corpus); + } + + #[test] + fn protocol_from_zero_flags_is_legacy() { + assert_eq!(KeyboardProtocol::from_kitty_flags(0), KeyboardProtocol::Legacy); + } + + #[test] + fn protocol_from_nonzero_flags_is_kitty() { + assert_eq!( + KeyboardProtocol::from_kitty_flags(7), + KeyboardProtocol::Kitty { flags: 7 } + ); } } diff --git a/src/main.rs b/src/main.rs index 9d5d1b16..5756026b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod pane; mod persist; mod platform; mod pty_callbacks; +mod raw_input; mod selection; mod sound; mod ui; @@ -301,7 +302,12 @@ fn main() -> io::Result<()> { io::stdout(), EnableMouseCapture, EnableBracketedPaste, - PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS + | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES + ) )?; // tmux doesn't understand kitty keyboard protocol push (\e[>1u). diff --git a/src/pane.rs b/src/pane.rs index 9e8d6b1e..d1d5bae0 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1,7 +1,7 @@ use std::cell::Cell; use std::io::{BufWriter, Read, Write}; use std::sync::{ - atomic::{AtomicBool, AtomicU32, Ordering}, + atomic::{AtomicU16, AtomicU32, Ordering}, Arc, RwLock, }; @@ -83,7 +83,7 @@ pub struct PaneRuntime { resize_tx: mpsc::Sender<(u16, u16)>, current_size: Cell<(u16, u16)>, child_pid: Arc<AtomicU32>, - pub kitty_keyboard: Arc<AtomicBool>, + pub kitty_keyboard_flags: Arc<AtomicU16>, /// Live screen content snapshot — updated by reader, read by detector. /// Decouples detection from parser viewport state (scrollback). /// Kept alive here so the Arc isn't dropped; tasks hold their own clones. @@ -165,7 +165,7 @@ impl PaneRuntime { .map_err(|e| std::io::Error::other(e.to_string()))?; let responses = PtyResponses::new(); - let kitty_keyboard = responses.kitty_keyboard.clone(); + let kitty_keyboard_flags = responses.kitty_keyboard_flags.clone(); let parser = Arc::new(RwLock::new(vt100::Parser::new_with_callbacks( rows, cols, @@ -419,7 +419,7 @@ impl PaneRuntime { resize_tx, current_size: Cell::new((rows, cols)), child_pid, - kitty_keyboard, + kitty_keyboard_flags, screen_content, detect_handle, }) diff --git a/src/pty_callbacks.rs b/src/pty_callbacks.rs index 113dbc89..44000701 100644 --- a/src/pty_callbacks.rs +++ b/src/pty_callbacks.rs @@ -8,7 +8,7 @@ //! don't need responses. Only QUERY sequences need us to reply. use std::sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU16, Ordering}, Arc, Mutex, }; @@ -23,8 +23,10 @@ pub struct PtyResponses { /// pops → fish's mode 1 is restored). kitty_stack: Arc<Mutex<Vec<u16>>>, /// Derived from kitty_stack: true when stack is non-empty. - /// Shared with PaneRuntime for fast-path key encoding decisions. + /// Kept for quick feature checks. pub kitty_keyboard: Arc<AtomicBool>, + /// Exact active kitty keyboard flags from the top of the stack. + pub kitty_keyboard_flags: Arc<AtomicU16>, } impl PtyResponses { @@ -123,6 +125,7 @@ impl vt100::Callbacks for PtyResponses { let mut stack = self.kitty_stack.lock().unwrap(); stack.push(param0); self.kitty_keyboard.store(true, Ordering::Relaxed); + self.kitty_keyboard_flags.store(param0, Ordering::Relaxed); } // Kitty keyboard pop: \e[<Nu → child reverts to previous mode @@ -135,8 +138,10 @@ impl vt100::Callbacks for PtyResponses { break; } } + let flags = stack.last().copied().unwrap_or(0); self.kitty_keyboard .store(!stack.is_empty(), Ordering::Relaxed); + self.kitty_keyboard_flags.store(flags, Ordering::Relaxed); } // === Terminal Identification === @@ -261,28 +266,33 @@ mod tests { // Initially off assert!(!r.kitty_keyboard.load(Ordering::Relaxed)); + assert_eq!(r.kitty_keyboard_flags.load(Ordering::Relaxed), 0); // Fish pushes mode 1 p.process(b"\x1b[>1u"); assert!(r.kitty_keyboard.load(Ordering::Relaxed)); + assert_eq!(r.kitty_keyboard_flags.load(Ordering::Relaxed), 1); p.process(b"\x1b[?u"); assert_eq!(r.take(), b"\x1b[?1u"); // Neovim pushes mode 3 p.process(b"\x1b[>3u"); assert!(r.kitty_keyboard.load(Ordering::Relaxed)); + assert_eq!(r.kitty_keyboard_flags.load(Ordering::Relaxed), 3); p.process(b"\x1b[?u"); assert_eq!(r.take(), b"\x1b[?3u"); // Neovim pops → fish's mode 1 is restored p.process(b"\x1b[<u"); assert!(r.kitty_keyboard.load(Ordering::Relaxed)); + assert_eq!(r.kitty_keyboard_flags.load(Ordering::Relaxed), 1); p.process(b"\x1b[?u"); assert_eq!(r.take(), b"\x1b[?1u"); // Fish pops → back to legacy p.process(b"\x1b[<u"); assert!(!r.kitty_keyboard.load(Ordering::Relaxed)); + assert_eq!(r.kitty_keyboard_flags.load(Ordering::Relaxed), 0); p.process(b"\x1b[?u"); assert_eq!(r.take(), b"\x1b[?0u"); } @@ -295,6 +305,7 @@ mod tests { // Pop with nothing on stack — should not crash p.process(b"\x1b[<u"); assert!(!r.kitty_keyboard.load(Ordering::Relaxed)); + assert_eq!(r.kitty_keyboard_flags.load(Ordering::Relaxed), 0); } #[test] @@ -307,16 +318,19 @@ mod tests { p.process(b"\x1b[>3u"); p.process(b"\x1b[>5u"); assert!(r.kitty_keyboard.load(Ordering::Relaxed)); + assert_eq!(r.kitty_keyboard_flags.load(Ordering::Relaxed), 5); // Pop 2 at once p.process(b"\x1b[<2u"); assert!(r.kitty_keyboard.load(Ordering::Relaxed)); + assert_eq!(r.kitty_keyboard_flags.load(Ordering::Relaxed), 1); p.process(b"\x1b[?u"); assert_eq!(r.take(), b"\x1b[?1u"); // only first push remains // Pop last one p.process(b"\x1b[<u"); assert!(!r.kitty_keyboard.load(Ordering::Relaxed)); + assert_eq!(r.kitty_keyboard_flags.load(Ordering::Relaxed), 0); } #[test] diff --git a/src/raw_input.rs b/src/raw_input.rs new file mode 100644 index 00000000..fbfc070a --- /dev/null +++ b/src/raw_input.rs @@ -0,0 +1,633 @@ +use std::io::Read; + +use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + +#[cfg(unix)] +use std::os::fd::AsRawFd; +use tokio::sync::mpsc; + +use crate::input::{parse_terminal_key_sequence, TerminalKey}; + +const ESC: u8 = 0x1b; +const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~"; +const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~"; + +#[derive(Debug)] +pub enum RawInputEvent { + Key(TerminalKey), + Paste(String), + Mouse(MouseEvent), + Unsupported, +} + +pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> { + let (tx, rx) = mpsc::channel(256); + + std::thread::spawn(move || { + let stdin = std::io::stdin(); + let mut reader = stdin.lock(); + let mut scratch = [0u8; 1024]; + let mut buffer = Vec::<u8>::new(); + + loop { + match reader.read(&mut scratch) { + Ok(0) => break, + Ok(n) => { + buffer.extend_from_slice(&scratch[..n]); + drain_buffer(&mut buffer, &tx); + + if !buffer.is_empty() && stdin_read_ready(&reader, 10) == Some(false) { + flush_incomplete_buffer(&mut buffer, &tx); + } + } + Err(_) => break, + } + } + }); + + rx +} + +fn drain_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) { + loop { + let Some((event, consumed)) = extract_one_event(buffer) else { + break; + }; + buffer.drain(..consumed); + let _ = tx.blocking_send(event); + } +} + +fn flush_incomplete_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) { + if buffer.is_empty() { + return; + } + + if buffer.as_slice() == [ESC] { + let _ = tx.blocking_send(RawInputEvent::Key(TerminalKey::new( + crossterm::event::KeyCode::Esc, + KeyModifiers::empty(), + ))); + buffer.clear(); + return; + } + + if let Ok(text) = std::str::from_utf8(buffer) { + if let Some(key) = parse_terminal_key_sequence(text) { + let _ = tx.blocking_send(RawInputEvent::Key(key)); + buffer.clear(); + return; + } + } + + tracing::debug!(bytes = ?buffer, "dropping incomplete raw input buffer after timeout"); + buffer.clear(); +} + +#[cfg(unix)] +fn stdin_read_ready<R: AsRawFd>(_reader: &R, _timeout_ms: i32) -> Option<bool> { + #[cfg(unix)] + { + let fd = _reader.as_raw_fd(); + return poll_read_ready(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)] + struct PollFd { + fd: i32, + events: i16, + revents: i16, + } + + unsafe extern "C" { + fn poll(fds: *mut PollFd, nfds: usize, timeout: i32) -> i32; + } + + const POLLIN: i16 = 0x0001; + + let mut pfd = PollFd { + fd, + events: POLLIN, + revents: 0, + }; + + let result = unsafe { poll(&mut pfd as *mut PollFd, 1, timeout_ms) }; + if result < 0 { + None + } else { + Some(result > 0) + } +} + +fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { + if buffer.is_empty() { + return None; + } + + if buffer.starts_with(BRACKETED_PASTE_START) { + let end = find_subsequence(buffer, BRACKETED_PASTE_END)?; + let content = std::str::from_utf8(&buffer[BRACKETED_PASTE_START.len()..end]).ok()?; + return Some(( + RawInputEvent::Paste(content.to_string()), + end + BRACKETED_PASTE_END.len(), + )); + } + + if buffer[0] == ESC { + let seq_len = complete_escape_sequence_len(buffer)?; + let seq = std::str::from_utf8(&buffer[..seq_len]).ok()?; + + if let Some(mouse) = parse_sgr_mouse(seq) { + return Some((RawInputEvent::Mouse(mouse), seq_len)); + } + + if let Some(key) = parse_terminal_key_sequence(seq) { + return Some((RawInputEvent::Key(key), seq_len)); + } + + tracing::debug!(sequence = ?seq, "dropping unsupported escape sequence"); + return Some((RawInputEvent::Unsupported, seq_len)); + } + + let ch = std::str::from_utf8(buffer).ok()?.chars().next()?; + let consumed = ch.len_utf8(); + let seq = &buffer[..consumed]; + let text = std::str::from_utf8(seq).ok()?; + let key = parse_terminal_key_sequence(text)?; + Some((RawInputEvent::Key(key), consumed)) +} + +fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> { + if buffer.len() == 1 { + return None; + } + + if buffer.starts_with(b"\x1b\x1b") { + return complete_escape_sequence_len(&buffer[1..]).map(|len| len + 1); + } + + if buffer.starts_with(b"\x1b[") { + if buffer.starts_with(b"\x1b[<") { + return find_csi_final(buffer, b"Mm"); + } + return find_csi_final(buffer, b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"); + } + + if buffer.starts_with(b"\x1b]") || buffer.starts_with(b"\x1bP") || buffer.starts_with(b"\x1b_") { + return find_subsequence(buffer, b"\x1b\\").map(|idx| idx + 2); + } + + if buffer.starts_with(b"\x1bO") { + return (buffer.len() >= 3).then_some(3); + } + + Some(2) +} + +fn find_csi_final(buffer: &[u8], finals: &[u8]) -> Option<usize> { + for (idx, byte) in buffer.iter().enumerate().skip(2) { + if finals.contains(byte) { + return Some(idx + 1); + } + } + None +} + +fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> { + haystack.windows(needle.len()).position(|window| window == needle) +} + +fn parse_sgr_mouse(sequence: &str) -> Option<MouseEvent> { + let body = sequence.strip_prefix("\x1b[<")?; + let final_char = body.chars().last()?; + if final_char != 'M' && final_char != 'm' { + return None; + } + + let payload = &body[..body.len() - 1]; + let mut parts = payload.split(';'); + let cb = parts.next()?.parse::<u8>().ok()?; + let column = parts.next()?.parse::<u16>().ok()?.checked_sub(1)?; + let row = parts.next()?.parse::<u16>().ok()?.checked_sub(1)?; + let (kind, modifiers) = parse_mouse_cb(cb)?; + + let kind = if final_char == 'm' { + match kind { + MouseEventKind::Down(button) => MouseEventKind::Up(button), + other => other, + } + } else { + kind + }; + + Some(MouseEvent { + kind, + column, + row, + modifiers, + }) +} + +fn parse_mouse_cb(cb: u8) -> Option<(MouseEventKind, KeyModifiers)> { + let button_number = (cb & 0b0000_0011) | ((cb & 0b1100_0000) >> 4); + let dragging = cb & 0b0010_0000 == 0b0010_0000; + + let kind = match (button_number, dragging) { + (0, false) => MouseEventKind::Down(MouseButton::Left), + (1, false) => MouseEventKind::Down(MouseButton::Middle), + (2, false) => MouseEventKind::Down(MouseButton::Right), + (0, true) => MouseEventKind::Drag(MouseButton::Left), + (1, true) => MouseEventKind::Drag(MouseButton::Middle), + (2, true) => MouseEventKind::Drag(MouseButton::Right), + (3, false) => MouseEventKind::Up(MouseButton::Left), + (3, true) | (4, true) | (5, true) => MouseEventKind::Moved, + (4, false) => MouseEventKind::ScrollUp, + (5, false) => MouseEventKind::ScrollDown, + (6, false) => MouseEventKind::ScrollLeft, + (7, false) => MouseEventKind::ScrollRight, + _ => return None, + }; + + let mut modifiers = KeyModifiers::empty(); + if cb & 0b0000_0100 != 0 { + modifiers |= KeyModifiers::SHIFT; + } + if cb & 0b0000_1000 != 0 { + modifiers |= KeyModifiers::ALT; + } + if cb & 0b0001_0000 != 0 { + modifiers |= KeyModifiers::CONTROL; + } + + Some((kind, modifiers)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEventKind}; + + fn assert_raw_key(event: RawInputEvent, code: KeyCode, modifiers: KeyModifiers) { + let RawInputEvent::Key(key) = event else { + panic!("expected key"); + }; + assert_eq!(key.code, code); + assert_eq!(key.modifiers, modifiers); + } + + fn decode_hex(hex: &str) -> Vec<u8> { + let hex = hex.trim(); + assert_eq!(hex.len() % 2, 0, "hex string must have even length"); + (0..hex.len()) + .step_by(2) + .map(|idx| u8::from_str_radix(&hex[idx..idx + 2], 16).unwrap()) + .collect() + } + + fn parse_fixture_key_code(value: &str) -> KeyCode { + match value { + "enter" => KeyCode::Enter, + "tab" => KeyCode::Tab, + "backspace" => KeyCode::Backspace, + "esc" => KeyCode::Esc, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "insert" => KeyCode::Insert, + "delete" => KeyCode::Delete, + value if value.starts_with("char:") => { + KeyCode::Char(value.trim_start_matches("char:").chars().next().unwrap()) + } + other => panic!("unsupported fixture key code: {other}"), + } + } + + fn parse_fixture_modifiers(value: &str) -> KeyModifiers { + if value == "-" || value.is_empty() { + return KeyModifiers::empty(); + } + + let mut modifiers = KeyModifiers::empty(); + for part in value.split('+') { + match part { + "shift" => modifiers |= KeyModifiers::SHIFT, + "alt" => modifiers |= KeyModifiers::ALT, + "control" => modifiers |= KeyModifiers::CONTROL, + "super" => modifiers |= KeyModifiers::SUPER, + "hyper" => modifiers |= KeyModifiers::HYPER, + "meta" => modifiers |= KeyModifiers::META, + other => panic!("unsupported fixture modifier: {other}"), + } + } + modifiers + } + + fn collect_events(rx: &mut mpsc::Receiver<RawInputEvent>) -> Vec<RawInputEvent> { + let mut events = Vec::new(); + while let Ok(event) = rx.try_recv() { + events.push(event); + } + events + } + + fn drain_chunk(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>, chunk: &[u8]) { + buffer.extend_from_slice(chunk); + drain_buffer(buffer, tx); + } + + #[test] + fn parses_kitty_shift_letter_release() { + let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[108:76;2:3u").unwrap() else { + panic!("expected key"); + }; + assert_eq!(consumed, 13); + assert_eq!(key.code, KeyCode::Char('l')); + assert_eq!(key.modifiers, KeyModifiers::SHIFT); + assert_eq!(key.kind, KeyEventKind::Release); + assert_eq!(key.shifted_codepoint, Some('L' as u32)); + } + + #[test] + fn parses_bracketed_paste() { + let (RawInputEvent::Paste(text), consumed) = + extract_one_event(b"\x1b[200~hello\x1b[201~rest").unwrap() + else { + panic!("expected paste"); + }; + assert_eq!(text, "hello"); + assert_eq!(consumed, 17); + } + + #[test] + fn parses_sgr_mouse() { + let (RawInputEvent::Mouse(mouse), consumed) = extract_one_event(b"\x1b[<0;20;10M").unwrap() else { + panic!("expected mouse"); + }; + assert_eq!(consumed, 11); + assert_eq!(mouse.kind, MouseEventKind::Down(MouseButton::Left)); + assert_eq!(mouse.column, 19); + assert_eq!(mouse.row, 9); + } + + #[test] + fn parses_legacy_up_arrow() { + let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[A").unwrap() else { + panic!("expected key"); + }; + assert_eq!(consumed, 3); + assert_eq!(key.code, KeyCode::Up); + } + + #[test] + fn parses_xterm_alt_up_arrow() { + let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[1;3A").unwrap() else { + panic!("expected key"); + }; + assert_eq!(consumed, 6); + assert_eq!(key.code, KeyCode::Up); + assert_eq!(key.modifiers, KeyModifiers::ALT); + } + + #[test] + fn raw_input_family_matrix_is_covered() { + let cases: &[(&[u8], KeyCode, KeyModifiers)] = &[ + (b"\x02", KeyCode::Char('b'), KeyModifiers::CONTROL), + (b"\r", KeyCode::Enter, KeyModifiers::empty()), + (b"\t", KeyCode::Tab, KeyModifiers::empty()), + (b"\x7f", KeyCode::Backspace, KeyModifiers::empty()), + (b"\x1b[A", KeyCode::Up, KeyModifiers::empty()), + (b"\x1b[1;3A", KeyCode::Up, KeyModifiers::ALT), + (b"\x1b[57420;1u", KeyCode::Down, KeyModifiers::empty()), + (b"\x1b[57423;1u", KeyCode::Home, KeyModifiers::empty()), + (b"\x1b[49:33;2:1u", KeyCode::Char('1'), KeyModifiers::SHIFT), + ]; + + for (bytes, code, modifiers) in cases { + let (event, consumed) = extract_one_event(bytes).unwrap(); + assert_eq!(consumed, bytes.len()); + assert_raw_key(event, *code, *modifiers); + } + } + + #[test] + fn flushes_lone_escape_after_timeout() { + let (tx, mut rx) = mpsc::channel(4); + let mut buffer = vec![ESC]; + flush_incomplete_buffer(&mut buffer, &tx); + assert!(buffer.is_empty()); + let event = rx.try_recv().unwrap(); + let RawInputEvent::Key(key) = event else { + panic!("expected key"); + }; + assert_eq!(key.code, KeyCode::Esc); + } + + #[test] + fn parses_raw_ctrl_b() { + let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x02").unwrap() else { + panic!("expected key"); + }; + assert_eq!(consumed, 1); + assert_eq!(key.code, KeyCode::Char('b')); + assert_eq!(key.modifiers, KeyModifiers::CONTROL); + } + + fn assert_fixture_extracts_whole_events(corpus: &str, macos_layout: bool) { + for line in corpus.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let mut columns: Vec<_> = line.split('\t').collect(); + if columns.len() == 5 { + columns.push(""); + } + + if macos_layout { + if columns.len() == 6 { + columns.push(""); + } + assert_eq!(columns.len(), 7, "macOS fixture row must have 7 columns: {line}"); + if columns[2].is_empty() { + continue; + } + let bytes = decode_hex(columns[2]); + let (event, consumed) = extract_one_event(&bytes).unwrap(); + assert_eq!(consumed, bytes.len(), "fixture should extract a whole event: {line}"); + assert_raw_key(event, parse_fixture_key_code(columns[3]), parse_fixture_modifiers(columns[4])); + } else { + if columns.len() == 5 { + columns.push(""); + } + let (bytes_hex, code, modifiers) = match columns.len() { + 6 => { + if columns[1].chars().all(|ch| ch.is_ascii_hexdigit()) { + (columns[1], columns[2], columns[3]) + } else { + (columns[2], columns[3], columns[4]) + } + } + 7 => (columns[2], columns[3], columns[4]), + _ => panic!("fixture row must have 6 or 7 columns: {line}"), + }; + assert!(bytes_hex.chars().all(|ch| ch.is_ascii_hexdigit()), "non-hex fixture bytes: {bytes_hex} in {line}"); + let bytes = decode_hex(bytes_hex); + let (event, consumed) = extract_one_event(&bytes).unwrap(); + assert_eq!(consumed, bytes.len(), "fixture should extract a whole event: {line}"); + assert_raw_key(event, parse_fixture_key_code(code), parse_fixture_modifiers(modifiers)); + } + } + } + + #[test] + fn raw_input_corpus_fixture_extracts_whole_events() { + let corpus = include_str!("../tests/fixtures/keyboard_protocol_corpus.tsv"); + assert_fixture_extracts_whole_events(corpus, false); + } + + #[test] + fn raw_input_macos_terminal_variants_fixture_extracts_whole_events() { + let corpus = include_str!("../tests/fixtures/macos_terminal_variants.tsv"); + assert_fixture_extracts_whole_events(corpus, true); + } + + #[test] + fn raw_input_linux_terminal_variants_fixture_extracts_whole_events() { + let corpus = include_str!("../tests/fixtures/linux_terminal_variants.tsv"); + assert_fixture_extracts_whole_events(corpus, false); + } + + #[test] + fn chunked_legacy_arrow_waits_for_completion() { + let (tx, mut rx) = mpsc::channel(8); + let mut buffer = Vec::new(); + + drain_chunk(&mut buffer, &tx, b"\x1b"); + assert_eq!(buffer, b"\x1b"); + assert!(collect_events(&mut rx).is_empty()); + + drain_chunk(&mut buffer, &tx, b"[A"); + assert!(buffer.is_empty()); + let events = collect_events(&mut rx); + assert_eq!(events.len(), 1); + assert_raw_key(events.into_iter().next().unwrap(), KeyCode::Up, KeyModifiers::empty()); + } + + #[test] + fn lone_escape_is_buffered_until_timeout_flush() { + let (tx, mut rx) = mpsc::channel(8); + let mut buffer = Vec::new(); + + drain_chunk(&mut buffer, &tx, b"\x1b"); + assert_eq!(buffer, b"\x1b"); + assert!(collect_events(&mut rx).is_empty()); + + flush_incomplete_buffer(&mut buffer, &tx); + assert!(buffer.is_empty()); + let events = collect_events(&mut rx); + assert_eq!(events.len(), 1); + assert_raw_key(events.into_iter().next().unwrap(), KeyCode::Esc, KeyModifiers::empty()); + } + + #[test] + fn escape_followed_by_arrow_before_flush_does_not_emit_escape() { + let (tx, mut rx) = mpsc::channel(8); + let mut buffer = Vec::new(); + + drain_chunk(&mut buffer, &tx, b"\x1b"); + assert_eq!(buffer, b"\x1b"); + assert!(collect_events(&mut rx).is_empty()); + + drain_chunk(&mut buffer, &tx, b"[B"); + assert!(buffer.is_empty()); + let events = collect_events(&mut rx); + assert_eq!(events.len(), 1); + assert_raw_key(events.into_iter().next().unwrap(), KeyCode::Down, KeyModifiers::empty()); + } + + #[test] + fn escape_followed_by_alt_char_before_flush_becomes_alt_key() { + let (tx, mut rx) = mpsc::channel(8); + let mut buffer = Vec::new(); + + drain_chunk(&mut buffer, &tx, b"\x1b"); + assert_eq!(buffer, b"\x1b"); + assert!(collect_events(&mut rx).is_empty()); + + drain_chunk(&mut buffer, &tx, b"b"); + assert!(buffer.is_empty()); + let events = collect_events(&mut rx); + assert_eq!(events.len(), 1); + assert_raw_key( + events.into_iter().next().unwrap(), + KeyCode::Char('b'), + KeyModifiers::ALT, + ); + } + + #[test] + fn chunked_kitty_sequence_waits_for_completion() { + let (tx, mut rx) = mpsc::channel(8); + let mut buffer = Vec::new(); + + drain_chunk(&mut buffer, &tx, b"\x1b[49:33;2:"); + assert_eq!(buffer, b"\x1b[49:33;2:"); + assert!(collect_events(&mut rx).is_empty()); + + drain_chunk(&mut buffer, &tx, b"1u"); + assert!(buffer.is_empty()); + let events = collect_events(&mut rx); + assert_eq!(events.len(), 1); + assert_raw_key(events.into_iter().next().unwrap(), KeyCode::Char('1'), KeyModifiers::SHIFT); + } + + #[test] + fn chunked_bracketed_paste_waits_for_terminator() { + let (tx, mut rx) = mpsc::channel(8); + let mut buffer = Vec::new(); + + drain_chunk(&mut buffer, &tx, b"\x1b[200~hello"); + assert_eq!(buffer, b"\x1b[200~hello"); + assert!(collect_events(&mut rx).is_empty()); + + drain_chunk(&mut buffer, &tx, b"\x1b[201~"); + assert!(buffer.is_empty()); + let events = collect_events(&mut rx); + assert_eq!(events.len(), 1); + let RawInputEvent::Paste(text) = &events[0] else { + panic!("expected paste"); + }; + assert_eq!(text, "hello"); + } + + #[test] + fn chunked_utf8_waits_for_continuation_byte() { + let (tx, mut rx) = mpsc::channel(8); + let mut buffer = Vec::new(); + + drain_chunk(&mut buffer, &tx, "é".as_bytes().get(..1).unwrap()); + assert_eq!(buffer, vec![0xC3]); + assert!(collect_events(&mut rx).is_empty()); + + drain_chunk(&mut buffer, &tx, "é".as_bytes().get(1..).unwrap()); + assert!(buffer.is_empty()); + let events = collect_events(&mut rx); + assert_eq!(events.len(), 1); + assert_raw_key(events.into_iter().next().unwrap(), KeyCode::Char('é'), KeyModifiers::empty()); + } +} |
