lattice_protocol/chord.rs
1//! Renderer-neutral chord representation -- the typed canonical
2//! form the keymap trie indexes by.
3//!
4//! One [`KeyChord`] is one keypress: a [`KeyKind`] (a character or a named
5//! [`SpecialKey`]) plus [`KeyMods`]. A binding path is a sequence of them,
6//! written in vim notation and parsed by [`parse_chord_sequence`]; `Display`
7//! prints a chord back in the same notation, so parse → display → parse is
8//! the identity for every chord the parser can produce (except `<F13>`..
9//! `<F24>`; see [`special_label`]).
10//!
11//! # Examples
12//!
13//! ```
14//! use lattice_protocol::{KeyChord, KeyKind, KeyMods, SpecialKey, parse_chord_sequence};
15//!
16//! let seq = parse_chord_sequence("<C-w>j<Esc>").unwrap();
17//! assert_eq!(
18//! seq,
19//! vec![
20//! KeyChord::ctrl('w'),
21//! KeyChord::char('j'),
22//! KeyChord::special(SpecialKey::Esc),
23//! ]
24//! );
25//!
26//! // Round-trip through the canonical spelling.
27//! let text: String = seq.iter().map(ToString::to_string).collect();
28//! assert_eq!(text, "<C-w>j<Esc>");
29//! assert_eq!(parse_chord_sequence(&text).unwrap(), seq);
30//!
31//! // Shift on a letter folds into its case; on a named key it is kept.
32//! assert_eq!(parse_chord_sequence("<S-a>").unwrap(), vec![KeyChord::char('A')]);
33//! let shift_tab = KeyChord::new(KeyKind::Special(SpecialKey::Tab), KeyMods::SHIFT);
34//! assert_eq!(shift_tab.to_string(), "<S-Tab>");
35//! ```
36//!
37//! K.2.1 (2026-06-01): moved from `lattice-host::chord` into
38//! `lattice-protocol`, alongside the other renderer-neutral wire
39//! types (`Position`, `Edit`, `Selection`, …). The substrate
40//! sits at the dependency floor so any crate that constructs a
41//! binding -- including mode crates like `lattice-multibuffer`
42//! that contribute keymaps via `Mode::keymap()` -- can do so
43//! without depending on `lattice-host`. `lattice-host::chord` is
44//! retained as a re-export shim for one release cycle to avoid
45//! downstream churn.
46//!
47//! Phase 5.4 split: every type + parser + formatter here is pure
48//! data. The crossterm-coupled side (`KeyEvent → KeyChord`
49//! conversion, `format_chord` for ratatui-driven describe-key
50//! output) lives in `lattice-ui-tui::chord` and reaches into the
51//! neutral types defined here. The future `lattice-ui-gpui` ships
52//! its own adapter from GPUI's key event type into the same
53//! [`KeyChord`] without coordinating with the TUI's adapter.
54//!
55//! ## Notation conventions
56//!
57//! Match the strings the keymap registry catalog uses:
58//!
59//! - Bare printable char: `"a"`, `"$"`, `"0"`. No angles.
60//! - Modifier-only-Shift on a printable char: folded into the char
61//! (`"A"`, not `"<S-a>"`). Shift on a non-character key keeps the
62//! prefix: `"<S-Tab>"`, `"<S-F1>"`.
63//! - Ctrl: `"<C-x>"` -- always lowercase letter, even if the
64//! keyboard reports it uppercase.
65//! - Alt / Meta: `"<M-x>"` (`"<A-x>"` is accepted on input).
66//! - Super / Cmd: `"<D-x>"`.
67//! - Combined modifiers in canonical order `C, S, M, D`: `"<C-S-x>"`,
68//! `"<C-M-x>"`. Input accepts them in any order; each at most once.
69//! - Named special keys: `<Esc>`, `<Tab>`, `<CR>`, `<BS>`, `<Up>`,
70//! `<Down>`, `<Left>`, `<Right>`, `<Home>`, `<End>`, `<PageUp>`,
71//! `<PageDown>`, `<Insert>`, `<Delete>`, `<F1>`-`<F12>`, `<Space>`.
72//! Input also accepts the aliases `<Escape>`, `<Enter>`, `<Return>`,
73//! `<Backspace>`, `<Ins>`, `<Del>` and `<F13>`-`<F24>`.
74//! - An unmodified `<Space>` is the space *character* (`Char(' ')`), and
75//! prints as `<Space>`; a modified one (`<C-Space>`) is the special key.
76//! - Literal `<` types as `<lt>` (vim convention) so the parser
77//! reading these strings can disambiguate.
78
79use std::fmt;
80use std::str::FromStr;
81
82/// Canonical, stack-allocated representation of one chord.
83///
84/// `Copy` so call sites can pass it freely without lifetime
85/// gymnastics; `Hash` + `Eq` so it works as a `HashMap` key in
86/// the keymap trie. Memory: 8 bytes (1-byte mod bitfield + 1-byte
87/// discriminant + a 4-byte char or 1-byte SpecialKey, padded).
88///
89/// Parse one chord with `str::parse` (`FromStr`), a sequence with
90/// [`parse_chord_sequence`]; `Display` writes the canonical notation.
91///
92/// # Examples
93///
94/// ```
95/// use lattice_protocol::{ChordParseError, KeyChord, KeyKind, KeyMods, SpecialKey};
96///
97/// let chord: KeyChord = "<C-S-Tab>".parse().unwrap();
98/// assert_eq!(chord.key, KeyKind::Special(SpecialKey::Tab));
99/// assert_eq!(chord.mods, KeyMods::CTRL | KeyMods::SHIFT);
100/// assert_eq!(chord.to_string(), "<C-S-Tab>");
101///
102/// // `<` and a bare space are escaped when printed.
103/// assert_eq!(KeyChord::char('<').to_string(), "<lt>");
104/// assert_eq!(KeyChord::char(' ').to_string(), "<Space>");
105///
106/// // `FromStr` wants exactly one chord.
107/// assert!(matches!("ab".parse::<KeyChord>(), Err(ChordParseError::BodyTooLong { .. })));
108/// ```
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110pub struct KeyChord {
111 /// Which key was pressed.
112 pub key: KeyKind,
113 /// Modifiers held with it. For a letter, Shift is encoded in the case of
114 /// [`KeyKind::Char`] instead, so this carries no SHIFT bit.
115 pub mods: KeyMods,
116}
117
118/// What kind of key the chord represents.
119///
120/// `Char` covers printable characters and Ctrl/Alt-modified chars
121/// (the modifier lives in `mods`). For *letters* the case
122/// encodes shift (vim convention: `A` is shift+a; we don't carry
123/// `KeyMods::SHIFT` for plain letters). For non-letter chars
124/// (`$`, `0`, `<`, ...) the case is the only valid form.
125///
126/// `Special` covers named keys (`Esc`, `Enter`, `Tab`, `Up`, ...)
127/// where shift IS carried separately because `<S-Tab>` is a
128/// genuinely different chord from `<Tab>`.
129///
130/// Function keys live in `Special::F(u8)` rather than as their
131/// own enum variant to keep the type compact; `1..=24` covers
132/// every reasonable terminal.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
134pub enum KeyKind {
135 /// A character key: the character it produces (already shifted, so `A`
136 /// or `$`, never `a`+Shift or `4`+Shift).
137 Char(char),
138 /// A named, non-character key.
139 Special(SpecialKey),
140}
141
142/// Named special keys. Renderer-neutral; crossterm's
143/// `KeyCode::BackTab` is normalised away by the TUI adapter
144/// (`from_crossterm`) into `Special(Tab) + KeyMods::SHIFT` so the
145/// trie has one entry for "shift-tab" rather than two ambiguous
146/// ones.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
148pub enum SpecialKey {
149 /// Escape — `<Esc>`.
150 Esc,
151 /// Return / Enter — `<CR>`.
152 Enter,
153 /// Tab — `<Tab>`; Shift-Tab is this plus [`KeyMods::SHIFT`].
154 Tab,
155 /// Backspace — `<BS>`.
156 Backspace,
157 /// Space *with a modifier* (`<C-Space>`). A bare space is
158 /// `KeyKind::Char(' ')`; see the module notes.
159 Space,
160 /// Arrow up — `<Up>`.
161 Up,
162 /// Arrow down — `<Down>`.
163 Down,
164 /// Arrow left — `<Left>`.
165 Left,
166 /// Arrow right — `<Right>`.
167 Right,
168 /// Home — `<Home>`.
169 Home,
170 /// End — `<End>`.
171 End,
172 /// Page Up — `<PageUp>`.
173 PageUp,
174 /// Page Down — `<PageDown>`.
175 PageDown,
176 /// Insert — `<Insert>`.
177 Insert,
178 /// Forward delete — `<Delete>`.
179 Delete,
180 /// Function keys F1..=F24. `F(0)` is reserved (invalid).
181 F(u8),
182}
183
184/// Modifier bitfield. `Copy + Eq + Hash` so the whole `KeyChord`
185/// fits in a CPU register.
186///
187/// The raw `u8` is public for adapters; prefer the named constants, which
188/// combine with `|` (or [`Self::with`]).
189///
190/// # Examples
191///
192/// ```
193/// use lattice_protocol::KeyMods;
194///
195/// let mods = KeyMods::CTRL | KeyMods::ALT;
196/// assert!(mods.ctrl() && mods.alt() && !mods.shift());
197/// assert_eq!(mods.without(KeyMods::ALT), KeyMods::CTRL);
198/// assert!(KeyMods::NONE.is_empty());
199/// ```
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
201pub struct KeyMods(pub u8);
202
203impl KeyMods {
204 /// No modifiers.
205 pub const NONE: Self = Self(0);
206 /// Control — `C-` in notation.
207 pub const CTRL: Self = Self(1 << 0);
208 /// Shift — `S-` in notation (only carried for non-letter keys).
209 pub const SHIFT: Self = Self(1 << 1);
210 /// Alt / Meta — `M-` in notation.
211 pub const ALT: Self = Self(1 << 2);
212 /// Super / Cmd / Windows — `D-` in notation.
213 pub const SUPER: Self = Self(1 << 3);
214
215 /// Whether Control is held.
216 #[inline]
217 pub const fn ctrl(self) -> bool {
218 self.0 & Self::CTRL.0 != 0
219 }
220 /// Whether Shift is held.
221 #[inline]
222 pub const fn shift(self) -> bool {
223 self.0 & Self::SHIFT.0 != 0
224 }
225 /// Whether Alt / Meta is held.
226 #[inline]
227 pub const fn alt(self) -> bool {
228 self.0 & Self::ALT.0 != 0
229 }
230 /// Whether Super is held. (Trailing underscore: `super` is a keyword.)
231 #[inline]
232 pub const fn super_(self) -> bool {
233 self.0 & Self::SUPER.0 != 0
234 }
235 /// Whether no modifier is held.
236 #[inline]
237 pub const fn is_empty(self) -> bool {
238 self.0 == 0
239 }
240
241 /// Add `other`'s modifiers (a `const` spelling of `self | other`).
242 #[inline]
243 pub const fn with(self, other: Self) -> Self {
244 Self(self.0 | other.0)
245 }
246
247 /// Strip a modifier (used by renderer adapters to clear the
248 /// redundant SHIFT bit on bare printable chars where the
249 /// terminal already encoded shift in the case / shifted
250 /// symbol).
251 #[inline]
252 pub const fn without(self, other: Self) -> Self {
253 Self(self.0 & !other.0)
254 }
255}
256
257impl std::ops::BitOr for KeyMods {
258 type Output = Self;
259 #[inline]
260 fn bitor(self, rhs: Self) -> Self {
261 Self(self.0 | rhs.0)
262 }
263}
264
265/// Parse-side error variants. Detail-level so `:bind`-style error
266/// messages can surface what was wrong.
267///
268/// Every `at` is the byte offset, in the string handed to the parser, of the
269/// token that failed — the `<` of an angle token, or the character itself.
270///
271/// # Examples
272///
273/// ```
274/// use lattice_protocol::{ChordParseError, parse_chord_sequence};
275///
276/// assert_eq!(parse_chord_sequence(""), Err(ChordParseError::Empty));
277/// assert_eq!(
278/// parse_chord_sequence("g<C-w"),
279/// Err(ChordParseError::UnterminatedAngle { at: 1 })
280/// );
281/// assert!(matches!(
282/// parse_chord_sequence("<Foo>"),
283/// Err(ChordParseError::UnknownName { .. })
284/// ));
285/// ```
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum ChordParseError {
288 /// String was empty.
289 Empty,
290 /// `<...>` token had no closing `>`.
291 UnterminatedAngle {
292 /// Byte offset of the unclosed `<`.
293 at: usize,
294 },
295 /// `<...>` token body was empty (`<>`).
296 EmptyAngle {
297 /// Byte offset of the `<`.
298 at: usize,
299 },
300 /// `<...>` body referenced an unknown name (`<Foo>`, `<F99>`,
301 /// `<C-S-X>` where the body chunk after modifiers is
302 /// unrecognised).
303 UnknownName {
304 /// The unrecognised name, modifiers stripped (`Foo`).
305 name: String,
306 /// Byte offset of the token's `<`.
307 at: usize,
308 },
309 /// Modifier prefix (`C-`, `S-`, `M-`) without a body (`<C->`).
310 DanglingModifier {
311 /// Byte offset of the token's `<`.
312 at: usize,
313 },
314 /// The same modifier appeared twice in one token (`<C-C-x>`).
315 DuplicateModifier {
316 /// Byte offset of the token's `<`.
317 at: usize,
318 },
319 /// Input that should be exactly one chord was longer. Raised only by
320 /// `KeyChord::from_str`, for trailing input after the first chord
321 /// (`"ab"`, `"<Esc>x"`); a multi-character body such as `<C-foo>`
322 /// reports [`Self::UnknownName`] instead.
323 BodyTooLong {
324 /// The whole input string.
325 name: String,
326 /// Byte offset of the first chord (always 0).
327 at: usize,
328 },
329 /// Sequence parser saw a stray `>` (no matching `<`).
330 StrayClose {
331 /// Byte offset of the `>`.
332 at: usize,
333 },
334}
335
336impl fmt::Display for ChordParseError {
337 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338 match self {
339 Self::Empty => write!(f, "empty chord string"),
340 Self::UnterminatedAngle { at } => {
341 write!(f, "unterminated `<...>` at byte {at}")
342 }
343 Self::EmptyAngle { at } => {
344 write!(f, "empty `<>` at byte {at}")
345 }
346 Self::UnknownName { name, at } => {
347 write!(f, "unknown chord name `{name}` at byte {at}")
348 }
349 Self::DanglingModifier { at } => {
350 write!(f, "dangling modifier prefix at byte {at}")
351 }
352 Self::DuplicateModifier { at } => {
353 write!(f, "duplicate modifier at byte {at}")
354 }
355 Self::BodyTooLong { name, at } => {
356 write!(
357 f,
358 "body `{name}` after modifiers is not a single chord at byte {at}"
359 )
360 }
361 Self::StrayClose { at } => {
362 write!(f, "stray `>` at byte {at}")
363 }
364 }
365 }
366}
367
368impl std::error::Error for ChordParseError {}
369
370impl KeyChord {
371 /// Build directly from kind + mods. Useful for tests +
372 /// internal callers; production callers go through the
373 /// renderer-specific adapter (`lattice_ui_tui::chord::from_event`
374 /// for the TUI, the analogous function in the future GPUI
375 /// adapter).
376 #[inline]
377 pub const fn new(key: KeyKind, mods: KeyMods) -> Self {
378 Self { key, mods }
379 }
380
381 /// Plain printable character with no modifiers.
382 #[inline]
383 pub const fn char(c: char) -> Self {
384 Self {
385 key: KeyKind::Char(c),
386 mods: KeyMods::NONE,
387 }
388 }
389
390 /// Ctrl-modified character. Letter case is normalised by the
391 /// caller; convention is lowercase (`<C-c>`, not `<C-C>`).
392 #[inline]
393 pub const fn ctrl(c: char) -> Self {
394 Self {
395 key: KeyKind::Char(c),
396 mods: KeyMods::CTRL,
397 }
398 }
399
400 /// Special key with no modifiers.
401 #[inline]
402 pub const fn special(k: SpecialKey) -> Self {
403 Self {
404 key: KeyKind::Special(k),
405 mods: KeyMods::NONE,
406 }
407 }
408}
409
410/// One element of a keymap registration path.
411///
412/// `Literal` matches a specific chord; `CharLiteral` matches any
413/// single bare-char chord (no modifiers). The wildcard captures
414/// the matched char so the resulting [`crate::ids::CommandId`]
415/// invocation can receive it (mark / register / find-char
416/// targets all flow through this).
417///
418/// Lives in `lattice-protocol` alongside [`KeyChord`] because
419/// mode crates (which contribute keymap bindings via
420/// `Mode::keymap()`) need to construct paths without depending
421/// on `lattice-host`. The matcher engine
422/// (`KeymapTrie` / `KeymapLayer` / `BoundCommand`) stays in host:
423/// it owns the lookup hot path, not the wire shape.
424#[derive(Debug, Clone, PartialEq, Eq, Hash)]
425pub enum ChordPattern {
426 /// Matches exactly this chord.
427 Literal(KeyChord),
428 /// Matches any single unmodified character chord, capturing the char
429 /// (the `{char}` of `m{char}`, `"{char}`, `f{char}`).
430 CharLiteral,
431}
432
433impl fmt::Display for KeyChord {
434 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
435 // Plain printable chars with no modifiers render bare, except the two
436 // that cannot survive it: `<` escapes as `<lt>` so the parser can
437 // round-trip without ambiguity, and a literal space escapes as
438 // `<Space>` for the same reason plus a second one.
439 //
440 // The parser round-trip alone does hold for a bare `" "` —
441 // `parse_chord_sequence(" ")` reads it back as `Char(' ')`. What does
442 // NOT hold is every context that delimits on whitespace or asks a
443 // human to read the result:
444 //
445 // - the `:` line. `:describe-key` captures chords by appending
446 // `Display` tokens to the command line, so a captured space
447 // appended `" "` and the ex-parser split the argument on it — the
448 // user pressed Space and got `describe-key` with no argument.
449 // - every listing. `:map`, `:keymap`, which-key and describe-key's own
450 // "… is not bound" line rendered the leader as an INVISIBLE column.
451 // `<Space>ff` reads; ` ff` does not.
452 //
453 // The chord itself is unchanged — `Char(' ')`, what both decoders
454 // produce for an unmodified space (see `parse_angle_body`). This is
455 // only how it is spelled back out.
456 if self.mods.is_empty()
457 && let KeyKind::Char(c) = self.key
458 {
459 if c == '<' {
460 return f.write_str("<lt>");
461 }
462 if c == ' ' {
463 return f.write_str("<Space>");
464 }
465 return write!(f, "{c}");
466 }
467
468 f.write_str("<")?;
469 if self.mods.ctrl() {
470 f.write_str("C-")?;
471 }
472 if self.mods.shift() {
473 f.write_str("S-")?;
474 }
475 if self.mods.alt() {
476 f.write_str("M-")?;
477 }
478 if self.mods.super_() {
479 f.write_str("D-")?;
480 }
481 match self.key {
482 KeyKind::Char(c) => {
483 // Ctrl/Alt-letter is rendered lowercase
484 // (normalised by the renderer adapter); `<C-S-c>`
485 // is distinct from `<C-c>` only by the explicit
486 // S- prefix.
487 write!(f, "{c}")?;
488 }
489 KeyKind::Special(s) => {
490 f.write_str(special_label(s))?;
491 }
492 }
493 f.write_str(">")
494 }
495}
496
497/// Canonical name for a `SpecialKey`. Round-trips through
498/// `parse_special`. Renderer-neutral text; both the TUI's
499/// `format_chord` and any future GPUI describe-key renderer use
500/// this label.
501///
502/// The one exception to the round-trip: `F13`..=`F24` (and the invalid
503/// `F(0)`) all render as `"F?"`, although the parser accepts `<F13>`..`<F24>`.
504///
505/// # Examples
506///
507/// ```
508/// use lattice_protocol::{SpecialKey, special_label};
509///
510/// assert_eq!(special_label(SpecialKey::Enter), "CR");
511/// assert_eq!(special_label(SpecialKey::F(5)), "F5");
512/// ```
513pub fn special_label(k: SpecialKey) -> &'static str {
514 match k {
515 SpecialKey::Esc => "Esc",
516 SpecialKey::Enter => "CR",
517 SpecialKey::Tab => "Tab",
518 SpecialKey::Backspace => "BS",
519 SpecialKey::Space => "Space",
520 SpecialKey::Up => "Up",
521 SpecialKey::Down => "Down",
522 SpecialKey::Left => "Left",
523 SpecialKey::Right => "Right",
524 SpecialKey::Home => "Home",
525 SpecialKey::End => "End",
526 SpecialKey::PageUp => "PageUp",
527 SpecialKey::PageDown => "PageDown",
528 SpecialKey::Insert => "Insert",
529 SpecialKey::Delete => "Delete",
530 SpecialKey::F(1) => "F1",
531 SpecialKey::F(2) => "F2",
532 SpecialKey::F(3) => "F3",
533 SpecialKey::F(4) => "F4",
534 SpecialKey::F(5) => "F5",
535 SpecialKey::F(6) => "F6",
536 SpecialKey::F(7) => "F7",
537 SpecialKey::F(8) => "F8",
538 SpecialKey::F(9) => "F9",
539 SpecialKey::F(10) => "F10",
540 SpecialKey::F(11) => "F11",
541 SpecialKey::F(12) => "F12",
542 SpecialKey::F(_) => "F?", // 13..24; renderer-only fallback
543 }
544}
545
546/// Inverse of `special_label`. Used by the parser when an
547/// `<...>` body's modifier-stripped chunk is more than one
548/// char (`<Esc>`, `<F12>`, etc.).
549fn parse_special(name: &str) -> Option<SpecialKey> {
550 Some(match name {
551 "Esc" | "Escape" => SpecialKey::Esc,
552 "CR" | "Enter" | "Return" => SpecialKey::Enter,
553 "Tab" => SpecialKey::Tab,
554 "BS" | "Backspace" => SpecialKey::Backspace,
555 "Space" => SpecialKey::Space,
556 "Up" => SpecialKey::Up,
557 "Down" => SpecialKey::Down,
558 "Left" => SpecialKey::Left,
559 "Right" => SpecialKey::Right,
560 "Home" => SpecialKey::Home,
561 "End" => SpecialKey::End,
562 "PageUp" => SpecialKey::PageUp,
563 "PageDown" => SpecialKey::PageDown,
564 "Insert" | "Ins" => SpecialKey::Insert,
565 "Delete" | "Del" => SpecialKey::Delete,
566 n if n.starts_with('F') => {
567 let num: u8 = n[1..].parse().ok()?;
568 if (1..=24).contains(&num) {
569 SpecialKey::F(num)
570 } else {
571 return None;
572 }
573 }
574 _ => return None,
575 })
576}
577
578impl FromStr for KeyChord {
579 type Err = ChordParseError;
580
581 fn from_str(s: &str) -> Result<Self, Self::Err> {
582 // Single-chord parse: either one bare char or one
583 // `<...>` token.
584 let mut iter = s.char_indices();
585 let (start, first) = iter.next().ok_or(ChordParseError::Empty)?;
586 if first == '<' {
587 let close_rel = s[start + 1..]
588 .find('>')
589 .ok_or(ChordParseError::UnterminatedAngle { at: start })?;
590 let body_end = start + 1 + close_rel;
591 let body = &s[start + 1..body_end];
592 // Single-chord parse rejects trailing input -- the
593 // caller is asking for ONE chord, not a sequence.
594 if body_end + 1 != s.len() {
595 return Err(ChordParseError::BodyTooLong {
596 name: s.to_string(),
597 at: start,
598 });
599 }
600 return parse_angle_body(body, start);
601 }
602 // Bare char. Reject trailing input for the same reason.
603 if iter.next().is_some() {
604 return Err(ChordParseError::BodyTooLong {
605 name: s.to_string(),
606 at: start,
607 });
608 }
609 Ok(KeyChord::char(first))
610 }
611}
612
613/// Parse the body of an `<...>` token (without the angles).
614/// Handles modifier prefixes (`C-`, `S-`, `M-`, `D-`), the
615/// `lt` literal, special-key names, and bare letters.
616fn parse_angle_body(body: &str, at: usize) -> Result<KeyChord, ChordParseError> {
617 if body.is_empty() {
618 return Err(ChordParseError::EmptyAngle { at });
619 }
620 if body == "lt" {
621 return Ok(KeyChord::char('<'));
622 }
623 let mut mods = KeyMods::NONE;
624 let mut rest = body;
625 loop {
626 // Each modifier prefix is exactly two bytes (`C-`,
627 // `S-`, `M-`, `D-`). Walk them off the front.
628 if rest.len() < 2 || rest.as_bytes()[1] != b'-' {
629 break;
630 }
631 let prefix = rest.as_bytes()[0];
632 let m = match prefix {
633 b'C' => KeyMods::CTRL,
634 b'S' => KeyMods::SHIFT,
635 b'M' | b'A' => KeyMods::ALT,
636 b'D' => KeyMods::SUPER,
637 _ => break,
638 };
639 if mods.0 & m.0 != 0 {
640 return Err(ChordParseError::DuplicateModifier { at });
641 }
642 mods = mods | m;
643 rest = &rest[2..];
644 }
645 if rest.is_empty() {
646 return Err(ChordParseError::DanglingModifier { at });
647 }
648 // After modifiers: either a single char or a special-key name.
649 let mut chars = rest.chars();
650 let first = chars.next().expect("rest non-empty checked above");
651 if chars.next().is_none() {
652 // Single char body. Letters with Ctrl / Alt normalise to
653 // lowercase to match the adapter's canonical form. Plain
654 // shift on a letter folds into the case (`<S-a>` -> `A`).
655 if mods.ctrl() || mods.alt() {
656 return Ok(KeyChord {
657 key: KeyKind::Char(first.to_ascii_lowercase()),
658 mods,
659 });
660 }
661 if mods.shift() && first.is_ascii_alphabetic() {
662 // <S-a> = `A`; strip shift.
663 mods = KeyMods(mods.0 & !KeyMods::SHIFT.0);
664 return Ok(KeyChord {
665 key: KeyKind::Char(first.to_ascii_uppercase()),
666 mods,
667 });
668 }
669 return Ok(KeyChord {
670 key: KeyKind::Char(first),
671 mods,
672 });
673 }
674 // Multi-char body -- must be a special-key name.
675 let special = parse_special(rest).ok_or_else(|| ChordParseError::UnknownName {
676 name: rest.to_string(),
677 at,
678 })?;
679 // **An UNMODIFIED `<Space>` is the literal character, not the special key.**
680 //
681 // Both key decoders already apply exactly this rule — a bare space arrives
682 // as `Char(' ')` and only a modified one becomes `Special(Space)` (TUI
683 // `chord::from_event`, GPUI `gpui_chord::from_event`). The parser did not,
684 // so the two spellings of the same keypress disagreed and anything bound
685 // via the string `<Space>` could never be typed.
686 //
687 // That is not hypothetical: the DEFAULT `keymap.leader` is `<Space>`, and
688 // `<leader>` is substituted at BIND time, so every `<leader>x` binding in
689 // the editor landed under `Special(Space)` while the keyboard produced
690 // `Char(' ')`. The entire leader keymap was unreachable.
691 //
692 // Normalising here rather than at each decoder is what makes it stay
693 // fixed: the parser is the one path every plugin binding, user keymap and
694 // `:map` command shares, and both renderers already agree with each other.
695 // `<S-Space>` / `<C-Space>` are untouched — a modifier still means the
696 // special key, because a modified space is not text.
697 if special == SpecialKey::Space && mods.is_empty() {
698 return Ok(KeyChord {
699 key: KeyKind::Char(' '),
700 mods,
701 });
702 }
703 Ok(KeyChord {
704 key: KeyKind::Special(special),
705 mods,
706 })
707}
708
709/// Parse a chord-sequence string into the canonical
710/// `Vec<KeyChord>` the keymap trie indexes by.
711///
712/// Examples:
713///
714/// - `"j"` -> `[char('j')]`
715/// - `"gg"` -> `[char('g'), char('g')]`
716/// - `"<C-w>j"` -> `[ctrl('w'), char('j')]`
717/// - `"dw"` -> `[char('d'), char('w')]`
718/// - `"<lt>"` -> `[char('<')]`
719///
720/// Used by:
721/// - The `keymap_entry!` macro at startup to convert the
722/// catalog's `&'static str` chord into the trie's typed key.
723/// - `:bind` user / plugin invocations that take a
724/// chord-string at runtime.
725///
726/// `<leader>` is *not* understood here: it is substituted before parsing, at
727/// bind time, by the keymap layer.
728///
729/// # Errors
730///
731/// A [`ChordParseError`] naming the first bad token; the empty string is
732/// [`ChordParseError::Empty`].
733///
734/// # Examples
735///
736/// ```
737/// use lattice_protocol::{KeyChord, parse_chord_sequence};
738///
739/// assert_eq!(
740/// parse_chord_sequence("gg").unwrap(),
741/// vec![KeyChord::char('g'), KeyChord::char('g')]
742/// );
743/// // Ctrl letters normalise to lowercase; `<lt>` is a literal `<`.
744/// assert_eq!(parse_chord_sequence("<C-W>").unwrap(), vec![KeyChord::ctrl('w')]);
745/// assert_eq!(parse_chord_sequence("<lt>").unwrap(), vec![KeyChord::char('<')]);
746/// // An unmodified <Space> is the space character.
747/// assert_eq!(parse_chord_sequence("<Space>f").unwrap()[0], KeyChord::char(' '));
748/// ```
749pub fn parse_chord_sequence(s: &str) -> Result<Vec<KeyChord>, ChordParseError> {
750 if s.is_empty() {
751 return Err(ChordParseError::Empty);
752 }
753 let mut out = Vec::new();
754 let mut i = 0;
755 let bytes = s.as_bytes();
756 while i < bytes.len() {
757 if bytes[i] == b'<' {
758 // Walk to matching `>`. Brackets don't nest in
759 // chord notation.
760 let close = match s[i + 1..].find('>') {
761 Some(rel) => i + 1 + rel,
762 None => return Err(ChordParseError::UnterminatedAngle { at: i }),
763 };
764 let body = &s[i + 1..close];
765 out.push(parse_angle_body(body, i)?);
766 i = close + 1;
767 } else if bytes[i] == b'>' {
768 return Err(ChordParseError::StrayClose { at: i });
769 } else {
770 // One UTF-8 char. Walk to the next char boundary.
771 let ch_len = utf8_char_len(bytes[i]);
772 let end = i + ch_len;
773 let ch = s[i..end].chars().next().expect("valid utf-8 boundary");
774 out.push(KeyChord::char(ch));
775 i = end;
776 }
777 }
778 Ok(out)
779}
780
781/// UTF-8 byte length of the leading char given its first byte.
782/// Returns 1 for ASCII / continuation bytes (defensive; the
783/// caller has already checked it's a leading byte).
784#[inline]
785fn utf8_char_len(b: u8) -> usize {
786 if b < 0x80 {
787 1
788 } else if b & 0b1110_0000 == 0b1100_0000 {
789 2
790 } else if b & 0b1111_0000 == 0b1110_0000 {
791 3
792 } else if b & 0b1111_1000 == 0b1111_0000 {
793 4
794 } else {
795 1
796 }
797}
798
799/// Number of bytes the last chord token occupies at the end of
800/// `text`, treating it as a sequence of chord tokens (one chord
801/// per logical keypress). Used by chord-capture's backspace
802/// handler to remove a whole token instead of a single char.
803///
804/// A token is either:
805/// - A `<…>` group (matched balanced angle brackets).
806/// - A single character.
807///
808/// Returns 0 if `text` is empty.
809///
810/// # Examples
811///
812/// ```
813/// use lattice_protocol::last_chord_token_byte_len;
814///
815/// assert_eq!(last_chord_token_byte_len("gg<C-w>"), 5); // "<C-w>"
816/// assert_eq!(last_chord_token_byte_len("<C-w>é"), 2); // one 2-byte char
817/// assert_eq!(last_chord_token_byte_len(""), 0);
818/// ```
819pub fn last_chord_token_byte_len(text: &str) -> usize {
820 let bytes = text.as_bytes();
821 let n = bytes.len();
822 if n == 0 {
823 return 0;
824 }
825 if bytes[n - 1] == b'>' {
826 // Walk back to the matching `<`. Brackets don't nest in
827 // chord notation (no `<<…>>`), so a simple scan suffices.
828 let mut i = n;
829 while i > 0 {
830 i -= 1;
831 if bytes[i] == b'<' {
832 return n - i;
833 }
834 }
835 // Unbalanced `>` -- treat as a single-byte token.
836 return 1;
837 }
838 // Plain char token. UTF-8 safe: walk back to a char boundary.
839 let mut i = n - 1;
840 while i > 0 && (bytes[i] & 0b1100_0000) == 0b1000_0000 {
841 i -= 1;
842 }
843 n - i
844}
845
846#[cfg(test)]
847mod tests {
848 #![allow(clippy::unwrap_used)]
849 use super::*;
850
851 /// **A bare `<Space>` parses to the literal char, not the special key.**
852 ///
853 /// Both key decoders produce `Char(' ')` for an unmodified space, so a
854 /// parser that produced `Special(Space)` made the two spellings of one
855 /// keypress disagree — and nothing bound as `<Space>` could be typed.
856 ///
857 /// The default `keymap.leader` IS `<Space>` and `<leader>` is substituted
858 /// at bind time, so this made the whole leader keymap unreachable.
859 #[test]
860 fn a_bare_space_parses_to_the_literal_char() {
861 let seq = parse_chord_sequence("<Space>").unwrap();
862 assert_eq!(seq.len(), 1);
863 assert_eq!(seq[0].key, KeyKind::Char(' '));
864 assert!(seq[0].mods.is_empty());
865 }
866
867 /// The leader sequence a `<leader>oa` binding actually lands under.
868 #[test]
869 fn a_leader_sequence_parses_as_a_typeable_space_then_letters() {
870 let seq = parse_chord_sequence("<Space>oa").unwrap();
871 assert_eq!(
872 seq.iter().map(|c| c.key).collect::<Vec<_>>(),
873 vec![KeyKind::Char(' '), KeyKind::Char('o'), KeyKind::Char('a')],
874 "every element must be what the keyboard produces: {seq:?}"
875 );
876 }
877
878 /// A MODIFIED space is still the special key — a modified space is not
879 /// text, and `<C-Space>` must keep matching what the decoders now build.
880 #[test]
881 fn a_modified_space_stays_the_special_key() {
882 for spelling in ["<C-Space>", "<S-Space>", "<A-Space>"] {
883 let seq = parse_chord_sequence(spelling).unwrap();
884 assert_eq!(
885 seq[0].key,
886 KeyKind::Special(SpecialKey::Space),
887 "{spelling} keeps the special key"
888 );
889 assert!(!seq[0].mods.is_empty(), "{spelling} keeps its modifier");
890 }
891 }
892
893 /// `<Space>` must survive a Display→parse round trip, and must survive it
894 /// as a **visible, non-splitting** token.
895 ///
896 /// Display used to render the literal char as a bare `" "`. That
897 /// round-tripped through `parse_chord_sequence` and through nothing else:
898 /// the `:` line delimits arguments on whitespace, so `:describe-key`'s
899 /// chord capture appended a space and submitted an empty argument; and a
900 /// keymap listing rendered the leader as an invisible column.
901 ///
902 /// So the assertion is two-part on purpose. Round-tripping alone is what
903 /// the old spelling already satisfied, which is why it survived.
904 #[test]
905 fn a_space_chord_round_trips_through_display() {
906 let parsed = parse_chord_sequence("<Space>oa").unwrap();
907 let rendered: String = parsed.iter().map(|c| c.to_string()).collect();
908 assert_eq!(
909 parse_chord_sequence(&rendered).unwrap(),
910 parsed,
911 "rendered as {rendered:?}"
912 );
913 assert_eq!(
914 rendered, "<Space>oa",
915 "the leader must render as a token a human can see and a \
916 whitespace-delimited parser will not split"
917 );
918 assert!(
919 !rendered.contains(' '),
920 "no rendered chord sequence may contain a raw space: {rendered:?}"
921 );
922 }
923
924 /// A MODIFIED space keeps rendering through the modifier path, which
925 /// already spelled it `<C-Space>`. The new escape must not double up.
926 #[test]
927 fn a_modified_space_still_renders_once() {
928 let seq = parse_chord_sequence("<C-Space>").unwrap();
929 assert_eq!(seq[0].to_string(), "<C-Space>");
930 }
931
932 #[test]
933 fn keychord_display_round_trips_through_from_str() {
934 // Pick representative chords across every shape:
935 // bare char, Ctrl/Alt letters, multi-modifier, specials,
936 // shift-special, function keys, the `<lt>` literal.
937 let cases: &[KeyChord] = &[
938 KeyChord::char('a'),
939 KeyChord::char('$'),
940 KeyChord::char('A'),
941 KeyChord::ctrl('c'),
942 KeyChord {
943 key: KeyKind::Char('x'),
944 mods: KeyMods::CTRL | KeyMods::SHIFT,
945 },
946 KeyChord {
947 key: KeyKind::Char('q'),
948 mods: KeyMods::ALT,
949 },
950 KeyChord::special(SpecialKey::Esc),
951 KeyChord::special(SpecialKey::Enter),
952 KeyChord::special(SpecialKey::Backspace),
953 KeyChord {
954 key: KeyKind::Special(SpecialKey::Tab),
955 mods: KeyMods::SHIFT,
956 },
957 KeyChord::special(SpecialKey::F(1)),
958 KeyChord::special(SpecialKey::F(12)),
959 KeyChord::char('<'),
960 KeyChord::char('>'),
961 ];
962 for c in cases {
963 let s = c.to_string();
964 let parsed: KeyChord = s
965 .parse()
966 .unwrap_or_else(|e| panic!("re-parse {s:?}: {e:?}"));
967 assert_eq!(parsed, *c, "round-trip differs for {s:?}");
968 }
969 }
970
971 #[test]
972 fn parse_chord_sequence_walks_mixed_tokens() {
973 let seq = parse_chord_sequence("<C-w>j").unwrap();
974 assert_eq!(seq, vec![KeyChord::ctrl('w'), KeyChord::char('j')]);
975 }
976
977 #[test]
978 fn parse_chord_sequence_handles_multi_key_built_in_chords() {
979 assert_eq!(
980 parse_chord_sequence("gg").unwrap(),
981 vec![KeyChord::char('g'), KeyChord::char('g')]
982 );
983 assert_eq!(
984 parse_chord_sequence("dw").unwrap(),
985 vec![KeyChord::char('d'), KeyChord::char('w')]
986 );
987 assert_eq!(
988 parse_chord_sequence("zt").unwrap(),
989 vec![KeyChord::char('z'), KeyChord::char('t')]
990 );
991 }
992
993 #[test]
994 fn parse_chord_sequence_handles_lt_literal() {
995 assert_eq!(
996 parse_chord_sequence("<lt>").unwrap(),
997 vec![KeyChord::char('<')]
998 );
999 // Mid-sequence too.
1000 assert_eq!(
1001 parse_chord_sequence("a<lt>b").unwrap(),
1002 vec![
1003 KeyChord::char('a'),
1004 KeyChord::char('<'),
1005 KeyChord::char('b')
1006 ]
1007 );
1008 }
1009
1010 #[test]
1011 fn parse_chord_sequence_rejects_unterminated_angle() {
1012 assert!(matches!(
1013 parse_chord_sequence("<C-x"),
1014 Err(ChordParseError::UnterminatedAngle { .. })
1015 ));
1016 }
1017
1018 #[test]
1019 fn parse_chord_sequence_rejects_stray_close() {
1020 assert!(matches!(
1021 parse_chord_sequence("a>b"),
1022 Err(ChordParseError::StrayClose { .. })
1023 ));
1024 }
1025
1026 #[test]
1027 fn parse_chord_sequence_rejects_unknown_special() {
1028 assert!(matches!(
1029 parse_chord_sequence("<Foo>"),
1030 Err(ChordParseError::UnknownName { .. })
1031 ));
1032 assert!(matches!(
1033 parse_chord_sequence("<F99>"),
1034 Err(ChordParseError::UnknownName { .. })
1035 ));
1036 }
1037
1038 #[test]
1039 fn parse_chord_sequence_rejects_dangling_modifier() {
1040 assert!(matches!(
1041 parse_chord_sequence("<C->"),
1042 Err(ChordParseError::DanglingModifier { .. })
1043 ));
1044 }
1045
1046 #[test]
1047 fn parse_chord_sequence_rejects_duplicate_modifier() {
1048 assert!(matches!(
1049 parse_chord_sequence("<C-C-x>"),
1050 Err(ChordParseError::DuplicateModifier { .. })
1051 ));
1052 }
1053
1054 #[test]
1055 fn parse_chord_sequence_accepts_uppercase_shifted_letter_via_s_prefix() {
1056 // `<S-a>` is a legacy form; canonicalises to `Char('A')`
1057 // (vim does the same).
1058 assert_eq!(
1059 parse_chord_sequence("<S-a>").unwrap(),
1060 vec![KeyChord::char('A')]
1061 );
1062 }
1063
1064 #[test]
1065 fn last_chord_token_treats_angle_group_as_one_unit() {
1066 assert_eq!(last_chord_token_byte_len("<C-c>"), 5);
1067 assert_eq!(last_chord_token_byte_len("a<C-c>"), 5);
1068 assert_eq!(last_chord_token_byte_len("<Esc>"), 5);
1069 }
1070
1071 #[test]
1072 fn last_chord_token_handles_plain_char() {
1073 assert_eq!(last_chord_token_byte_len("abc"), 1);
1074 assert_eq!(last_chord_token_byte_len("a"), 1);
1075 }
1076
1077 #[test]
1078 fn last_chord_token_handles_empty() {
1079 assert_eq!(last_chord_token_byte_len(""), 0);
1080 }
1081
1082 #[test]
1083 fn last_chord_token_handles_utf8_char() {
1084 // Two-byte UTF-8 char (é) should pop as one unit.
1085 assert_eq!(last_chord_token_byte_len("aé"), 2);
1086 }
1087}