Skip to main content

lattice_host/
keymap_replace.rs

1//! Replace-mode binding registration + drift-test helpers.
2//!
3//! Audit slice 8.d. The smallest mode in `input.rs`'s
4//! hand-rolled `match` table -- only four binding shapes:
5//!
6//! | Chord                | Action                            |
7//! |----------------------|-----------------------------------|
8//! | `<Esc>`              | `EnterMode(Normal)`               |
9//! | `<BS>`               | `ReplaceUndoLast`                 |
10//! | `<CR>`               | `Insert("\n")`                    |
11//! | any bare printable   | `OverwriteChar(c)` (wildcard)     |
12//!
13//! Plus the legacy guard: any modifier-bearing key (`<C-x>`,
14//! `<M-x>`, ...) returns `Action::None`. The trie's
15//! "wildcard only matches bare printable chars" rule
16//! (audit slice 8.b) preserves this for free.
17//!
18//! ## What this slice ships
19//!
20//! - [`register_replace_bindings`] -- the four entries the
21//!   App's startup wires into the [`KeymapHandle`] under
22//!   `KeymapLayer::Builtin` + `BindingMode::Replace`.
23//! - [`dispatch_replace`] -- the registry-driven dispatcher.
24//!   `KeyEvent → KeyChord → handle.lookup → Action`.
25//!   `input::translate` calls this for `ModalState::Replace`;
26//!   the App constructs a populated [`KeymapHandle`] in
27//!   [`crate::app::App::new`] and the runtime threads it
28//!   through [`crate::input::TranslateContext`] every frame.
29//! - **Drift test** in this module's test block -- exhaustive
30//!   over the keys Replace mode cares about, asserting that
31//!   `dispatch_replace(handle, ev) ==`
32//!   the legacy `translate_replace`'s reference body for every
33//!   input. The reference body is kept private to the test
34//!   module (a literal copy of the pre-migration match table)
35//!   so a future refactor can't drift the dispatcher away from
36//!   the documented Replace-mode semantics. Slice 8.i retires
37//!   the reference body once every binding routes through a
38//!   real `CommandInvocation`.
39//!
40//! ## Migration template
41//!
42//! Subsequent slices (8.e Visual, 8.f Insert, 8.g Normal)
43//! follow the same shape: `register_<mode>_bindings`,
44//! `dispatch_<mode>`, and a per-mode drift test against the
45//! legacy `translate_<mode>` function. Keep the legacy
46//! function private to its test module for the duration of
47//! the migration; drop it in slice 8.i when the merged
48//! catalog covers the whole keystroke surface.
49
50use lattice_grammar::{CommandInvocation, SourceLocation};
51
52use crate::action::Action;
53use crate::actions::ActionIds;
54use crate::chord::{KeyChord, SpecialKey};
55use crate::keymap::BindingMode;
56use crate::keymap_registry::KeymapHandle;
57use crate::keymap_trie::{ChordPattern, KeymapLayer, LookupResult};
58
59/// Register the four Replace-mode bindings into the supplied
60/// handle's `Builtin` layer. Called by the App at startup
61/// (slice 8.h wires the call site once the App-level keymap
62/// boot finalises).
63///
64/// Sources are captured at this file + line so
65/// `:describe-key` shows e.g.
66/// `<Esc> -> EnterMode(Normal)  (builtin, keymap_replace.rs:71)`.
67pub fn register_replace_bindings(handle: &KeymapHandle, actions: &ActionIds) {
68    let layer = KeymapLayer::Builtin;
69    let mode = BindingMode::Replace;
70
71    // <Esc> -> exit Replace mode.
72    handle.bind(
73        layer,
74        mode,
75        &[ChordPattern::Literal(KeyChord::special(SpecialKey::Esc))],
76        CommandInvocation::of(actions.enter_mode_normal),
77        replace_source(esc_line()),
78    );
79
80    // <BS> -> undo the last overwritten char.
81    handle.bind(
82        layer,
83        mode,
84        &[ChordPattern::Literal(KeyChord::special(
85            SpecialKey::Backspace,
86        ))],
87        CommandInvocation::of(actions.replace_undo_last),
88        replace_source(bs_line()),
89    );
90
91    // <CR> -> insert a newline (vim breaks the line in
92    // Replace mode, doesn't overwrite the existing one).
93    handle.bind(
94        layer,
95        mode,
96        &[ChordPattern::Literal(KeyChord::special(SpecialKey::Enter))],
97        CommandInvocation::of(actions.insert_newline),
98        replace_source(cr_line()),
99    );
100
101    // Any bare printable char -> overwrite. The dispatcher's
102    // captured-char machinery substitutes
103    // `Args::Char(c)` into the dispatched
104    // `CommandInvocation`'s args; the registered `ActionSpec`
105    // returns `Effect::AppAction(AppEffect::OverwriteChar(c))`
106    // (no validation -- any captured char is valid for
107    // overwrite).
108    handle.bind(
109        layer,
110        mode,
111        &[ChordPattern::CharLiteral],
112        CommandInvocation::of(actions.overwrite_char),
113        replace_source(wildcard_line()),
114    );
115}
116
117// Per-binding `SourceLocation` helpers. Tags the row's own
118// file:line so `:describe-key` provenance is real.
119fn replace_source(line: u32) -> SourceLocation {
120    SourceLocation::builtin_file(file!(), line)
121}
122const fn esc_line() -> u32 {
123    line!()
124}
125const fn bs_line() -> u32 {
126    line!()
127}
128const fn cr_line() -> u32 {
129    line!()
130}
131const fn wildcard_line() -> u32 {
132    line!()
133}
134
135// Convenience extension on [`KeymapHandle`] used by the
136// per-mode registration helpers in this slice family.
137// Slice 8.i.4.e: the `KeymapHandleLegacyExt` trait + `bind_legacy`
138// method retired. Every per-mode keymap module now binds typed
139// `CommandInvocation`s through `KeymapHandle::bind`; the
140// `BoundCommand::legacy_action` field is gone, and so is the
141// adapter that staged an `Action` payload through it.
142
143/// Dispatch a key event through the keymap registry.
144///
145/// Matches today's `translate_replace` semantics:
146///
147/// 1. `<C-…>` -> `Action::None`. (The legacy match table
148///    short-circuited `CONTROL` and only `CONTROL`; `<M-Esc>`
149///    still exits to Normal because the legacy `match` ran on
150///    `event.code` alone after the CONTROL guard.)
151/// 2. Strip the remaining modifiers (`ALT` / `SHIFT` / `SUPER`)
152///    before the lookup so `<M-Esc>` matches the bare-`<Esc>`
153///    literal and `<M-x>` falls through the bare-char wildcard.
154///    For bare-char chords this is a no-op: `KeyChord::from_event`
155///    already strips redundant SHIFT (case encodes it). For
156///    specials like `<Esc>` it is the legacy "match by KeyCode
157///    alone" semantics expressed in trie terms.
158/// 3. `Bound` -> the bound action, with the wildcard
159///    `Action::OverwriteChar('\0')` placeholder substituted by
160///    the captured char.
161/// 4. `Unbound` / `Partial` -> `Action::None`. Replace mode has
162///    no multi-key chords today; `Partial` would only arise if
163///    a user-config / plugin pushes a layer with multi-key
164///    bindings.
165pub fn dispatch_replace(handle: &KeymapHandle, chord: &KeyChord) -> Action {
166    if chord.mods.ctrl() {
167        return Action::None;
168    }
169    // Strip SHIFT/ALT/SUPER -- Replace mode's catalog binds bare
170    // chords only (letters already encode shift in their case;
171    // alt/super never matter here). The CTRL guard above keeps
172    // `<C-x>` etc. out before we strip.
173    let chord = KeyChord {
174        key: chord.key,
175        mods: chord
176            .mods
177            .without(crate::chord::KeyMods::SHIFT)
178            .without(crate::chord::KeyMods::ALT)
179            .without(crate::chord::KeyMods::SUPER),
180    };
181    match handle.lookup(BindingMode::Replace, &[chord]) {
182        LookupResult::Bound { command, captured } => {
183            // Surface an `Action::Invoke(...)` carrying the bound
184            // `CommandInvocation`; `App::run_invocation` routes it
185            // through the dispatcher's `CommandKind::Action`
186            // branch, which produces the matching
187            // `Effect::AppAction(...)`. If the wildcard captured
188            // a char (slice 8.i.3), fold it into the dispatched
189            // invocation's `Args::Char(c)` so the bound
190            // `ActionSpec`'s apply closure can see it.
191            let mut inv = command.command.clone();
192            if let Some(&c) = captured.first() {
193                inv = inv.with_args(lattice_grammar::args::Args::Char(c));
194            }
195            Action::Invoke(inv)
196        }
197        LookupResult::Partial | LookupResult::Unbound => Action::None,
198    }
199}