Skip to content

Commit 0f086ab

Browse files
vshylovclaude
authored andcommitted
feat(event): report the kitty protocol's base layout key
The kitty keyboard protocol's "report alternate keys" enhancement sends up to two extra codepoints with each key: the shifted key and the base layout key - the key at the same physical position on a standard PC-101 keyboard. The parser read the shifted one (only when shift was held) and dropped the rest, so applications had no way to match shortcuts by physical key: under a Cyrillic, Greek or Hebrew layout every Ctrl+<letter> binding is dead, because the reported character is the layout's, not the label's. Parse both alternates positionally (either may be empty, e.g. `CSI 1076::108;5u`) and expose the base layout key as `KeyEvent::base_layout_code`, alongside the existing flag-gated `kind`/`state` fields. The new field takes no part in `PartialEq`/`Hash`: it is metadata about the same key press, and including it would silently break the ubiquitous `event == KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)` comparison as soon as the enhancement is enabled. Existing behaviour, including the shifted-key substitution, is unchanged - no existing test needed updating. Closes #968. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 34636cf commit 0f086ab

3 files changed

Lines changed: 134 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
# Unreleased
22

3+
## Added ⭐
4+
5+
- Report the Kitty keyboard protocol's *base layout key* as
6+
`KeyEvent::base_layout_code`, so shortcuts can be matched by physical key
7+
regardless of the active keyboard layout (#968). Set only when
8+
`KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS` is enabled.
9+
310
## Breaking ⚠️
411

512
- Raise the minimum supported Rust version from 1.63 to 1.85.
613
- Remove `IsTty` trait.
714
Use the standard library's [`std::io::IsTerminal`](https://doc.rust-lang.org/std/io/trait.IsTerminal.html) trait instead,
815
which provides equivalent functionality.
16+
- `KeyEvent` gained a `base_layout_code` field: struct-literal construction and
17+
exhaustive destructuring need updating. The `KeyEvent::new*` constructors,
18+
`PartialEq` and `Hash` are unaffected — the new field takes no part in
19+
equality, so comparisons against manually built events keep working.
920

1021
## Changed ⚙️
1122

src/event.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,24 @@ pub struct KeyEvent {
933933
/// Only set if [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with
934934
/// [`PushKeyboardEnhancementFlags`].
935935
pub state: KeyEventState,
936+
/// The key at the same physical position on a standard PC-101 keyboard,
937+
/// independent of the active keyboard layout.
938+
///
939+
/// This is what makes layout-independent shortcuts possible: with a Cyrillic
940+
/// layout active, the physical `C` key reports `KeyCode::Char('с')` in
941+
/// [`code`](Self::code) and `Some(KeyCode::Char('c'))` here, so an
942+
/// application can match `Ctrl+C` regardless of the layout the user types in.
943+
///
944+
/// Only set if [`KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS`] has been
945+
/// enabled with [`PushKeyboardEnhancementFlags`], and only on Unix — the
946+
/// Windows console API does not report it.
947+
///
948+
/// This field deliberately takes no part in [`PartialEq`]/[`Hash`], so that
949+
/// comparing against a manually constructed event — the common
950+
/// `event == KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)`
951+
/// pattern — keeps working once the enhancement is enabled.
952+
#[cfg_attr(feature = "serde", serde(default))]
953+
pub base_layout_code: Option<KeyCode>,
936954
}
937955

938956
impl KeyEvent {
@@ -942,6 +960,7 @@ impl KeyEvent {
942960
modifiers,
943961
kind: KeyEventKind::Press,
944962
state: KeyEventState::empty(),
963+
base_layout_code: None,
945964
}
946965
}
947966

@@ -955,6 +974,7 @@ impl KeyEvent {
955974
modifiers,
956975
kind,
957976
state: KeyEventState::empty(),
977+
base_layout_code: None,
958978
}
959979
}
960980

@@ -969,9 +989,17 @@ impl KeyEvent {
969989
modifiers,
970990
kind,
971991
state,
992+
base_layout_code: None,
972993
}
973994
}
974995

