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