aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-30 13:01:32 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-30 13:01:52 +0300
commit741fbbe8b30774247f8a68831d8df469672e1947 (patch)
tree608afe0a8415ea1a3ee2035f809795dc09809dfc /src
parentca14500717864b1c9b1885cbbc6a451a19b9caf8 (diff)
refactor: split terminal keyboard protocol module
Diffstat (limited to 'src')
-rw-r--r--src/input.rs1161
-rw-r--r--src/input/encode.rs285
-rw-r--r--src/input/mod.rs10
-rw-r--r--src/input/model.rs57
-rw-r--r--src/input/parse.rs225
-rw-r--r--src/input/tests.rs634
6 files changed, 1211 insertions, 1161 deletions
diff --git a/src/input.rs b/src/input.rs
deleted file mode 100644
index 8d11e335..00000000
--- a/src/input.rs
+++ /dev/null
@@ -1,1161 +0,0 @@
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
-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.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: &TerminalKey, flags: u16) -> Option<Vec<u8>> {
- let mods = key.modifiers;
-
- // Unmodified keys use legacy encoding (more compatible)
- if mods.is_empty() {
- return None;
- }
-
- // Plain Ctrl+letter is well-represented in legacy (bytes 1-26)
- if mods == KeyModifiers::CONTROL {
- if let KeyCode::Char(c) = key.code {
- if c.is_ascii_alphabetic() {
- return None; // let legacy handle it
- }
- }
- }
-
- // Special keys (arrows, F-keys, etc.) have well-established legacy
- // xterm modified formats (\x1b[1;3A for Alt+Up, etc.) that are universally
- // understood. Even Ghostty sends these in legacy format with kitty mode on.
- // Only use CSI u for character keys and keys without legacy representations.
- match key.code {
- KeyCode::Up
- | KeyCode::Down
- | KeyCode::Left
- | KeyCode::Right
- | KeyCode::Home
- | KeyCode::End
- | KeyCode::PageUp
- | KeyCode::PageDown
- | KeyCode::Insert
- | KeyCode::Delete
- | KeyCode::F(_) => {
- return None; // let legacy handle these
- }
- _ => {}
- }
-
- 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);
-
- 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).
-fn encode_legacy(key: KeyEvent) -> Vec<u8> {
- let mods = key.modifiers;
-
- // Modified special keys (arrows, home, end, etc.) use xterm format:
- // \x1b[1;{modifier}A for arrows/home/end
- // \x1b[{n};{modifier}~ for insert/delete/pgup/pgdn
- // The ESC-prefix hack doesn't work for these since they're already escape sequences.
- if !mods.is_empty() {
- if let Some(bytes) = encode_modified_special(key.code, mods) {
- return bytes;
- }
- }
-
- // Alt modifier on character keys: prefix with ESC
- if mods.contains(KeyModifiers::ALT) {
- let inner = KeyEvent::new(key.code, mods.difference(KeyModifiers::ALT));
- let mut bytes = vec![0x1b];
- bytes.extend(encode_legacy_inner(inner));
- return bytes;
- }
- encode_legacy_inner(key)
-}
-
-/// xterm-style encoding for modified special keys.
-/// Modifier value: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0)
-fn encode_modified_special(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> {
- let modifier = xterm_modifier(mods);
- if modifier <= 1 {
- return None; // no modifiers to encode
- }
-
- match code {
- // CSI 1;{mod}{letter} format
- KeyCode::Up => Some(format!("\x1b[1;{modifier}A").into_bytes()),
- KeyCode::Down => Some(format!("\x1b[1;{modifier}B").into_bytes()),
- KeyCode::Right => Some(format!("\x1b[1;{modifier}C").into_bytes()),
- KeyCode::Left => Some(format!("\x1b[1;{modifier}D").into_bytes()),
- KeyCode::Home => Some(format!("\x1b[1;{modifier}H").into_bytes()),
- KeyCode::End => Some(format!("\x1b[1;{modifier}F").into_bytes()),
- // CSI {n};{mod}~ format
- KeyCode::Insert => Some(format!("\x1b[2;{modifier}~").into_bytes()),
- KeyCode::Delete => Some(format!("\x1b[3;{modifier}~").into_bytes()),
- KeyCode::PageUp => Some(format!("\x1b[5;{modifier}~").into_bytes()),
- KeyCode::PageDown => Some(format!("\x1b[6;{modifier}~").into_bytes()),
- // F1-F4: CSI 1;{mod}{P-S}
- KeyCode::F(1) => Some(format!("\x1b[1;{modifier}P").into_bytes()),
- KeyCode::F(2) => Some(format!("\x1b[1;{modifier}Q").into_bytes()),
- KeyCode::F(3) => Some(format!("\x1b[1;{modifier}R").into_bytes()),
- KeyCode::F(4) => Some(format!("\x1b[1;{modifier}S").into_bytes()),
- // F5-F12: CSI {n};{mod}~
- KeyCode::F(n @ 5..=12) => {
- let code = match n {
- 5 => 15,
- 6 => 17,
- 7 => 18,
- 8 => 19,
- 9 => 20,
- 10 => 21,
- 11 => 23,
- 12 => 24,
- _ => unreachable!(),
- };
- Some(format!("\x1b[{code};{modifier}~").into_bytes())
- }
- _ => None,
- }
-}
-
-/// xterm modifier encoding: 1 + shift(1) + alt(2) + ctrl(4)
-/// Used for legacy modified special keys (arrows, function keys, etc.)
-fn xterm_modifier(mods: KeyModifiers) -> u32 {
- let mut m = 1u32;
- if mods.contains(KeyModifiers::SHIFT) {
- m += 1;
- }
- if mods.contains(KeyModifiers::ALT) {
- m += 2;
- }
- if mods.contains(KeyModifiers::CONTROL) {
- m += 4;
- }
- m
-}
-
-/// Kitty protocol modifier encoding: 1 + shift(1) + alt(2) + ctrl(4) + super(8) + hyper(16) + meta(32)
-/// Superset of xterm — adds Super/Hyper/Meta bits.
-fn kitty_modifier(mods: KeyModifiers) -> u32 {
- let mut m = xterm_modifier(mods);
- if mods.contains(KeyModifiers::SUPER) {
- m += 8;
- }
- if mods.contains(KeyModifiers::HYPER) {
- m += 16;
- }
- if mods.contains(KeyModifiers::META) {
- m += 32;
- }
- 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) => {
- if key.modifiers.contains(KeyModifiers::CONTROL) {
- let upper = ch.to_ascii_uppercase();
- match upper {
- 'A'..='Z' => vec![upper as u8 - 64],
- ' ' | '@' | '2' => vec![0],
- '[' | '3' => vec![27],
- '\\' | '4' => vec![28],
- ']' | '5' => vec![29],
- '^' | '6' => vec![30],
- '_' | '7' | '-' => vec![31],
- _ => vec![ch as u8],
- }
- } else {
- let mut buf = [0u8; 4];
- ch.encode_utf8(&mut buf).as_bytes().to_vec()
- }
- }
- KeyCode::Enter => vec![b'\r'],
- KeyCode::Backspace => vec![127],
- KeyCode::Tab => vec![9],
- KeyCode::BackTab => vec![27, 91, 90],
- KeyCode::Esc => vec![27],
- KeyCode::Left => vec![27, 91, 68],
- KeyCode::Right => vec![27, 91, 67],
- KeyCode::Up => vec![27, 91, 65],
- KeyCode::Down => vec![27, 91, 66],
- KeyCode::Home => vec![27, 91, 72],
- KeyCode::End => vec![27, 91, 70],
- KeyCode::PageUp => vec![27, 91, 53, 126],
- KeyCode::PageDown => vec![27, 91, 54, 126],
- KeyCode::Delete => vec![27, 91, 51, 126],
- KeyCode::Insert => vec![27, 91, 50, 126],
- KeyCode::F(n) => encode_f_key(n),
- _ => vec![],
- }
-}
-
-fn encode_f_key(n: u8) -> Vec<u8> {
- match n {
- 1 => vec![27, 79, 80],
- 2 => vec![27, 79, 81],
- 3 => vec![27, 79, 82],
- 4 => vec![27, 79, 83],
- 5 => vec![27, 91, 49, 53, 126],
- 6 => vec![27, 91, 49, 55, 126],
- 7 => vec![27, 91, 49, 56, 126],
- 8 => vec![27, 91, 49, 57, 126],
- 9 => vec![27, 91, 50, 48, 126],
- 10 => vec![27, 91, 50, 49, 126],
- 11 => vec![27, 91, 50, 51, 126],
- 12 => vec![27, 91, 50, 52, 126],
- _ => vec![],
- }
-}
-
-#[cfg(test)]
-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, KeyboardProtocol::Legacy), vec![b'\r']);
- }
-
- #[test]
- fn legacy_ctrl_c() {
- let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![3]);
- }
-
- #[test]
- fn legacy_shift_enter_is_just_cr() {
- 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, 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, KeyboardProtocol::Legacy), b"\x1b[1;3A");
- }
-
- #[test]
- fn legacy_shift_right() {
- let key = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT);
- 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, 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, KeyboardProtocol::Legacy), b"\x1b[1;6F");
- }
-
- #[test]
- fn legacy_alt_delete() {
- let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT);
- 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, 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, KeyboardProtocol::Legacy), b"\x1ba");
- }
-
- #[test]
- fn kitty_shift_enter() {
- let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
- assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"\x1b[13;2u");
- }
-
- #[test]
- fn kitty_ctrl_shift_a() {
- let key = KeyEvent::new(
- KeyCode::Char('a'),
- KeyModifiers::CONTROL | KeyModifiers::SHIFT,
- );
- 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, 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, 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, KeyboardProtocol::Kitty { flags: 1 }), b"a");
- }
-
- #[test]
- fn kitty_shift_tab() {
- let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT);
- 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, 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/input/encode.rs b/src/input/encode.rs
new file mode 100644
index 00000000..657fab80
--- /dev/null
+++ b/src/input/encode.rs
@@ -0,0 +1,285 @@
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+use super::{KeyboardProtocol, TerminalKey};
+
+const KITTY_FLAG_REPORT_EVENT_TYPES: u16 = 0b0000_0010;
+const KITTY_FLAG_REPORT_ALTERNATE_KEYS: u16 = 0b0000_0100;
+
+/// 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.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: &TerminalKey, flags: u16) -> Option<Vec<u8>> {
+ let mods = key.modifiers;
+
+ // Unmodified keys use legacy encoding (more compatible)
+ if mods.is_empty() {
+ return None;
+ }
+
+ // Plain Ctrl+letter is well-represented in legacy (bytes 1-26)
+ if mods == KeyModifiers::CONTROL {
+ if let KeyCode::Char(c) = key.code {
+ if c.is_ascii_alphabetic() {
+ return None; // let legacy handle it
+ }
+ }
+ }
+
+ // Special keys (arrows, F-keys, etc.) have well-established legacy
+ // xterm modified formats (\x1b[1;3A for Alt+Up, etc.) that are universally
+ // understood. Even Ghostty sends these in legacy format with kitty mode on.
+ // Only use CSI u for character keys and keys without legacy representations.
+ match key.code {
+ KeyCode::Up
+ | KeyCode::Down
+ | KeyCode::Left
+ | KeyCode::Right
+ | KeyCode::Home
+ | KeyCode::End
+ | KeyCode::PageUp
+ | KeyCode::PageDown
+ | KeyCode::Insert
+ | KeyCode::Delete
+ | KeyCode::F(_) => {
+ return None; // let legacy handle these
+ }
+ _ => {}
+ }
+
+ 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);
+
+ 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).
+fn encode_legacy(key: KeyEvent) -> Vec<u8> {
+ let mods = key.modifiers;
+
+ // Modified special keys (arrows, home, end, etc.) use xterm format:
+ // \x1b[1;{modifier}A for arrows/home/end
+ // \x1b[{n};{modifier}~ for insert/delete/pgup/pgdn
+ // The ESC-prefix hack doesn't work for these since they're already escape sequences.
+ if !mods.is_empty() {
+ if let Some(bytes) = encode_modified_special(key.code, mods) {
+ return bytes;
+ }
+ }
+
+ // Alt modifier on character keys: prefix with ESC
+ if mods.contains(KeyModifiers::ALT) {
+ let inner = KeyEvent::new(key.code, mods.difference(KeyModifiers::ALT));
+ let mut bytes = vec![0x1b];
+ bytes.extend(encode_legacy_inner(inner));
+ return bytes;
+ }
+ encode_legacy_inner(key)
+}
+
+/// xterm-style encoding for modified special keys.
+/// Modifier value: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0)
+fn encode_modified_special(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> {
+ let modifier = xterm_modifier(mods);
+ if modifier <= 1 {
+ return None; // no modifiers to encode
+ }
+
+ match code {
+ // CSI 1;{mod}{letter} format
+ KeyCode::Up => Some(format!("\x1b[1;{modifier}A").into_bytes()),
+ KeyCode::Down => Some(format!("\x1b[1;{modifier}B").into_bytes()),
+ KeyCode::Right => Some(format!("\x1b[1;{modifier}C").into_bytes()),
+ KeyCode::Left => Some(format!("\x1b[1;{modifier}D").into_bytes()),
+ KeyCode::Home => Some(format!("\x1b[1;{modifier}H").into_bytes()),
+ KeyCode::End => Some(format!("\x1b[1;{modifier}F").into_bytes()),
+ // CSI {n};{mod}~ format
+ KeyCode::Insert => Some(format!("\x1b[2;{modifier}~").into_bytes()),
+ KeyCode::Delete => Some(format!("\x1b[3;{modifier}~").into_bytes()),
+ KeyCode::PageUp => Some(format!("\x1b[5;{modifier}~").into_bytes()),
+ KeyCode::PageDown => Some(format!("\x1b[6;{modifier}~").into_bytes()),
+ // F1-F4: CSI 1;{mod}{P-S}
+ KeyCode::F(1) => Some(format!("\x1b[1;{modifier}P").into_bytes()),
+ KeyCode::F(2) => Some(format!("\x1b[1;{modifier}Q").into_bytes()),
+ KeyCode::F(3) => Some(format!("\x1b[1;{modifier}R").into_bytes()),
+ KeyCode::F(4) => Some(format!("\x1b[1;{modifier}S").into_bytes()),
+ // F5-F12: CSI {n};{mod}~
+ KeyCode::F(n @ 5..=12) => {
+ let code = match n {
+ 5 => 15,
+ 6 => 17,
+ 7 => 18,
+ 8 => 19,
+ 9 => 20,
+ 10 => 21,
+ 11 => 23,
+ 12 => 24,
+ _ => unreachable!(),
+ };
+ Some(format!("\x1b[{code};{modifier}~").into_bytes())
+ }
+ _ => None,
+ }
+}
+
+/// xterm modifier encoding: 1 + shift(1) + alt(2) + ctrl(4)
+/// Used for legacy modified special keys (arrows, function keys, etc.)
+fn xterm_modifier(mods: KeyModifiers) -> u32 {
+ let mut m = 1u32;
+ if mods.contains(KeyModifiers::SHIFT) {
+ m += 1;
+ }
+ if mods.contains(KeyModifiers::ALT) {
+ m += 2;
+ }
+ if mods.contains(KeyModifiers::CONTROL) {
+ m += 4;
+ }
+ m
+}
+
+/// Kitty protocol modifier encoding: 1 + shift(1) + alt(2) + ctrl(4) + super(8) + hyper(16) + meta(32)
+/// Superset of xterm — adds Super/Hyper/Meta bits.
+fn kitty_modifier(mods: KeyModifiers) -> u32 {
+ let mut m = xterm_modifier(mods);
+ if mods.contains(KeyModifiers::SUPER) {
+ m += 8;
+ }
+ if mods.contains(KeyModifiers::HYPER) {
+ m += 16;
+ }
+ if mods.contains(KeyModifiers::META) {
+ m += 32;
+ }
+ 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) => {
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
+ let upper = ch.to_ascii_uppercase();
+ match upper {
+ 'A'..='Z' => vec![upper as u8 - 64],
+ ' ' | '@' | '2' => vec![0],
+ '[' | '3' => vec![27],
+ '\\' | '4' => vec![28],
+ ']' | '5' => vec![29],
+ '^' | '6' => vec![30],
+ '_' | '7' | '-' => vec![31],
+ _ => vec![ch as u8],
+ }
+ } else {
+ let mut buf = [0u8; 4];
+ ch.encode_utf8(&mut buf).as_bytes().to_vec()
+ }
+ }
+ KeyCode::Enter => vec![b'\r'],
+ KeyCode::Backspace => vec![127],
+ KeyCode::Tab => vec![9],
+ KeyCode::BackTab => vec![27, 91, 90],
+ KeyCode::Esc => vec![27],
+ KeyCode::Left => vec![27, 91, 68],
+ KeyCode::Right => vec![27, 91, 67],
+ KeyCode::Up => vec![27, 91, 65],
+ KeyCode::Down => vec![27, 91, 66],
+ KeyCode::Home => vec![27, 91, 72],
+ KeyCode::End => vec![27, 91, 70],
+ KeyCode::PageUp => vec![27, 91, 53, 126],
+ KeyCode::PageDown => vec![27, 91, 54, 126],
+ KeyCode::Delete => vec![27, 91, 51, 126],
+ KeyCode::Insert => vec![27, 91, 50, 126],
+ KeyCode::F(n) => encode_f_key(n),
+ _ => vec![],
+ }
+}
+
+fn encode_f_key(n: u8) -> Vec<u8> {
+ match n {
+ 1 => vec![27, 79, 80],
+ 2 => vec![27, 79, 81],
+ 3 => vec![27, 79, 82],
+ 4 => vec![27, 79, 83],
+ 5 => vec![27, 91, 49, 53, 126],
+ 6 => vec![27, 91, 49, 55, 126],
+ 7 => vec![27, 91, 49, 56, 126],
+ 8 => vec![27, 91, 49, 57, 126],
+ 9 => vec![27, 91, 50, 48, 126],
+ 10 => vec![27, 91, 50, 49, 126],
+ 11 => vec![27, 91, 50, 51, 126],
+ 12 => vec![27, 91, 50, 52, 126],
+ _ => vec![],
+ }
+}
diff --git a/src/input/mod.rs b/src/input/mod.rs
new file mode 100644
index 00000000..de4330a4
--- /dev/null
+++ b/src/input/mod.rs
@@ -0,0 +1,10 @@
+mod encode;
+mod model;
+mod parse;
+
+pub use encode::{encode_key, encode_terminal_key};
+pub use model::{KeyboardProtocol, TerminalKey};
+pub use parse::parse_terminal_key_sequence;
+
+#[cfg(test)]
+mod tests;
diff --git a/src/input/model.rs b/src/input/model.rs
new file mode 100644
index 00000000..31c521e8
--- /dev/null
+++ b/src/input/model.rs
@@ -0,0 +1,57 @@
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+#[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 }
+ }
+ }
+}
diff --git a/src/input/parse.rs b/src/input/parse.rs
new file mode 100644
index 00000000..1662d9ff
--- /dev/null
+++ b/src/input/parse.rs
@@ -0,0 +1,225 @@
+use crossterm::event::{KeyCode, KeyModifiers};
+
+use super::TerminalKey;
+
+#[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
+}
diff --git a/src/input/tests.rs b/src/input/tests.rs
new file mode 100644
index 00000000..ec8fdf63
--- /dev/null
+++ b/src/input/tests.rs
@@ -0,0 +1,634 @@
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+use super::{
+ encode_key, encode_terminal_key, parse_terminal_key_sequence, KeyboardProtocol, TerminalKey,
+};
+
+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, KeyboardProtocol::Legacy), vec![b'\r']);
+}
+
+#[test]
+fn legacy_ctrl_c() {
+ let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![3]);
+}
+
+#[test]
+fn legacy_shift_enter_is_just_cr() {
+ 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, 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, KeyboardProtocol::Legacy), b"\x1b[1;3A");
+}
+
+#[test]
+fn legacy_shift_right() {
+ let key = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT);
+ 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, 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, KeyboardProtocol::Legacy), b"\x1b[1;6F");
+}
+
+#[test]
+fn legacy_alt_delete() {
+ let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT);
+ 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, 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, KeyboardProtocol::Legacy), b"\x1ba");
+}
+
+#[test]
+fn kitty_shift_enter() {
+ let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
+ b"\x1b[13;2u"
+ );
+}
+
+#[test]
+fn kitty_ctrl_shift_a() {
+ let key = KeyEvent::new(
+ KeyCode::Char('a'),
+ KeyModifiers::CONTROL | KeyModifiers::SHIFT,
+ );
+ 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, 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, 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, KeyboardProtocol::Kitty { flags: 1 }), b"a");
+}
+
+#[test]
+fn kitty_shift_tab() {
+ let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT);
+ 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, 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 }
+ );
+}