aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs41
-rw-r--r--src/app/mod.rs60
-rw-r--r--src/input.rs882
-rw-r--r--src/main.rs8
-rw-r--r--src/pane.rs8
-rw-r--r--src/pty_callbacks.rs18
-rw-r--r--src/raw_input.rs633
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());
+ }
+}