958 lines
37 KiB
Rust
958 lines
37 KiB
Rust
use crossterm::event::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode};
|
||
|
||
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 mut fields = body.split(';');
|
||
let key_part = fields.next()?;
|
||
let modifier_part = fields.next().unwrap_or("1");
|
||
let associated_text = fields.next();
|
||
if fields.next().is_some() {
|
||
return None;
|
||
}
|
||
|
||
let (modifier_text, event_type) = split_modifier_and_event(modifier_part);
|
||
let modifier = modifier_text.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());
|
||
|
||
if let Some(text) = associated_text {
|
||
if text.parse::<u32>().ok()? != codepoint {
|
||
return None;
|
||
}
|
||
}
|
||
|
||
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,
|
||
is_text_commit: false,
|
||
})
|
||
}
|
||
|
||
#[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 {
|
||
// In raw mode, Enter is carriage return. A bare line feed is ambiguous in
|
||
// terminal input and is commonly used for Ctrl+J or Shift+Enter workarounds.
|
||
// Preserve LF by letting it fall through to legacy control-byte parsing.
|
||
"\r" => Some(TerminalKey::new(KeyCode::Enter, KeyModifiers::empty())),
|
||
"\t" => Some(TerminalKey::new(KeyCode::Tab, KeyModifiers::empty())),
|
||
"\x1b" => Some(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())),
|
||
"\x1b\x7f" => Some(TerminalKey::new(KeyCode::Backspace, KeyModifiers::ALT)),
|
||
"\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()?;
|
||
let mut modifiers = KeyModifiers::ALT;
|
||
if ch.is_ascii_uppercase() {
|
||
modifiers |= KeyModifiers::SHIFT;
|
||
}
|
||
Some(TerminalKey::new(KeyCode::Char(ch), modifiers))
|
||
} 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" | "\x1bOA" => Some(TerminalKey::new(KeyCode::Up, KeyModifiers::empty())),
|
||
"\x1b[B" | "\x1bOB" => Some(TerminalKey::new(KeyCode::Down, KeyModifiers::empty())),
|
||
"\x1b[C" | "\x1bOC" => Some(TerminalKey::new(KeyCode::Right, KeyModifiers::empty())),
|
||
"\x1b[D" | "\x1bOD" => 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())),
|
||
"\x1bOp" => Some(TerminalKey::new(KeyCode::Char('0'), KeyModifiers::empty())),
|
||
"\x1bOq" => Some(TerminalKey::new(KeyCode::Char('1'), KeyModifiers::empty())),
|
||
"\x1bOr" => Some(TerminalKey::new(KeyCode::Char('2'), KeyModifiers::empty())),
|
||
"\x1bOs" => Some(TerminalKey::new(KeyCode::Char('3'), KeyModifiers::empty())),
|
||
"\x1bOt" => Some(TerminalKey::new(KeyCode::Char('4'), KeyModifiers::empty())),
|
||
"\x1bOu" => Some(TerminalKey::new(KeyCode::Char('5'), KeyModifiers::empty())),
|
||
"\x1bOv" => Some(TerminalKey::new(KeyCode::Char('6'), KeyModifiers::empty())),
|
||
"\x1bOw" => Some(TerminalKey::new(KeyCode::Char('7'), KeyModifiers::empty())),
|
||
"\x1bOx" => Some(TerminalKey::new(KeyCode::Char('8'), KeyModifiers::empty())),
|
||
"\x1bOy" => Some(TerminalKey::new(KeyCode::Char('9'), KeyModifiers::empty())),
|
||
"\x1bOn" => Some(TerminalKey::new(KeyCode::Char('.'), KeyModifiers::empty())),
|
||
"\x1bOl" => Some(TerminalKey::new(KeyCode::Char(','), KeyModifiers::empty())),
|
||
"\x1bOm" => Some(TerminalKey::new(KeyCode::Char('-'), KeyModifiers::empty())),
|
||
"\x1bOk" => Some(TerminalKey::new(KeyCode::Char('+'), KeyModifiers::empty())),
|
||
"\x1bOj" => Some(TerminalKey::new(KeyCode::Char('*'), KeyModifiers::empty())),
|
||
"\x1bOo" => Some(TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty())),
|
||
"\x1bOM" => Some(TerminalKey::new(KeyCode::Enter, KeyModifiers::empty())),
|
||
"\x1bOP" | "\x1b[11~" => Some(TerminalKey::new(KeyCode::F(1), KeyModifiers::empty())),
|
||
"\x1bOQ" | "\x1b[12~" => Some(TerminalKey::new(KeyCode::F(2), KeyModifiers::empty())),
|
||
"\x1bOR" | "\x1b[13~" => Some(TerminalKey::new(KeyCode::F(3), KeyModifiers::empty())),
|
||
"\x1bOS" | "\x1b[14~" => Some(TerminalKey::new(KeyCode::F(4), KeyModifiers::empty())),
|
||
"\x1b[15~" => Some(TerminalKey::new(KeyCode::F(5), KeyModifiers::empty())),
|
||
"\x1b[17~" => Some(TerminalKey::new(KeyCode::F(6), KeyModifiers::empty())),
|
||
"\x1b[18~" => Some(TerminalKey::new(KeyCode::F(7), KeyModifiers::empty())),
|
||
"\x1b[19~" => Some(TerminalKey::new(KeyCode::F(8), KeyModifiers::empty())),
|
||
"\x1b[20~" => Some(TerminalKey::new(KeyCode::F(9), KeyModifiers::empty())),
|
||
"\x1b[21~" => Some(TerminalKey::new(KeyCode::F(10), KeyModifiers::empty())),
|
||
"\x1b[23~" => Some(TerminalKey::new(KeyCode::F(11), KeyModifiers::empty())),
|
||
"\x1b[24~" => Some(TerminalKey::new(KeyCode::F(12), 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_and_event = body.strip_suffix(suffix_char)?;
|
||
let (modifier_text, event_type) = split_modifier_and_event(modifier_and_event);
|
||
let mod_value = modifier_text.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,
|
||
'P' => KeyCode::F(1),
|
||
'Q' => KeyCode::F(2),
|
||
'R' => KeyCode::F(3),
|
||
'S' => KeyCode::F(4),
|
||
_ => return None,
|
||
};
|
||
return Some(
|
||
TerminalKey::new(code, key_modifiers_from_u8(mod_value))
|
||
.with_kind(parse_kitty_event_type(event_type)?),
|
||
);
|
||
}
|
||
}
|
||
|
||
let tilde_body = body.strip_suffix('~')?;
|
||
let (code_part, modifier_part) = tilde_body.split_once(';')?;
|
||
let (modifier_text, event_type) = split_modifier_and_event(modifier_part);
|
||
let mod_value = modifier_text.parse::<u8>().ok()?.checked_sub(1)?;
|
||
let code = match code_part {
|
||
"2" => KeyCode::Insert,
|
||
"3" => KeyCode::Delete,
|
||
"5" => KeyCode::PageUp,
|
||
"6" => KeyCode::PageDown,
|
||
"15" => KeyCode::F(5),
|
||
"17" => KeyCode::F(6),
|
||
"18" => KeyCode::F(7),
|
||
"19" => KeyCode::F(8),
|
||
"20" => KeyCode::F(9),
|
||
"21" => KeyCode::F(10),
|
||
"23" => KeyCode::F(11),
|
||
"24" => KeyCode::F(12),
|
||
_ => return None,
|
||
};
|
||
Some(
|
||
TerminalKey::new(code, key_modifiers_from_u8(mod_value))
|
||
.with_kind(parse_kitty_event_type(event_type)?),
|
||
)
|
||
}
|
||
|
||
fn split_modifier_and_event(input: &str) -> (&str, Option<&str>) {
|
||
match input.split_once(':') {
|
||
Some((modifier, event)) if !modifier.is_empty() => (modifier, Some(event)),
|
||
_ => (input, None),
|
||
}
|
||
}
|
||
|
||
#[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),
|
||
57358 => Some(KeyCode::CapsLock),
|
||
57359 => Some(KeyCode::ScrollLock),
|
||
57360 => Some(KeyCode::NumLock),
|
||
57361 => Some(KeyCode::PrintScreen),
|
||
57362 => Some(KeyCode::Pause),
|
||
57363 => Some(KeyCode::Menu),
|
||
57376..=57398 => Some(KeyCode::F((codepoint - 57376 + 13) as u8)),
|
||
57399 => Some(KeyCode::Char('0')),
|
||
57400 => Some(KeyCode::Char('1')),
|
||
57401 => Some(KeyCode::Char('2')),
|
||
57402 => Some(KeyCode::Char('3')),
|
||
57403 => Some(KeyCode::Char('4')),
|
||
57404 => Some(KeyCode::Char('5')),
|
||
57405 => Some(KeyCode::Char('6')),
|
||
57406 => Some(KeyCode::Char('7')),
|
||
57407 => Some(KeyCode::Char('8')),
|
||
57408 => Some(KeyCode::Char('9')),
|
||
57409 => Some(KeyCode::Char('.')),
|
||
57410 => Some(KeyCode::Char('/')),
|
||
57411 => Some(KeyCode::Char('*')),
|
||
57412 => Some(KeyCode::Char('-')),
|
||
57413 => Some(KeyCode::Char('+')),
|
||
57415 => Some(KeyCode::Char('=')),
|
||
57416 => Some(KeyCode::Char(',')),
|
||
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),
|
||
57427 => Some(KeyCode::KeypadBegin),
|
||
57428 => Some(KeyCode::Media(MediaKeyCode::Play)),
|
||
57429 => Some(KeyCode::Media(MediaKeyCode::Pause)),
|
||
57430 => Some(KeyCode::Media(MediaKeyCode::PlayPause)),
|
||
57431 => Some(KeyCode::Media(MediaKeyCode::Reverse)),
|
||
57432 => Some(KeyCode::Media(MediaKeyCode::Stop)),
|
||
57433 => Some(KeyCode::Media(MediaKeyCode::FastForward)),
|
||
57434 => Some(KeyCode::Media(MediaKeyCode::Rewind)),
|
||
57435 => Some(KeyCode::Media(MediaKeyCode::TrackNext)),
|
||
57436 => Some(KeyCode::Media(MediaKeyCode::TrackPrevious)),
|
||
57437 => Some(KeyCode::Media(MediaKeyCode::Record)),
|
||
57438 => Some(KeyCode::Media(MediaKeyCode::LowerVolume)),
|
||
57439 => Some(KeyCode::Media(MediaKeyCode::RaiseVolume)),
|
||
57440 => Some(KeyCode::Media(MediaKeyCode::MuteVolume)),
|
||
57441 => Some(KeyCode::Modifier(ModifierKeyCode::LeftShift)),
|
||
57442 => Some(KeyCode::Modifier(ModifierKeyCode::LeftControl)),
|
||
57443 => Some(KeyCode::Modifier(ModifierKeyCode::LeftAlt)),
|
||
57444 => Some(KeyCode::Modifier(ModifierKeyCode::LeftSuper)),
|
||
57445 => Some(KeyCode::Modifier(ModifierKeyCode::LeftHyper)),
|
||
57446 => Some(KeyCode::Modifier(ModifierKeyCode::LeftMeta)),
|
||
57447 => Some(KeyCode::Modifier(ModifierKeyCode::RightShift)),
|
||
57448 => Some(KeyCode::Modifier(ModifierKeyCode::RightControl)),
|
||
57449 => Some(KeyCode::Modifier(ModifierKeyCode::RightAlt)),
|
||
57450 => Some(KeyCode::Modifier(ModifierKeyCode::RightSuper)),
|
||
57451 => Some(KeyCode::Modifier(ModifierKeyCode::RightHyper)),
|
||
57452 => Some(KeyCode::Modifier(ModifierKeyCode::RightMeta)),
|
||
57453 => Some(KeyCode::Modifier(ModifierKeyCode::IsoLevel3Shift)),
|
||
57454 => Some(KeyCode::Modifier(ModifierKeyCode::IsoLevel5Shift)),
|
||
value if is_kitty_functional_codepoint(value) => None,
|
||
value => char::from_u32(value).map(KeyCode::Char),
|
||
}
|
||
}
|
||
|
||
fn is_kitty_functional_codepoint(codepoint: u32) -> bool {
|
||
(57358..=57454).contains(&codepoint)
|
||
}
|
||
|
||
#[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
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use crossterm::event::{KeyCode, KeyModifiers, ModifierKeyCode};
|
||
|
||
use super::*;
|
||
use crate::input::{encode_terminal_key, KeyboardProtocol};
|
||
|
||
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 parse_legacy_f_keys() {
|
||
let cases = [
|
||
("\x1bOP", KeyCode::F(1)),
|
||
("\x1b[11~", KeyCode::F(1)),
|
||
("\x1bOQ", KeyCode::F(2)),
|
||
("\x1b[12~", KeyCode::F(2)),
|
||
("\x1bOR", KeyCode::F(3)),
|
||
("\x1b[13~", KeyCode::F(3)),
|
||
("\x1bOS", KeyCode::F(4)),
|
||
("\x1b[14~", KeyCode::F(4)),
|
||
];
|
||
|
||
for (sequence, code) in cases {
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence(sequence).expect("f key should parse"),
|
||
code,
|
||
KeyModifiers::empty(),
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
}
|
||
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence("\x1b[15~").expect("f5 should parse"),
|
||
KeyCode::F(5),
|
||
KeyModifiers::empty(),
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
assert_eq!(parse_terminal_key_sequence("\x1b[10~"), None);
|
||
assert_eq!(parse_terminal_key_sequence("\x1b[16~"), None);
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence("\x1b[1~").expect("home should parse"),
|
||
KeyCode::Home,
|
||
KeyModifiers::empty(),
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence("\x1b[4~").expect("end should parse"),
|
||
KeyCode::End,
|
||
KeyModifiers::empty(),
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence("\x1b[5~").expect("pageup should parse"),
|
||
KeyCode::PageUp,
|
||
KeyModifiers::empty(),
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence("\x1b[6~").expect("pagedown should parse"),
|
||
KeyCode::PageDown,
|
||
KeyModifiers::empty(),
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_legacy_application_keypad_sequences() {
|
||
let cases = [
|
||
("\x1bOp", KeyCode::Char('0')),
|
||
("\x1bOq", KeyCode::Char('1')),
|
||
("\x1bOr", KeyCode::Char('2')),
|
||
("\x1bOs", KeyCode::Char('3')),
|
||
("\x1bOt", KeyCode::Char('4')),
|
||
("\x1bOu", KeyCode::Char('5')),
|
||
("\x1bOv", KeyCode::Char('6')),
|
||
("\x1bOw", KeyCode::Char('7')),
|
||
("\x1bOx", KeyCode::Char('8')),
|
||
("\x1bOy", KeyCode::Char('9')),
|
||
("\x1bOn", KeyCode::Char('.')),
|
||
("\x1bOl", KeyCode::Char(',')),
|
||
("\x1bOm", KeyCode::Char('-')),
|
||
("\x1bOk", KeyCode::Char('+')),
|
||
("\x1bOj", KeyCode::Char('*')),
|
||
("\x1bOo", KeyCode::Char('/')),
|
||
("\x1bOM", KeyCode::Enter),
|
||
];
|
||
|
||
for (sequence, code) in cases {
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence(sequence).expect("keypad sequence should parse"),
|
||
code,
|
||
KeyModifiers::empty(),
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_legacy_alt_shift_letter_preserves_shift() {
|
||
let key = parse_terminal_key_sequence("\x1bA").expect("alt-shift letter should parse");
|
||
assert_terminal_key_eq(
|
||
key,
|
||
KeyCode::Char('A'),
|
||
KeyModifiers::ALT | KeyModifiers::SHIFT,
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\x1bA");
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_legacy_ss3_sequence_remains_unsupported() {
|
||
assert!(parse_terminal_key_sequence("\x1bOz").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn parse_modified_f_keys() {
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence("\x1b[1;2P").expect("shift+f1 should parse"),
|
||
KeyCode::F(1),
|
||
KeyModifiers::SHIFT,
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence("\x1b[1;3S").expect("alt+f4 should parse"),
|
||
KeyCode::F(4),
|
||
KeyModifiers::ALT,
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence("\x1b[1;4S").expect("shift+alt+f4 should parse"),
|
||
KeyCode::F(4),
|
||
KeyModifiers::SHIFT | KeyModifiers::ALT,
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
assert_terminal_key_eq(
|
||
parse_terminal_key_sequence("\x1b[15;2~").expect("shift+f5 should parse"),
|
||
KeyCode::F(5),
|
||
KeyModifiers::SHIFT,
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
assert_eq!(parse_terminal_key_sequence("\x1b[11;2~"), None);
|
||
assert_eq!(parse_terminal_key_sequence("\x1b[14;1~"), None);
|
||
assert_eq!(parse_terminal_key_sequence("\x1b[14;3~"), None);
|
||
}
|
||
|
||
#[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_kitty_sequence_preserves_non_us_shift_pairs() {
|
||
for (sequence, base, shifted) in [
|
||
("\x1b[50:34;2:1u", '2', '"'),
|
||
("\x1b[38:49;2:1u", '&', '1'),
|
||
("\x1b[305:73;2:1u", 'ı', 'I'),
|
||
("\x1b[287:286;2:1u", 'ğ', 'Ğ'),
|
||
] {
|
||
let key = parse_terminal_key_sequence(sequence).unwrap();
|
||
assert_eq!(key.code, KeyCode::Char(base));
|
||
assert_eq!(key.modifiers, KeyModifiers::SHIFT);
|
||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
|
||
assert_eq!(key.shifted_codepoint, Some(shifted as u32));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_kitty_sequence_with_associated_emoji_text() {
|
||
let key = parse_terminal_key_sequence("\x1b[128512;1;128512u").unwrap();
|
||
assert_terminal_key_eq(
|
||
key,
|
||
KeyCode::Char('😀'),
|
||
KeyModifiers::empty(),
|
||
crossterm::event::KeyEventKind::Press,
|
||
None,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reject_unmodeled_kitty_associated_text() {
|
||
assert_eq!(parse_terminal_key_sequence("\x1b[128512;1;128513u"), None);
|
||
assert_eq!(
|
||
parse_terminal_key_sequence("\x1b[128512;1;128512:65039u"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_kitty_alt_backspace_sequence() {
|
||
let key = parse_terminal_key_sequence("\x1b[127;3u").unwrap();
|
||
assert_eq!(key.code, KeyCode::Backspace);
|
||
assert_eq!(key.modifiers, KeyModifiers::ALT);
|
||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
|
||
assert_eq!(key.shifted_codepoint, None);
|
||
}
|
||
|
||
#[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_legacy_alt_backspace_sequence() {
|
||
let key = parse_terminal_key_sequence("\x1b\x7f").unwrap();
|
||
assert_eq!(key.code, KeyCode::Backspace);
|
||
assert_eq!(key.modifiers, KeyModifiers::ALT);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_kitty_modifier_sequence() {
|
||
let key = parse_terminal_key_sequence("\x1b[57441;2:1u").unwrap();
|
||
assert_eq!(key.code, KeyCode::Modifier(ModifierKeyCode::LeftShift));
|
||
assert_eq!(key.modifiers, KeyModifiers::SHIFT);
|
||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ghostty_enhanced_up_arrow_press_sequence() {
|
||
let key = parse_terminal_key_sequence("\x1b[1;1:1A").unwrap();
|
||
assert_eq!(key.code, KeyCode::Up);
|
||
assert_eq!(key.modifiers, KeyModifiers::empty());
|
||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ghostty_enhanced_up_arrow_release_sequence() {
|
||
let key = parse_terminal_key_sequence("\x1b[1;1:3A").unwrap();
|
||
assert_eq!(key.code, KeyCode::Up);
|
||
assert_eq!(key.modifiers, KeyModifiers::empty());
|
||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ghostty_enhanced_pageup_press_sequence() {
|
||
let key = parse_terminal_key_sequence("\x1b[5;1:1~").unwrap();
|
||
assert_eq!(key.code, KeyCode::PageUp);
|
||
assert_eq!(key.modifiers, KeyModifiers::empty());
|
||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ghostty_enhanced_pagedown_release_sequence() {
|
||
let key = parse_terminal_key_sequence("\x1b[6;1:3~").unwrap();
|
||
assert_eq!(key.code, KeyCode::PageDown);
|
||
assert_eq!(key.modifiers, KeyModifiers::empty());
|
||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ghostty_enhanced_delete_repeat_sequence() {
|
||
let key = parse_terminal_key_sequence("\x1b[3;1:2~").unwrap();
|
||
assert_eq!(key.code, KeyCode::Delete);
|
||
assert_eq!(key.modifiers, KeyModifiers::empty());
|
||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Repeat);
|
||
}
|
||
|
||
#[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 parse_legacy_lf_sequence_as_ctrl_j() {
|
||
let key = parse_terminal_key_sequence("\n").unwrap();
|
||
assert_eq!(key.code, KeyCode::Char('j'));
|
||
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_lf_roundtrips_as_lf() {
|
||
let key = parse_terminal_key_sequence("\n").unwrap();
|
||
assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\n");
|
||
}
|
||
|
||
#[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,
|
||
);
|
||
}
|
||
|
||
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 kitty_functional_key_matrix_is_covered() {
|
||
let cases = [
|
||
("\x1b[57399;1u", KeyCode::Char('0')),
|
||
("\x1b[57400;1u", KeyCode::Char('1')),
|
||
("\x1b[57401;1u", KeyCode::Char('2')),
|
||
("\x1b[57402;1u", KeyCode::Char('3')),
|
||
("\x1b[57403;1u", KeyCode::Char('4')),
|
||
("\x1b[57404;1u", KeyCode::Char('5')),
|
||
("\x1b[57405;1u", KeyCode::Char('6')),
|
||
("\x1b[57406;1u", KeyCode::Char('7')),
|
||
("\x1b[57407;1u", KeyCode::Char('8')),
|
||
("\x1b[57408;1u", KeyCode::Char('9')),
|
||
("\x1b[57409;1u", KeyCode::Char('.')),
|
||
("\x1b[57410;1u", KeyCode::Char('/')),
|
||
("\x1b[57411;1u", KeyCode::Char('*')),
|
||
("\x1b[57412;1u", KeyCode::Char('-')),
|
||
("\x1b[57413;1u", KeyCode::Char('+')),
|
||
("\x1b[57414;1u", KeyCode::Enter),
|
||
("\x1b[57415;1u", KeyCode::Char('=')),
|
||
("\x1b[57416;1u", KeyCode::Char(',')),
|
||
("\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 unknown_kitty_functional_key_remains_unsupported() {
|
||
assert!(parse_terminal_key_sequence("\x1b[57364;1u").is_none());
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|