Skip to main content

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