Skip to main content

lattice_ui_tui/
keymap_visual.rs

1//! Crossterm-coupled test harness for the renderer-neutral
2//! `lattice_host::keymap_visual` catalog. Production code
3//! moved to lattice-host in slice 5.4 / slice 4; the tests
4//! stay here because their `ev()` helper builds `KeyChord`
5//! values via `crate::chord::from_event(&KeyEvent { ... })`.
6
7pub use lattice_host::keymap_visual::*;
8
9#[cfg(test)]
10mod tests {
11    #![allow(clippy::unwrap_used, clippy::panic, unused_imports)]
12    use super::*;
13    use crate::actions::ActionIds;
14    use crate::app::Action;
15    use crate::chord::KeyChord;
16    use crate::keymap_registry::KeymapHandle;
17    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
18    use lattice_grammar::{CommandRegistry, VisualKind, builtins::Builtins, builtins::populate};
19    use lattice_syntax::SyntaxMotionIds;
20    use lattice_syntax::SyntaxTextObjectIds;
21
22    fn ev(code: KeyCode, mods: KeyModifiers) -> KeyChord {
23        let raw = KeyEvent {
24            code,
25            modifiers: mods,
26            kind: KeyEventKind::Press,
27            state: KeyEventState::NONE,
28        };
29        crate::chord::from_event(&raw).expect("test event converts to a chord")
30    }
31
32    fn fixture() -> (
33        CommandRegistry,
34        Builtins,
35        ActionIds,
36        SyntaxTextObjectIds,
37        SyntaxMotionIds,
38    ) {
39        let mut r = CommandRegistry::new();
40        let b = populate(&mut r);
41        let a = crate::actions::populate(&mut r, &b);
42        let so = lattice_syntax::register_syntax_text_objects(&mut r);
43        let sm = lattice_syntax::register_syntax_motions(&mut r);
44        (r, b, a, so, sm)
45    }
46
47    fn populated_handle(
48        r: &CommandRegistry,
49        b: &Builtins,
50        a: &ActionIds,
51        so: &SyntaxTextObjectIds,
52        sm: &SyntaxMotionIds,
53    ) -> KeymapHandle {
54        let h = KeymapHandle::new();
55        // VM.4: the keymap mirrors motions into Visual at every write once it
56        // has a registry. Without this, every motion chord below would
57        // dispatch to `Action::None` in Visual.
58        h.set_command_registry(std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
59            r.clone(),
60        )));
61        register_visual_bindings(&h, b, a, so);
62        // Operators (`d` / `c` / `y` / `>` / `<`) bind into Visual via
63        // `register_operator_bindings` (called by `register_normal_bindings`), not
64        // `register_visual_bindings` -- an operator acts on the selection
65        // by design. Tests here dispatch those operator chords in Visual,
66        // so the Normal catalog must be registered too. The `x` / `s`
67        // Visual-only aliases still come from `register_visual_bindings`.
68        crate::keymap_normal::register_normal_bindings(&h, b, a, so, sm);
69        // VM.1: operator-pending rows. Visual's motions come from the keymap's
70        // mirror (VM.4), armed by `set_command_registry` above.
71        crate::keymap_normal::expand_grammar_rows(
72            &h,
73            r,
74            b,
75            lattice_host::keymap_trie::KeymapLayer::Builtin,
76        );
77        h
78    }
79
80    /// Dispatch a fresh (non-mid-sequence) Visual chord -- empty
81    /// `partial_chord`. The bulk of the catalog is single-key.
82    fn dv(h: &KeymapHandle, chord: &KeyChord, kind: VisualKind) -> Action {
83        dispatch_visual(h, chord, kind, &[], &[])
84    }
85
86    #[test]
87    fn esc_exits_visual_in_all_kinds() {
88        let (reg, b, a, so, sm) = fixture();
89        let h = populated_handle(&reg, &b, &a, &so, &sm);
90        for kind in [
91            VisualKind::Charwise,
92            VisualKind::Linewise,
93            VisualKind::Blockwise,
94        ] {
95            let r = dv(&h, &ev(KeyCode::Esc, KeyModifiers::NONE), kind);
96            match r {
97                Action::Invoke(inv) => assert_eq!(inv.command, a.exit_visual),
98                other => panic!("kind={kind:?}: expected Invoke(exit_visual), got {other:?}"),
99            }
100        }
101    }
102
103    #[test]
104    fn lowercase_v_toggles_out_of_visual() {
105        let (reg, b, a, so, sm) = fixture();
106        let h = populated_handle(&reg, &b, &a, &so, &sm);
107        let r = dv(
108            &h,
109            &ev(KeyCode::Char('v'), KeyModifiers::NONE),
110            VisualKind::Charwise,
111        );
112        match r {
113            Action::Invoke(inv) => assert_eq!(inv.command, a.exit_visual),
114            other => panic!("expected Invoke(exit_visual), got {other:?}"),
115        }
116    }
117
118    #[test]
119    fn uppercase_v_toggles_out_of_visual() {
120        let (reg, b, a, so, sm) = fixture();
121        let h = populated_handle(&reg, &b, &a, &so, &sm);
122        let r = dv(
123            &h,
124            &ev(KeyCode::Char('V'), KeyModifiers::NONE),
125            VisualKind::Linewise,
126        );
127        match r {
128            Action::Invoke(inv) => assert_eq!(inv.command, a.exit_visual),
129            other => panic!("expected Invoke(exit_visual), got {other:?}"),
130        }
131    }
132
133    #[test]
134    fn motion_h_invokes_char_left() {
135        let (reg, b, a, so, sm) = fixture();
136        let h = populated_handle(&reg, &b, &a, &so, &sm);
137        let r = dv(
138            &h,
139            &ev(KeyCode::Char('h'), KeyModifiers::NONE),
140            VisualKind::Charwise,
141        );
142        match r {
143            Action::Invoke(inv) => assert_eq!(inv.command, b.char_left.0),
144            other => panic!("expected Invoke(char_left), got {other:?}"),
145        }
146    }
147
148    #[test]
149    fn arrow_left_aliases_to_char_left() {
150        let (reg, b, a, so, sm) = fixture();
151        let h = populated_handle(&reg, &b, &a, &so, &sm);
152        let r = dv(
153            &h,
154            &ev(KeyCode::Left, KeyModifiers::NONE),
155            VisualKind::Charwise,
156        );
157        match r {
158            Action::Invoke(inv) => assert_eq!(inv.command, b.char_left.0),
159            other => panic!("expected Invoke(char_left), got {other:?}"),
160        }
161    }
162
163    #[test]
164    fn delete_in_visual_carries_selection_range() {
165        let (reg, b, a, so, sm) = fixture();
166        let h = populated_handle(&reg, &b, &a, &so, &sm);
167        let r = dv(
168            &h,
169            &ev(KeyCode::Char('d'), KeyModifiers::NONE),
170            VisualKind::Charwise,
171        );
172        match r {
173            Action::Invoke(inv) => {
174                assert_eq!(inv.command, b.delete.0);
175                assert!(matches!(inv.range, Some(lattice_grammar::Range::Selection)));
176            }
177            other => panic!("expected Invoke(delete, Selection), got {other:?}"),
178        }
179    }
180
181    #[test]
182    fn x_in_visual_aliases_to_delete() {
183        let (reg, b, a, so, sm) = fixture();
184        let h = populated_handle(&reg, &b, &a, &so, &sm);
185        let r = dv(
186            &h,
187            &ev(KeyCode::Char('x'), KeyModifiers::NONE),
188            VisualKind::Charwise,
189        );
190        match r {
191            Action::Invoke(inv) => assert_eq!(inv.command, b.delete.0),
192            other => panic!("expected Invoke(delete), got {other:?}"),
193        }
194    }
195
196    #[test]
197    fn s_in_visual_aliases_to_change() {
198        let (reg, b, a, so, sm) = fixture();
199        let h = populated_handle(&reg, &b, &a, &so, &sm);
200        let r = dv(
201            &h,
202            &ev(KeyCode::Char('s'), KeyModifiers::NONE),
203            VisualKind::Charwise,
204        );
205        match r {
206            Action::Invoke(inv) => assert_eq!(inv.command, b.change.0),
207            other => panic!("expected Invoke(change), got {other:?}"),
208        }
209    }
210
211    /// Concern #1 contract: an operator acts on the Visual selection BY
212    /// DESIGN. A contributed, MULTI-KEY operator (narrow's `zn` shape)
213    /// registered through `register_operator_bindings` resolves in Visual to
214    /// `op.with_range(Selection)` with ZERO per-operator Visual binding
215    /// hand-listed — exactly the path `d`/`c`/`y` now use. Reuses
216    /// `b.delete`'s OperatorId under the novel `zn` prefix; the contract
217    /// under test is keymap generation across modes, not the effect.
218    #[test]
219    fn contributed_operator_acts_on_visual_selection_by_design() {
220        let (_reg, b, a, so, sm) = fixture();
221        let _ = &a;
222        let h = KeymapHandle::new();
223        let z = crate::keymap_trie::ChordPattern::Literal(KeyChord::char('z'));
224        let n = crate::keymap_trie::ChordPattern::Literal(KeyChord::char('n'));
225        crate::keymap_normal::register_operator_bindings(
226            &h,
227            &[z, n.clone()],
228            b.delete,
229            Some(n),
230            &b,
231            &so,
232            &sm,
233            false,
234        );
235
236        // Visual `zn`: `z` absorbs as a partial, `n` resolves the pair to
237        // the operator carrying `Range::Selection`.
238        let prefix = [ev(KeyCode::Char('z'), KeyModifiers::NONE)];
239        let r = dispatch_visual(
240            &h,
241            &ev(KeyCode::Char('n'), KeyModifiers::NONE),
242            VisualKind::Charwise,
243            &prefix,
244            &[],
245        );
246        match r {
247            Action::Invoke(inv) => {
248                assert_eq!(inv.command, b.delete.0);
249                assert!(
250                    matches!(inv.range, Some(lattice_grammar::Range::Selection)),
251                    "a contributed operator must carry Range::Selection in Visual"
252                );
253            }
254            other => panic!("expected Invoke(op, Selection), got {other:?}"),
255        }
256
257        // ...and the SAME registration still yields the Normal
258        // operator-pending family: `zn` + motion `j` targets the motion.
259        // Proves one `register_operator_bindings` call wires BOTH modes — Visual
260        // selection-operability is intrinsic, not a second binding.
261        let znj = lattice_host::keymap_normal::lookup_normal_with_prefix(
262            &h,
263            &[KeyChord::char('z'), KeyChord::char('n')],
264            &KeyChord::char('j'),
265            &[],
266        );
267        match znj {
268            Action::Invoke(inv) => assert_eq!(inv.command, b.delete.0),
269            other => panic!("expected Normal `znj` Invoke(op), got {other:?}"),
270        }
271    }
272
273    #[test]
274    fn visual_r_x_resolves_to_replace_char_on_selection() {
275        // `v_r{char}`: `r` absorbs as a partial, the next key resolves
276        // the pair to the replace-char operator carrying
277        // `Range::Selection`, with the captured char folded into
278        // `Args::Char`.
279        let (reg, b, a, so, sm) = fixture();
280        let h = populated_handle(&reg, &b, &a, &so, &sm);
281        let prefix = [ev(KeyCode::Char('r'), KeyModifiers::NONE)];
282        let r = dispatch_visual(
283            &h,
284            &ev(KeyCode::Char('X'), KeyModifiers::NONE),
285            VisualKind::Charwise,
286            &prefix,
287            &[],
288        );
289        match r {
290            Action::Invoke(inv) => {
291                assert_eq!(inv.command, b.replace_char.0);
292                assert!(
293                    matches!(inv.range, Some(lattice_grammar::Range::Selection)),
294                    "Visual r must carry Range::Selection, got {:?}",
295                    inv.range
296                );
297                assert!(
298                    matches!(inv.args, lattice_grammar::args::Args::Char('X')),
299                    "captured char rides on the operator args, got {:?}",
300                    inv.args
301                );
302            }
303            other => panic!("expected Invoke(replace_char, Selection, Char('X')), got {other:?}"),
304        }
305    }
306
307    #[test]
308    fn visual_r_alone_absorbs_partial_chord() {
309        let (reg, b, a, so, sm) = fixture();
310        let h = populated_handle(&reg, &b, &a, &so, &sm);
311        let r = dv(
312            &h,
313            &ev(KeyCode::Char('r'), KeyModifiers::NONE),
314            VisualKind::Charwise,
315        );
316        assert!(
317            matches!(r, Action::AbsorbPartialChord(c) if c == KeyChord::char('r')),
318            "expected AbsorbPartialChord(r), got {r:?}"
319        );
320    }
321
322    #[test]
323    fn capital_i_only_in_blockwise() {
324        let (reg, b, a, so, sm) = fixture();
325        let h = populated_handle(&reg, &b, &a, &so, &sm);
326        // Charwise: I has no binding -> None.
327        let r = dv(
328            &h,
329            &ev(KeyCode::Char('I'), KeyModifiers::NONE),
330            VisualKind::Charwise,
331        );
332        assert!(matches!(r, Action::None), "charwise I: {r:?}");
333        // Linewise: same.
334        let r = dv(
335            &h,
336            &ev(KeyCode::Char('I'), KeyModifiers::NONE),
337            VisualKind::Linewise,
338        );
339        assert!(matches!(r, Action::None), "linewise I: {r:?}");
340        // Blockwise: I -> EnterBlockVisualInsert.
341        let r = dv(
342            &h,
343            &ev(KeyCode::Char('I'), KeyModifiers::NONE),
344            VisualKind::Blockwise,
345        );
346        assert!(
347            matches!(r, Action::EnterBlockVisualInsert),
348            "block I: {r:?}"
349        );
350    }
351
352    #[test]
353    fn capital_a_only_in_blockwise() {
354        let (reg, b, a, so, sm) = fixture();
355        let h = populated_handle(&reg, &b, &a, &so, &sm);
356        let r = dv(
357            &h,
358            &ev(KeyCode::Char('A'), KeyModifiers::NONE),
359            VisualKind::Charwise,
360        );
361        assert!(matches!(r, Action::None));
362        let r = dv(
363            &h,
364            &ev(KeyCode::Char('A'), KeyModifiers::NONE),
365            VisualKind::Blockwise,
366        );
367        assert!(matches!(r, Action::EnterBlockVisualAppend));
368    }
369
370    #[test]
371    fn ctrl_modifier_yields_none() {
372        let (reg, b, a, so, sm) = fixture();
373        let h = populated_handle(&reg, &b, &a, &so, &sm);
374        let r = dv(
375            &h,
376            &ev(KeyCode::Char('h'), KeyModifiers::CONTROL),
377            VisualKind::Charwise,
378        );
379        assert!(matches!(r, Action::None));
380    }
381
382    /// Modifier transparency: `<M-h>` falls through to char_left
383    /// just like the legacy `translate_visual` did. Same rationale
384    /// as Replace mode (slice 8.d): the legacy match table only
385    /// short-circuited CONTROL.
386    #[test]
387    fn alt_h_in_visual_invokes_char_left() {
388        let (reg, b, a, so, sm) = fixture();
389        let h = populated_handle(&reg, &b, &a, &so, &sm);
390        let r = dv(
391            &h,
392            &ev(KeyCode::Char('h'), KeyModifiers::ALT),
393            VisualKind::Charwise,
394        );
395        match r {
396            Action::Invoke(inv) => assert_eq!(inv.command, b.char_left.0),
397            other => panic!("expected Invoke(char_left), got {other:?}"),
398        }
399    }
400
401    // ---- Text objects in Visual mode (visual-foundation slice).
402    //
403    // `viw` / `vaw` / `vi{` / `vaf` / `vaC` ... are two-key chords
404    // (`i` / `a` prefix + object char) resolved via the same
405    // partial-chord machinery Normal mode uses. The first key absorbs
406    // into the host's `partial_chord`; the second resolves the pair.
407    // There is NO per-object code -- every row in
408    // `keymap_normal::text_object_rows` works automatically.
409
410    /// Bare `i` (in Visual) is a text-object prefix: it must absorb
411    /// into `partial_chord`, not no-op.
412    #[test]
413    fn bare_i_absorbs_as_text_object_prefix() {
414        let (reg, b, a, so, sm) = fixture();
415        let h = populated_handle(&reg, &b, &a, &so, &sm);
416        let r = dv(
417            &h,
418            &ev(KeyCode::Char('i'), KeyModifiers::NONE),
419            VisualKind::Charwise,
420        );
421        match r {
422            Action::AbsorbPartialChord(c) => {
423                assert_eq!(c, ev(KeyCode::Char('i'), KeyModifiers::NONE));
424            }
425            other => panic!("expected AbsorbPartialChord(i), got {other:?}"),
426        }
427    }
428
429    /// Bare `a` likewise absorbs as a text-object prefix.
430    #[test]
431    fn bare_a_absorbs_as_text_object_prefix() {
432        let (reg, b, a, so, sm) = fixture();
433        let h = populated_handle(&reg, &b, &a, &so, &sm);
434        let r = dv(
435            &h,
436            &ev(KeyCode::Char('a'), KeyModifiers::NONE),
437            VisualKind::Charwise,
438        );
439        assert!(
440            matches!(r, Action::AbsorbPartialChord(_)),
441            "expected AbsorbPartialChord(a), got {r:?}"
442        );
443    }
444
445    /// `viw` -> with `partial_chord = [i]`, the `w` resolves to a
446    /// bare `inner_word` text-object invocation (no operator, no
447    /// range -- the grammar's `execute_text_object` sets the span).
448    #[test]
449    fn viw_resolves_to_bare_inner_word() {
450        let (reg, b, a, so, sm) = fixture();
451        let h = populated_handle(&reg, &b, &a, &so, &sm);
452        let prefix = [ev(KeyCode::Char('i'), KeyModifiers::NONE)];
453        let r = dispatch_visual(
454            &h,
455            &ev(KeyCode::Char('w'), KeyModifiers::NONE),
456            VisualKind::Charwise,
457            &prefix,
458            &[],
459        );
460        match r {
461            Action::Invoke(inv) => {
462                assert_eq!(inv.command, b.inner_word.0);
463                assert!(inv.range.is_none(), "bare text object carries no range");
464                assert!(inv.target.is_none(), "bare text object carries no target");
465            }
466            other => panic!("expected Invoke(inner_word), got {other:?}"),
467        }
468    }
469
470    /// `vaw` -> around-word via the `a` prefix.
471    #[test]
472    fn vaw_resolves_to_bare_around_word() {
473        let (reg, b, a, so, sm) = fixture();
474        let h = populated_handle(&reg, &b, &a, &so, &sm);
475        let prefix = [ev(KeyCode::Char('a'), KeyModifiers::NONE)];
476        let r = dispatch_visual(
477            &h,
478            &ev(KeyCode::Char('w'), KeyModifiers::NONE),
479            VisualKind::Charwise,
480            &prefix,
481            &[],
482        );
483        match r {
484            Action::Invoke(inv) => assert_eq!(inv.command, b.around_word.0),
485            other => panic!("expected Invoke(around_word), got {other:?}"),
486        }
487    }
488
489    /// `vi{` -> inner brace (a bracket-family alias). Confirms the
490    /// shared table's alias rows reach Visual mode too.
491    #[test]
492    fn vi_brace_resolves_to_inner_brace() {
493        let (reg, b, a, so, sm) = fixture();
494        let h = populated_handle(&reg, &b, &a, &so, &sm);
495        let prefix = [ev(KeyCode::Char('i'), KeyModifiers::NONE)];
496        let r = dispatch_visual(
497            &h,
498            &ev(KeyCode::Char('{'), KeyModifiers::NONE),
499            VisualKind::Charwise,
500            &prefix,
501            &[],
502        );
503        match r {
504            Action::Invoke(inv) => assert_eq!(inv.command, b.inner_brace.0),
505            other => panic!("expected Invoke(inner_brace), got {other:?}"),
506        }
507    }
508
509    /// `vaf` -> around-function: the tree-sitter structural object,
510    /// proving the syntax rows are wired identically in Visual.
511    #[test]
512    fn vaf_resolves_to_around_function() {
513        let (reg, b, a, so, sm) = fixture();
514        let h = populated_handle(&reg, &b, &a, &so, &sm);
515        let prefix = [ev(KeyCode::Char('a'), KeyModifiers::NONE)];
516        let r = dispatch_visual(
517            &h,
518            &ev(KeyCode::Char('f'), KeyModifiers::NONE),
519            VisualKind::Charwise,
520            &prefix,
521            &[],
522        );
523        match r {
524            Action::Invoke(inv) => assert_eq!(inv.command, so.around_function.0),
525            other => panic!("expected Invoke(around_function), got {other:?}"),
526        }
527    }
528
529    /// `vaC` -> around-comment (the N.1.6 comment object), capital C.
530    #[test]
531    fn va_capital_c_resolves_to_around_comment() {
532        let (reg, b, a, so, sm) = fixture();
533        let h = populated_handle(&reg, &b, &a, &so, &sm);
534        let prefix = [ev(KeyCode::Char('a'), KeyModifiers::NONE)];
535        let r = dispatch_visual(
536            &h,
537            &ev(KeyCode::Char('C'), KeyModifiers::NONE),
538            VisualKind::Charwise,
539            &prefix,
540            &[],
541        );
542        match r {
543            Action::Invoke(inv) => assert_eq!(inv.command, b.around_comment.0),
544            other => panic!("expected Invoke(around_comment), got {other:?}"),
545        }
546    }
547
548    /// Garbage after a text-object prefix aborts (returns `None`), so
549    /// the host clears `partial_chord` -- matching vim cancelling the
550    /// prefix on an unbound second key.
551    #[test]
552    fn unbound_after_prefix_yields_none() {
553        let (reg, b, a, so, sm) = fixture();
554        let h = populated_handle(&reg, &b, &a, &so, &sm);
555        let prefix = [ev(KeyCode::Char('i'), KeyModifiers::NONE)];
556        let r = dispatch_visual(
557            &h,
558            &ev(KeyCode::Char('z'), KeyModifiers::NONE),
559            VisualKind::Charwise,
560            &prefix,
561            &[],
562        );
563        assert!(matches!(r, Action::None), "expected None, got {r:?}");
564    }
565}