lattice_host/keymap_select.rs
1//! Select-mode dispatch (SN.3d.1).
2//!
3//! Select mode (`ModalState::Select(VisualKind)`) is Visual's sibling:
4//! the same selection *geometry*, inverted *typing* semantics. A bare
5//! printable key **replaces the whole selection with that char and
6//! drops into Insert** ([`Action::SelectOvertype`]); motions that can't be
7//! typed extend the selection exactly as in Visual. See
8//! `docs/dev/architecture/select-mode.md`.
9//!
10//! ## Why this is genuinely new dispatch, not "`dispatch_visual` + a flag"
11//!
12//! [`crate::keymap_visual::dispatch_visual`] has **no** printable
13//! fallthrough — an unbound printable in Visual is a no-op. The defining
14//! Select behaviour is exactly that fallthrough: an unbound printable
15//! overtypes. The reference for the fallthrough is
16//! [`crate::keymap_insert`]'s `literal_text_fallback` (CTRL → `None`,
17//! `Char(c)` → an edit), mapped here to the replace-and-insert edit
18//! (select-mode.md §3) rather than a plain insert.
19//!
20//! ## Dispatch order
21//!
22//! 1. **Mode-control chords** (fire regardless of the binding table):
23//! `<Esc>` → [`Action::ExitSelect`]; `<C-g>` →
24//! [`Action::ToggleVisualSelect`] (toggle back to Visual, selection
25//! preserved); `<C-o>` → one-shot Normal — *recognised but post-MVP*
26//! per select-mode.md §3, swallowed (`Action::None`) so a stray
27//! `<C-o>` never overtypes a literal char.
28//! 2. **Mid-sequence** (the prefix of a multi-key Select binding a mode
29//! contributed, already absorbed into `partial_chord`) → resolve
30//! `[partial..., chord]` against the `BindingMode::Select` table — the
31//! same partial-chord machinery Normal/Visual use.
32//! 3. **Fresh chord** → `BindingMode::Select` lookup. `Bound` →
33//! its action (motion extends / exit); `Partial` → absorb;
34//! `Unbound` → the overtype fallthrough.
35//!
36//! A bound key wins over the fallthrough, so what the Select table binds
37//! decides what can be typed. The table holds only keys that can't be:
38//! the keymap's motion mirror (VM.4) admits a motion only when its first
39//! chord wouldn't overtype, using [`lattice_keymap::overtypes_in_select`],
40//! the same predicate the fallthrough calls. Nothing else binds a bare
41//! printable here (VM.5): vim's Select has no swap-ends `o` and no text
42//! objects either, because those keys are typed text. `<C-g>` flips to
43//! Visual for both.
44
45use lattice_grammar::VisualKind;
46
47use lattice_mode::mode::ModeId;
48
49use crate::action::Action;
50use crate::chord::{KeyChord, KeyKind, KeyMods, SpecialKey};
51use crate::keymap::BindingMode;
52use crate::keymap_registry::KeymapHandle;
53use crate::keymap_trie::{KeymapLayer, LookupResult};
54
55/// Dispatch a Select-mode key event. See the module docs for the
56/// ordering contract. `partial_chord` is the host's running multi-key
57/// prefix (empty on a fresh chord; holds an absorbed prefix mid-sequence),
58/// identical to the Visual path.
59pub fn translate_select(
60 handle: &KeymapHandle,
61 chord: &KeyChord,
62 _kind: VisualKind,
63 partial_chord: &[KeyChord],
64 active_minor_modes: &[ModeId],
65) -> Action {
66 // 0. SN.3d.4: active minor-mode bindings own the chord first —
67 // the same `KeymapLayer::MinorMode` consultation Insert mode
68 // does (`dispatch_insert`), now wired for Select. A snippet
69 // placeholder focused in Select keeps `<Tab>` / `<S-Tab>`
70 // (navigate, keeping the default) and `<Esc>` (leave the
71 // snippet — a `fall_through` binding that then runs the native
72 // `<Esc>` = `ExitSelect`) live. Without this, those bindings
73 // were dead the moment a default-bearing placeholder selected,
74 // because Select dispatch never consulted minor layers and its
75 // `<Esc>` was hardcoded below. We intercept ONLY a winner that
76 // lives on a minor layer; a base-table `Bound` is a motion /
77 // text-object that `native_select_action` resolves.
78 if let Some(action) = minor_select_action(handle, chord, partial_chord, active_minor_modes) {
79 return action;
80 }
81 native_select_action(handle, chord, partial_chord)
82}
83
84/// SN.3d.4: the native (minor-free) Select dispatch — the original
85/// `translate_select` body. Resolves the hardcoded mode-control chords,
86/// the base `BindingMode::Select` motion / text-object table, and the
87/// overtype fallthrough. Used both as the normal path (when
88/// no active minor binding claims the chord) AND as the `fall_through`
89/// continuation for a minor `<Esc>` (mode action THEN `ExitSelect`).
90/// Being minor-free, it cannot re-enter `minor_select_action`, so the
91/// fall-through never loops or fires the mode action twice.
92fn native_select_action(
93 handle: &KeymapHandle,
94 chord: &KeyChord,
95 partial_chord: &[KeyChord],
96) -> Action {
97 // 1. Mode-control chords. `<Esc>` exits to Normal even mid-
98 // text-object (abandons any absorbed prefix — there are no
99 // Select multi-key chords yet, so this is a no-op in practice).
100 if matches!(chord.key, KeyKind::Special(SpecialKey::Esc)) {
101 return Action::ExitSelect;
102 }
103 if chord.mods.ctrl() {
104 match chord.key {
105 // `<C-g>` is reserved in both Visual and Select for the
106 // toggle (select-mode.md §4). One handler flips whichever
107 // is active, preserving the selection geometry.
108 KeyKind::Char('g') => return Action::ToggleVisualSelect,
109 // `<C-o>` one-shot Normal — vim parity, post-MVP
110 // (select-mode.md §3). Swallow so it never overtypes.
111 KeyKind::Char('o') => return Action::None,
112 // CG.1 (2026-08-07): the `_ => return Action::None` catch-all
113 // that used to close this match is GONE, mirroring the same
114 // removal in `dispatch_visual` — see the comment there for
115 // why (it made every CTRL binding in this mode, including
116 // any a plugin registers over WIT, structurally unreachable).
117 //
118 // The two arms above stay hardcoded because both are mode
119 // *control*, not command lookup. Everything else falls
120 // through to the trie. A bare CTRL chord with no binding
121 // still ends at `Action::None` — via the lookup below, which
122 // is the difference that matters.
123 _ => {}
124 }
125 }
126
127 // 2. Mid-sequence resolution against the Select table.
128 if !partial_chord.is_empty() {
129 let chord = normalize_for_select_lookup(*chord);
130 let mut path: Vec<KeyChord> = partial_chord.to_vec();
131 path.push(chord);
132 return match handle.lookup(BindingMode::Select, &path) {
133 LookupResult::Bound { command, captured } => {
134 crate::keymap_normal::action_from_bound_with_capture(&command, &captured)
135 }
136 LookupResult::Partial => Action::AbsorbPartialChord(chord),
137 LookupResult::Unbound => Action::None,
138 };
139 }
140
141 // 3. Fresh chord. A bound motion / exit / text-object prefix wins;
142 // an UNBOUND key that overtypes falls through to overtype.
143 let looked_up = normalize_for_select_lookup(*chord);
144 match handle.lookup(BindingMode::Select, &[looked_up]) {
145 LookupResult::Bound { command, captured } => {
146 crate::keymap_normal::action_from_bound_with_capture(&command, &captured)
147 }
148 LookupResult::Partial => Action::AbsorbPartialChord(looked_up),
149 LookupResult::Unbound => printable_overtype_fallback(chord),
150 }
151}
152
153/// SN.3d.4: resolve an active minor-mode binding for the chord in
154/// Select mode, or `None` to defer to `native_select_action`.
155///
156/// Mirrors `dispatch_insert`'s minor-layer consultation: look the chord
157/// up WITH the active minor set, but act only when the winner lives on
158/// a `KeymapLayer::MinorMode` layer — a `Bound` on the base Select
159/// table is a motion / text-object the native path owns. A
160/// `fall_through` minor binding (the snippet `<Esc>`) runs its mode
161/// action and then chains the native Select action for the same chord.
162fn minor_select_action(
163 handle: &KeymapHandle,
164 chord: &KeyChord,
165 partial_chord: &[KeyChord],
166 active_minor_modes: &[ModeId],
167) -> Option<Action> {
168 if active_minor_modes.is_empty() {
169 return None;
170 }
171 // Minor bindings are keyed like Insert's (keep CTRL + SHIFT) so
172 // `<S-Tab>` stays distinct from `<Tab>`; the base-Select normalize
173 // strips SHIFT and would collapse the two.
174 //
175 // OS.0b: raw-then-fallback, same as `dispatch_insert` — try the
176 // chord AS PRESSED first so a mode that deliberately binds an
177 // ALT/SUPER-bearing chord in Select is reachable, falling back to
178 // the normalized form only when the raw lookup finds nothing.
179 let lookup = crate::keymap_insert::lookup_insert_chord(
180 handle,
181 BindingMode::Select,
182 partial_chord,
183 *chord,
184 active_minor_modes,
185 );
186 let LookupResult::Bound { command, captured } = lookup.result else {
187 return None;
188 };
189 // Only a minor-layer winner is mode-owned; a base-table `Bound`
190 // defers to `native_select_action`.
191 if !matches!(command.layer, KeymapLayer::MinorMode(_)) {
192 return None;
193 }
194 let action = crate::keymap_normal::action_from_bound_with_capture(&command, &captured);
195 if !command.fall_through {
196 return Some(action);
197 }
198 // `fall_through`: mode action, then the NATIVE continuation for the
199 // chord (`<Esc>` → `ExitSelect`). Native is minor-free, so no loop.
200 Some(crate::keymap_insert::chain_actions(
201 action,
202 native_select_action(handle, chord, partial_chord),
203 ))
204}
205
206/// The Select fallthrough: a key that overtypes replaces the selection.
207/// Mirrors [`crate::keymap_insert`]'s `literal_text_fallback`, but maps
208/// the key to [`Action::SelectOvertype`] (replace-and-insert) instead of a
209/// plain insert.
210///
211/// Which keys overtype is [`lattice_keymap::overtypes_in_select`], the ONE
212/// definition the keymap's motion mirror also calls (VM.4), so the Select
213/// table and Select typing can't disagree about it. Vim's rule: "Printable
214/// characters, <NL> and <CR> cause the selection to be deleted, and Vim
215/// enters Insert mode." Both `<CR>` and `<NL>` (Ctrl-J) type a newline.
216fn printable_overtype_fallback(chord: &KeyChord) -> Action {
217 // CG.1: the modifier check lives in the predicate, not in the caller.
218 // `<C-w>` is a chord, not typing, and must never replace the user's
219 // selection with a `w`. That used to be a blanket `return Action::None`
220 // for every CTRL chord at the top of `native_select_action`, which also
221 // made the Select trie unreachable for CTRL bindings (see the comment
222 // there). Checking here keeps the guarantee and lets a real binding win
223 // first — same shape as Replace mode, whose wildcard only matches bare
224 // printable chars.
225 if !lattice_keymap::overtypes_in_select(chord) {
226 return Action::None;
227 }
228 match chord.key {
229 KeyKind::Special(SpecialKey::Enter) => Action::SelectOvertype('\n'),
230 // `<NL>`: the predicate admits `j` with Ctrl only as Ctrl-J.
231 KeyKind::Char('j') if chord.mods.ctrl() => Action::SelectOvertype('\n'),
232 KeyKind::Char(c) => Action::SelectOvertype(c),
233 _ => Action::None,
234 }
235}
236
237/// Strip SHIFT / ALT / SUPER for the Select trie lookup — same
238/// treatment as the Visual path (`keymap_visual::normalize_for_visual_lookup`):
239/// the catalog binds bare chords only; CONTROL is filtered by the
240/// caller before this runs.
241fn normalize_for_select_lookup(chord: KeyChord) -> KeyChord {
242 KeyChord {
243 key: chord.key,
244 mods: chord
245 .mods
246 .without(KeyMods::SHIFT)
247 .without(KeyMods::ALT)
248 .without(KeyMods::SUPER),
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::keymap_trie::ChordPattern;
256
257 fn empty_handle() -> KeymapHandle {
258 // The dispatch tests below run against an EMPTY Select table, so
259 // every lookup is `Unbound` — a fresh printable overtypes and the
260 // control chords fire. The parity tests use a fully POPULATED
261 // handle (`populated_handle`).
262 KeymapHandle::new()
263 }
264
265 /// Build a handle from a real, populated command registry, the same path
266 /// boot takes (`editor_boot.rs`). Select has no binder of its own: its
267 /// table is whatever the keymap's motion mirror writes.
268 fn populated_handle() -> KeymapHandle {
269 use lattice_grammar::CommandRegistry;
270 use lattice_grammar::builtins::populate as grammar_builtins_populate;
271 let mut registry = CommandRegistry::new();
272 let builtins = grammar_builtins_populate(&mut registry);
273 let action_ids = crate::actions::populate(&mut registry, &builtins);
274 let syntax_textobjects = lattice_syntax::register_syntax_text_objects(&mut registry);
275 let syntax_motions = lattice_syntax::register_syntax_motions(&mut registry);
276 let registry = std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(registry));
277 let h = KeymapHandle::new();
278 // VM.4: as boot does. With a registry the keymap mirrors motions into
279 // Visual and Select at every write. Without one, Select would hold no
280 // motions and the sweep below would pass vacuously.
281 h.set_command_registry(registry.clone());
282 crate::keymap_visual::register_visual_bindings(
283 &h,
284 &builtins,
285 &action_ids,
286 &syntax_textobjects,
287 );
288 // Operators bind into Visual via `register_operator_bindings` (called by
289 // `register_normal_bindings`), not `register_visual_bindings` --
290 // an operator acts on the selection by design. The parity test
291 // below (`operators_bind_in_visual_but_never_in_select`) reads
292 // those Visual operator binds, so the full Normal catalog must
293 // be registered here too.
294 crate::keymap_normal::register_normal_bindings(
295 &h,
296 &builtins,
297 &action_ids,
298 &syntax_textobjects,
299 &syntax_motions,
300 );
301 // The operator-pending rows, as boot adds them.
302 crate::keymap_normal::expand_grammar_rows(
303 &h,
304 ®istry.load(),
305 &builtins,
306 KeymapLayer::Builtin,
307 );
308 h
309 }
310
311 fn bound_command_id(
312 h: &KeymapHandle,
313 mode: BindingMode,
314 chords: &[KeyChord],
315 ) -> Option<lattice_protocol::ids::CommandId> {
316 match h.lookup(mode, chords) {
317 LookupResult::Bound { command, .. } => Some(command.command.command),
318 _ => None,
319 }
320 }
321
322 // `Action` derives only `Debug, Clone` (no `PartialEq`), so the
323 // assertions match on the variant rather than `assert_eq!`.
324
325 #[test]
326 fn bare_printable_overtypes() {
327 let h = empty_handle();
328 assert!(matches!(
329 translate_select(&h, &KeyChord::char('x'), VisualKind::Charwise, &[], &[]),
330 Action::SelectOvertype('x')
331 ));
332 // A letter that is a Visual *operator* (`d`) still overtypes in
333 // Select — operators are NOT registered in the Select table, so
334 // it falls through. This is the inverted-semantics core.
335 assert!(matches!(
336 translate_select(&h, &KeyChord::char('d'), VisualKind::Charwise, &[], &[]),
337 Action::SelectOvertype('d')
338 ));
339 }
340
341 /// Vim's Select rule names `<NL>` and `<CR>` alongside printables. Both
342 /// type a newline over the selection. A modified `<CR>` is a chord.
343 #[test]
344 fn enter_and_ctrl_j_overtype_with_a_newline() {
345 let h = empty_handle();
346 for (label, chord) in [
347 ("<CR>", KeyChord::special(SpecialKey::Enter)),
348 ("<C-j>", KeyChord::ctrl('j')),
349 ] {
350 assert!(
351 matches!(
352 translate_select(&h, &chord, VisualKind::Charwise, &[], &[]),
353 Action::SelectOvertype('\n')
354 ),
355 "{label} must overtype with a newline"
356 );
357 }
358 let ctrl_enter = KeyChord::new(KeyKind::Special(SpecialKey::Enter), KeyMods::CTRL);
359 assert!(matches!(
360 translate_select(&h, &ctrl_enter, VisualKind::Charwise, &[], &[]),
361 Action::None
362 ));
363 }
364
365 #[test]
366 fn esc_exits_select() {
367 let h = empty_handle();
368 assert!(matches!(
369 translate_select(
370 &h,
371 &KeyChord::special(SpecialKey::Esc),
372 VisualKind::Linewise,
373 &[],
374 &[]
375 ),
376 Action::ExitSelect
377 ));
378 }
379
380 #[test]
381 fn ctrl_g_toggles_to_visual() {
382 let h = empty_handle();
383 assert!(matches!(
384 translate_select(&h, &KeyChord::ctrl('g'), VisualKind::Charwise, &[], &[]),
385 Action::ToggleVisualSelect
386 ));
387 }
388
389 #[test]
390 fn ctrl_o_is_swallowed_post_mvp() {
391 let h = empty_handle();
392 assert!(matches!(
393 translate_select(&h, &KeyChord::ctrl('o'), VisualKind::Charwise, &[], &[]),
394 Action::None
395 ));
396 }
397
398 #[test]
399 fn other_control_chords_are_noops() {
400 let h = empty_handle();
401 assert!(matches!(
402 translate_select(&h, &KeyChord::ctrl('w'), VisualKind::Charwise, &[], &[]),
403 Action::None
404 ));
405 }
406
407 #[test]
408 fn special_keys_do_not_overtype() {
409 let h = empty_handle();
410 // A special (non-Char) key with no binding is a no-op, never a
411 // spurious overtype.
412 assert!(matches!(
413 translate_select(
414 &h,
415 &KeyChord::special(SpecialKey::Tab),
416 VisualKind::Charwise,
417 &[],
418 &[]
419 ),
420 Action::None
421 ));
422 }
423
424 // ── Visual / Select parity, as select-mode.md §4 now states it ──
425
426 /// VM.4: Visual takes every motion; Select takes only the ones that can't
427 /// be typed.
428 ///
429 /// This replaces `visual_and_select_share_every_motion`, which asserted
430 /// the opposite for printable motions and so held a Select bug in place:
431 /// a bound printable takes the keystroke before the overtype fallback
432 /// runs. `motion_rows` mixes printables (`w`, `0`, `$`) with
433 /// non-printables (arrows, Home, End), so one walk exercises both halves
434 /// of the rule. The tree-sitter structural motions (`]f`, …) start with a
435 /// printable and are Visual-only; the all-layers drift test in
436 /// `tests/a_motion_is_live_in_visual.rs` covers them.
437 #[test]
438 fn select_takes_only_motions_that_cannot_be_typed() {
439 use lattice_grammar::CommandRegistry;
440 use lattice_grammar::builtins::populate as grammar_builtins_populate;
441 // A throwaway registry yields the motion CHORD lists; the chords are
442 // literal keys, independent of any registry's ids.
443 let mut throwaway = CommandRegistry::new();
444 let builtins = grammar_builtins_populate(&mut throwaway);
445 let h = populated_handle();
446 let (mut printable, mut non_printable) = (0usize, 0usize);
447 for (chord, _motion) in crate::keymap_normal::motion_rows(&builtins) {
448 let ChordPattern::Literal(c) = chord else {
449 continue;
450 };
451 let path = [c];
452 assert!(
453 bound_command_id(&h, BindingMode::Visual, &path).is_some(),
454 "Visual must bind motion {c:?}"
455 );
456 let in_select = bound_command_id(&h, BindingMode::Select, &path).is_some();
457 if lattice_keymap::overtypes_in_select(&c) {
458 printable += 1;
459 assert!(
460 !in_select,
461 "printable motion {c:?} is bound in Select, so it would take typed text"
462 );
463 } else {
464 non_printable += 1;
465 assert!(
466 in_select,
467 "non-printable motion {c:?} must extend in Select"
468 );
469 }
470 }
471 assert!(
472 printable >= 10 && non_printable >= 4,
473 "test premise: both halves exercised ({printable} printable / {non_printable} not)"
474 );
475 }
476
477 /// Every printable character overtypes a Select selection when run
478 /// against the POPULATED table, as boot builds it.
479 ///
480 /// `bare_printable_overtypes` uses an EMPTY table, so it could never
481 /// notice a printable being bound. `visual_and_select_share_every_motion`
482 /// went further and ASSERTED the printable motions were bound in Select,
483 /// which locked the bug in. This sweep is the replacement.
484 #[test]
485 fn every_printable_overtypes_against_the_populated_table() {
486 let h = populated_handle();
487 assert!(
488 bound_command_id(
489 &h,
490 BindingMode::Select,
491 &[KeyChord::special(SpecialKey::PageDown)]
492 )
493 .is_some(),
494 "test premise: the mirror populated Select, so an empty table isn't passing this"
495 );
496
497 let mut stolen = Vec::new();
498 for c in ' '..='~' {
499 match translate_select(&h, &KeyChord::char(c), VisualKind::Charwise, &[], &[]) {
500 Action::SelectOvertype(got) if got == c => {}
501 other => stolen.push(format!("{c:?} -> {other:?}")),
502 }
503 }
504 for (label, chord) in [
505 ("<CR>", KeyChord::special(SpecialKey::Enter)),
506 ("<C-j>", KeyChord::ctrl('j')),
507 ] {
508 match translate_select(&h, &chord, VisualKind::Charwise, &[], &[]) {
509 Action::SelectOvertype('\n') => {}
510 other => stolen.push(format!("{label} -> {other:?}")),
511 }
512 }
513 assert!(
514 stolen.is_empty(),
515 "keys that don't overtype in Select:\n{}",
516 stolen.join("\n")
517 );
518 }
519
520 /// VM.5: `o` swaps the selection's ends in Visual, and types an `o` in
521 /// Select. Vim's Select has no swap-ends; `<C-g>` to Visual, then `o`.
522 ///
523 /// Replaces `visual_and_select_share_swap_ends`, which asserted that
524 /// Select bound `o`, so a placeholder couldn't be replaced by text
525 /// starting with it.
526 #[test]
527 fn o_overtypes_in_select_but_swaps_ends_in_visual() {
528 let h = populated_handle();
529 let o = KeyChord::char('o');
530 assert!(bound_command_id(&h, BindingMode::Visual, &[o]).is_some());
531 assert!(matches!(
532 translate_select(&h, &o, VisualKind::Charwise, &[], &[]),
533 Action::SelectOvertype('o')
534 ));
535 }
536
537 /// VM.5: `iw` selects a word in Visual; in Select `i` and `a` are typed
538 /// text, so they must not be a text-object prefix waiting for a second
539 /// key.
540 ///
541 /// Replaces `visual_and_select_share_text_objects`, which asserted that
542 /// Select bound `iw`. A bound prefix returns `Partial` and absorbs the key,
543 /// so `info` typed over a placeholder lost its `i`.
544 #[test]
545 fn a_text_object_prefix_overtypes_in_select() {
546 let h = populated_handle();
547 assert!(
548 bound_command_id(
549 &h,
550 BindingMode::Visual,
551 &[KeyChord::char('i'), KeyChord::char('w')]
552 )
553 .is_some(),
554 "Visual must bind `iw`"
555 );
556 for c in ['i', 'a'] {
557 match translate_select(&h, &KeyChord::char(c), VisualKind::Charwise, &[], &[]) {
558 Action::SelectOvertype(got) if got == c => {}
559 other => panic!("`{c}` must overtype in Select, got {other:?}"),
560 }
561 }
562 }
563
564 /// **Operators are Visual-ONLY.** In Select a printable overtypes, so
565 /// `d` / `x` / `c` / `s` / `y` / `>` / `<` must stay UNBOUND in the
566 /// Select table — the dispatcher's fallthrough turns them into
567 /// overtypes. This pins the inverted-semantics contract.
568 #[test]
569 fn operators_bind_in_visual_but_never_in_select() {
570 let h = populated_handle();
571 for op in ['d', 'x', 'c', 's', 'y', '>', '<'] {
572 let path = [KeyChord::char(op)];
573 assert!(
574 bound_command_id(&h, BindingMode::Visual, &path).is_some(),
575 "Visual must bind operator `{op}`"
576 );
577 assert_eq!(
578 bound_command_id(&h, BindingMode::Select, &path),
579 None,
580 "Select must NOT bind operator `{op}` — it overtypes instead"
581 );
582 }
583 }
584}