Skip to main content

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}