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, InsertAt, 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 // ── Insert entry — the whole vim family, one row each ──────────
343 //
344 // Until 2026-08-12 this was ONE row: `i`. `a`, `A`, `I`, `o` and `O`
345 // were unbound, so `A` on a line resolved to `Action::Pending` and did
346 // nothing at all — no move, no mode change, no message.
347 //
348 // Binding bare `a` and `i` is safe DESPITE the text objects (`daw`,
349 // `di(`) that also begin with them, and the reason is worth stating
350 // because it is the one thing that makes this table correct: the
351 // runtime's `consume_object_key` runs BEFORE the sequence stepper and
352 // before this table, and claims `i`/`a` only while an operator is
353 // armed (`escriba-runtime`, `OpState::Awaiting`). With nothing pending
354 // they fall through to here. `escriba-keymap`'s own "single bindings
355 // win over sequence prefixes" rule would otherwise have shadowed every
356 // text object the moment `a` got a binding.
357 //
358 // Every entry is `EnterInsert`, never `ChangeMode(Insert)`: the caret
359 // placement IS the difference between the six, and a mode change
360 // cannot carry it.
361 for (key, at, label) in [
362 (Key::Char('i'), InsertAt::Caret, "insert"),
363 (Key::Char('I'), InsertAt::FirstNonBlank, "first non-blank"),
364 (Key::Char('a'), InsertAt::AfterCaret, "append"),
365 (Key::Char('A'), InsertAt::LineEnd, "append at end of line"),
366 (Key::Char('o'), InsertAt::OpenBelow, "open line below"),
367 (Key::Char('O'), InsertAt::OpenAbove, "open line above"),
368 ] {
369 nm(&mut m, key, Action::EnterInsert(at), label);
370 }
371 nm(
372 &mut m,
373 Key::Char('v'),
374 Action::ChangeMode(Mode::Visual),
375 "visual",
376 );
377 nm(
378 &mut m,
379 Key::Char('V'),
380 Action::ChangeMode(Mode::VisualLine),
381 "visual line",
382 );
383 nm(
384 &mut m,
385 Key::Char(':'),
386 Action::ChangeMode(Mode::Command),
387 "command",
388 );
389 nm(&mut m, Key::Char('u'), Action::Undo, "undo");
390 nm(
391 &mut m,
392 Key::Char('.'),
393 Action::RepeatLastChange,
394 "repeat last change",
395 );
396 nm(&mut m, Key::Ctrl('r'), Action::Redo, "redo");
397 // Insert → Normal on Esc.
398 m.bind(
399 Mode::Insert,
400 Key::Esc,
401 Action::ChangeMode(Mode::Normal),
402 "to normal",
403 );
404 // ── Insert-mode editing ───────────────────────────────────────
405 // Until 2026-08-09 `Esc` above was the ONLY Insert-mode binding, and
406 // `dispatch` short-circuits `Key::Char` + `Key::Enter` before the
407 // table is consulted — so every key below fell through to
408 // `Action::Pending` and did nothing. Insert mode could be typed into
409 // and never corrected: no erase, no caret movement. The keys were not
410 // "unimplemented", they were unbound; `Action::Backspace`'s executor
411 // had been waiting for a caller.
412 m.bind(
413 Mode::Insert,
414 Key::Backspace,
415 Action::Backspace,
416 "erase one char",
417 );
418 m.bind(
419 Mode::Insert,
420 Key::Delete,
421 Action::DeleteForward,
422 "delete char at caret",
423 );
424 // The bigger erases. `<BS>`/`<Del>` landed first and these were left
425 // behind for a day, which made Insert mode able to erase one character
426 // at a time and nothing larger — a mis-typed word had to be dismantled
427 // letter by letter. They share the erase family's routing, so the
428 // binding is the whole change: the runtime already knows whether a
429 // prompt is open.
430 m.bind(
431 Mode::Insert,
432 Key::Ctrl('w'),
433 Action::DeleteWordBefore,
434 "erase word before caret",
435 );
436 m.bind(
437 Mode::Insert,
438 Key::Ctrl('u'),
439 Action::DeleteToLineStart,
440 "erase to line start",
441 );
442 // `<C-h>` IS backspace: terminals send 0x08 for it, and vim treats the
443 // two as one key in Insert. Whether a given terminal reports the
444 // physical Backspace as `Backspace` or as `Ctrl('h')` is the
445 // terminal's business, not the operator's — binding both is what makes
446 // the answer stop mattering.
447 m.bind(
448 Mode::Insert,
449 Key::Ctrl('h'),
450 Action::Backspace,
451 "erase one char",
452 );
453 // Arrows in Insert are vi-compatible (vim's `esckeys`) and are what
454 // "edit it" means to anyone who did not grow up on hjkl. They reuse
455 // the ordinary motions, so the cursor-clamp + viewport-follow
456 // invariants come along unchanged.
457 for (key, motion, label) in [
458 (Key::Left, Motion::Left, "caret left"),
459 (Key::Right, Motion::Right, "caret right"),
460 (Key::Up, Motion::Up, "caret up"),
461 (Key::Down, Motion::Down, "caret down"),
462 (Key::Home, Motion::LineStart, "caret to line start"),
463 (Key::End, Motion::LineEnd, "caret to line end"),
464 ] {
465 m.bind(Mode::Insert, key, Action::Move(motion), label);
466 }
467 m.bind(
468 Mode::Command,
469 Key::Esc,
470 Action::ChangeMode(Mode::Normal),
471 "abort",
472 );
473 m.bind(Mode::Command, Key::Enter, Action::SubmitCommand, "submit");
474 m.bind(
475 Mode::Command,
476 Key::Up,
477 Action::PromptHistory { back: true },
478 "older search",
479 );
480 m.bind(
481 Mode::Command,
482 Key::Down,
483 Action::PromptHistory { back: false },
484 "newer search",
485 );
486 m.bind(
487 Mode::Command,
488 Key::Backspace,
489 Action::Backspace,
490 "erase one char",
491 );
492 m.bind(
493 Mode::Command,
494 Key::Delete,
495 Action::DeleteForward,
496 "delete char at caret",
497 );
498 // Caret editing inside the prompt. Without these the prompt is
499 // append-only, so a typo in the middle of a pattern can only be fixed
500 // by deleting everything back to it.
501 m.bind(
502 Mode::Command,
503 Key::Left,
504 Action::PromptCaret {
505 to: CaretMove::Left,
506 },
507 "caret left",
508 );
509 m.bind(
510 Mode::Command,
511 Key::Right,
512 Action::PromptCaret {
513 to: CaretMove::Right,
514 },
515 "caret right",
516 );
517 m.bind(
518 Mode::Command,
519 Key::Home,
520 Action::PromptCaret {
521 to: CaretMove::Start,
522 },
523 "caret to start",
524 );
525 m.bind(
526 Mode::Command,
527 Key::End,
528 Action::PromptCaret { to: CaretMove::End },
529 "caret to end",
530 );
531 m.bind(
532 Mode::Command,
533 Key::Ctrl('w'),
534 Action::DeleteWordBefore,
535 "delete word before caret",
536 );
537 // Walk the preview without committing — `/pat` then `<C-g><C-g>` is
538 // `/pat<CR>nn`, except Escape still takes you home.
539 m.bind(
540 Mode::Command,
541 Key::Ctrl('g'),
542 Action::SearchPreviewStep { forward: true },
543 "preview next match",
544 );
545 m.bind(
546 Mode::Command,
547 Key::Ctrl('t'),
548 Action::SearchPreviewStep { forward: false },
549 "preview previous match",
550 );
551 m.bind(
552 Mode::Command,
553 Key::Ctrl('u'),
554 Action::DeleteToLineStart,
555 "clear to start",
556 );
557
558 // ── search ────────────────────────────────────────────────────
559 // `/` and `?` open the prompt; `<CR>` is the existing SubmitCommand,
560 // which the runtime routes to the search when a search prompt is open.
561 // That routing is typed (Option<Prompt>), not a mode flag to forget.
562 nm(
563 &mut m,
564 Key::Char('/'),
565 Action::SearchOpen(SearchDirection::Forward),
566 "search forward",
567 );
568 nm(
569 &mut m,
570 Key::Char('?'),
571 Action::SearchOpen(SearchDirection::Backward),
572 "search backward",
573 );
574 // `n`/`N` are MOTIONS, not standalone jumps. Binding them to
575 // `Action::Move` is what makes `dn` / `yN` compose — the
576 // operator-pending machine only recognises `Action::Move` as an
577 // operand. `Action::SearchRepeat` remains a valid action (a user rc or
578 // the tatara-lisp binding table may name it) and the runtime routes it
579 // through the same executor, so there is exactly one code path.
580 nm(
581 &mut m,
582 Key::Char('n'),
583 Action::Move(Motion::SearchNext),
584 "next match",
585 );
586 nm(
587 &mut m,
588 Key::Char('N'),
589 Action::Move(Motion::SearchPrev),
590 "previous match",
591 );
592
593 // `gn` / `gN` — the match as an OBJECT, so `cgn` changes the whole
594 // match and `.` repeats that on the next one.
595 m.bind_sequence(
596 Mode::Normal,
597 vec![Key::Char('g'), Key::Char('n')],
598 Action::TextObject(TextObject::NextMatch),
599 "next match (object)",
600 );
601 m.bind_sequence(
602 Mode::Normal,
603 vec![Key::Char('g'), Key::Char('N')],
604 Action::TextObject(TextObject::PrevMatch),
605 "previous match (object)",
606 );
607
608 // ── `ff` — format the buffer ───────────────────────────────────
609 //
610 // The operator's blnvim binding, which escriba ships parity with:
611 // `blackmatter-nvim`'s `groups/keybindings/init.lua` maps a bare `ff`
612 // in normal mode to conform's format. Same keys here so the muscle
613 // memory transfers between the two editors.
614 //
615 // A SEQUENCE, and it costs nothing today: `f` (find-char) is not
616 // implemented in escriba, so there is no single binding for `ff` to
617 // shadow. When `f` lands it will need deciding — keymap's rule is that
618 // a single binding WINS over a sequence prefix, so binding `f` would
619 // silently kill `ff` rather than conflict with it. nvim pays the mirror
620 // cost of that today: `ff` there shadows `f` followed by `f`.
621 //
622 // `Action::Command` rather than a dedicated action: `lsp.format` is the
623 // name the catalog already binds from `<leader>lf`, `:Format` and a
624 // `BufWritePre` hook, and a second route to the same verb is how two
625 // spellings of one thing drift apart.
626 m.bind_sequence(
627 Mode::Normal,
628 vec![Key::Char('f'), Key::Char('f')],
629 Action::Command {
630 name: "lsp.format".to_string(),
631 args: Vec::new(),
632 },
633 "format buffer",
634 );
635
636 // ── jumplist ──────────────────────────────────────────────────
637 // The return ticket for every far jump above. Without it a committed
638 // search is a one-way door.
639 nm(&mut m, Key::Ctrl('o'), Action::JumpBack, "jump back");
640 nm(&mut m, Key::Ctrl('i'), Action::JumpForward, "jump forward");
641 nm(
642 &mut m,
643 Key::Char('*'),
644 Action::SearchWord { reverse: false },
645 "search word forward",
646 );
647 nm(
648 &mut m,
649 Key::Char('#'),
650 Action::SearchWord { reverse: true },
651 "search word backward",
652 );
653 m.bind(
654 Mode::Visual,
655 Key::Esc,
656 Action::ChangeMode(Mode::Normal),
657 "to normal",
658 );
659 m.bind(
660 Mode::VisualLine,
661 Key::Esc,
662 Action::ChangeMode(Mode::Normal),
663 "to normal",
664 );
665 m
666 }
667
668 pub fn bind(&mut self, mode: Mode, key: Key, action: Action, desc: impl Into<String>) {
669 let binding = Binding::new(action, desc);
670 self.note_collisions(mode, std::slice::from_ref(&key), &binding);
671 self.bindings.insert((mode, key), binding);
672 }
673
674 /// Record anything about this bind that will surprise its author.
675 ///
676 /// Called on every bind, single or sequence. Detection is DEFAULT-ON and
677 /// costs one hash lookup plus one conversion — a keymap that only tells
678 /// you about collisions when asked is a keymap nobody asks.
679 fn note_collisions(&mut self, mode: Mode, keys: &[Key], binding: &Binding) {
680 let Some(first) = keys.first() else { return };
681 let spelled = format!("{keys:?}");
682
683 // (1) Does the world own it? For a sequence this is its OPENER —
684 // a sequence whose first key never arrives can never begin.
685 if let Some(hk) = to_hotkey(first) {
686 if let Some(why) = self.reserved.refuse(&hk) {
687 self.collisions.push(Collision::Reserved {
688 mode,
689 key: spelled.clone(),
690 description: binding.description.clone(),
691 why,
692 });
693 }
694 }
695
696 // (2) Is something already here? `HashMap::insert` returns the old
697 // value and every caller dropped it, so a displaced binding left no
698 // trace at all.
699 let existing = if keys.len() == 1 {
700 self.bindings
701 .get(&(mode, first.clone()))
702 .map(|b| &b.description)
703 } else {
704 self.sequences
705 .get(&(mode, keys.to_vec()))
706 .map(|b| &b.description)
707 };
708 if let Some(replaced) = existing {
709 self.collisions.push(Collision::Displaced {
710 mode,
711 key: spelled,
712 replaced: replaced.clone(),
713 with: binding.description.clone(),
714 });
715 }
716 }
717
718 /// Every collision recorded while this keymap was built.
719 ///
720 /// Read by `--list-rc` and reported at boot. An empty slice is the
721 /// claim "every binding escriba ships can actually fire".
722 #[must_use]
723 pub fn collisions(&self) -> &[Collision] {
724 &self.collisions
725 }
726
727 /// Collisions that mean a key can NEVER fire, as opposed to one that was
728 /// deliberately overridden.
729 pub fn fatal_collisions(&self) -> impl Iterator<Item = &Collision> {
730 self.collisions.iter().filter(|c| c.is_fatal())
731 }
732
733 #[must_use]
734 pub fn lookup(&self, mode: Mode, key: &Key) -> Option<&Binding> {
735 self.bindings.get(&(mode, key.clone()))
736 }
737
738 /// The leader key — what `<leader>` resolves to when a sequence
739 /// binding is applied. Defaults to `,` (blnvim parity).
740 #[must_use]
741 pub fn leader(&self) -> &Key {
742 &self.leader
743 }
744
745 /// Override the leader key. Applied before sequence bindings so
746 /// `<leader>`-prefixed specs resolve against the chosen prefix.
747 pub fn set_leader(&mut self, key: Key) {
748 self.leader = key;
749 }
750
751 /// Bind a multi-key SEQUENCE — `<leader>ff` →
752 /// `[Char(','), Char('f'), Char('f')]`, `gg` →
753 /// `[Char('g'), Char('g')]`. A length-1 sequence delegates to
754 /// [`bind`](Keymap::bind) so callers never special-case it; an
755 /// empty sequence is a no-op.
756 pub fn bind_sequence(
757 &mut self,
758 mode: Mode,
759 keys: Vec<Key>,
760 action: Action,
761 desc: impl Into<String>,
762 ) {
763 match keys.as_slice() {
764 [] => {}
765 [single] => self.bind(mode, single.clone(), action, desc),
766 _ => {
767 let binding = Binding::new(action, desc);
768 self.note_collisions(mode, &keys, &binding);
769 self.sequences.insert((mode, keys), binding);
770 }
771 }
772 }
773
774 /// Exact-match lookup for a full key sequence.
775 #[must_use]
776 pub fn lookup_sequence(&self, mode: Mode, keys: &[Key]) -> Option<&Binding> {
777 self.sequences.get(&(mode, keys.to_vec()))
778 }
779
780 /// Does any bound sequence in `mode` STRICTLY extend `prefix`
781 /// (i.e. `prefix` is a proper prefix of a longer bound sequence)?
782 /// Drives the runtime's pending-stroke state: a partial sequence
783 /// that is still a live prefix is held pending rather than
784 /// dispatched. Linear scan — fine at fleet sequence counts; a
785 /// trie is a later optimization if profiling ever asks for it.
786 #[must_use]
787 pub fn is_sequence_prefix(&self, mode: Mode, prefix: &[Key]) -> bool {
788 self.sequences
789 .keys()
790 .any(|(m, seq)| *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix))
791 }
792
793 /// Every bound sequence in `mode` that STRICTLY extends `prefix`.
794 ///
795 /// [`is_sequence_prefix`](Self::is_sequence_prefix) answers the same
796 /// question with a `bool` and throws away the matches it just found. Two
797 /// consumers need those matches:
798 ///
799 /// - a **which-key popup**, which must show what continues `<leader>`;
800 /// - a **reserved-chord audit**, which cannot check bindings it cannot
801 /// enumerate — and `sequences` is private, so from outside this crate
802 /// the multi-key half of the keymap was invisible.
803 ///
804 /// Same linear scan as `is_sequence_prefix`, so this costs nothing extra;
805 /// a trie is the same later optimization for both.
806 ///
807 /// Pass an empty `prefix` for every sequence in the mode.
808 #[must_use]
809 pub fn sequences_extending(&self, mode: Mode, prefix: &[Key]) -> Vec<(&[Key], &Binding)> {
810 let mut v: Vec<(&[Key], &Binding)> = self
811 .sequences
812 .iter()
813 .filter(|((m, seq), _)| {
814 *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix)
815 })
816 .map(|((_, seq), b)| (seq.as_slice(), b))
817 .collect();
818 // Sorted, because a which-key popup in HashMap order is a popup that
819 // reorders itself between presses.
820 v.sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0)));
821 v
822 }
823
824 /// Count of bound multi-key sequences — for `--keymap` / doctor.
825 #[must_use]
826 pub fn sequence_len(&self) -> usize {
827 self.sequences.len()
828 }
829
830 #[must_use]
831 pub fn dispatch(&self, state: &ModalState, key: &Key) -> CountedAction {
832 let mode = state.mode();
833 // The preemption rule is stated ONCE, in `preemption` below, and read
834 // twice: here, to answer the key, and by `escriba-banzuke`, to decide
835 // whether a DECLARATION for this pair could ever fire. It used to be
836 // three inline `if mode ==` blocks that only this function could see,
837 // so an rc binding a bare `j` in Insert parsed, applied, reported as
838 // applied — and was dead. Nothing could say so, because the fact that
839 // made it dead lived inside the dispatcher.
840 match preemption(mode, key) {
841 Some(Preemption::Always(p)) => return p.resolved(key),
842 Some(Preemption::WhenCounting(p)) if state.pending_count().is_some() => {
843 return p.resolved(key);
844 }
845 _ => {}
846 }
847 if let Some(b) = self.lookup(mode, key) {
848 return CountedAction::repeated(state.pending_count().unwrap_or(1), b.action.clone());
849 }
850 CountedAction::once(Action::Pending)
851 }
852
853 #[must_use]
854 pub fn len(&self) -> usize {
855 self.bindings.len()
856 }
857
858 #[must_use]
859 pub fn is_empty(&self) -> bool {
860 self.bindings.is_empty()
861 }
862
863 /// Sorted view over every binding — for `escriba --keymap` and palettes.
864 #[must_use]
865 pub fn entries_sorted(&self) -> Vec<(&Mode, &Key, &Binding)> {
866 let mut v: Vec<_> = self.bindings.iter().map(|((m, k), b)| (m, k, b)).collect();
867 v.sort_by(|a, b| {
868 (a.0.as_str(), format!("{:?}", a.1)).cmp(&(b.0.as_str(), format!("{:?}", b.1)))
869 });
870 v
871 }
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877
878 #[test]
879 fn default_vim_has_bindings() {
880 let k = Keymap::default_vim();
881 assert!(k.len() > 10);
882 assert!(k.lookup(Mode::Normal, &Key::Char('h')).is_some());
883 assert!(k.lookup(Mode::Insert, &Key::Esc).is_some());
884 assert!(k.lookup(Mode::Normal, &Key::Alt('f')).is_some());
885 }
886
887 #[test]
888 fn dispatch_normal_motion() {
889 let k = Keymap::default_vim();
890 let s = ModalState::new();
891 let a = k.dispatch(&s, &Key::Char('h'));
892 assert_eq!(a.count, 1);
893 assert_eq!(a.action, Action::Move(Motion::Left));
894 }
895
896 #[test]
897 fn dispatch_count_prefix_pends() {
898 let k = Keymap::default_vim();
899 let s = ModalState::new();
900 assert!(matches!(
901 k.dispatch(&s, &Key::Char('5')).action,
902 Action::Pending
903 ));
904 }
905
906 #[test]
907 fn dispatch_insert_char() {
908 let k = Keymap::default_vim();
909 let mut s = ModalState::new();
910 s.enter(Mode::Insert);
911 let a = k.dispatch(&s, &Key::Char('a'));
912 assert_eq!(a.action, Action::InsertChar('a'));
913 }
914
915 #[test]
916 fn lisp_structural_motions_bound() {
917 let k = Keymap::default_vim();
918 assert_eq!(
919 k.lookup(Mode::Normal, &Key::Alt('f')).unwrap().action,
920 Action::Move(Motion::ForwardSexp)
921 );
922 }
923
924 #[test]
925 fn default_leader_is_comma() {
926 assert_eq!(Keymap::new().leader(), &Key::Char(','));
927 }
928
929 #[test]
930 fn bind_sequence_stores_multikey_and_resolves() {
931 let mut k = Keymap::new();
932 let seq = vec![Key::Char(','), Key::Char('f'), Key::Char('f')];
933 k.bind_sequence(
934 Mode::Normal,
935 seq.clone(),
936 Action::Command {
937 name: "picker.files".into(),
938 args: vec![],
939 },
940 "find files",
941 );
942 // Exact match resolves.
943 let b = k.lookup_sequence(Mode::Normal, &seq).expect("seq bound");
944 assert!(matches!(&b.action, Action::Command { name, .. } if name == "picker.files"));
945 // Proper prefixes are live; the full sequence is NOT a prefix
946 // of itself.
947 assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(',')]));
948 assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(','), Key::Char('f')]));
949 assert!(!k.is_sequence_prefix(Mode::Normal, &seq));
950 // Wrong mode → not a prefix.
951 assert!(!k.is_sequence_prefix(Mode::Insert, &[Key::Char(',')]));
952 assert_eq!(k.sequence_len(), 1);
953 }
954
955 #[test]
956 fn bind_sequence_length_one_delegates_to_single() {
957 let mut k = Keymap::new();
958 k.bind_sequence(
959 Mode::Normal,
960 vec![Key::Char('x')],
961 Action::Undo,
962 "x is undo",
963 );
964 // Lands in the single-key table, not the sequence table.
965 assert_eq!(k.sequence_len(), 0);
966 assert!(k.lookup(Mode::Normal, &Key::Char('x')).is_some());
967 }
968}
969
970/// escriba's `Key` as the fleet's chord vocabulary.
971///
972/// # Why a conversion rather than a migration (yet)
973///
974/// `escriba_keymap::Key` folds the modifier INTO the key — `Ctrl(char)`,
975/// `Alt(char)` — over 16 variants. `awase::Hotkey` carries modifiers as a
976/// bitflag SET over 116 key variants. The escriba shape therefore cannot
977/// express `Ctrl+Shift+P`, cannot carry `Super`, and has no F-keys at all
978/// (`escriba-input` discards `KeyCode::F(_)` at the door).
979///
980/// Migrating the whole keymap is the destination and it touches 52 call
981/// sites. This conversion is what lets the **reserved-chord audit** run
982/// today, before that lands: a binding escriba cannot even ask about is a
983/// binding that silently dies when the window manager takes its chord.
984///
985/// Returns `None` for a key with no fleet spelling — today the shifted
986/// digits and punctuation (`#`, `$`, `*`), which awase's `Key` does not
987/// carry. That is the honest answer, and an audit must treat an unmappable
988/// key as UNAUDITED rather than as available: silently counting it as clean
989/// is how `Ctrl+Space` stayed hidden.
990#[must_use]
991pub fn to_hotkey(key: &Key) -> Option<awase::Hotkey> {
992 use awase::{Hotkey, Key as AK, Modifiers as M};
993 // `from_name` takes NAMES ("space"), not literal characters. Spelling a
994 // space as " " returns None — which is how `Ctrl+Space` slipped past the
995 // reserved audit while being bound in Insert mode AND owned by the OS.
996 let named = |c: char| match c {
997 ' ' => Some(AK::Space),
998 c => AK::from_name(&c.to_ascii_lowercase().to_string()),
999 };
1000 Some(match key {
1001 Key::Char(c) => Hotkey::new(M::NONE, named(*c)?),
1002 Key::Ctrl(c) => Hotkey::new(M::CTRL, named(*c)?),
1003 Key::Alt(c) => Hotkey::new(M::ALT, named(*c)?),
1004 Key::F(n) => Hotkey::new(M::NONE, AK::from_name(&format!("f{n}"))?),
1005 // Already a fleet chord — nothing to convert.
1006 Key::Chord(h) => *h,
1007 Key::Esc => Hotkey::new(M::NONE, AK::Escape),
1008 Key::Enter => Hotkey::new(M::NONE, AK::Return),
1009 Key::Tab => Hotkey::new(M::NONE, AK::Tab),
1010 Key::Backspace => Hotkey::new(M::NONE, AK::Backspace),
1011 Key::Delete => Hotkey::new(M::NONE, AK::Delete),
1012 Key::Left => Hotkey::new(M::NONE, AK::Left),
1013 Key::Right => Hotkey::new(M::NONE, AK::Right),
1014 Key::Up => Hotkey::new(M::NONE, AK::Up),
1015 Key::Down => Hotkey::new(M::NONE, AK::Down),
1016 Key::PageUp => Hotkey::new(M::NONE, AK::PageUp),
1017 Key::PageDown => Hotkey::new(M::NONE, AK::PageDown),
1018 Key::Home => Hotkey::new(M::NONE, AK::Home),
1019 Key::End => Hotkey::new(M::NONE, AK::End),
1020 })
1021}
1022
1023#[cfg(test)]
1024mod fleet_vocabulary {
1025 use super::*;
1026
1027 #[test]
1028 fn modifiers_survive_the_conversion() {
1029 let h = to_hotkey(&Key::Ctrl('w')).expect("ctrl+w maps");
1030 assert!(h.modifiers.contains(awase::Modifiers::CTRL));
1031 assert_eq!(h.key, awase::Key::W);
1032 }
1033
1034 #[test]
1035 fn named_keys_map_to_their_fleet_spelling() {
1036 // escriba says `Esc`/`Enter`; awase says `Escape`/`Return`. The
1037 // fleet atlas already warns that these two spellings diverge across
1038 // consumers, which is exactly what a shared vocabulary settles.
1039 assert_eq!(
1040 to_hotkey(&Key::Esc).map(|h| h.key),
1041 Some(awase::Key::Escape)
1042 );
1043 assert_eq!(
1044 to_hotkey(&Key::Enter).map(|h| h.key),
1045 Some(awase::Key::Return)
1046 );
1047 }
1048
1049 #[test]
1050 fn every_variant_of_escribas_key_has_a_fleet_spelling() {
1051 // If one did not, the reserved audit would have a blind spot exactly
1052 // where escriba's vocabulary is unusual — which is where a collision
1053 // is most likely.
1054 let all = [
1055 Key::Char('a'),
1056 Key::Ctrl('a'),
1057 Key::Alt('a'),
1058 Key::Esc,
1059 Key::Enter,
1060 Key::Tab,
1061 Key::Backspace,
1062 Key::Delete,
1063 Key::Left,
1064 Key::Right,
1065 Key::Up,
1066 Key::Down,
1067 Key::PageUp,
1068 Key::PageDown,
1069 Key::Home,
1070 Key::End,
1071 ];
1072 for k in all {
1073 assert!(to_hotkey(&k).is_some(), "{k:?} has no fleet spelling");
1074 }
1075 }
1076
1077 #[test]
1078 fn sequences_can_now_be_enumerated() {
1079 // `sequences` was private with no accessor, so the multi-key half of
1080 // the keymap was invisible from outside this crate — unauditable and
1081 // un-displayable.
1082 let k = Keymap::default_vim();
1083 let all = k.sequences_extending(Mode::Normal, &[]);
1084 assert!(!all.is_empty(), "the default keymap binds sequences");
1085 let g = k.sequences_extending(Mode::Normal, &[Key::Char('g')]);
1086 assert!(
1087 g.iter()
1088 .all(|(seq, _)| seq.first() == Some(&Key::Char('g'))),
1089 "a prefix query returns only its own continuations",
1090 );
1091 }
1092}
1093
1094#[cfg(test)]
1095mod collision_detection {
1096 use super::*;
1097
1098 #[test]
1099 fn a_reserved_chord_is_recorded_at_bind_time() {
1100 // Not discovered later by an audit — known the moment it is written.
1101 let mut m = Keymap::new();
1102 m.bind(Mode::Normal, Key::Alt('j'), Action::Undo, "focus down?");
1103 let c = m.collisions();
1104 assert_eq!(c.len(), 1, "{c:?}");
1105 assert!(c[0].is_fatal(), "a chord the world owns can never fire");
1106 assert!(
1107 c[0].report().contains("window manager"),
1108 "{}",
1109 c[0].report()
1110 );
1111 }
1112
1113 #[test]
1114 fn a_displaced_binding_is_recorded_but_not_fatal() {
1115 // Overriding is sometimes intended — the shipped rc deliberately
1116 // overrides defaults — so it is REPORTED, never refused. But "my
1117 // plugin's key stopped working" has no other explanation available.
1118 let mut m = Keymap::new();
1119 m.bind(Mode::Normal, Key::Char('x'), Action::Undo, "first");
1120 m.bind(Mode::Normal, Key::Char('x'), Action::Redo, "second");
1121 let c = m.collisions();
1122 assert_eq!(c.len(), 1);
1123 assert!(!c[0].is_fatal());
1124 let r = c[0].report();
1125 assert!(r.contains("first") && r.contains("second"), "{r}");
1126 }
1127
1128 #[test]
1129 // OPENER is shouted because which key is checked IS the point.
1130 #[allow(non_snake_case)]
1131 fn a_sequence_whose_OPENER_is_reserved_is_caught() {
1132 // `alt-j` then anything can never begin, because the first key never
1133 // arrives. Checking only single keys would miss the whole sequence.
1134 let mut m = Keymap::new();
1135 m.bind_sequence(
1136 Mode::Normal,
1137 vec![Key::Alt('j'), Key::Char('x')],
1138 Action::Undo,
1139 "dead sequence",
1140 );
1141 assert!(m.fatal_collisions().count() == 1, "{:?}", m.collisions(),);
1142 }
1143
1144 #[test]
1145 fn an_ordinary_keymap_records_nothing() {
1146 // The detector must be quiet when there is nothing to say, or it
1147 // becomes noise an operator learns to skip.
1148 let mut m = Keymap::new();
1149 m.bind(Mode::Normal, Key::Char('h'), Action::Undo, "left");
1150 m.bind(Mode::Normal, Key::Ctrl('w'), Action::Redo, "window prefix");
1151 assert!(m.collisions().is_empty(), "{:?}", m.collisions());
1152 }
1153
1154 #[test]
1155 fn the_shipped_default_keymap_is_clean() {
1156 let m = Keymap::default_vim();
1157 assert!(
1158 m.collisions().is_empty(),
1159 "escriba's own defaults must not collide:\n {}",
1160 m.collisions()
1161 .iter()
1162 .map(Collision::report)
1163 .collect::<Vec<_>>()
1164 .join("\n "),
1165 );
1166 }
1167}