996+
/// Returns the event with [`base_layout_code`](Self::base_layout_code) set.
997+
#[must_use = "this returns a new event instead of modifying the original"]
998+
pub fn with_base_layout_code(mut self, base_layout_code: Option<KeyCode>) -> KeyEvent {
999+
self.base_layout_code = base_layout_code;
1000+
self
1001+
}
1002+
9751003
// modifies the KeyEvent,
9761004
// so that KeyModifiers::SHIFT is present iff
9771005
// an uppercase char is present.
@@ -1012,23 +1040,29 @@ impl From<KeyCode> for KeyEvent {
10121040
modifiers: KeyModifiers::empty(),
10131041
kind: KeyEventKind::Press,
10141042
state: KeyEventState::empty(),
1043+
base_layout_code: None,
10151044
}
10161045
}
10171046
}
10181047

10191048
impl PartialEq for KeyEvent {
10201049
fn eq(&self, other: &KeyEvent) -> bool {
1050+
// `base_layout_code` is metadata about the same key press, not part of
1051+
// its identity: including it would break `event == KeyEvent::new(...)`
1052+
// comparisons as soon as `REPORT_ALTERNATE_KEYS` is enabled.
10211053
let KeyEvent {
10221054
code: lhs_code,
10231055
modifiers: lhs_modifiers,
10241056
kind: lhs_kind,
10251057
state: lhs_state,
1058+
base_layout_code: _,
10261059
} = self.normalize_case();
10271060
let KeyEvent {
10281061
code: rhs_code,
10291062
modifiers: rhs_modifiers,
10301063
kind: rhs_kind,
10311064
state: rhs_state,
1065+
base_layout_code: _,
10321066
} = other.normalize_case();
10331067
(lhs_code == rhs_code)
10341068
&& (lhs_modifiers == rhs_modifiers)
@@ -1041,11 +1075,14 @@ impl Eq for KeyEvent {}
10411075

10421076
impl Hash for KeyEvent {
10431077
fn hash<H: Hasher>(&self, hash_state: &mut H) {
1078+
// Excluded for the same reason as in `PartialEq`, and to keep `Hash`
1079+
// consistent with it.
10441080
let KeyEvent {
10451081
code,
10461082
modifiers,
10471083
kind,
10481084
state,
1085+
base_layout_code: _,
10491086
} = self.normalize_case();
10501087
code.hash(hash_state);
10511088
modifiers.hash(hash_state);

src/event/sys/unix/parse.rs

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -590,31 +590,50 @@ pub(crate) fn parse_csi_u_encoded_key_code(buffer: &[u8]) -> io::Result<Option<I
590590
}
591591
}
592592

593-
// When the "report alternate keys" flag is enabled in the Kitty Keyboard Protocol
594-
// and the terminal sends a keyboard event containing shift, the sequence will
595-
// contain an additional codepoint separated by a ':' character which contains
596-
// the shifted character according to the keyboard layout.
593+
// When the "report alternate keys" flag is enabled in the Kitty Keyboard Protocol,
594+
// the first field carries up to two more codepoints separated by ':' characters:
595+
// the shifted key and the base layout key, in that order. Either may be absent or
596+
// empty (`CSI 1076::108;5u` reports a base layout key but no shifted key), so both
597+
// are read positionally, before either is used.
598+
let shifted_key = next_alternate_key(&mut codepoints);
599+
let base_layout_key = next_alternate_key(&mut codepoints);
600+
601+
// The shifted key is the character the active layout produces with shift held, so
602+
// it replaces the key code and consumes the modifier.
597603
if modifiers.contains(KeyModifiers::SHIFT) {
598-
if let Some(shifted_c) = codepoints
599-
.next()
600-
.and_then(|codepoint| codepoint.parse::<u32>().ok())
601-
.and_then(char::from_u32)
602-
{
604+
if let Some(shifted_c) = shifted_key {
603605
keycode = KeyCode::Char(shifted_c);
604606
modifiers.set(KeyModifiers::SHIFT, false);
605607
}
606608
}
607609

608-
let input_event = Event::Key(KeyEvent::new_with_kind_and_state(
609-
keycode,
610-
modifiers,
611-
kind,
612-
state_from_keycode | state_from_modifiers,
613-
));
610+
let input_event = Event::Key(
611+
KeyEvent::new_with_kind_and_state(
612+
keycode,
613+
modifiers,
614+
kind,
615+
state_from_keycode | state_from_modifiers,
616+
)
617+
// The base layout key is the key at the same physical position on a standard
618+
// PC-101 keyboard, which lets applications match shortcuts by physical key
619+
// regardless of the layout the user types in.
620+
.with_base_layout_code(base_layout_key.map(KeyCode::Char)),
621+
);
614622

615623
Ok(Some(InternalEvent::Event(input_event)))
616624
}
617625

626+
/// Reads the next `:`-separated alternate key of a `CSI u` key field.
627+
///
628+
/// Returns `None` when the alternate is absent or empty, which the Kitty Keyboard
629+
/// Protocol allows for any of them.
630+
fn next_alternate_key<'a>(codepoints: &mut impl Iterator<Item = &'a str>) -> Option<char> {
631+
codepoints
632+
.next()
633+
.and_then(|codepoint| codepoint.parse::<u32>().ok())
634+
.and_then(char::from_u32)
635+
}
636+
618637
pub(crate) fn parse_csi_special_key_code(buffer: &[u8]) -> io::Result<Option<InternalEvent>> {
619638
assert!(buffer.starts_with(b"\x1B[")); // ESC [
620639
assert!(buffer.ends_with(b"~"));
@@ -864,10 +883,24 @@ pub(crate) fn parse_utf8_char(buffer: &[u8]) -> io::Result<Option<char>> {
864883

865884
#[cfg(test)]
866885
mod tests {
886+
use std::collections::hash_map::DefaultHasher;
887+
use std::hash::{Hash, Hasher};
888+
867889
use crate::event::{KeyEventState, KeyModifiers, MouseButton, MouseEvent};
868890

869891
use super::*;
870892

893+
/// Parses a sequence that is expected to yield exactly one key event.
894+
///
895+
/// Needed where a field is asserted directly instead of through
896+
/// `assert_eq!` on the whole event, which compares by key identity only.
897+
fn key_event(bytes: &[u8]) -> KeyEvent {
898+
match parse_event(bytes, false).unwrap() {
899+
Some(InternalEvent::Event(Event::Key(key))) => key,
900+
other => panic!("expected a key event, got {:?}", other),
901+
}
902+
}
903+
871904
#[test]
872905
fn test_esc_key() {
873906
assert_eq!(
@@ -1547,6 +1580,44 @@ mod tests {
15471580
);
15481581
}
15491582

1583+
#[test]
1584+
fn test_parse_csi_u_with_base_layout_key() {
1585+
// The physical `L` key pressed with ctrl on a Cyrillic layout: the layout
1586+
// reports `д` (U+0434), its shifted form is `Д` (U+0414), and the key at the
1587+
// same position on a PC-101 keyboard is `l` (U+006C).
1588+
let event = key_event(b"\x1B[1076:1044:108;5u");
1589+
assert_eq!(event.code, KeyCode::Char('\u{434}'));
1590+
assert_eq!(event.modifiers, KeyModifiers::CONTROL);
1591+
assert_eq!(event.base_layout_code, Some(KeyCode::Char('l')));
1592+
1593+
// The shifted key may be omitted while the base layout key is present.
1594+
let event = key_event(b"\x1B[1076::108;5u");
1595+
assert_eq!(event.code, KeyCode::Char('\u{434}'));
1596+
assert_eq!(event.base_layout_code, Some(KeyCode::Char('l')));
1597+
1598+
// Without the enhancement (or on a layout where the key is its own base),
1599+
// no alternates are reported.
1600+
assert_eq!(key_event(b"\x1B[97;5u").base_layout_code, None);
1601+
assert_eq!(key_event(b"\x1B[97:65;2u").base_layout_code, None);
1602+
}
1603+
1604+
#[test]
1605+
fn test_base_layout_key_does_not_affect_equality() {
1606+
// Applications compare against manually built events; reporting alternates
1607+
// must not break that, so the base layout key is not part of the identity.
1608+
let reported = key_event(b"\x1B[1076:1044:108;5u");
1609+
assert_eq!(
1610+
reported,
1611+
KeyEvent::new(KeyCode::Char('\u{434}'), KeyModifiers::CONTROL)
1612+
);
1613+
1614+
let mut with_base = DefaultHasher::new();
1615+
reported.hash(&mut with_base);
1616+
let mut without_base = DefaultHasher::new();
1617+
KeyEvent::new(KeyCode::Char('\u{434}'), KeyModifiers::CONTROL).hash(&mut without_base);
1618+
assert_eq!(with_base.finish(), without_base.finish());
1619+
}
1620+
15501621
#[test]
15511622
fn test_parse_csi_special_key_code_with_types() {
15521623
assert_eq!(

0 commit comments

Comments
 (0)