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