T08: Tab and F6 pass Ely's focus keys; each kitty flag reshapes only what it names; Cmd is Super; repeats and Command key-ups come from AppKit; IME commits stay UTF-8, as in kitty and Ghostty; key.zig out; the line rule reads every text file
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 9m0s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 14:59:59 -04:00
1 parent 25365a98c8
commit 11d7a30598
14 files changed
+505 -1147

No files matched your search

+115 -203
View File
@@ -1,3 +1,7 @@
mod legacy;
use legacy::{FUNCTION_TILDES, legacy, printable_key};
/// Whether a key went down, repeated, or came up.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
@@ -17,6 +21,8 @@ pub struct Key<'a> {
pub ctrl: bool,
/// Alt as a modifier, not as the key that composes characters.
pub alt: bool,
/// Cmd, the Windows key or Super: kitty's super.
pub platform: bool,
pub action: Action,
}
@@ -33,16 +39,24 @@ pub struct Kitty {
/// The bytes a key sends; none when it sends nothing or its text is the input method's to type.
pub fn encode(key: &Key, app_cursor: bool, kitty: Kitty) -> Option<Vec<u8>> {
let on = kitty.disambiguate || kitty.events || kitty.alternates || kitty.all || kitty.text;
if on {
encode_kitty(key, kitty)
} else {
legacy(key, app_cursor)
if !on {
return legacy(key, app_cursor);
}
if key.action == Action::Release && !kitty.events {
return None;
}
match functional(key.name) {
Some((number, end)) => named(key, number, end, app_cursor, kitty),
None => typed(key, printable(key.name)?, kitty),
}
}
/// xterm's modifier code: one plus shift 1, alt 2, ctrl 4.
/// xterm's modifier code: one plus shift 1, alt 2, ctrl 4, super 8.
fn code(key: &Key) -> u32 {
1 + u32::from(key.shift) + 2 * u32::from(key.alt) + 4 * u32::from(key.ctrl)
1 + u32::from(key.shift)
+ 2 * u32::from(key.alt)
+ 4 * u32::from(key.ctrl)
+ 8 * u32::from(key.platform)
}
/// The character a key name stands for, `space` included.
@@ -54,152 +68,20 @@ fn printable(name: &str) -> Option<char> {
}
}
fn legacy(key: &Key, app_cursor: bool) -> Option<Vec<u8>> {
if key.action == Action::Release {
return None;
}
if let Some(sequence) = function(key, app_cursor) {
return Some(sequence);
}
let ch = printable(key.name)?;
if key.ctrl {
if let Some(byte) = control(ch, key.shift) {
return Some(if key.alt {
vec![0x1B, byte]
} else {
vec![byte]
});
}
return Some(format!("\x1b[{};{}u", ch as u32, code(key)).into_bytes());
}
if key.alt {
let mut bytes = vec![0x1B];
if key.text.is_empty() {
let typed = if key.shift {
ch.to_ascii_uppercase()
} else {
ch
};
bytes.extend(typed.to_string().as_bytes());
} else {
bytes.extend(key.text.as_bytes());
}
return Some(bytes);
}
None
/// Whether the key types its text: some, none of it control, and no modifier that stops text.
fn types(key: &Key) -> bool {
let control = key.text.chars().any(char::is_control);
!key.text.is_empty() && !control && !key.ctrl && !key.alt && !key.platform
}
/// xterm's bytes for a function, cursor or editing key.
fn function(key: &Key, app_cursor: bool) -> Option<Vec<u8>> {
let mods = code(key);
let modified = mods > 1;
let cursor = |end: char| match (modified, app_cursor) {
(true, _) => format!("\x1b[1;{mods}{end}"),
(false, true) => format!("\x1bO{end}"),
(false, false) => format!("\x1b[{end}"),
};
let tilde = |n: u32| {
if modified {
format!("\x1b[{n};{mods}~")
} else {
format!("\x1b[{n}~")
}
};
let ss3 = |end: char| {
if modified {
format!("\x1b[1;{mods}{end}")
} else {
format!("\x1bO{end}")
}
};
let only = (key.shift, key.alt, key.ctrl);
let sequence = match key.name {
"up" => cursor('A'),
"down" => cursor('B'),
"right" => cursor('C'),
"left" => cursor('D'),
"home" => cursor('H'),
"end" => cursor('F'),
"insert" => tilde(2),
"delete" => tilde(3),
"pageup" => tilde(5),
"pagedown" => tilde(6),
"f1" => ss3('P'),
"f2" => ss3('Q'),
"f3" if modified => tilde(13),
"f3" => ss3('R'),
"f4" => ss3('S'),
"enter" => match only {
(false, false, false) => "\r".into(),
(false, true, false) => "\x1b\r".into(),
_ => format!("\x1b[27;{mods};13~"),
},
"tab" => match only {
(false, false, false) => "\t".into(),
(true, false, false) => "\x1b[Z".into(),
(false, true, false) => "\x1b\t".into(),
_ => format!("\x1b[27;{mods};9~"),
},
"escape" => match only {
(false, false, false) => "\x1b".into(),
(false, true, false) => "\x1b\x1b".into(),
_ => format!("\x1b[27;{mods};27~"),
},
"backspace" => {
let byte = if key.ctrl { 0x08 } else { 0x7F };
return Some(if key.alt {
vec![0x1B, byte]
} else {
vec![byte]
});
}
name => tilde(FUNCTION_TILDES.iter().find(|(f, _)| *f == name)?.1),
};
Some(sequence.into_bytes())
}
/// F5 to F25 and the numbers xterm sends them with.
const FUNCTION_TILDES: [(&str, u32); 21] = [
("f5", 15),
("f6", 17),
("f7", 18),
("f8", 19),
("f9", 20),
("f10", 21),
("f11", 23),
("f12", 24),
("f13", 25),
("f14", 26),
("f15", 28),
("f16", 29),
("f17", 31),
("f18", 32),
("f19", 33),
("f20", 34),
("f21", 42),
("f22", 43),
("f23", 44),
("f24", 45),
("f25", 46),
];
/// The C0 byte Ctrl turns a key into, as kitty and Ghostty do; `[`, `i` and `m` go as CSI u.
fn control(ch: char, shift: bool) -> Option<u8> {
if shift && ch != '@' {
return None;
}
Some(match ch {
' ' | '2' | '@' => 0,
'a'..='h' | 'j' | 'k' | 'l' | 'n'..='z' => ch as u8 - b'a' + 1,
'3' => 27,
'4' | '\\' => 28,
'5' | ']' => 29,
'6' | '^' | '~' => 30,
'7' | '/' | '_' => 31,
'8' | '?' => 127,
'0' | '1' | '9' => ch as u8,
_ => return None,
})
/// The text's code points joined by colons, control codes left out.
fn code_points(text: &str) -> String {
let points: Vec<String> = text
.chars()
.filter(|ch| !ch.is_control())
.map(|ch| (ch as u32).to_string())
.collect();
points.join(":")
}
/// kitty's functional keys: the number and the final byte.
@@ -230,68 +112,98 @@ fn functional(name: &str) -> Option<(u32, char)> {
})
}
fn encode_kitty(key: &Key, kitty: Kitty) -> Option<Vec<u8>> {
let control_text = key.text.chars().any(char::is_control);
if key.action == Action::Release {
let legacy = matches!(key.name, "enter" | "tab" | "backspace");
if !kitty.events || (legacy && !kitty.all) {
/// kitty's event field: 2 for a repeat, 3 for a release, none for a press or without the flag.
fn event(key: &Key, kitty: Kitty) -> Option<u32> {
match key.action {
Action::Press => None,
_ if !kitty.events => None,
Action::Repeat => Some(2),
Action::Release => Some(3),
}
}
/// A functional key: legacy bytes until a flag that reshapes it is on.
fn named(key: &Key, number: u32, end: char, app_cursor: bool, kitty: Kitty) -> Option<Vec<u8>> {
if !kitty.events && !kitty.disambiguate && !kitty.all {
return legacy(key, app_cursor);
}
let lone = matches!(key.name, "enter" | "tab" | "backspace");
if key.action == Action::Release && lone && !kitty.all {
return None;
}
let plain = code(key) == 1 && key.action != Action::Release && !kitty.all;
match key.name {
"enter" if plain => return Some(b"\r".to_vec()),
"tab" if plain => return Some(b"\t".to_vec()),
"backspace" if plain => return Some(vec![0x7F]),
"escape" if plain && !kitty.disambiguate => return Some(b"\x1b".to_vec()),
_ => {}
}
let mods = code(key);
let event = event(key, kitty);
let fields = match (mods, event) {
(_, Some(event)) => format!("{mods}:{event}"),
(1, None) => String::new(),
(_, None) => mods.to_string(),
};
let sequence = match (end, fields.is_empty()) {
('u' | '~', true) => format!("\x1b[{number}{end}"),
('u' | '~', false) => format!("\x1b[{number};{fields}{end}"),
(_, true) => format!("\x1b[{end}"),
(_, false) => format!("\x1b[1;{fields}{end}"),
};
Some(sequence.into_bytes())
}
/// A printable key: text, then legacy bytes, then CSI u once a flag has more to say.
fn typed(key: &Key, ch: char, kitty: Kitty) -> Option<Vec<u8>> {
let typing = types(key);
if typing && !kitty.all && key.action != Action::Release {
return None;
}
let shifted = key
.text
.chars()
.next()
.filter(|&typed| typing && typed != ch && !typed.is_control())
.or_else(|| (key.shift && ch.is_ascii_lowercase()).then(|| ch.to_ascii_uppercase()))
.filter(|_| key.shift && kitty.alternates);
let event = event(key, kitty);
let text = typing && kitty.text && key.action != Action::Release;
let mods = code(key);
if shifted.is_none() && event.is_none() && !text {
if mods == 1 && !kitty.all {
return None;
}
}
let shift_typed = key.shift && !key.text.is_empty() && !control_text;
let bound = key.ctrl || key.alt || (key.shift && !shift_typed);
if !kitty.all && !bound && key.action != Action::Release {
match key.name {
"enter" => return Some(b"\r".to_vec()),
"tab" => return Some(b"\t".to_vec()),
"backspace" => return Some(vec![0x7F]),
_ if !key.text.is_empty() && !control_text => return None,
_ => {}
if mods > 1
&& !kitty.disambiguate
&& !kitty.all
&& let Some(bytes) = printable_key(key, ch)
{
return Some(bytes);
}
}
let (number, end) = functional(key.name)
.or_else(|| printable(key.name).map(|ch| (ch.to_ascii_lowercase() as u32, 'u')))?;
let mods = code(key);
let event = match key.action {
Action::Press => 1,
Action::Repeat => 2,
Action::Release => 3,
};
let event = if kitty.events { event } else { 1 };
let mut out = String::from("\x1b[");
if end != 'u' && end != '~' {
if event > 1 {
out.push_str(&format!("1;{mods}:{event}"));
} else if mods > 1 {
out.push_str(&format!("1;{mods}"));
}
out.push(end);
return Some(out.into_bytes());
}
out.push_str(&number.to_string());
let shifted = key.text.chars().next().filter(|&typed| {
kitty.alternates && key.shift && typed as u32 != number && !typed.is_control()
});
let mut out = format!("\x1b[{}", ch.to_ascii_lowercase() as u32);
if let Some(shifted) = shifted {
out.push_str(&format!(":{}", shifted as u32));
}
let wrote_mods = event > 1 || mods > 1;
if event > 1 {
out.push_str(&format!(";{mods}:{event}"));
} else if mods > 1 {
out.push_str(&format!(";{mods}"));
let second = match (mods, event) {
(_, Some(event)) => format!("{mods}:{event}"),
(1, None) => String::new(),
(_, None) => mods.to_string(),
};
let third = if text {
code_points(key.text)
} else {
String::new()
};
if !second.is_empty() || !third.is_empty() {
out.push_str(&format!(";{second}"));
}
let text: Vec<String> = key
.text
.chars()
.filter(|ch| !ch.is_control())
.map(|ch| (ch as u32).to_string())
.collect();
if kitty.text && key.action != Action::Release && !key.ctrl && !key.alt && !text.is_empty() {
out.push_str(if wrote_mods { ";" } else { ";;" });
out.push_str(&text.join(":"));
if !third.is_empty() {
out.push_str(&format!(";{third}"));
}
out.push(end);
out.push('u');
Some(out.into_bytes())
}