Skip to main content

lattice_host/
keymap_visual.rs

1//! Visual-mode binding registration + drift-test helpers.
2//!
3//! Audit slice 8.e. The second mode migrated off `input::translate`'s
4//! hand-rolled match table; follows the [slice 8.d
5//! template][crate::keymap_replace] (`register_<mode>_bindings` +
6//! `dispatch_<mode>` + drift test against a frozen reference body
7//! of the legacy translator).
8//!
9//! ## Surface
10//!
11//! Visual mode is the same chord table for charwise / linewise /
12//! blockwise -- the kind only changes how `Range::Selection`
13//! resolves at operator-dispatch time (see
14//! `lattice-grammar::dispatcher` §5.2.3). Two block-only
15//! exceptions land before the trie lookup:
16//!
17//! - `I` -> [`Action::EnterBlockVisualInsert`] (blockwise only)
18//! - `A` -> [`Action::EnterBlockVisualAppend`] (blockwise only)
19//!
20//! These are pre-dispatch overrides rather than a separate
21//! `BindingMode::VisualBlock` -- the architecture's eventual model
22//! is a minor-mode layer pushed at blockwise entry / popped at
23//! exit (see `docs/dev/architecture/keymap-architecture.md` §5.3); slice 8.e keeps
24//! the surgical pre-check until that layer machinery lands. The
25//! drift test below pins the kind branch so a future graduation
26//! to `push_layer` is mechanical.
27//!
28//! Common-to-all-kinds bindings registered by
29//! [`register_visual_bindings`]:
30//!
31//! - **Exits**: `<Esc>` / `v` / `V` -> `ExitVisual`.
32//! - **Motions** (extend the selection): `h` / `<Left>` /
33//!   `j` / `<Down>` / `k` / `<Up>` / `l` / `<Right>` /
34//!   `0` / `<Home>` / `$` / `<End>` / `^` / `w` / `b` / `e` /
35//!   `W` / `B` / `E` / `}` / `{` / `)` / `(` / `G`. Each binds
36//!   to `CommandInvocation::of(motion.0)` -- the
37//!   non-`legacy_action` path; the dispatcher returns
38//!   `Action::Invoke(command.command.clone())`.
39//! - **Operators on selection**: an operator acts on the active
40//!   selection BY DESIGN, so its Visual binding is generated once at
41//!   operator registration, NOT hand-listed here. Every operator
42//!   ([`crate::keymap_normal::register_operator_bindings`] -- builtin `d` / `c`
43//!   / `y` / `>` / `<`, case `gU` / `gu` / `g~`, contributed `zn`, ...)
44//!   binds its trigger chord to
45//!   `CommandInvocation::of(op.0).with_range(Range::Selection)`; the
46//!   operator dispatcher's range walker resolves `Range::Selection`
47//!   against the active visual selection. The ONLY operator entries
48//!   that remain in [`register_visual_bindings`] are the two
49//!   Visual-only ALIASES `x` -> delete and `s` -> change (in Normal
50//!   `x` / `s` are different commands, so they are not the operators'
51//!   trigger chord and cannot come from the registration).
52//! - **Text objects** (set the selection to the object's span):
53//!   `i<obj>` / `a<obj>` for every object in the SHARED
54//!   [`crate::keymap_normal::text_object_rows`] table -- `viw`,
55//!   `vaw`, `vi{`, `vap`, `vaf` (function), `vac` (class),
56//!   `vaC` (comment), ... These are TWO-key chords resolved by
57//!   the same partial-chord machinery Normal mode uses (`i` / `a`
58//!   absorbs into the host's `partial_chord`, the object char
59//!   resolves the pair); see [`dispatch_visual`]. Each binds to a
60//!   bare `CommandInvocation::of(tobj.0)`, which the grammar's
61//!   `execute_text_object` turns into an `Effect::SelectionChange`
62//!   spanning the object. There is ZERO per-object code: the
63//!   binder iterates the shared table, so Visual and the Normal
64//!   operator-pending resolver can never drift.
65//!
66//! Slice 8.e's net win: every motion / operator binding moves
67//! off the `legacy_action` bridge and onto a real
68//! `CommandInvocation`. Only the three `ExitVisual` shapes plus
69//! the two block-only `Enter*` paths still carry a `legacy_action`
70//! -- they don't have a `CommandInvocation` peer today.
71
72use lattice_grammar::SourceLocation;
73use lattice_grammar::VisualKind;
74use lattice_grammar::builtins::Builtins;
75use lattice_grammar::command::CommandInvocation;
76use lattice_syntax::SyntaxTextObjectIds;
77
78use crate::action::Action;
79use crate::actions::ActionIds;
80use crate::chord::{KeyChord, KeyMods, SpecialKey};
81use crate::keymap::BindingMode;
82use crate::keymap_registry::KeymapHandle;
83use crate::keymap_trie::{ChordPattern, KeymapLayer, LookupResult};
84
85/// Register every chord the legacy `input::translate_visual`
86/// recognised into the supplied handle's `Builtin` layer under
87/// `BindingMode::Visual`. Called at App startup; the registration
88/// captures `builtins`'s motion / operator ids by value, so the
89/// resulting `BoundCommand`s never re-resolve at lookup time.
90///
91/// Sources are tagged at this file + line so `:describe-key`
92/// shows e.g.
93/// `h -> motion:char-left  (builtin, keymap_visual.rs:NN)`.
94pub fn register_visual_bindings(
95    handle: &KeymapHandle,
96    builtins: &Builtins,
97    actions: &ActionIds,
98    syntax_textobjects: &SyntaxTextObjectIds,
99) {
100    let layer = KeymapLayer::Builtin;
101    let mode = BindingMode::Visual;
102
103    // Exits: <Esc>, v, V. Promoted to typed `ExitVisual` action
104    // in slice 8.i.1.h.
105    handle.bind(
106        layer,
107        mode,
108        &[ChordPattern::Literal(KeyChord::special(SpecialKey::Esc))],
109        CommandInvocation::of(actions.exit_visual),
110        source(),
111    );
112    handle.bind(
113        layer,
114        mode,
115        &[ChordPattern::Literal(KeyChord::char('v'))],
116        CommandInvocation::of(actions.exit_visual),
117        source(),
118    );
119    handle.bind(
120        layer,
121        mode,
122        &[ChordPattern::Literal(KeyChord::char('V'))],
123        CommandInvocation::of(actions.exit_visual),
124        source(),
125    );
126
127    // `:` -- enter the command line from Visual (vim's `:'<,'>`). The
128    // `EnterCommandLine` handler is Visual-aware: it captures the
129    // selection into `last_visual` (so `'<` / `'>` and `Range::Selection`
130    // resolve to it) and prefills the cmdline with `'<,'>`. Without this
131    // binding `:` was unbound in Visual → `dispatch_visual` returned
132    // `Action::None`, so you could not invoke ANY command (`:s`,
133    // `:narrow`, `:w`, …) from a selection.
134    handle.bind(
135        layer,
136        mode,
137        &[ChordPattern::Literal(KeyChord::char(':'))],
138        CommandInvocation::of(actions.enter_command_line),
139        source(),
140    );
141
142    // `o` -- swap the cursor to the other end of the selection so the
143    // next motion / text object alters that end (vim's Visual `o`).
144    handle.bind(
145        layer,
146        mode,
147        &[ChordPattern::Literal(KeyChord::char('o'))],
148        CommandInvocation::of(actions.swap_visual_ends),
149        source(),
150    );
151
152    // VM.1 (2026-09-15): motions are NOT listed here any more, neither the
153    // builtin ones nor TSM.4's sixteen structural ones. They are DERIVED from
154    // each binding's command kind: since VM.4 the keymap mirrors every
155    // `CommandKind::Motion` Normal binding into Visual at the write
156    // (keymap-architecture.md §15), and `keymap_normal::expand_grammar_rows`
157    // adds the operator-pending peers.
158    //
159    // The list that used to live here was a re-registration of
160    // `keymap_normal::motion_rows`, and its doc comment claimed to be a single
161    // source of truth for three surfaces. It was not: `gg`, `f` / `F` / `t` /
162    // `T`, `<C-d>` / `<C-u>` and `<PageUp>` / `<PageDown>` all entered the
163    // Normal binder by other doors and so were dead in Visual. A second copy
164    // of a list cannot notice what the first one never had, and a plugin motion
165    // had no route into either copy — org's `[[` moved the cursor in Normal and
166    // did nothing here.
167    //
168    // What remains below is what is genuinely Visual-SPECIFIC: chords that mean
169    // something different here than in Normal.
170
171    // Visual-only operator ALIASES. The canonical operator trigger
172    // chords (`d` / `c` / `y` / `>` / `<`, case `gU` / `gu` / `g~`,
173    // contributed `zn`, ...) are deliberately NOT listed here: every
174    // operator gets its Visual selection-bind intrinsically from
175    // [`crate::keymap_normal::register_operator_bindings`] -- an operator acts on
176    // the active selection BY DESIGN, so the binding is generated once
177    // per operator at registration, uniformly across builtin and
178    // contributed operators. What remains here are the two chords that
179    // are operators ONLY in Visual: in Normal `x` is delete-char and `s`
180    // is substitute, so they are not the delete / change operators'
181    // trigger chord and cannot be derived from the operator registration.
182    // `Range::Selection` resolves at dispatch time to the active visual
183    // region.
184    let operator_aliases: &[(ChordPattern, lattice_grammar::registry::OperatorId)] = &[
185        (literal(KeyChord::char('x')), builtins.delete),
186        (literal(KeyChord::char('s')), builtins.change),
187    ];
188    for (chord, op) in operator_aliases {
189        handle.bind(
190            layer,
191            mode,
192            std::slice::from_ref(chord),
193            CommandInvocation::of(op.0).with_range(lattice_grammar::Range::Selection),
194            source(),
195        );
196    }
197
198    // `r<X>` -- replace every selected char with X (vim's Visual `r`).
199    // A two-key wildcard: `[r]` alone returns Partial, so `dispatch_visual`
200    // absorbs it into `partial_chord`; `[r, CharLiteral]` then folds the
201    // captured char into the operator's `Args::Char` via
202    // `action_from_bound_with_capture`. `Range::Selection` resolves to the
203    // active region at dispatch time; the `replace-char` operator overwrites
204    // every non-newline char in it and the host auto-exits Visual after the
205    // operator, exactly as `d` / `c` / `y` do. Unlike the Normal-mode `r`
206    // binding there is no target motion, so the captured char routes
207    // straight to the operator's args.
208    handle.bind(
209        layer,
210        mode,
211        &[literal(KeyChord::char('r')), ChordPattern::CharLiteral],
212        CommandInvocation::of(builtins.replace_char.0)
213            .with_range(lattice_grammar::Range::Selection),
214        source(),
215    );
216
217    // Text objects: `i<obj>` (inner) / `a<obj>` (around) set the
218    // selection to the object's span -- `viw`, `vaf`, `vaC`, `vi{`,
219    // ... Rows come from the SHARED
220    // [`crate::keymap_normal::text_object_rows`] table, the exact
221    // same table the Normal-mode operator-pending resolver consumes,
222    // so `viw` / `vaf` and `diw` / `daf` can never drift and there is
223    // ZERO per-object code here. A bare text-object invocation
224    // dispatches through the grammar's `execute_text_object`, which
225    // returns `Effect::SelectionChange` spanning the object; the host
226    // adopts both endpoints (see the `Effect::SelectionChange` arm in
227    // `dispatch.rs`). The two-key chord (`[i, w]`) resolves via the
228    // same partial-chord machinery Normal uses -- see `dispatch_visual`.
229    for (chord_aliases, inner_id, around_id) in
230        crate::keymap_normal::text_object_rows(builtins, syntax_textobjects)
231    {
232        for (prefix_char, tobj) in [('i', inner_id), ('a', around_id)] {
233            for chord in &chord_aliases {
234                handle.bind(
235                    layer,
236                    mode,
237                    &[literal(KeyChord::char(prefix_char)), chord.clone()],
238                    CommandInvocation::of(tobj.0),
239                    source(),
240                );
241            }
242        }
243    }
244}
245
246fn literal(chord: KeyChord) -> ChordPattern {
247    ChordPattern::Literal(chord)
248}
249
250fn source() -> SourceLocation {
251    // Per-row file + caller line would require a macro; the
252    // line-of-this-helper is fine for slice 8.e -- the motion /
253    // operator id in the bound command already disambiguates the
254    // entry to `:describe-key`. A row-precise capture lands when
255    // the catalog enumeration replaces these inline calls (slice
256    // 8.i).
257    SourceLocation::builtin_file(file!(), line!())
258}
259
260/// Strip SHIFT / ALT / SUPER for the Visual trie lookup -- the
261/// catalog binds bare chords only (SHIFT on bare letters is already
262/// folded into the case by `KeyChord::from_event`). CTRL is filtered
263/// by the caller before this runs. Same treatment as Normal /
264/// Replace mode -- legacy `translate_visual` matched on `event.code`
265/// alone after the CONTROL guard, so non-CONTROL modifiers are
266/// transparent.
267/// What [`lookup_visual_chord`] matched with, and the chord that produced it.
268struct VisualLookup {
269    result: LookupResult,
270    resolved: KeyChord,
271}
272
273/// Look `path` up RAW first, falling back to the normalized form.
274///
275/// OS.0d, and the third and last copy of the defect OS.0b found in Insert and
276/// OS.0c found in Normal. `normalize_for_visual_lookup` strips ALT and SUPER —
277/// and, unlike its two siblings, SHIFT as well — off every incoming chord
278/// before any lookup, so a mode declaring `binding-mode: visual` on
279/// `<M-Right>` or `<M-S-Right>` registers correctly and can never fire.
280///
281/// The extra SHIFT strip is why this needed its own helper rather than reusing
282/// Normal's: `<M-S-Right>` differs from `<M-Right>` only in SHIFT, so a
283/// normalize-first lookup collapses the two org verbs onto one. Raw-first
284/// separates them and the fallback still answers for every bare-chord binding
285/// the Visual catalog has always relied on — `V` then `d` is unaffected,
286/// because raw and normalized are the same chord there.
287fn lookup_visual_chord(
288    handle: &KeymapHandle,
289    path: &[KeyChord],
290    chord: KeyChord,
291    active_minor_modes: &[lattice_mode::ModeId],
292) -> VisualLookup {
293    let mut raw_path: Vec<KeyChord> = path.to_vec();
294    raw_path.push(chord);
295    let raw = handle.lookup_with_context(BindingMode::Visual, &raw_path, active_minor_modes);
296    if matches!(raw, LookupResult::Bound { .. } | LookupResult::Partial) {
297        return VisualLookup {
298            result: raw,
299            resolved: chord,
300        };
301    }
302    let normalized = normalize_for_visual_lookup(chord);
303    if normalized == chord {
304        return VisualLookup {
305            result: raw,
306            resolved: chord,
307        };
308    }
309    let mut normalized_path: Vec<KeyChord> = path.to_vec();
310    normalized_path.push(normalized);
311    VisualLookup {
312        result: handle.lookup_with_context(
313            BindingMode::Visual,
314            &normalized_path,
315            active_minor_modes,
316        ),
317        resolved: normalized,
318    }
319}
320
321fn normalize_for_visual_lookup(chord: KeyChord) -> KeyChord {
322    KeyChord {
323        key: chord.key,
324        mods: chord
325            .mods
326            .without(KeyMods::SHIFT)
327            .without(KeyMods::ALT)
328            .without(KeyMods::SUPER),
329    }
330}
331
332/// Dispatch a Visual-mode key event through the keymap registry.
333///
334/// 1. CONTROL-bearing key -> `Action::None`. (Legacy short-
335///    circuited `CONTROL` and only `CONTROL`.)
336/// 2. Mid-sequence (a text-object prefix `i` / `a` already in
337///    `partial_chord`): resolve `[partial_chord..., chord]` against
338///    the Visual catalog. This is the SAME partial-chord machinery
339///    Normal mode uses -- `viw` is `v` then the two-key chord
340///    `[i, w]`, `vaf` is `[a, f]`, etc. There is NO per-object code:
341///    every text object in [`crate::keymap_normal::text_object_rows`]
342///    works automatically, exactly as the user asked ("we shouldn't
343///    have to do any custom handling of any text objects").
344/// 3. Fresh keystroke. Blockwise overlay: `Char('I')` / `Char('A')`
345///    go to `EnterBlockVisualInsert` / `EnterBlockVisualAppend`
346///    before lookup. Charwise / linewise fall through.
347/// 4. Single-chord lookup. `Bound` -> `Invoke` (folding any wildcard
348///    capture); `Partial` -> `AbsorbPartialChord` (a bare `i` / `a`
349///    starts a text object -- absorb it so the next key resolves the
350///    pair); `Unbound` -> `Action::None`.
351///
352/// `partial_chord` is the host's running multi-key prefix (the same
353/// `Editor::partial_chord` the Normal path threads); it is empty on a
354/// fresh chord and holds the absorbed `[i]` / `[a]` mid-text-object.
355pub fn dispatch_visual(
356    handle: &KeymapHandle,
357    chord: &KeyChord,
358    kind: VisualKind,
359    partial_chord: &[KeyChord],
360    active_minor_modes: &[lattice_mode::ModeId],
361) -> Action {
362    // SN.3d.2: `<C-g>` toggles Visual → Select (reserved in both modes
363    // for the toggle; select-mode.md §4). Must precede the CONTROL
364    // short-circuit below — symmetric with `translate_select`, which
365    // handles `<C-g>` as a hardcoded mode-control chord on the Select
366    // side. `<C-g>` is otherwise unbound in Visual today.
367    if chord.mods.ctrl() && matches!(chord.key, crate::chord::KeyKind::Char('g')) {
368        return Action::ToggleVisualSelect;
369    }
370    // CG.1 (2026-08-07): the blanket `if chord.mods.ctrl() { return
371    // Action::None }` that used to sit here is GONE. It short-circuited
372    // every CONTROL chord before the trie was consulted, so a binding
373    // registered under `BindingMode::Visual` with a CTRL chord could
374    // never fire — the same defect PBH.3 fixed in Normal, where the
375    // count-digit guard made every `<C-digit>` chord unreachable.
376    //
377    // It was invisible because the Visual catalog registers no CTRL
378    // chords, so nothing in-tree noticed. Plugins can: `keymap_host.rs`
379    // maps WIT `Visual` / `Select` straight through, so a plugin binding
380    // `<C-x>` in Visual got silent nothing — an extensibility hole
381    // (paramount goal #2), not just a missing feature.
382    //
383    // The trie is authoritative now. `<C-g>` above stays a hardcoded
384    // arm because SN.3d's toggle is mode *control* (it changes which
385    // dispatcher runs), not a command lookup.
386
387    // Mid-sequence: a text-object prefix was absorbed last keystroke.
388    // Resolve the full path; the blockwise `I` / `A` overlay does not
389    // apply here (it is a fresh-chord-only shortcut).
390    if !partial_chord.is_empty() {
391        let found = lookup_visual_chord(handle, partial_chord, *chord, active_minor_modes);
392        return match found.result {
393            LookupResult::Bound { command, captured } => {
394                crate::keymap_normal::action_from_bound_with_capture(&command, &captured)
395            }
396            LookupResult::Partial => Action::AbsorbPartialChord(found.resolved),
397            LookupResult::Unbound => Action::None,
398        };
399    }
400
401    if matches!(kind, VisualKind::Blockwise) {
402        match chord.key {
403            crate::chord::KeyKind::Char('I') => return Action::EnterBlockVisualInsert,
404            crate::chord::KeyKind::Char('A') => return Action::EnterBlockVisualAppend,
405            _ => {}
406        }
407    }
408    let found = lookup_visual_chord(handle, &[], *chord, active_minor_modes);
409    let chord = found.resolved;
410    match found.result {
411        LookupResult::Bound { command, captured } => {
412            crate::keymap_normal::action_from_bound_with_capture(&command, &captured)
413        }
414        // A bare `i` / `a` is a text-object prefix: absorb it so the
415        // next key resolves `[i / a, obj]`. (Before text objects
416        // landed, Visual had no multi-key chords and this returned
417        // `Action::None`.)
418        LookupResult::Partial => Action::AbsorbPartialChord(chord),
419        LookupResult::Unbound => Action::None,
420    }
421}
422
423/// OS.0d regression tests: the Visual peer of `keymap_insert::os0b_tests` and
424/// `keymap_normal::os0c_tests`.
425///
426/// Visual's normalize strips SHIFT as well as ALT and SUPER, which is why it
427/// needed its own helper: `<M-S-Right>` differs from `<M-Right>` only in SHIFT,
428/// so normalizing first collapses two distinct org verbs onto one binding.
429#[cfg(test)]
430mod os0d_tests {
431    #![allow(clippy::unwrap_used, clippy::panic)]
432    use super::*;
433    use crate::chord::{KeyKind, SpecialKey};
434    use crate::keymap_trie::{ChordPattern, KeymapLayer};
435    use lattice_grammar::SourceLocation;
436    use lattice_grammar::command::CommandInvocation;
437    use lattice_mode::ModeId;
438    use lattice_protocol::ids::CommandId;
439
440    fn source() -> SourceLocation {
441        SourceLocation::builtin_file(file!(), line!())
442    }
443
444    fn lit(chord: KeyChord) -> ChordPattern {
445        ChordPattern::Literal(chord)
446    }
447
448    fn bound_command(result: &LookupResult) -> CommandId {
449        match result {
450            LookupResult::Bound { command, .. } => command.command.command,
451            other => panic!("expected Bound, got {other:?}"),
452        }
453    }
454
455    fn bind_visual(h: &KeymapHandle, mode: ModeId, chord: KeyChord, id: CommandId) {
456        h.bind(
457            KeymapLayer::MajorMode(mode),
458            BindingMode::Visual,
459            &[lit(chord)],
460            CommandInvocation::of(id),
461            source(),
462        );
463    }
464
465    /// An ALT-bearing Visual binding must reach the trie as pressed.
466    #[test]
467    fn an_alt_bearing_visual_binding_is_reachable() {
468        let h = KeymapHandle::new();
469        let mode = ModeId::new("os0d-alt-mode");
470        let id = CommandId::new(u64::MAX - 21);
471        let chord = KeyChord::new(KeyKind::Special(SpecialKey::Right), KeyMods::ALT);
472        bind_visual(&h, mode, chord, id);
473
474        let found = lookup_visual_chord(&h, &[], chord, &[mode]);
475        assert_eq!(bound_command(&found.result), id);
476        assert_eq!(found.resolved, chord);
477    }
478
479    /// The `&[mode]` on every lookup below is load-bearing, not boilerplate:
480    /// `bind_visual` binds into `KeymapLayer::MajorMode(mode)`, and since the
481    /// Visual dispatcher started gating on the active-mode slice a binding is
482    /// only reachable when its mode is active. These read `&[]` until then —
483    /// and passed, because the lookup ignored activation for every mode in the
484    /// editor. That is the bug they now have to be correct about.
485    ///
486    /// **The reason this needed its own helper.** `<M-S-Right>` and `<M-Right>`
487    /// differ only in SHIFT, which Visual's normalize strips — so a
488    /// normalize-first lookup would collapse OS.6's two verbs onto one.
489    #[test]
490    fn shift_alt_and_alt_are_different_bindings() {
491        let h = KeymapHandle::new();
492        let mode = ModeId::new("os0d-shift-mode");
493        let plain = CommandId::new(u64::MAX - 22);
494        let shifted = CommandId::new(u64::MAX - 23);
495        let alt_right = KeyChord::new(KeyKind::Special(SpecialKey::Right), KeyMods::ALT);
496        let shift_alt_right = KeyChord::new(
497            KeyKind::Special(SpecialKey::Right),
498            KeyMods::ALT | KeyMods::SHIFT,
499        );
500        bind_visual(&h, mode, alt_right, plain);
501        bind_visual(&h, mode, shift_alt_right, shifted);
502
503        assert_eq!(
504            bound_command(&lookup_visual_chord(&h, &[], alt_right, &[mode]).result),
505            plain
506        );
507        assert_eq!(
508            bound_command(&lookup_visual_chord(&h, &[], shift_alt_right, &[mode]).result),
509            shifted,
510            "<M-S-Right> must not collapse onto <M-Right>"
511        );
512    }
513
514    /// What must NOT change: the Visual catalog binds bare chords, and an
515    /// incoming SHIFT-bearing chord still falls back to the bare binding.
516    #[test]
517    fn a_bare_binding_is_still_reached_through_the_fallback() {
518        let h = KeymapHandle::new();
519        let mode = ModeId::new("os0d-bare-mode");
520        let id = CommandId::new(u64::MAX - 24);
521        let bare = KeyChord::new(KeyKind::Special(SpecialKey::Right), KeyMods::NONE);
522        bind_visual(&h, mode, bare, id);
523
524        let shifted = KeyChord::new(KeyKind::Special(SpecialKey::Right), KeyMods::SHIFT);
525        let found = lookup_visual_chord(&h, &[], shifted, &[mode]);
526        assert_eq!(
527            bound_command(&found.result),
528            id,
529            "<S-Right> with nothing claiming it still falls back to <Right>"
530        );
531        assert_eq!(
532            found.resolved, bare,
533            "and that is the form a Partial absorbs"
534        );
535    }
536}