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}