lattice_host/input.rs
1//! Translate canonical `KeyChord` values into `Action`s.
2//!
3//! Renderer-neutral dispatch entry point: reads modal state, the
4//! pending-key buffer, and the catalog of built-in command IDs to
5//! decide what each chord means. The `crossterm::KeyEvent → KeyChord`
6//! adapter (and the analogous future-renderer adapters) live in
7//! the renderer crates; this module never sees a raw key event.
8//!
9//! Shape per DESIGN.md §5.2.3: chord → typed `CommandInvocation`,
10//! so swapping in the layered keymap engine later is mechanical.
11
12use lattice_grammar::ModalState;
13use lattice_grammar::builtins::Builtins;
14
15use crate::action::Action;
16use crate::buffers::BufferKind;
17use crate::chord::{KeyChord, KeyKind, SpecialKey};
18use crate::keymap_insert::dispatch_insert;
19use crate::keymap_registry::KeymapHandle;
20use crate::keymap_replace::dispatch_replace;
21use crate::keymap_visual::dispatch_visual;
22
23pub struct TranslateContext<'a> {
24 pub modal: ModalState,
25 pub builtins: &'a Builtins,
26 /// In-progress count prefix; `0` means none. Translate uses this to
27 /// disambiguate the `0` key (line_start when no count in progress;
28 /// digit-zero appended to count otherwise).
29 pub pending_count: u32,
30 /// Operator-side count latched at `Pending::AfterOperator`
31 /// activation (App moves `pending_count` -> `op_count` then
32 /// resets `pending_count` so the in-progress digits track
33 /// the *motion* side of `<op-count><op><motion-count><motion>`).
34 /// Slice 8.g.iv reads this in `keymap_normal::attach_count`
35 /// to multiply with the motion count when an
36 /// `Action::Invoke` resolves.
37 pub op_count: u32,
38 /// True when a macro is currently being recorded. Translate uses
39 /// this so `q` while recording stops, while `q` otherwise starts a
40 /// new recording.
41 pub recording_macro: bool,
42 /// Which buffer the App's input pipeline currently routes to.
43 /// Driven by [`crate::app::App::active_buffer`]; defaults to
44 /// [`BufferKind::Document`]. Help buffers route through the
45 /// same Normal-mode chord grammar (motions, `<C-o>` / `<C-i>`,
46 /// `gg` / `G`, etc.) -- only three buffer-local bindings
47 /// differ: `Esc` / `q` dismiss the help overlay, and `<CR>`
48 /// follows the link under the cursor.
49 pub active_buffer: BufferKind,
50 /// True when the command-line completion popup is open
51 /// (DESIGN.md §5.11.3). Tab / S-Tab / Enter / Esc are claimed
52 /// by the popup before falling through to Command mode.
53 pub completion_open: bool,
54 /// True when the cmdline cursor sits on an `ArgKind::Chord`
55 /// arg slot. In this mode every key event renders to a chord
56 /// token and gets appended — **no key is reserved**, so `<CR>`,
57 /// `<Esc>` and `<BS>` describe themselves rather than acting as
58 /// submit / cancel / delete (DK.2). Multi-stroke sequences
59 /// (`gg`, `<C-w>j`) work by pressing each chord in turn; the
60 /// keymap trie decides when the sequence is finished, so there
61 /// is no terminator to press.
62 pub chord_capture: bool,
63 /// True when a picker (`Picker` overlay) is open. Picker
64 /// claims every key before the modal handlers see it: char
65 /// keys append to the query, `<Up>` / `<C-p>` / `<Down>` /
66 /// `<C-n>` move selection, `<CR>` accepts, `<Esc>` dismisses.
67 pub picker_open: bool,
68 /// True when the **Insert-mode completion popup** is open
69 /// (Phase 4.2.g.1). Activates the completion-popup minor
70 /// mode: `<C-n>` / `<C-p>` navigate, `<C-y>` / `<Tab>` /
71 /// `<CR>` accept, `<C-e>` cancels, `<Esc>` cancels and
72 /// exits Insert. Bindings inside this layer override the
73 /// usual Insert-mode + Normal-mode meanings (notably
74 /// `<C-d>` becomes "toggle docs popup" instead of
75 /// "shift-left-indent" / "half-page-down"). Closing the
76 /// popup deactivates the layer; original bindings restore.
77 pub insert_completion_open: bool,
78 /// True when an `ActiveSnippet` is in flight (Phase
79 /// 4.2.g.4). Activates the active-snippet minor mode:
80 /// `<Tab>` jumps to the next placeholder, `<S-Tab>` to
81 /// the previous, `<Esc>` exits the snippet (and Insert).
82 /// The layer claims `<Tab>` ahead of Insert-mode's
83 /// "insert literal tab" so placeholder navigation is
84 /// stable; closing the snippet deactivates the layer.
85 pub snippet_active: bool,
86 /// Terminal-mode T2.a (2026-05-25): true when
87 /// `terminal-insert-mode` is active on the active Terminal
88 /// buffer. `translate` short-circuits early in this state:
89 /// keystrokes encode to ANSI bytes (via
90 /// `keymap_terminal::key_to_ansi`) and emit
91 /// `Action::TerminalInput` instead of going through the
92 /// modal-state dispatchers.
93 pub terminal_insert_active: bool,
94 /// Terminal-mode T2.b.0 (2026-05-25): resolved value of the
95 /// `terminal.esc-exits` typed option for the active pane's
96 /// buffer. When `true` and `terminal_insert_active` is also
97 /// true, `<Esc>` emits `Action::ExitTerminalInsert` instead
98 /// of encoding to `\x1b` for the PTY. Users running nested
99 /// vim / htop inside the terminal flip the option off and
100 /// use `<C-\><C-n>` (T2.c) to exit.
101 pub terminal_esc_exits: bool,
102 /// Terminal-mode T2.c (2026-05-25): DECCKM mode bit
103 /// (application-cursor-keys) from the active terminal's
104 /// alacritty `Term`. Threaded into
105 /// `keymap_terminal::key_to_ansi_with_mode` so arrow keys
106 /// encode as SS3 (`ESC O A`) vs CSI (`ESC [ A`).
107 pub terminal_app_cursor_keys: bool,
108 /// Terminal-mode T2.c (2026-05-25): `<C-\>` exit chord is
109 /// armed and waiting for the confirm key. When set, the
110 /// translate layer routes the next keystroke into the
111 /// chord-resolution branch instead of the normal encoder.
112 pub terminal_insert_exit_pending: bool,
113 /// 2026-05-25: true when the active Terminal buffer holds
114 /// an in-flight Visual selection (`t.visual.is_some()`).
115 /// Terminal-Visual lives on the buffer (modal stays Normal)
116 /// so the `<Esc>` / `v` exit chords don't come through
117 /// `keymap_visual`'s `ExitVisual` bindings; this layer
118 /// short-circuits them when the flag is set.
119 pub terminal_visual_active: bool,
120 /// Layered keymap registry (DESIGN.md §5.2.3, audit
121 /// slice 8.c -- 8.d). `translate` consults this instead
122 /// of the per-mode hand-rolled `match` tables one slice
123 /// at a time as the migration progresses; Replace mode
124 /// is the first migrated dispatcher. Borrowed for the
125 /// duration of the translate call -- a single
126 /// `ArcSwap::load` happens inside the dispatcher.
127 pub keymap: &'a KeymapHandle,
128 /// Slice 8.i.4.a: in-flight partial-chord stack from
129 /// `App::partial_chord`. When non-empty, `translate_normal`
130 /// runs the keymap lookup with this as the prefix instead
131 /// of the legacy `match pending` body; the simple
132 /// prefix-only Pending variants (AfterG / AfterZ /
133 /// AfterCtrlW / AfterSetMark / AfterJumpMarkLine /
134 /// AfterJumpMarkExact / AfterRegister / AfterMacroStart /
135 /// AfterMacroPlay) all funnel through here now.
136 pub partial_chord: &'a [crate::chord::KeyChord],
137 /// D.5.b (2026-05-30): active buffer's minor modes, in
138 /// activation order. Threaded into `lookup_with_context` so
139 /// chord bindings registered under `MinorMode(ModeId)`
140 /// layers only fire on buffers where the corresponding
141 /// mode is in `ActiveModes.minors()`. Empty slice means
142 /// "no minor modes active" — minor-mode bindings are
143 /// invisible to dispatch under that constraint
144 /// (K.1.c fast path). Normal-mode dispatch uses this
145 /// today (`lookup_normal` / `lookup_normal_with_prefix`);
146 /// Visual / Insert / Replace stay on the legacy
147 /// all-registered-modes-active `lookup` until D.5
148 /// extends their grammar.
149 pub active_minor_modes: &'a [lattice_mode::ModeId],
150}
151
152pub fn translate(ctx: TranslateContext<'_>, chord: KeyChord) -> Action {
153 // Slice 5.4 (slice 5): renderer-neutral dispatch entry point.
154 // Takes a canonical `KeyChord` directly; the crossterm-coupled
155 // `KeyEvent → KeyChord` adapter lives in
156 // `lattice_ui_tui::input::translate`, which is the thin shim
157 // every renderer's runtime calls. The future
158 // `lattice_ui_gpui::input::translate` ships its own analogous
159 // shim and feeds chords into this same function.
160
161 // Picker overlay precedes everything (DESIGN.md §5.9.7): the
162 // user is in a focused "type to filter, Enter to act" state;
163 // modal handlers never see these keys until the picker is
164 // dismissed. `<C-c>` still drops the picker rather than the
165 // app so an open picker isn't a foot-gun.
166 if ctx.picker_open {
167 return translate_picker(chord);
168 }
169
170 // Terminal-mode T2.a (2026-05-25): when Terminal-Insert is
171 // active, EVERY keystroke encodes to ANSI and goes to the
172 // PTY — including `<C-c>` (which becomes shell SIGINT, not
173 // "quit the editor"). The one escape is `<C-\><C-n>` which
174 // exits Terminal-Insert and falls back to Normal-in-terminal;
175 // T2.a handles the `<C-\>` half here and lets `<C-n>` arrive
176 // as a separate event with the mode now off. T2.c will add
177 // a stateful two-key chord so the escape sequence doesn't
178 // leak `\x1c` into the PTY between the two keys.
179 if ctx.terminal_insert_active && matches!(ctx.active_buffer, BufferKind::Terminal) {
180 // Terminal-mode T2.c (2026-05-25): two-key exit chord.
181 // If we're already in "armed" state (previous key was
182 // `<C-\>`) the next keystroke resolves the chord:
183 // - `<C-n>` confirms the exit
184 // - anything else: send `\x1c` (the lost `<C-\>`)
185 // plus the chord's normal PTY bytes
186 // Either way the arming clears (handled by the
187 // `Action::ExitTerminalInsert` / `Action::TerminalInput`
188 // dispatch arms).
189 if ctx.terminal_insert_exit_pending {
190 if chord == KeyChord::ctrl('n') {
191 return Action::ExitTerminalInsert;
192 }
193 let mut bytes = vec![0x1c];
194 if let Some(b) =
195 crate::keymap_terminal::key_to_ansi_with_mode(&chord, ctx.terminal_app_cursor_keys)
196 {
197 bytes.extend(b);
198 }
199 return Action::TerminalInput(bytes);
200 }
201 if chord == KeyChord::ctrl('\\') {
202 // Arm the chord; second key resolves above.
203 return Action::TerminalArmExitChord;
204 }
205 // Terminal-mode T2.b.0 (2026-05-25): `<Esc>` exit gated
206 // by `terminal.esc-exits` (default true). When off, Esc
207 // falls through to the encoder and reaches the PTY as
208 // `\x1b` so nested programs (vim, htop, less) keep their
209 // own Esc semantics.
210 if ctx.terminal_esc_exits
211 && matches!(chord.key, KeyKind::Special(SpecialKey::Esc))
212 && chord.mods.is_empty()
213 {
214 return Action::ExitTerminalInsert;
215 }
216 // T2.c (2026-05-25): encode with DECCKM awareness so
217 // arrow keys flip to SS3 when the program has flipped
218 // application-cursor-keys mode.
219 if let Some(bytes) =
220 crate::keymap_terminal::key_to_ansi_with_mode(&chord, ctx.terminal_app_cursor_keys)
221 {
222 return Action::TerminalInput(bytes);
223 }
224 return Action::None;
225 }
226
227 // Slice 8.f: the completion-popup and active-snippet minor
228 // modes used to short-circuit `translate` from here. They
229 // now register as `KeymapLayer::MinorMode` layers via
230 // `App::sync_keymap_overlays`, pushed on overlay activation
231 // and popped on deactivation. The Insert-mode dispatcher
232 // (`dispatch_insert`) consults the merged trie, which
233 // already accounts for the layer stack -- so popup / snippet
234 // overrides resolve at lookup time without a per-`translate`
235 // pre-pass. Push order (snippet first, popup second) makes
236 // popup win on overlapping chords (preserving the legacy
237 // "popup precedes snippet" gating).
238
239 // Chord-capture overlay precedes normal dispatch, because
240 // looking up a chord's binding via `:describe-key` is a
241 // legitimate user need.
242 //
243 // DK.2: the overlay no longer reserves Esc (or CR, or BS) — the trie ends
244 // the sequence instead, so every key is describable. The user is still
245 // never stuck: a trie is finite, so any sequence resolves to Bound or
246 // Unbound within a keystroke or two and submits itself.
247 if matches!(ctx.modal, ModalState::Command) && ctx.chord_capture {
248 return translate_command_chord_capture(chord);
249 }
250
251 // Buffer-local bindings for read-only buffers (Help / FileTree;
252 // DESIGN.md §5.9 buffer-local keymap layer): a small fixed set
253 // of bindings unique to those kinds (dismiss + follow-link)
254 // intercept first, then everything else flows through
255 // `translate_normal` so the chord grammar (`gg`, `<C-d>`,
256 // `<C-o>` / `<C-i>`, motions, viewport jumps) works identically
257 // to the document path. The cursor that those motions move is
258 // decided at apply time by `App::active_buffer`, not here.
259 // LM.4: FileTree left this shared gate — `file-tree-mode` owns `<CR>` /
260 // `-` / `<C-s>` / `<C-v>` / `<C-t>` through its `MajorMode` keymap +
261 // `action_handlers` now. Help is following the same path: `<Esc>` is now
262 // owned by `help-mode`'s keymap (`action:help-dismiss` → `Effect::DismissPopup`,
263 // which the host applies as close-split-pane / dismiss-popup / restore), so
264 // it is NOT intercepted here. `<CR>` follow-link and `-` oil-up stay on this
265 // gate for now (`q` does NOT dismiss — it falls through to macro-record
266 // start). The other Help close paths are `:bd` and the State-A auto-dismiss
267 // in App::apply.
268 if matches!(ctx.active_buffer, BufferKind::Help)
269 && matches!(ctx.modal, ModalState::Normal)
270 && ctx.partial_chord.is_empty()
271 {
272 match chord.key {
273 KeyKind::Special(SpecialKey::Enter) => return Action::FollowLink,
274 KeyKind::Char('-') => return Action::OilNavigateUp,
275 _ => {}
276 }
277 }
278
279 // Dashboard is a read-only, link-bearing help-style buffer: `<CR>`
280 // follows the link under the cursor (dashboard.md §9.2), routed to the
281 // same `do_help_follow_link` dispatcher via the `Action::FollowLink` arm.
282 // Deliberately NOT folded into the Help / FileTree gate above: that gate
283 // also maps `-` → `OilNavigateUp`, which on a non-oil buffer OPENS the
284 // oil file browser — surprising on the launch page. Only link-follow is
285 // special here; every other key keeps its plain Normal-mode meaning.
286 if matches!(ctx.active_buffer, BufferKind::Dashboard)
287 && matches!(ctx.modal, ModalState::Normal)
288 && ctx.partial_chord.is_empty()
289 && matches!(chord.key, KeyKind::Special(SpecialKey::Enter))
290 {
291 return Action::FollowLink;
292 }
293
294 // LM.3: the `BufferKind::Oil` gate (Enter → FollowLink, `-` →
295 // OilNavigateUp) is gone — `oil-mode` owns `<CR>` / `-` / `<C-s>` /
296 // `<C-v>` / `<C-t>` through its `MajorMode` keymap + `action_handlers`,
297 // resolved by the generic chord dispatcher scoped to oil buffers.
298 // (The shared `Help | FileTree` gate above still routes file-tree until
299 // LM.4 migrates `file-tree-mode`.)
300
301 // Terminal-mode T2.a / T2.b (2026-05-25) — Normal-in-terminal
302 // buffer-local bindings. Vim's full insert-entry set
303 // (`i`/`a`/`I`/`A`) all funnel through one action because
304 // the terminal grid has no "before/after column" or "BOL/EOL"
305 // semantics — the shell owns the cursor, and the moment we
306 // hand control back, every keystroke flows to the PTY.
307 // Documenting the four chords keeps muscle memory honest
308 // (users coming from vim's `:terminal` instinctively type
309 // `a` to mean "insert"), but the resulting action is the same.
310 // T2.c adds `<C-w>` window-prefix routing. Other keys fall
311 // through to the standard normal-mode grammar (the
312 // scrollback-motion path lands in T3).
313 // Terminal-mode T3 (2026-05-25, MotionAdapter refactor): the
314 // only kind-specific interception in `translate` for Terminal
315 // buffers is the Insert-entry chord. Every other motion /
316 // action keystroke (j / k / G / gg / <C-d> / <C-u> / <C-f> /
317 // <C-b> / <C-e> / <C-y>) flows through the standard
318 // Normal-mode keymap → `Editor::run_invocation` →
319 // `run_terminal_invocation`. The substrate-aware
320 // translation from "the line_down motion" to "scroll the
321 // alacritty grid" lives on the runner, not on this layer.
322 // (`i` / `a` / `I` / `A` stay here because they switch
323 // *minor mode*, which is a renderer-layer concern: the
324 // translate layer needs to start encoding subsequent
325 // keystrokes to ANSI immediately.)
326 if matches!(ctx.active_buffer, BufferKind::Terminal)
327 && !ctx.terminal_insert_active
328 && matches!(ctx.modal, ModalState::Normal)
329 && ctx.partial_chord.is_empty()
330 && !chord.mods.ctrl()
331 && !chord.mods.alt()
332 {
333 // 2026-05-25: terminal-Visual exit. Visual lives on the
334 // buffer (modal stays Normal), so `keymap_visual`'s
335 // `ExitVisual` bindings never fire. Intercept the four
336 // vim-standard exits here when terminal-Visual is in
337 // flight: `<Esc>`, `v`, `V`, `<C-v>`. The Ctrl-V case
338 // is below the !mods.ctrl() guard, so handle it after
339 // the gate.
340 if ctx.terminal_visual_active {
341 match chord.key {
342 KeyKind::Special(crate::chord::SpecialKey::Esc) => {
343 return Action::ExitVisual;
344 }
345 KeyKind::Char('v') | KeyKind::Char('V') => {
346 return Action::ExitVisual;
347 }
348 _ => {}
349 }
350 }
351 match chord.key {
352 KeyKind::Char('i') | KeyKind::Char('a') | KeyKind::Char('I') | KeyKind::Char('A') => {
353 return Action::EnterTerminalInsert;
354 }
355 _ => {}
356 }
357 }
358 // 2026-05-25: `<C-v>` toggle for blockwise terminal-Visual.
359 // Sits outside the `!ctrl()` gate above so the Ctrl modifier
360 // bit doesn't disqualify it.
361 if matches!(ctx.active_buffer, BufferKind::Terminal)
362 && !ctx.terminal_insert_active
363 && ctx.terminal_visual_active
364 && matches!(ctx.modal, ModalState::Normal)
365 && ctx.partial_chord.is_empty()
366 && chord.mods.ctrl()
367 && !chord.mods.alt()
368 && matches!(chord.key, KeyKind::Char('v'))
369 {
370 return Action::ExitVisual;
371 }
372
373 match ctx.modal {
374 // Slice 8.f: Insert mode dispatches through the layered
375 // registry. Base bindings live in
376 // `keymap_insert::register_insert_bindings`; the
377 // completion-popup and active-snippet overlays ride as
378 // `KeymapLayer::MinorMode` layers managed by
379 // `App::sync_keymap_overlays`. The drift test in
380 // `keymap_insert::tests` is the regression net.
381 ModalState::Insert => dispatch_insert(
382 ctx.keymap,
383 lattice_keymap::BindingMode::Insert,
384 &chord,
385 ctx.partial_chord,
386 ctx.active_minor_modes,
387 ),
388 ModalState::Normal => translate_normal(
389 chord,
390 ctx.builtins,
391 ctx.pending_count,
392 ctx.op_count,
393 ctx.recording_macro,
394 ctx.keymap,
395 ctx.partial_chord,
396 ctx.active_minor_modes,
397 ),
398 // MB.1: the `:` line is a buffer-backed readline surface. Keys
399 // route through the universal Insert dispatcher onto the focused
400 // `*command-line*` buffer; `command-line-mode`'s Insert-layer
401 // keymap supplies submit / cancel / history / completion. The
402 // chord-capture overlay (missing-arg `Chord` slot) is handled
403 // earlier at the top of `translate`.
404 ModalState::Command => dispatch_insert(
405 ctx.keymap,
406 lattice_keymap::BindingMode::Command,
407 &chord,
408 ctx.partial_chord,
409 ctx.active_minor_modes,
410 ),
411 // MB.5a: the `/`·`?` search line is a buffer-backed readline
412 // surface (peer of the `:` command line). Keys route through
413 // the universal Insert dispatcher onto the focused
414 // `*search-line*` buffer; `search-line-mode`'s Insert-layer
415 // keymap supplies submit / cancel.
416 ModalState::Search(_) => dispatch_insert(
417 ctx.keymap,
418 lattice_keymap::BindingMode::Search,
419 &chord,
420 ctx.partial_chord,
421 ctx.active_minor_modes,
422 ),
423 // `Effect::OpenPrompt`'s generic one-line prompt — the third
424 // buffer-backed readline surface, and dispatched exactly like
425 // its two peers above: `prompt-line-mode`'s Insert-layer keymap
426 // supplies submit (`<CR>`) and cancel (`<Esc>` / `<C-c>`), and
427 // everything else self-inserts into the focused prompt buffer.
428 //
429 // **This arm was missing.** Every key in an open prompt fell to
430 // the `_ => Action::None` catch-all below, so the prompt could
431 // not be typed into, submitted, or cancelled — the editor had to
432 // be killed to escape it. `prompt-line-mode` bound all three
433 // chords correctly and `do_prompt_line_{submit,cancel}` were
434 // both right; nothing could reach them.
435 //
436 // It survived because no test drove a key through this path:
437 // the prompt's tests called `do_prompt_line_submit` directly,
438 // which works fine on a dispatcher that never calls it. The
439 // regression net is now `app::cmdline::prompt_line` in
440 // `lattice-ui-tui`, which presses real keys.
441 ModalState::Prompt => dispatch_insert(
442 ctx.keymap,
443 lattice_keymap::BindingMode::Prompt,
444 &chord,
445 ctx.partial_chord,
446 ctx.active_minor_modes,
447 ),
448 // Slice 8.e: Visual mode dispatches through the layered
449 // registry. The hand-rolled match table moved to
450 // `keymap_visual::register_visual_bindings`; the
451 // `kind`-specific block-only `I` / `A` overrides stay
452 // pre-lookup in `dispatch_visual` until the architecture's
453 // minor-mode-on-Visual layer push lands. The drift test
454 // in `keymap_visual::tests` is the regression net.
455 ModalState::Visual(kind) => dispatch_visual(
456 ctx.keymap,
457 &chord,
458 kind,
459 ctx.partial_chord,
460 ctx.active_minor_modes,
461 ),
462 // SN.3d.1: Select mode — Visual's sibling with inverted typing
463 // semantics. Genuinely new dispatch (a bare printable overtypes
464 // the selection); see `keymap_select::translate_select`.
465 // SN.3d.4: unlike Visual above, Select DOES consult active
466 // minor-mode keymaps (it takes `ctx.active_minor_modes`), so a
467 // mode that focuses a span — the snippet placeholder default —
468 // keeps its `<Tab>` / `<S-Tab>` / `<Esc>` bindings live in
469 // Select exactly as in Insert. Visual's minor-mode layer push
470 // is still outstanding (the comment above).
471 ModalState::Select(kind) => crate::keymap_select::translate_select(
472 ctx.keymap,
473 &chord,
474 kind,
475 ctx.partial_chord,
476 ctx.active_minor_modes,
477 ),
478 // Slice 8.d: Replace mode dispatches through the
479 // layered registry. `translate_replace`'s legacy match
480 // table moved to `keymap_replace::register_replace_bindings`
481 // + the `dispatch_replace` adapter; the drift test in
482 // `keymap_replace::tests` keeps both honest until 8.i
483 // retires the legacy reference.
484 ModalState::Replace => dispatch_replace(ctx.keymap, &chord),
485 // OperatorPending routes to no-op (it's a transient resolution
486 // state inside translate_normal, not a top-level reachable state).
487 _ => Action::None,
488 }
489}
490
491/// Cmdline chord-capture overlay. Reserves **nothing**: every chord
492/// stringifies through `KeyChord::Display` and becomes one chord token in the
493/// cmdline, and the keymap trie decides when the sequence is finished
494/// (`Editor::do_command_line_append_chord`).
495///
496/// ## Why nothing is reserved
497///
498/// This used to reserve `<Esc>` / `<CR>` / `<BS>` as cancel / submit /
499/// delete-token, because a chord ARGUMENT is a sequence — `gg`, `<C-w>v`,
500/// `<leader>fz` — and something has to say when it ends. An earlier design
501/// auto-submitted on the first captured chord and made every multi-key chord
502/// undescribable, which is why the explicit terminator was introduced.
503///
504/// The cost was that those three keys could not be described at all. The doc
505/// comment here claimed the missing-arg prompt path was an escape hatch; it is
506/// not, because that path opens the same command line and sets the same
507/// `chord_capture` flag, so it landed in this same branch. `:describe-key` had
508/// no way to answer "what does Enter do".
509///
510/// The trie already knows when a sequence is complete — it is the same
511/// `Partial` / `Bound` / `Unbound` question the dispatch loop asks on every
512/// keystroke. Asking it here removes the need for a terminator, so no key has
513/// to be reserved and the emacs `C-h k` behaviour falls out: press the key, get
514/// its description, including for keys that would otherwise be controls.
515///
516/// The trade this accepts: there is no mid-sequence abort. In practice the
517/// sequence ends within a keystroke or two of whatever the user pressed, and
518/// dismissing an unwanted description costs one `q`.
519fn translate_command_chord_capture(chord: KeyChord) -> Action {
520 Action::CommandLineAppendChord(chord.to_string())
521}
522
523/// Picker-overlay key router. See [`lattice_picker::Picker`] for
524/// the data shape. Reserved keys (Esc / CR / BS / arrows /
525/// Ctrl-{n,p,c}) drive the picker's intrinsic actions; printable
526/// chars append to the query; everything else is swallowed.
527fn translate_picker(chord: KeyChord) -> Action {
528 if chord.mods.ctrl() {
529 return match chord.key {
530 // C-c dismisses the picker (not the app) so the user
531 // can always abort.
532 KeyKind::Char('c') => Action::PickerDismiss,
533 KeyKind::Char('n') => Action::PickerSelectNext,
534 KeyKind::Char('p') => Action::PickerSelectPrev,
535 // C-u clears the query in one stroke (vim's cmdline
536 // shortcut, applied here for consistency).
537 KeyKind::Char('u') => Action::PickerBackspace, // approximate; per-char today
538 // Issue #32 (2026-05-22): open candidate file in
539 // split / vsplit / tab. File-targeting outcomes
540 // route through the override; non-file outcomes
541 // ignore it (same as `<CR>`).
542 KeyKind::Char('s') => Action::PickerAcceptInSplit,
543 KeyKind::Char('v') => Action::PickerAcceptInVSplit,
544 KeyKind::Char('t') => Action::PickerAcceptInTab,
545 // LR.5: send the FILTERED result set somewhere editable —
546 // the telescope idiom. Echoes on a picker whose opener
547 // declared no bulk meaning, rather than doing nothing.
548 // PD.1: remove the selected row from whatever backs the list.
549 // The SOURCE decides what that means by naming a command
550 // (`PickerSourceSpec::delete_command`); a source that names none
551 // — every one but `projects` today — leaves this doing nothing,
552 // the same silence `<C-l>` keeps in a picker with no depth.
553 //
554 // Never a filesystem delete. Removing a project from the list is
555 // forgetting a path; deleting a directory is oil's job.
556 KeyKind::Char('d') => Action::PickerDelete,
557 KeyKind::Char('q') => Action::PickerBulkAccept,
558 // YR.5b: open the yank picker over this one and append the
559 // pick to THIS picker's query. `<C-r>` rather than the
560 // plan's `M-y` so it is the same key as in Insert mode —
561 // one chord for "give me something I copied", wherever you
562 // are. It was unbound here.
563 KeyKind::Char('r') => Action::OpenYankPicker,
564 // PC.10: go INTO the selected candidate, for a live source that
565 // has a notion of depth (`dir-pick` today); a no-op elsewhere —
566 // the source's `descend` defaults to `None`.
567 //
568 // `l` is ranger / lf / nnn / vifm's "enter" — there it is plain
569 // `l`, which a picker cannot use because its query takes every
570 // printable key, so the control variant carries it.
571 KeyKind::Char('l') => Action::PickerDescend,
572 // PH.1: back OUT where the source has depth, delete the previous
573 // word everywhere else — vim's `c_CTRL-W`, which on a path IS
574 // "up one component". Moved here from `<C-h>`: `j`/`k` would read
575 // as the vertical pair (fzf binds them to select next / prev), and
576 // `h` is the help key everywhere else in the editor.
577 KeyKind::Char('w') => Action::PickerAscendOrDeleteWord,
578 // PH.1: this picker's help page. `<C-h>` is the editor's help
579 // prefix in Normal mode (`<C-h>k`, `<C-h>m`) and emacs's in the
580 // minibuffer, so it means the same thing here.
581 KeyKind::Char('h') => Action::PickerHelp,
582 _ => Action::None,
583 };
584 }
585 match chord.key {
586 // MG.29: `<Esc>` UNWINDS one submenu level before it closes
587 // anything — `TransientDismiss`, not `PickerDismiss`.
588 //
589 // The unwind logic and its dispatch arm shipped with MG.29 and
590 // were unit-tested; nothing ever emitted the action, so `<Esc>`
591 // kept closing the whole chain and only `<BS>` popped. The arm's
592 // own comment described the behaviour as if it were live, which
593 // is how it went unnoticed.
594 //
595 // Safe for a plain picker: `transient_unwind` returns false when
596 // there is no transient, and the arm then does exactly what
597 // `PickerDismiss` does. `<C-c>` stays a hard close, so there is
598 // still one key that leaves a deep chain in a single press.
599 KeyKind::Special(SpecialKey::Esc) => Action::TransientDismiss,
600 KeyKind::Special(SpecialKey::Enter) => Action::PickerAccept,
601 KeyKind::Special(SpecialKey::Backspace) => Action::PickerBackspace,
602 KeyKind::Special(SpecialKey::Up) => Action::PickerSelectPrev,
603 KeyKind::Special(SpecialKey::Down) => Action::PickerSelectNext,
604 // PP.5: `<Tab>` DRILLS IN where the source has depth, and selects the
605 // next row everywhere else.
606 //
607 // PC.10 rejected exactly this — `<Tab>` is `PickerSelectNext` in every
608 // picker, and giving one picker a `<Tab>` that means something else is
609 // the inconsistency the UX-convention rule exists to prevent. The
610 // reversal is that same rule read against the right reference: emacs's
611 // `read-directory-name` is what `dir-pick` is modelled on, and there
612 // `<Tab>` completes the path while `C-n` / `C-p` move the selection.
613 // `<Tab>` = select-next is OUR deviation, not emacs's.
614 //
615 // And in the picker this was reported from it did nothing at all: one
616 // row matched `/Users/dh`, so select-next wrapped onto the row already
617 // selected. A key that visibly does nothing is worse than one that
618 // means two things.
619 //
620 // Selection movement is untouched: `<C-n>` / `<C-p>` and the arrows
621 // are separate arms above, so nothing loses a way to move.
622 KeyKind::Special(SpecialKey::Tab) if !chord.mods.shift() => {
623 Action::PickerDescendOrSelectNext
624 }
625 KeyKind::Special(SpecialKey::Tab) if chord.mods.shift() => Action::PickerSelectPrev,
626 KeyKind::Char(c) if !chord.mods.ctrl() => Action::PickerAppend(c),
627 _ => Action::None,
628 }
629}
630
631fn translate_normal(
632 chord: KeyChord,
633 builtins: &Builtins,
634 pending_count: u32,
635 op_count: u32,
636 recording_macro: bool,
637 keymap: &KeymapHandle,
638 partial_chord: &[KeyChord],
639 active_minor_modes: &[lattice_mode::ModeId],
640) -> Action {
641 // Slice 8.g.iv: every Normal-mode action flows through
642 // `attach_count` so motion / operator counts are baked into
643 // the resolved `CommandInvocation` before the action leaves
644 // translate. App's dispatcher reads `inv.count` directly
645 // (no separate `pending_count * op_count` math at the
646 // dispatch site any more) -- only fold-aware count
647 // *expansion* stays App-side because it depends on the
648 // active fold model.
649 let action = compute_normal_action(
650 chord,
651 builtins,
652 pending_count,
653 recording_macro,
654 keymap,
655 partial_chord,
656 active_minor_modes,
657 );
658 crate::keymap_normal::attach_count(action, pending_count, op_count)
659}
660
661fn compute_normal_action(
662 chord: KeyChord,
663 builtins: &Builtins,
664 pending_count: u32,
665 recording_macro: bool,
666 keymap: &KeymapHandle,
667 partial_chord: &[KeyChord],
668 active_minor_modes: &[lattice_mode::ModeId],
669) -> Action {
670 let _ = builtins;
671 // Slice 8.i.4: every multi-key Normal-mode chord flows through
672 // `App::partial_chord`. When non-empty, peek the full path through
673 // the trie ONCE: `Bound` resolves to the bound action, a deeper
674 // `Partial` returns `Action::AbsorbPartialChord`, and `Unbound`
675 // returns `Action::None` (which `App::apply` turns into a
676 // partial_chord clear). We compute it up front because the digit
677 // hoist below needs to know whether this key completes a chord.
678 let prefix_action = (!partial_chord.is_empty()).then(|| {
679 crate::keymap_normal::lookup_normal_with_prefix(
680 keymap,
681 partial_chord,
682 &chord,
683 active_minor_modes,
684 )
685 });
686
687 // Numeric prefix: `1`-`9` always start (or extend) a count; `0`
688 // extends an in-progress count but otherwise is line_start. This is
689 // vim's standard count parsing.
690 //
691 // Slice 8.i.4.f: digit handling must run BEFORE falling into the
692 // partial_chord continuation. Without it, typing `2` after `d`
693 // (partial_chord=['d']) routes to `lookup_normal_with_prefix(['d'],
694 // '2')` -- unbound -- aborting the operator; vim flows like `d2w`,
695 // `2d3w`, `5gg` would never see the digit.
696 //
697 // S2 (emacs-keys) refinement: the one exception the original 8.i.4.f
698 // comment anticipated -- when the pending prefix actually BINDS this
699 // key as a chord (`Bound`/deeper `Partial`, i.e. `prefix_action` is
700 // not `None`), the digit is the chord's literal second key, not a
701 // count, so the trie wins. This is mode-agnostic: any layer binding a
702 // `[prefix, digit]` chord (emacs-keys `<C-x>2` / `<C-x>3`) benefits.
703 // PBH.3 fix: a count digit is typed WITHOUT modifiers. Before this
704 // guard the check was on `chord.key` alone, so `Char('6') + CTRL`
705 // matched `to_digit` and `<C-6>` was consumed as a count before the
706 // trie was ever consulted — making **every `<C-digit>` chord
707 // unreachable in Normal mode**, not just this feature's. (Emacs-keys
708 // `<C-x>2` / `<C-x>3` escaped only via the `prefix_resolves_chord`
709 // exception above, which is why the hole went unnoticed.)
710 //
711 // `is_empty()` rather than "no ctrl": shift-digit yields a symbol
712 // (`^`, `&`), not `Char('6')`, so no legitimate count arrives with
713 // any modifier set.
714 let prefix_resolves_chord = matches!(prefix_action, Some(ref a) if !matches!(a, Action::None));
715 if !prefix_resolves_chord
716 && chord.mods.is_empty()
717 && let KeyKind::Char(c) = chord.key
718 && let Some(digit) = c.to_digit(10)
719 && (digit > 0 || pending_count > 0)
720 {
721 return Action::PushDigit(digit as u8);
722 }
723
724 // CG.1 note: `<C-g>` mid-chord (`d` then `<C-g>`) resolves here as an
725 // unbound continuation, so it aborts the pending operator and stops —
726 // it does NOT also reach `Action::Cancel`. That matches vim, where an
727 // invalid continuation cancels the prefix, and it leaves the user in
728 // Normal where a second `<C-g>` does cancel any in-flight async op.
729 // Making one press do both needs translate to know WHICH `CommandId`
730 // is cancel (the trie resolves to `Action::Invoke(inv)`, not
731 // `Action::Cancel` — the variant only materialises after the grammar
732 // runs the `ActionSpec`), which means threading it through every
733 // `TranslateContext` construction site. Not worth it for a two-press
734 // papercut; revisit if CG.2/CG.3 show it biting in practice.
735 if let Some(action) = prefix_action {
736 return action;
737 }
738
739 // `q` while a macro is recording stops the recording. The
740 // trie's `[q]` binding arms `Pending::AfterMacroStart`, but
741 // that's the wrong action when the user is mid-recording --
742 // the App-side `recording_macro` state determines which
743 // path to take, and the trie is stateless. Short-circuit
744 // here so `lookup_normal` doesn't see the `q`.
745 if recording_macro && matches!(chord.key, KeyKind::Char('q')) {
746 return Action::StopMacroRecord;
747 }
748
749 // Slice 8.g.vi closes out: every Normal-mode chord -- bare,
750 // SHIFT-cased, CTRL-bearing, multi-key prefix, wildcard --
751 // now lives in the layered registry under
752 // `BindingMode::Normal`. The dispatcher reduces to: pending
753 // resolution -> digit prefix -> recording-`q` short-circuit
754 // -> trie lookup. `lookup_normal` returns `Some(action)` for
755 // any matched chord; on `None` we fall through to
756 // `Action::None`.
757 crate::keymap_normal::lookup_normal(keymap, &chord, active_minor_modes).unwrap_or(Action::None)
758}