1062 lines
36 KiB
Rust
1062 lines
36 KiB
Rust
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
|
|
|
|
use super::{KeyboardProtocol, MouseProtocolEncoding, TerminalKey};
|
|
|
|
const KITTY_FLAG_REPORT_EVENT_TYPES: u16 = 0b0000_0010;
|
|
const KITTY_FLAG_REPORT_ALTERNATE_KEYS: u16 = 0b0000_0100;
|
|
const KITTY_FLAG_REPORT_ALL_KEYS: u16 = 0b0000_1000;
|
|
|
|
/// Encode a key event for a PTY child using the pane's negotiated keyboard protocol.
|
|
#[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers
|
|
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> {
|
|
// A release event only produces bytes when the pane protocol reports event
|
|
// types (Kitty REPORT_EVENT_TYPES). Otherwise the child expects a single
|
|
// legacy byte per keystroke, so re-emitting it on release would double keys
|
|
// like Enter/Backspace. The Ghostty wrapper can route release events through
|
|
// this fallback, so guard the fallback encoder too.
|
|
if key.kind == crossterm::event::KeyEventKind::Release && !protocol.reports_event_types() {
|
|
return Vec::new();
|
|
}
|
|
|
|
if let Some(bytes) = encode_text_input(&key) {
|
|
return bytes;
|
|
}
|
|
|
|
if let KeyboardProtocol::Kitty { flags } = protocol {
|
|
if let Some(bytes) = try_encode_csi_u(&key, flags) {
|
|
return bytes;
|
|
}
|
|
}
|
|
if key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types() {
|
|
return Vec::new();
|
|
}
|
|
encode_legacy(key)
|
|
}
|
|
|
|
#[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers
|
|
pub fn encode_cursor_key(code: KeyCode, application_cursor: bool) -> Vec<u8> {
|
|
match (code, application_cursor) {
|
|
(KeyCode::Up, true) => b"\x1bOA".to_vec(),
|
|
(KeyCode::Down, true) => b"\x1bOB".to_vec(),
|
|
(KeyCode::Right, true) => b"\x1bOC".to_vec(),
|
|
(KeyCode::Left, true) => b"\x1bOD".to_vec(),
|
|
(KeyCode::Up, false) => b"\x1b[A".to_vec(),
|
|
(KeyCode::Down, false) => b"\x1b[B".to_vec(),
|
|
(KeyCode::Right, false) => b"\x1b[C".to_vec(),
|
|
(KeyCode::Left, false) => b"\x1b[D".to_vec(),
|
|
_ => encode_legacy(KeyEvent::new(code, KeyModifiers::empty()).into()),
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)] // exercised in input unit tests; pane runtime uses backend helpers
|
|
pub fn encode_mouse_scroll(
|
|
kind: MouseEventKind,
|
|
column: u16,
|
|
row: u16,
|
|
modifiers: KeyModifiers,
|
|
encoding: MouseProtocolEncoding,
|
|
) -> Option<Vec<u8>> {
|
|
let button = match kind {
|
|
MouseEventKind::ScrollUp => 64u16,
|
|
MouseEventKind::ScrollDown => 65u16,
|
|
MouseEventKind::ScrollLeft => 66u16,
|
|
MouseEventKind::ScrollRight => 67u16,
|
|
_ => return None,
|
|
};
|
|
encode_mouse_cb(button, false, column, row, modifiers, encoding)
|
|
}
|
|
|
|
#[allow(dead_code)] // exercised in input unit tests; pane runtime uses backend helpers
|
|
pub fn encode_mouse_button(
|
|
kind: MouseEventKind,
|
|
column: u16,
|
|
row: u16,
|
|
modifiers: KeyModifiers,
|
|
encoding: MouseProtocolEncoding,
|
|
) -> Option<Vec<u8>> {
|
|
let (button, release) = match kind {
|
|
MouseEventKind::Down(MouseButton::Left) => (0u16, false),
|
|
MouseEventKind::Down(MouseButton::Middle) => (1u16, false),
|
|
MouseEventKind::Down(MouseButton::Right) => (2u16, false),
|
|
MouseEventKind::Up(MouseButton::Left) => (0u16, true),
|
|
MouseEventKind::Up(MouseButton::Middle) => (1u16, true),
|
|
MouseEventKind::Up(MouseButton::Right) => (2u16, true),
|
|
MouseEventKind::Drag(MouseButton::Left) => (32u16, false),
|
|
MouseEventKind::Drag(MouseButton::Middle) => (33u16, false),
|
|
MouseEventKind::Drag(MouseButton::Right) => (34u16, false),
|
|
_ => return None,
|
|
};
|
|
encode_mouse_cb(button, release, column, row, modifiers, encoding)
|
|
}
|
|
|
|
#[allow(dead_code)] // only reached through mouse encoding helpers above
|
|
fn encode_mouse_cb(
|
|
base_button: u16,
|
|
release: bool,
|
|
column: u16,
|
|
row: u16,
|
|
modifiers: KeyModifiers,
|
|
encoding: MouseProtocolEncoding,
|
|
) -> Option<Vec<u8>> {
|
|
let mut cb = match (encoding, release) {
|
|
(MouseProtocolEncoding::Sgr, true) => base_button,
|
|
(_, true) => 3,
|
|
(_, false) => base_button,
|
|
};
|
|
if modifiers.contains(KeyModifiers::SHIFT) {
|
|
cb += 4;
|
|
}
|
|
if modifiers.contains(KeyModifiers::ALT) {
|
|
cb += 8;
|
|
}
|
|
if modifiers.contains(KeyModifiers::CONTROL) {
|
|
cb += 16;
|
|
}
|
|
|
|
let column = column as u32 + 1;
|
|
let row = row as u32 + 1;
|
|
|
|
match encoding {
|
|
MouseProtocolEncoding::Sgr => Some(
|
|
format!(
|
|
"\x1b[<{cb};{column};{row}{}",
|
|
if release { 'm' } else { 'M' }
|
|
)
|
|
.into_bytes(),
|
|
),
|
|
MouseProtocolEncoding::Default => {
|
|
let cb = u8::try_from(cb + 32).ok()?;
|
|
let column = u8::try_from(column + 32).ok()?;
|
|
let row = u8::try_from(row + 32).ok()?;
|
|
Some(vec![0x1b, b'[', b'M', cb, column, row])
|
|
}
|
|
MouseProtocolEncoding::Utf8 => {
|
|
let mut bytes = Vec::with_capacity(16);
|
|
bytes.extend_from_slice(b"\x1b[M");
|
|
push_mouse_codepoint(&mut bytes, cb as u32 + 32)?;
|
|
push_mouse_codepoint(&mut bytes, column + 32)?;
|
|
push_mouse_codepoint(&mut bytes, row + 32)?;
|
|
Some(bytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)] // only reached through mouse encoding helpers above
|
|
fn push_mouse_codepoint(bytes: &mut Vec<u8>, value: u32) -> Option<()> {
|
|
let ch = char::from_u32(value)?;
|
|
let mut buf = [0u8; 4];
|
|
bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
|
|
Some(())
|
|
}
|
|
|
|
/// 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;
|
|
let event_suffix = kitty_event_suffix(key, flags);
|
|
let report_all_keys = flags & KITTY_FLAG_REPORT_ALL_KEYS != 0;
|
|
|
|
if !report_all_keys
|
|
&& key.modifiers.is_empty()
|
|
&& matches!(key.code, KeyCode::Enter | KeyCode::Tab | KeyCode::Backspace)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
// Unmodified keys use legacy encoding (more compatible)
|
|
if mods.is_empty() && event_suffix.is_none() && !report_all_keys {
|
|
return None;
|
|
}
|
|
|
|
// 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(_)
|
|
if event_suffix.is_none() && !report_all_keys =>
|
|
{
|
|
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),
|
|
KeyCode::Left => (57417, None),
|
|
KeyCode::Right => (57418, None),
|
|
KeyCode::Up => (57419, None),
|
|
KeyCode::Down => (57420, None),
|
|
KeyCode::PageUp => (57421, None),
|
|
KeyCode::PageDown => (57422, None),
|
|
KeyCode::Home => (57423, None),
|
|
KeyCode::End => (57424, None),
|
|
KeyCode::Insert => (57425, None),
|
|
KeyCode::Delete => (57426, None),
|
|
_ => return None, // fall back to legacy for unhandled keys
|
|
};
|
|
|
|
let modifier = kitty_modifier(mods);
|
|
|
|
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: TerminalKey) -> 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 = TerminalKey {
|
|
modifiers: mods.difference(KeyModifiers::ALT),
|
|
..key
|
|
};
|
|
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 encode_text_input(key: &TerminalKey) -> Option<Vec<u8>> {
|
|
let ch = match key.code {
|
|
KeyCode::Char(ch) => ch,
|
|
_ => return None,
|
|
};
|
|
|
|
if key.modifiers.is_empty() {
|
|
match key.kind {
|
|
crossterm::event::KeyEventKind::Press | crossterm::event::KeyEventKind::Repeat => {
|
|
let mut buf = [0u8; 4];
|
|
return Some(ch.encode_utf8(&mut buf).as_bytes().to_vec());
|
|
}
|
|
crossterm::event::KeyEventKind::Release => return Some(Vec::new()),
|
|
}
|
|
}
|
|
|
|
if key.modifiers != KeyModifiers::SHIFT {
|
|
return None;
|
|
}
|
|
|
|
let shifted_ch = shifted_text_char(key, ch)?;
|
|
|
|
match key.kind {
|
|
crossterm::event::KeyEventKind::Press | crossterm::event::KeyEventKind::Repeat => {
|
|
let mut buf = [0u8; 4];
|
|
Some(shifted_ch.encode_utf8(&mut buf).as_bytes().to_vec())
|
|
}
|
|
crossterm::event::KeyEventKind::Release => Some(Vec::new()),
|
|
}
|
|
}
|
|
|
|
fn shifted_text_char(key: &TerminalKey, ch: char) -> Option<char> {
|
|
if let Some(shifted) = key.shifted_codepoint.and_then(char::from_u32) {
|
|
return Some(shifted);
|
|
}
|
|
|
|
if ch.is_ascii_uppercase() {
|
|
return Some(ch);
|
|
}
|
|
|
|
if ch.is_ascii_lowercase() {
|
|
return Some(ch.to_ascii_uppercase());
|
|
}
|
|
|
|
if is_shifted_ascii_punctuation(ch) {
|
|
return Some(ch);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn is_shifted_ascii_punctuation(ch: char) -> bool {
|
|
matches!(
|
|
ch,
|
|
'!' | '@'
|
|
| '#'
|
|
| '$'
|
|
| '%'
|
|
| '^'
|
|
| '&'
|
|
| '*'
|
|
| '('
|
|
| ')'
|
|
| '_'
|
|
| '+'
|
|
| '{'
|
|
| '}'
|
|
| '|'
|
|
| ':'
|
|
| '"'
|
|
| '<'
|
|
| '>'
|
|
| '?'
|
|
| '~'
|
|
)
|
|
}
|
|
|
|
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: TerminalKey) -> 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 ch = if key.modifiers == KeyModifiers::SHIFT {
|
|
shifted_text_char(&key, ch).unwrap_or(ch)
|
|
} else {
|
|
ch
|
|
};
|
|
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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
use super::*;
|
|
use crate::input::parse_terminal_key_sequence;
|
|
|
|
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);
|
|
}
|
|
|
|
#[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_ctrl_slash_aliases_ctrl_underscore() {
|
|
let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL);
|
|
assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![31]);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_shift_enter_is_just_cr() {
|
|
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
|
|
assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_alt_up() {
|
|
let key = KeyEvent::new(KeyCode::Up, KeyModifiers::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() {
|
|
let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
|
|
assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1ba");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_alt_shift_punctuation_uses_shifted_text() {
|
|
let key = parse_terminal_key_sequence("\x1b[44:60;4u").unwrap();
|
|
assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\x1b<");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_alt_backspace_sends_escape_delete() {
|
|
let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
|
|
assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b\x7f");
|
|
}
|
|
|
|
#[test]
|
|
fn application_cursor_keys_use_ss3_sequences() {
|
|
assert_eq!(encode_cursor_key(KeyCode::Up, true), b"\x1bOA");
|
|
assert_eq!(encode_cursor_key(KeyCode::Down, true), b"\x1bOB");
|
|
}
|
|
|
|
#[test]
|
|
fn normal_cursor_keys_use_csi_sequences() {
|
|
assert_eq!(encode_cursor_key(KeyCode::Up, false), b"\x1b[A");
|
|
assert_eq!(encode_cursor_key(KeyCode::Down, false), b"\x1b[B");
|
|
}
|
|
|
|
#[test]
|
|
fn sgr_mouse_scroll_encodes_wheel_button_and_coordinates() {
|
|
let encoded = encode_mouse_scroll(
|
|
crossterm::event::MouseEventKind::ScrollDown,
|
|
4,
|
|
6,
|
|
KeyModifiers::SHIFT,
|
|
MouseProtocolEncoding::Sgr,
|
|
)
|
|
.expect("mouse scroll should encode");
|
|
|
|
assert_eq!(encoded, b"\x1b[<69;5;7M");
|
|
}
|
|
|
|
#[test]
|
|
fn sgr_mouse_release_keeps_button_code() {
|
|
let encoded = encode_mouse_button(
|
|
crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
|
|
11,
|
|
9,
|
|
KeyModifiers::empty(),
|
|
MouseProtocolEncoding::Sgr,
|
|
)
|
|
.expect("mouse release should encode");
|
|
|
|
assert_eq!(encoded, b"\x1b[<0;12;10m");
|
|
}
|
|
|
|
#[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_sends_text() {
|
|
let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT);
|
|
assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"L");
|
|
}
|
|
|
|
#[test]
|
|
fn kitty_shift_uppercase_letter_ignores_alternate_key_reporting_for_text() {
|
|
let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT);
|
|
assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"L");
|
|
}
|
|
|
|
#[test]
|
|
fn kitty_shift_lowercase_letter_sends_uppercase_text() {
|
|
let key = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::SHIFT);
|
|
assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"L");
|
|
}
|
|
|
|
#[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_alt_backspace_uses_csi_u() {
|
|
let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
|
|
assert_eq!(
|
|
encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
|
|
b"\x1b[127;3u"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kitty_plain_ctrl_c_uses_csi_u() {
|
|
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
|
assert_eq!(
|
|
encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
|
|
b"\x1b[99;5u"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kitty_plain_ctrl_c_includes_press_event_when_requested() {
|
|
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
|
assert_eq!(
|
|
encode_key(key, KeyboardProtocol::Kitty { flags: 3 }),
|
|
b"\x1b[99;5:1u"
|
|
);
|
|
}
|
|
|
|
#[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_report_event_types_keeps_basic_compatibility_keys_legacy() {
|
|
let cases = [
|
|
(KeyCode::Enter, b"\r".as_slice()),
|
|
(KeyCode::Tab, b"\t".as_slice()),
|
|
(KeyCode::Backspace, b"\x7f".as_slice()),
|
|
];
|
|
|
|
for (code, expected) in cases {
|
|
let press = KeyEvent::new_with_kind(
|
|
code,
|
|
KeyModifiers::empty(),
|
|
crossterm::event::KeyEventKind::Press,
|
|
);
|
|
assert_eq!(
|
|
encode_key(press, KeyboardProtocol::Kitty { flags: 3 }),
|
|
expected,
|
|
"{code:?} press should stay legacy-compatible without REPORT_ALL_KEYS"
|
|
);
|
|
|
|
let repeat = KeyEvent::new_with_kind(
|
|
code,
|
|
KeyModifiers::empty(),
|
|
crossterm::event::KeyEventKind::Repeat,
|
|
);
|
|
assert_eq!(
|
|
encode_key(repeat, KeyboardProtocol::Kitty { flags: 3 }),
|
|
expected,
|
|
"{code:?} repeat should stay legacy-compatible without REPORT_ALL_KEYS"
|
|
);
|
|
|
|
let release = KeyEvent::new_with_kind(
|
|
code,
|
|
KeyModifiers::empty(),
|
|
crossterm::event::KeyEventKind::Release,
|
|
);
|
|
assert_eq!(
|
|
encode_key(release, KeyboardProtocol::Kitty { flags: 3 }),
|
|
b"",
|
|
"{code:?} release should not fall back to legacy bytes"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn kitty_report_all_keys_encodes_basic_compatibility_keys_with_events() {
|
|
let enter_press = KeyEvent::new_with_kind(
|
|
KeyCode::Enter,
|
|
KeyModifiers::empty(),
|
|
crossterm::event::KeyEventKind::Press,
|
|
);
|
|
assert_eq!(
|
|
encode_key(enter_press, KeyboardProtocol::Kitty { flags: 9 }),
|
|
b"\x1b[13;1u"
|
|
);
|
|
|
|
let backspace_press = KeyEvent::new_with_kind(
|
|
KeyCode::Backspace,
|
|
KeyModifiers::empty(),
|
|
crossterm::event::KeyEventKind::Press,
|
|
);
|
|
assert_eq!(
|
|
encode_key(backspace_press, KeyboardProtocol::Kitty { flags: 11 }),
|
|
b"\x1b[127;1:1u"
|
|
);
|
|
|
|
let backspace_release = KeyEvent::new_with_kind(
|
|
KeyCode::Backspace,
|
|
KeyModifiers::empty(),
|
|
crossterm::event::KeyEventKind::Release,
|
|
);
|
|
assert_eq!(
|
|
encode_key(backspace_release, KeyboardProtocol::Kitty { flags: 11 }),
|
|
b"\x1b[127;1:3u"
|
|
);
|
|
}
|
|
|
|
#[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_shift_letter_release_does_not_emit_text() {
|
|
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"");
|
|
}
|
|
|
|
#[test]
|
|
fn kitty_shifted_punctuation_literals_send_text() {
|
|
for ch in "!@#$%^&*()_+{}|:\"<>?~".chars() {
|
|
let key = TerminalKey::new(KeyCode::Char(ch), KeyModifiers::SHIFT);
|
|
let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
|
|
assert_eq!(encoded, ch.to_string().into_bytes(), "ch={ch}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn kitty_shifted_punctuation_release_does_not_emit_text() {
|
|
let key = TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT)
|
|
.with_kind(crossterm::event::KeyEventKind::Release);
|
|
assert_eq!(
|
|
encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }),
|
|
b""
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kitty_shifted_punctuation_does_not_infer_layout() {
|
|
let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT);
|
|
assert_eq!(
|
|
encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }),
|
|
b"\x1b[49;2:1u"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kitty_modified_shifted_punctuation_stays_modified_key() {
|
|
for (modifiers, expected) in [
|
|
(
|
|
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
|
b"\x1b[33;6:1u".as_slice(),
|
|
),
|
|
(
|
|
KeyModifiers::ALT | KeyModifiers::SHIFT,
|
|
b"\x1b[33;4:1u".as_slice(),
|
|
),
|
|
(
|
|
KeyModifiers::SUPER | KeyModifiers::SHIFT,
|
|
b"\x1b[33;10:1u".as_slice(),
|
|
),
|
|
] {
|
|
let key = TerminalKey::new(KeyCode::Char('!'), modifiers);
|
|
let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
|
|
assert_eq!(encoded, expected, "modifiers={modifiers:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn release_bytes_gated_on_report_event_types() {
|
|
for code in [KeyCode::Enter, KeyCode::Backspace] {
|
|
let release = KeyEvent::new_with_kind(
|
|
code,
|
|
KeyModifiers::empty(),
|
|
crossterm::event::KeyEventKind::Release,
|
|
);
|
|
|
|
// Legacy and Kitty disambiguate-only (no REPORT_EVENT_TYPES) must not
|
|
// emit a byte on release, otherwise Enter/Backspace double (issue #769).
|
|
assert_eq!(encode_key(release, KeyboardProtocol::Legacy), b"");
|
|
assert_eq!(
|
|
encode_key(release, KeyboardProtocol::Kitty { flags: 1 }),
|
|
b""
|
|
);
|
|
}
|
|
|
|
let modified_release = KeyEvent::new_with_kind(
|
|
KeyCode::Enter,
|
|
KeyModifiers::CONTROL,
|
|
crossterm::event::KeyEventKind::Release,
|
|
);
|
|
assert_eq!(
|
|
encode_key(modified_release, KeyboardProtocol::Kitty { flags: 3 }),
|
|
b"\x1b[13;5:3u"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kitty_shifted_symbol_sends_text() {
|
|
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"!"
|
|
);
|
|
}
|
|
|
|
#[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_prefers_text_over_roundtrip_key_identity() {
|
|
let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT)
|
|
.with_shifted_codepoint('!' as u32);
|
|
let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
|
|
assert_eq!(encoded, b"!");
|
|
}
|
|
|
|
#[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_shifted_symbol_pair_matrix_is_encoded_as_text() {
|
|
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 });
|
|
assert_eq!(encoded, shifted.to_string().into_bytes(), "base={base}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn chinese_char_encodes_as_utf8() {
|
|
let key = TerminalKey::new(KeyCode::Char('中'), KeyModifiers::empty());
|
|
let encoded = encode_terminal_key(key, KeyboardProtocol::Legacy);
|
|
assert_eq!(encoded, "中".as_bytes());
|
|
}
|
|
|
|
#[test]
|
|
fn chinese_char_with_kitty_protocol_encodes_as_utf8() {
|
|
let key = TerminalKey::new(KeyCode::Char('文'), KeyModifiers::empty());
|
|
let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
|
|
assert_eq!(encoded, "文".as_bytes());
|
|
}
|
|
|
|
#[test]
|
|
fn chinese_char_with_modifiers_falls_back_to_kitty_encoding() {
|
|
let key = TerminalKey::new(KeyCode::Char('测'), KeyModifiers::ALT);
|
|
let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
|
|
assert!(!encoded.is_empty());
|
|
assert_ne!(encoded, "测".as_bytes());
|
|
}
|
|
}
|