escriba_keymap/lib.rs
1//! `escriba-keymap` — mode-aware keybinding dispatch.
2
3extern crate self as escriba_keymap;
4
5use escriba_search::{CaretMove, Direction as SearchDirection};
6use std::collections::HashMap;
7
8use escriba_core::{Action, CountedAction, Mode, Motion, Operator, TextObject};
9use escriba_mode::ModalState;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum Key {
14 Char(char),
15 Esc,
16 Enter,
17 Tab,
18 Backspace,
19 Delete,
20 Left,
21 Right,
22 Up,
23 Down,
24 PageUp,
25 PageDown,
26 Home,
27 End,
28 Ctrl(char),
29 Alt(char),
30 /// A function key. Discarded at the door until now
31 /// (`escriba-input`: `KeyCode::F(_) => return None`), so `<F5>` could be
32 /// declared and never arrive.
33 F(u8),
34 /// Anything the shorthands above cannot say: more than one modifier,
35 /// `Shift` as a modifier rather than a capital letter, `Super`/`Cmd`.
36 ///
37 /// `Ctrl(char)` and `Alt(char)` fold the modifier INTO the key, so they
38 /// can carry exactly one. The translator has always computed all four
39 /// modifier flags and then thrown `shift` and `meta` away for want of
40 /// somewhere to put them; this is that somewhere.
41 ///
42 /// Carries `awase::Hotkey` directly rather than a parallel spelling —
43 /// escriba's vocabulary widens by consuming the fleet's, not by growing
44 /// a fifth one.
45 Chord(awase::Hotkey),
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Binding {
50 pub action: Action,
51 pub description: String,
52}
53
54impl Binding {
55 #[must_use]
56 pub fn new(action: Action, description: impl Into<String>) -> Self {
57 Self {
58 action,
59 description: description.into(),
60 }
61 }
62}
63
64/// A binding that will not do what its author intended.
65///
66/// Recorded at BIND time rather than discovered later, because both kinds are
67/// silent by construction: a reserved chord never receives its event, and an
68/// overwritten binding simply stops existing. Neither produces an error at
69/// the moment it happens, and both present as "that key is broken".
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum Collision {
72 /// Something outside escriba owns this chord — the OS, the window
73 /// manager, the terminal. The binding can never fire.
74 ///
75 /// Not arbitrable: no amount of reordering inside escriba changes it.
76 Reserved {
77 mode: Mode,
78 key: String,
79 description: String,
80 /// Who took it and what for.
81 why: String,
82 },
83 /// A later binding displaced an earlier one for the same chord.
84 ///
85 /// Sometimes intended — the shipped rc deliberately overrides defaults —
86 /// which is why this is REPORTED rather than refused. But it is reported,
87 /// because "my plugin's key stopped working" has no other explanation
88 /// available to an operator.
89 Displaced {
90 mode: Mode,
91 key: String,
92 replaced: String,
93 with: String,
94 },
95}
96
97impl Collision {
98 /// One line an operator can act on.
99 #[must_use]
100 pub fn report(&self) -> String {
101 match self {
102 Self::Reserved {
103 mode,
104 key,
105 description,
106 why,
107 } => format!("{mode:?} {key} ({description}) — {why}"),
108 Self::Displaced {
109 mode,
110 key,
111 replaced,
112 with,
113 } => format!("{mode:?} {key} — \"{replaced}\" was replaced by \"{with}\""),
114 }
115 }
116
117 /// Can this binding ever fire?
118 #[must_use]
119 pub const fn is_fatal(&self) -> bool {
120 matches!(self, Self::Reserved { .. })
121 }
122}
123
124/// What `dispatch` does with a key INSTEAD of consulting the binding table.
125///
126/// Named so a reader can tell the three apart: they look alike from the
127/// dispatcher's side (all three return before `lookup`) and are completely
128/// different to an operator trying to bind the key.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Preempted {
131 /// Normal mode: the key is composing a count (`5` of `5dd`).
132 Count,
133 /// Insert mode: a printable character types itself.
134 SelfInsert,
135 /// Insert mode: `<CR>` opens a line.
136 Newline,
137 /// Command mode: a printable character types into the prompt.
138 PromptInsert,
139}
140
141impl Preempted {
142 /// What `dispatch` answers. Kept beside the classification so the two
143 /// cannot drift — the whole point of hoisting this out of `dispatch`.
144 fn resolved(self, key: &Key) -> CountedAction {
145 match self {
146 Self::Count => CountedAction::once(Action::Pending),
147 Self::SelfInsert | Self::PromptInsert => match key {
148 Key::Char(c) => CountedAction::once(Action::InsertChar(*c)),
149 // Unreachable via `preemption`, which only returns these two
150 // for `Key::Char`. Answered rather than `unreachable!()`: a
151 // dispatcher that panics on an unexpected key is a worse
152 // failure than one that declines it.
153 _ => CountedAction::once(Action::Pending),
154 },
155 Self::Newline => CountedAction::once(Action::InsertChar('\n')),
156 }
157 }
158}
159
160/// WHETHER `dispatch` preempts a key, and whether it always does.
161///
162/// The distinction is load-bearing for reachability: `1`–`9` in Normal are
163/// preempted unconditionally, so a binding for one can never fire. `0` is
164/// preempted only while a count is being typed — which is exactly why `0` is
165/// bindable, and IS bound, to `Motion::LineStart`.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum Preemption {
168 /// The key never reaches the binding table. A declaration is dead.
169 Always(Preempted),
170 /// The key reaches the table unless a count is pending. Bindable.
171 WhenCounting(Preempted),
172}
173
174/// Does `dispatch` answer `(mode, key)` before the binding table is consulted?
175///
176/// **The single statement of the rule.** [`Keymap::dispatch`] reads it to
177/// answer keys; anything auditing whether a DECLARATION could ever fire reads
178/// it to say so. Before it existed the rule lived in three inline `if mode ==`
179/// blocks inside `dispatch`, visible to nothing else, so a binding for a bare
180/// printable character in Insert mode parsed, applied, reported as applied —
181/// and was dead on arrival with nowhere to learn that from.
182///
183/// Total over the preempting cases; everything else falls through to `None`,
184/// which is the safe direction (a key wrongly called reachable shows up as a
185/// binding that does not work, a key wrongly called dead hides a working one).
186#[must_use]
187pub fn preemption(mode: Mode, key: &Key) -> Option<Preemption> {
188 match (mode, key) {
189 (Mode::Normal, Key::Char('0')) => Some(Preemption::WhenCounting(Preempted::Count)),
190 (Mode::Normal, Key::Char(c)) if c.is_ascii_digit() => {
191 Some(Preemption::Always(Preempted::Count))
192 }
193 (Mode::Insert, Key::Char(_)) => Some(Preemption::Always(Preempted::SelfInsert)),
194 (Mode::Insert, Key::Enter) => Some(Preemption::Always(Preempted::Newline)),
195 (Mode::Command, Key::Char(_)) => Some(Preemption::Always(Preempted::PromptInsert)),
196 _ => None,
197 }
198}
199
200#[derive(Debug, Clone)]
201pub struct Keymap {
202 bindings: HashMap<(Mode, Key), Binding>,
203 /// Multi-key sequence bindings (`<leader>ff`, `gg`, `<C-w>h`).
204 /// Keyed by the full key sequence; resolved by the runtime's
205 /// pending-stroke loop ([`lookup_sequence`](Keymap::lookup_sequence)
206 /// + [`is_sequence_prefix`](Keymap::is_sequence_prefix)).
207 sequences: HashMap<(Mode, Vec<Key>), Binding>,
208 /// The prefix `<leader>` resolves to at sequence-apply time.
209 leader: Key,
210 /// Chords the world owns. Consulted on every bind, so a binding that
211 /// cannot fire is known at CONSTRUCTION rather than discovered by an
212 /// operator pressing a dead key.
213 reserved: awase::Reserved,
214 /// Every collision seen while building this keymap, in bind order.
215 collisions: Vec<Collision>,
216}
217
218impl Default for Keymap {
219 fn default() -> Self {
220 Self {
221 bindings: HashMap::new(),
222 sequences: HashMap::new(),
223 reserved: awase::Reserved::fleet_darwin(),
224 collisions: Vec::new(),
225 // blnvim's leader is comma; escriba ships blnvim-parity
226 // defaults, so the prefix users press matches muscle memory.
227 leader: Key::Char(','),
228 }
229 }
230}
231
232impl Keymap {
233 #[must_use]
234 pub fn new() -> Self {
235 Self::default()
236 }
237
238 #[must_use]
239 pub fn default_vim() -> Self {
240 let mut m = Self::new();
241 let nm = |m: &mut Keymap, k: Key, a: Action, d: &'static str| m.bind(Mode::Normal, k, a, d);
242 nm(
243 &mut m,
244 Key::Char('h'),
245 Action::Move(Motion::Left),
246 "move left",
247 );
248 nm(
249 &mut m,
250 Key::Char('l'),
251 Action::Move(Motion::Right),
252 "move right",
253 );
254 nm(
255 &mut m,
256 Key::Char('j'),
257 Action::Move(Motion::Down),
258 "move down",
259 );
260 nm(&mut m, Key::Char('k'), Action::Move(Motion::Up), "move up");
261 nm(
262 &mut m,
263 Key::Char('w'),
264 Action::Move(Motion::WordStartNext),
265 "word forward",
266 );
267 nm(
268 &mut m,
269 Key::Char('b'),
270 Action::Move(Motion::WordStartPrev),
271 "word back",
272 );
273 nm(
274 &mut m,
275 Key::Char('e'),
276 Action::Move(Motion::WordEndNext),
277 "word end",
278 );
279 nm(
280 &mut m,
281 Key::Char('0'),
282 Action::Move(Motion::LineStart),
283 "line start",
284 );
285 nm(
286 &mut m,
287 Key::Char('$'),
288 Action::Move(Motion::LineEnd),
289 "line end",
290 );
291 nm(
292 &mut m,
293 Key::Char('G'),
294 Action::Move(Motion::DocEnd),
295 "doc end",
296 );
297 // Operators — `d`/`c`/`y` arm the operator-pending FSM; the next
298 // motion composes (e.g. `dw`, `c$`, `y0`).
299 nm(
300 &mut m,
301 Key::Char('d'),
302 Action::Operator(Operator::Delete),
303 "delete (operator)",
304 );
305 nm(
306 &mut m,
307 Key::Char('c'),
308 Action::Operator(Operator::Change),
309 "change (operator)",
310 );
311 nm(
312 &mut m,
313 Key::Char('y'),
314 Action::Operator(Operator::Yank),
315 "yank (operator)",
316 );
317 // Structural Lisp motions — Alt-prefixed like emacs paredit.
318 nm(
319 &mut m,
320 Key::Alt('f'),
321 Action::Move(Motion::ForwardSexp),
322 "forward sexp",
323 );
324 nm(
325 &mut m,
326 Key::Alt('b'),
327 Action::Move(Motion::BackwardSexp),
328 "backward sexp",
329 );
330 nm(
331 &mut m,
332 Key::Alt('u'),
333 Action::Move(Motion::UpList),
334 "up list",
335 );
336 nm(
337 &mut m,
338 Key::Alt('d'),
339 Action::Move(Motion::DownList),
340 "down list",
341 );
342 // Mode changes.
343 nm(
344 &mut m,
345 Key::Char('i'),
346 Action::ChangeMode(Mode::Insert),
347 "insert",
348 );
349 nm(
350 &mut m,
351 Key::Char('v'),
352 Action::ChangeMode(Mode::Visual),
353 "visual",
354 );
355 nm(
356 &mut m,
357 Key::Char('V'),
358 Action::ChangeMode(Mode::VisualLine),
359 "visual line",
360 );
361 nm(
362 &mut m,
363 Key::Char(':'),
364 Action::ChangeMode(Mode::Command),
365 "command",
366 );
367 nm(&mut m, Key::Char('u'), Action::Undo, "undo");
368 nm(
369 &mut m,
370 Key::Char('.'),
371 Action::RepeatLastChange,
372 "repeat last change",
373 );
374 nm(&mut m, Key::Ctrl('r'), Action::Redo, "redo");
375 // Insert → Normal on Esc.
376 m.bind(
377 Mode::Insert,
378 Key::Esc,
379 Action::ChangeMode(Mode::Normal),
380 "to normal",
381 );
382 // ── Insert-mode editing ───────────────────────────────────────
383 // Until 2026-08-09 `Esc` above was the ONLY Insert-mode binding, and
384 // `dispatch` short-circuits `Key::Char` + `Key::Enter` before the
385 // table is consulted — so every key below fell through to
386 // `Action::Pending` and did nothing. Insert mode could be typed into
387 // and never corrected: no erase, no caret movement. The keys were not
388 // "unimplemented", they were unbound; `Action::Backspace`'s executor
389 // had been waiting for a caller.
390 m.bind(
391 Mode::Insert,
392 Key::Backspace,
393 Action::Backspace,
394 "erase one char",
395 );
396 m.bind(
397 Mode::Insert,
398 Key::Delete,
399 Action::DeleteForward,
400 "delete char at caret",
401 );
402 // The bigger erases. `<BS>`/`<Del>` landed first and these were left
403 // behind for a day, which made Insert mode able to erase one character
404 // at a time and nothing larger — a mis-typed word had to be dismantled
405 // letter by letter. They share the erase family's routing, so the
406 // binding is the whole change: the runtime already knows whether a
407 // prompt is open.
408 m.bind(
409 Mode::Insert,
410 Key::Ctrl('w'),
411 Action::DeleteWordBefore,
412 "erase word before caret",
413 );
414 m.bind(
415 Mode::Insert,
416 Key::Ctrl('u'),
417 Action::DeleteToLineStart,
418 "erase to line start",
419 );
420 // `<C-h>` IS backspace: terminals send 0x08 for it, and vim treats the
421 // two as one key in Insert. Whether a given terminal reports the
422 // physical Backspace as `Backspace` or as `Ctrl('h')` is the
423 // terminal's business, not the operator's — binding both is what makes
424 // the answer stop mattering.
425 m.bind(
426 Mode::Insert,
427 Key::Ctrl('h'),
428 Action::Backspace,
429 "erase one char",
430 );
431 // Arrows in Insert are vi-compatible (vim's `esckeys`) and are what
432 // "edit it" means to anyone who did not grow up on hjkl. They reuse
433 // the ordinary motions, so the cursor-clamp + viewport-follow
434 // invariants come along unchanged.
435 for (key, motion, label) in [
436 (Key::Left, Motion::Left, "caret left"),
437 (Key::Right, Motion::Right, "caret right"),
438 (Key::Up, Motion::Up, "caret up"),
439 (Key::Down, Motion::Down, "caret down"),
440 (Key::Home, Motion::LineStart, "caret to line start"),
441 (Key::End, Motion::LineEnd, "caret to line end"),
442 ] {
443 m.bind(Mode::Insert, key, Action::Move(motion), label);
444 }
445 m.bind(
446 Mode::Command,
447 Key::Esc,
448 Action::ChangeMode(Mode::Normal),
449 "abort",
450 );
451 m.bind(Mode::Command, Key::Enter, Action::SubmitCommand, "submit");
452 m.bind(
453 Mode::Command,
454 Key::Up,
455 Action::PromptHistory { back: true },
456 "older search",
457 );
458 m.bind(
459 Mode::Command,
460 Key::Down,
461 Action::PromptHistory { back: false },
462 "newer search",
463 );
464 m.bind(
465 Mode::Command,
466 Key::Backspace,
467 Action::Backspace,
468 "erase one char",
469 );
470 m.bind(
471 Mode::Command,
472 Key::Delete,
473 Action::DeleteForward,
474 "delete char at caret",
475 );
476 // Caret editing inside the prompt. Without these the prompt is
477 // append-only, so a typo in the middle of a pattern can only be fixed
478 // by deleting everything back to it.
479 m.bind(
480 Mode::Command,
481 Key::Left,
482 Action::PromptCaret {
483 to: CaretMove::Left,
484 },
485 "caret left",
486 );
487 m.bind(
488 Mode::Command,
489 Key::Right,
490 Action::PromptCaret {
491 to: CaretMove::Right,
492 },
493 "caret right",
494 );
495 m.bind(
496 Mode::Command,
497 Key::Home,
498 Action::PromptCaret {
499 to: CaretMove::Start,
500 },
501 "caret to start",
502 );
503 m.bind(
504 Mode::Command,
505 Key::End,
506 Action::PromptCaret { to: CaretMove::End },
507 "caret to end",
508 );
509 m.bind(
510 Mode::Command,
511 Key::Ctrl('w'),
512 Action::DeleteWordBefore,
513 "delete word before caret",
514 );
515 // Walk the preview without committing — `/pat` then `<C-g><C-g>` is
516 // `/pat<CR>nn`, except Escape still takes you home.
517 m.bind(
518 Mode::Command,
519 Key::Ctrl('g'),
520 Action::SearchPreviewStep { forward: true },
521 "preview next match",
522 );
523 m.bind(
524 Mode::Command,
525 Key::Ctrl('t'),
526 Action::SearchPreviewStep { forward: false },
527 "preview previous match",
528 );
529 m.bind(
530 Mode::Command,
531 Key::Ctrl('u'),
532 Action::DeleteToLineStart,
533 "clear to start",
534 );
535
536 // ── search ────────────────────────────────────────────────────
537 // `/` and `?` open the prompt; `<CR>` is the existing SubmitCommand,
538 // which the runtime routes to the search when a search prompt is open.
539 // That routing is typed (Option<Prompt>), not a mode flag to forget.
540 nm(
541 &mut m,
542 Key::Char('/'),
543 Action::SearchOpen(SearchDirection::Forward),
544 "search forward",
545 );
546 nm(
547 &mut m,
548 Key::Char('?'),
549 Action::SearchOpen(SearchDirection::Backward),
550 "search backward",
551 );
552 // `n`/`N` are MOTIONS, not standalone jumps. Binding them to
553 // `Action::Move` is what makes `dn` / `yN` compose — the
554 // operator-pending machine only recognises `Action::Move` as an
555 // operand. `Action::SearchRepeat` remains a valid action (a user rc or
556 // the tatara-lisp binding table may name it) and the runtime routes it
557 // through the same executor, so there is exactly one code path.
558 nm(
559 &mut m,
560 Key::Char('n'),
561 Action::Move(Motion::SearchNext),
562 "next match",
563 );
564 nm(
565 &mut m,
566 Key::Char('N'),
567 Action::Move(Motion::SearchPrev),
568 "previous match",
569 );
570
571 // `gn` / `gN` — the match as an OBJECT, so `cgn` changes the whole
572 // match and `.` repeats that on the next one.
573 m.bind_sequence(
574 Mode::Normal,
575 vec![Key::Char('g'), Key::Char('n')],
576 Action::TextObject(TextObject::NextMatch),
577 "next match (object)",
578 );
579 m.bind_sequence(
580 Mode::Normal,
581 vec![Key::Char('g'), Key::Char('N')],
582 Action::TextObject(TextObject::PrevMatch),
583 "previous match (object)",
584 );
585
586 // ── jumplist ──────────────────────────────────────────────────
587 // The return ticket for every far jump above. Without it a committed
588 // search is a one-way door.
589 nm(&mut m, Key::Ctrl('o'), Action::JumpBack, "jump back");
590 nm(&mut m, Key::Ctrl('i'), Action::JumpForward, "jump forward");
591 nm(
592 &mut m,
593 Key::Char('*'),
594 Action::SearchWord { reverse: false },
595 "search word forward",
596 );
597 nm(
598 &mut m,
599 Key::Char('#'),
600 Action::SearchWord { reverse: true },
601 "search word backward",
602 );
603 m.bind(
604 Mode::Visual,
605 Key::Esc,
606 Action::ChangeMode(Mode::Normal),
607 "to normal",
608 );
609 m.bind(
610 Mode::VisualLine,
611 Key::Esc,
612 Action::ChangeMode(Mode::Normal),
613 "to normal",
614 );
615 m
616 }
617
618 pub fn bind(&mut self, mode: Mode, key: Key, action: Action, desc: impl Into<String>) {
619 let binding = Binding::new(action, desc);
620 self.note_collisions(mode, std::slice::from_ref(&key), &binding);
621 self.bindings.insert((mode, key), binding);
622 }
623
624 /// Record anything about this bind that will surprise its author.
625 ///
626 /// Called on every bind, single or sequence. Detection is DEFAULT-ON and
627 /// costs one hash lookup plus one conversion — a keymap that only tells
628 /// you about collisions when asked is a keymap nobody asks.
629 fn note_collisions(&mut self, mode: Mode, keys: &[Key], binding: &Binding) {
630 let Some(first) = keys.first() else { return };
631 let spelled = format!("{keys:?}");
632
633 // (1) Does the world own it? For a sequence this is its OPENER —
634 // a sequence whose first key never arrives can never begin.
635 if let Some(hk) = to_hotkey(first) {
636 if let Some(why) = self.reserved.refuse(&hk) {
637 self.collisions.push(Collision::Reserved {
638 mode,
639 key: spelled.clone(),
640 description: binding.description.clone(),
641 why,
642 });
643 }
644 }
645
646 // (2) Is something already here? `HashMap::insert` returns the old
647 // value and every caller dropped it, so a displaced binding left no
648 // trace at all.
649 let existing = if keys.len() == 1 {
650 self.bindings
651 .get(&(mode, first.clone()))
652 .map(|b| &b.description)
653 } else {
654 self.sequences
655 .get(&(mode, keys.to_vec()))
656 .map(|b| &b.description)
657 };
658 if let Some(replaced) = existing {
659 self.collisions.push(Collision::Displaced {
660 mode,
661 key: spelled,
662 replaced: replaced.clone(),
663 with: binding.description.clone(),
664 });
665 }
666 }
667
668 /// Every collision recorded while this keymap was built.
669 ///
670 /// Read by `--list-rc` and reported at boot. An empty slice is the
671 /// claim "every binding escriba ships can actually fire".
672 #[must_use]
673 pub fn collisions(&self) -> &[Collision] {
674 &self.collisions
675 }
676
677 /// Collisions that mean a key can NEVER fire, as opposed to one that was
678 /// deliberately overridden.
679 pub fn fatal_collisions(&self) -> impl Iterator<Item = &Collision> {
680 self.collisions.iter().filter(|c| c.is_fatal())
681 }
682
683 #[must_use]
684 pub fn lookup(&self, mode: Mode, key: &Key) -> Option<&Binding> {
685 self.bindings.get(&(mode, key.clone()))
686 }
687
688 /// The leader key — what `<leader>` resolves to when a sequence
689 /// binding is applied. Defaults to `,` (blnvim parity).
690 #[must_use]
691 pub fn leader(&self) -> &Key {
692 &self.leader
693 }
694
695 /// Override the leader key. Applied before sequence bindings so
696 /// `<leader>`-prefixed specs resolve against the chosen prefix.
697 pub fn set_leader(&mut self, key: Key) {
698 self.leader = key;
699 }
700
701 /// Bind a multi-key SEQUENCE — `<leader>ff` →
702 /// `[Char(','), Char('f'), Char('f')]`, `gg` →
703 /// `[Char('g'), Char('g')]`. A length-1 sequence delegates to
704 /// [`bind`](Keymap::bind) so callers never special-case it; an
705 /// empty sequence is a no-op.
706 pub fn bind_sequence(
707 &mut self,
708 mode: Mode,
709 keys: Vec<Key>,
710 action: Action,
711 desc: impl Into<String>,
712 ) {
713 match keys.as_slice() {
714 [] => {}
715 [single] => self.bind(mode, single.clone(), action, desc),
716 _ => {
717 let binding = Binding::new(action, desc);
718 self.note_collisions(mode, &keys, &binding);
719 self.sequences.insert((mode, keys), binding);
720 }
721 }
722 }
723
724 /// Exact-match lookup for a full key sequence.
725 #[must_use]
726 pub fn lookup_sequence(&self, mode: Mode, keys: &[Key]) -> Option<&Binding> {
727 self.sequences.get(&(mode, keys.to_vec()))
728 }
729
730 /// Does any bound sequence in `mode` STRICTLY extend `prefix`
731 /// (i.e. `prefix` is a proper prefix of a longer bound sequence)?
732 /// Drives the runtime's pending-stroke state: a partial sequence
733 /// that is still a live prefix is held pending rather than
734 /// dispatched. Linear scan — fine at fleet sequence counts; a
735 /// trie is a later optimization if profiling ever asks for it.
736 #[must_use]
737 pub fn is_sequence_prefix(&self, mode: Mode, prefix: &[Key]) -> bool {
738 self.sequences
739 .keys()
740 .any(|(m, seq)| *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix))
741 }
742
743 /// Every bound sequence in `mode` that STRICTLY extends `prefix`.
744 ///
745 /// [`is_sequence_prefix`](Self::is_sequence_prefix) answers the same
746 /// question with a `bool` and throws away the matches it just found. Two
747 /// consumers need those matches:
748 ///
749 /// - a **which-key popup**, which must show what continues `<leader>`;
750 /// - a **reserved-chord audit**, which cannot check bindings it cannot
751 /// enumerate — and `sequences` is private, so from outside this crate
752 /// the multi-key half of the keymap was invisible.
753 ///
754 /// Same linear scan as `is_sequence_prefix`, so this costs nothing extra;
755 /// a trie is the same later optimization for both.
756 ///
757 /// Pass an empty `prefix` for every sequence in the mode.
758 #[must_use]
759 pub fn sequences_extending(&self, mode: Mode, prefix: &[Key]) -> Vec<(&[Key], &Binding)> {
760 let mut v: Vec<(&[Key], &Binding)> = self
761 .sequences
762 .iter()
763 .filter(|((m, seq), _)| {
764 *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix)
765 })
766 .map(|((_, seq), b)| (seq.as_slice(), b))
767 .collect();
768 // Sorted, because a which-key popup in HashMap order is a popup that
769 // reorders itself between presses.
770 v.sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0)));
771 v
772 }
773
774 /// Count of bound multi-key sequences — for `--keymap` / doctor.
775 #[must_use]
776 pub fn sequence_len(&self) -> usize {
777 self.sequences.len()
778 }
779
780 #[must_use]
781 pub fn dispatch(&self, state: &ModalState, key: &Key) -> CountedAction {
782 let mode = state.mode();
783 // The preemption rule is stated ONCE, in `preemption` below, and read
784 // twice: here, to answer the key, and by `escriba-banzuke`, to decide
785 // whether a DECLARATION for this pair could ever fire. It used to be
786 // three inline `if mode ==` blocks that only this function could see,
787 // so an rc binding a bare `j` in Insert parsed, applied, reported as
788 // applied — and was dead. Nothing could say so, because the fact that
789 // made it dead lived inside the dispatcher.
790 match preemption(mode, key) {
791 Some(Preemption::Always(p)) => return p.resolved(key),
792 Some(Preemption::WhenCounting(p)) if state.pending_count().is_some() => {
793 return p.resolved(key);
794 }
795 _ => {}
796 }
797 if let Some(b) = self.lookup(mode, key) {
798 return CountedAction::repeated(state.pending_count().unwrap_or(1), b.action.clone());
799 }
800 CountedAction::once(Action::Pending)
801 }
802
803 #[must_use]
804 pub fn len(&self) -> usize {
805 self.bindings.len()
806 }
807
808 #[must_use]
809 pub fn is_empty(&self) -> bool {
810 self.bindings.is_empty()
811 }
812
813 /// Sorted view over every binding — for `escriba --keymap` and palettes.
814 #[must_use]
815 pub fn entries_sorted(&self) -> Vec<(&Mode, &Key, &Binding)> {
816 let mut v: Vec<_> = self.bindings.iter().map(|((m, k), b)| (m, k, b)).collect();
817 v.sort_by(|a, b| {
818 (a.0.as_str(), format!("{:?}", a.1)).cmp(&(b.0.as_str(), format!("{:?}", b.1)))
819 });
820 v
821 }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 #[test]
829 fn default_vim_has_bindings() {
830 let k = Keymap::default_vim();
831 assert!(k.len() > 10);
832 assert!(k.lookup(Mode::Normal, &Key::Char('h')).is_some());
833 assert!(k.lookup(Mode::Insert, &Key::Esc).is_some());
834 assert!(k.lookup(Mode::Normal, &Key::Alt('f')).is_some());
835 }
836
837 #[test]
838 fn dispatch_normal_motion() {
839 let k = Keymap::default_vim();
840 let s = ModalState::new();
841 let a = k.dispatch(&s, &Key::Char('h'));
842 assert_eq!(a.count, 1);
843 assert_eq!(a.action, Action::Move(Motion::Left));
844 }
845
846 #[test]
847 fn dispatch_count_prefix_pends() {
848 let k = Keymap::default_vim();
849 let s = ModalState::new();
850 assert!(matches!(
851 k.dispatch(&s, &Key::Char('5')).action,
852 Action::Pending
853 ));
854 }
855
856 #[test]
857 fn dispatch_insert_char() {
858 let k = Keymap::default_vim();
859 let mut s = ModalState::new();
860 s.enter(Mode::Insert);
861 let a = k.dispatch(&s, &Key::Char('a'));
862 assert_eq!(a.action, Action::InsertChar('a'));
863 }
864
865 #[test]
866 fn lisp_structural_motions_bound() {
867 let k = Keymap::default_vim();
868 assert_eq!(
869 k.lookup(Mode::Normal, &Key::Alt('f')).unwrap().action,
870 Action::Move(Motion::ForwardSexp)
871 );
872 }
873
874 #[test]
875 fn default_leader_is_comma() {
876 assert_eq!(Keymap::new().leader(), &Key::Char(','));
877 }
878
879 #[test]
880 fn bind_sequence_stores_multikey_and_resolves() {
881 let mut k = Keymap::new();
882 let seq = vec![Key::Char(','), Key::Char('f'), Key::Char('f')];
883 k.bind_sequence(
884 Mode::Normal,
885 seq.clone(),
886 Action::Command {
887 name: "picker.files".into(),
888 args: vec![],
889 },
890 "find files",
891 );
892 // Exact match resolves.
893 let b = k.lookup_sequence(Mode::Normal, &seq).expect("seq bound");
894 assert!(matches!(&b.action, Action::Command { name, .. } if name == "picker.files"));
895 // Proper prefixes are live; the full sequence is NOT a prefix
896 // of itself.
897 assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(',')]));
898 assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(','), Key::Char('f')]));
899 assert!(!k.is_sequence_prefix(Mode::Normal, &seq));
900 // Wrong mode → not a prefix.
901 assert!(!k.is_sequence_prefix(Mode::Insert, &[Key::Char(',')]));
902 assert_eq!(k.sequence_len(), 1);
903 }
904
905 #[test]
906 fn bind_sequence_length_one_delegates_to_single() {
907 let mut k = Keymap::new();
908 k.bind_sequence(
909 Mode::Normal,
910 vec![Key::Char('x')],
911 Action::Undo,
912 "x is undo",
913 );
914 // Lands in the single-key table, not the sequence table.
915 assert_eq!(k.sequence_len(), 0);
916 assert!(k.lookup(Mode::Normal, &Key::Char('x')).is_some());
917 }
918}
919
920/// escriba's `Key` as the fleet's chord vocabulary.
921///
922/// # Why a conversion rather than a migration (yet)
923///
924/// `escriba_keymap::Key` folds the modifier INTO the key — `Ctrl(char)`,
925/// `Alt(char)` — over 16 variants. `awase::Hotkey` carries modifiers as a
926/// bitflag SET over 116 key variants. The escriba shape therefore cannot
927/// express `Ctrl+Shift+P`, cannot carry `Super`, and has no F-keys at all
928/// (`escriba-input` discards `KeyCode::F(_)` at the door).
929///
930/// Migrating the whole keymap is the destination and it touches 52 call
931/// sites. This conversion is what lets the **reserved-chord audit** run
932/// today, before that lands: a binding escriba cannot even ask about is a
933/// binding that silently dies when the window manager takes its chord.
934///
935/// Returns `None` for a key with no fleet spelling — today the shifted
936/// digits and punctuation (`#`, `$`, `*`), which awase's `Key` does not
937/// carry. That is the honest answer, and an audit must treat an unmappable
938/// key as UNAUDITED rather than as available: silently counting it as clean
939/// is how `Ctrl+Space` stayed hidden.
940#[must_use]
941pub fn to_hotkey(key: &Key) -> Option<awase::Hotkey> {
942 use awase::{Hotkey, Key as AK, Modifiers as M};
943 // `from_name` takes NAMES ("space"), not literal characters. Spelling a
944 // space as " " returns None — which is how `Ctrl+Space` slipped past the
945 // reserved audit while being bound in Insert mode AND owned by the OS.
946 let named = |c: char| match c {
947 ' ' => Some(AK::Space),
948 c => AK::from_name(&c.to_ascii_lowercase().to_string()),
949 };
950 Some(match key {
951 Key::Char(c) => Hotkey::new(M::NONE, named(*c)?),
952 Key::Ctrl(c) => Hotkey::new(M::CTRL, named(*c)?),
953 Key::Alt(c) => Hotkey::new(M::ALT, named(*c)?),
954 Key::F(n) => Hotkey::new(M::NONE, AK::from_name(&format!("f{n}"))?),
955 // Already a fleet chord — nothing to convert.
956 Key::Chord(h) => *h,
957 Key::Esc => Hotkey::new(M::NONE, AK::Escape),
958 Key::Enter => Hotkey::new(M::NONE, AK::Return),
959 Key::Tab => Hotkey::new(M::NONE, AK::Tab),
960 Key::Backspace => Hotkey::new(M::NONE, AK::Backspace),
961 Key::Delete => Hotkey::new(M::NONE, AK::Delete),
962 Key::Left => Hotkey::new(M::NONE, AK::Left),
963 Key::Right => Hotkey::new(M::NONE, AK::Right),
964 Key::Up => Hotkey::new(M::NONE, AK::Up),
965 Key::Down => Hotkey::new(M::NONE, AK::Down),
966 Key::PageUp => Hotkey::new(M::NONE, AK::PageUp),
967 Key::PageDown => Hotkey::new(M::NONE, AK::PageDown),
968 Key::Home => Hotkey::new(M::NONE, AK::Home),
969 Key::End => Hotkey::new(M::NONE, AK::End),
970 })
971}
972
973#[cfg(test)]
974mod fleet_vocabulary {
975 use super::*;
976
977 #[test]
978 fn modifiers_survive_the_conversion() {
979 let h = to_hotkey(&Key::Ctrl('w')).expect("ctrl+w maps");
980 assert!(h.modifiers.contains(awase::Modifiers::CTRL));
981 assert_eq!(h.key, awase::Key::W);
982 }
983
984 #[test]
985 fn named_keys_map_to_their_fleet_spelling() {
986 // escriba says `Esc`/`Enter`; awase says `Escape`/`Return`. The
987 // fleet atlas already warns that these two spellings diverge across
988 // consumers, which is exactly what a shared vocabulary settles.
989 assert_eq!(
990 to_hotkey(&Key::Esc).map(|h| h.key),
991 Some(awase::Key::Escape)
992 );
993 assert_eq!(
994 to_hotkey(&Key::Enter).map(|h| h.key),
995 Some(awase::Key::Return)
996 );
997 }
998
999 #[test]
1000 fn every_variant_of_escribas_key_has_a_fleet_spelling() {
1001 // If one did not, the reserved audit would have a blind spot exactly
1002 // where escriba's vocabulary is unusual — which is where a collision
1003 // is most likely.
1004 let all = [
1005 Key::Char('a'),
1006 Key::Ctrl('a'),
1007 Key::Alt('a'),
1008 Key::Esc,
1009 Key::Enter,
1010 Key::Tab,
1011 Key::Backspace,
1012 Key::Delete,
1013 Key::Left,
1014 Key::Right,
1015 Key::Up,
1016 Key::Down,
1017 Key::PageUp,
1018 Key::PageDown,
1019 Key::Home,
1020 Key::End,
1021 ];
1022 for k in all {
1023 assert!(to_hotkey(&k).is_some(), "{k:?} has no fleet spelling");
1024 }
1025 }
1026
1027 #[test]
1028 fn sequences_can_now_be_enumerated() {
1029 // `sequences` was private with no accessor, so the multi-key half of
1030 // the keymap was invisible from outside this crate — unauditable and
1031 // un-displayable.
1032 let k = Keymap::default_vim();
1033 let all = k.sequences_extending(Mode::Normal, &[]);
1034 assert!(!all.is_empty(), "the default keymap binds sequences");
1035 let g = k.sequences_extending(Mode::Normal, &[Key::Char('g')]);
1036 assert!(
1037 g.iter()
1038 .all(|(seq, _)| seq.first() == Some(&Key::Char('g'))),
1039 "a prefix query returns only its own continuations",
1040 );
1041 }
1042}
1043
1044#[cfg(test)]
1045mod collision_detection {
1046 use super::*;
1047
1048 #[test]
1049 fn a_reserved_chord_is_recorded_at_bind_time() {
1050 // Not discovered later by an audit — known the moment it is written.
1051 let mut m = Keymap::new();
1052 m.bind(Mode::Normal, Key::Alt('j'), Action::Undo, "focus down?");
1053 let c = m.collisions();
1054 assert_eq!(c.len(), 1, "{c:?}");
1055 assert!(c[0].is_fatal(), "a chord the world owns can never fire");
1056 assert!(
1057 c[0].report().contains("window manager"),
1058 "{}",
1059 c[0].report()
1060 );
1061 }
1062
1063 #[test]
1064 fn a_displaced_binding_is_recorded_but_not_fatal() {
1065 // Overriding is sometimes intended — the shipped rc deliberately
1066 // overrides defaults — so it is REPORTED, never refused. But "my
1067 // plugin's key stopped working" has no other explanation available.
1068 let mut m = Keymap::new();
1069 m.bind(Mode::Normal, Key::Char('x'), Action::Undo, "first");
1070 m.bind(Mode::Normal, Key::Char('x'), Action::Redo, "second");
1071 let c = m.collisions();
1072 assert_eq!(c.len(), 1);
1073 assert!(!c[0].is_fatal());
1074 let r = c[0].report();
1075 assert!(r.contains("first") && r.contains("second"), "{r}");
1076 }
1077
1078 #[test]
1079 // OPENER is shouted because which key is checked IS the point.
1080 #[allow(non_snake_case)]
1081 fn a_sequence_whose_OPENER_is_reserved_is_caught() {
1082 // `alt-j` then anything can never begin, because the first key never
1083 // arrives. Checking only single keys would miss the whole sequence.
1084 let mut m = Keymap::new();
1085 m.bind_sequence(
1086 Mode::Normal,
1087 vec![Key::Alt('j'), Key::Char('x')],
1088 Action::Undo,
1089 "dead sequence",
1090 );
1091 assert!(m.fatal_collisions().count() == 1, "{:?}", m.collisions(),);
1092 }
1093
1094 #[test]
1095 fn an_ordinary_keymap_records_nothing() {
1096 // The detector must be quiet when there is nothing to say, or it
1097 // becomes noise an operator learns to skip.
1098 let mut m = Keymap::new();
1099 m.bind(Mode::Normal, Key::Char('h'), Action::Undo, "left");
1100 m.bind(Mode::Normal, Key::Ctrl('w'), Action::Redo, "window prefix");
1101 assert!(m.collisions().is_empty(), "{:?}", m.collisions());
1102 }
1103
1104 #[test]
1105 fn the_shipped_default_keymap_is_clean() {
1106 let m = Keymap::default_vim();
1107 assert!(
1108 m.collisions().is_empty(),
1109 "escriba's own defaults must not collide:\n {}",
1110 m.collisions()
1111 .iter()
1112 .map(Collision::report)
1113 .collect::<Vec<_>>()
1114 .join("\n "),
1115 );
1116 }
1117}