Skip to main content

lattice_host/
keymap_cancel.rs

1//! CG.1 — the foreground-cancel binding (`<C-g>`).
2//!
3//! Design: `docs/dev/architecture/cancellation.md`; sequencing:
4//! `docs/dev/operations/slice-plans/cancellation.md`.
5//!
6//! ## The chord
7//!
8//! `<C-g>` — emacs `keyboard-quit`. Registered at
9//! `KeymapLayer::Builtin`, so it works unconditionally rather than
10//! depending on `:set emacs-keys`.
11//!
12//! It is the only apt chord that is actually free. `<C-c>` — vim's own
13//! interrupt, and the obvious first choice — cannot be used: it is a
14//! mode *prefix* here (`<C-c>g` / `<C-c>f` magit dispatch, `<C-c><C-c>`
15//! / `<C-c><C-k>` commit-rebase-notes), and `KeymapTrie::lookup` returns
16//! `Bound` at a terminal node regardless of its children, so a depth-1
17//! binding makes every one of those chords unreachable. Of the remaining
18//! free CTRL chords (`a c g j k m x z`): `c` and `x` are prefixes,
19//! `j` / `m` are the literal LF / CR terminals send for Enter, `z` is
20//! the suspend convention, `a` is vim's increment and `k` is Insert's
21//! kill-to-end-of-line.
22//!
23//! ## Modes covered
24//!
25//! `Normal`, `Insert`, `Replace`, and the three readline surfaces —
26//! `Command`, `Search`, `Prompt`. The minibuffers resolve keys in their
27//! own contexts, not Insert's (`keymap-architecture.md` §5.7), so each
28//! needs its own entry: while they borrowed the Insert table this set
29//! named only the first three, and `<C-g>` went dead on a stuck `:`
30//! line the day they stopped borrowing it.
31//!
32//! **Not `Visual` or `Select`.** SN.3d owns `<C-g>` there as the
33//! Visual↔Select toggle — vim-canonical, and the only path between the
34//! two modes that preserves the selection (`select-mode.md` §4). Those
35//! arms are hardcoded ahead of the trie lookup in `dispatch_visual` /
36//! `native_select_action`, so they would win anyway; leaving the modes
37//! out of this set keeps the intent explicit rather than accidental.
38//!
39//! The cost is that Visual and Select have no cancel chord: from there
40//! it is `<Esc>` then `<C-g>`. Accepted deliberately — Visual is a
41//! transient state a user is rarely parked in while waiting on a scan,
42//! and the alternative was relocating a vim-canonical chord.
43//!
44//! ## Why not `<Esc>`
45//!
46//! An earlier revision of this slice folded cancellation into `<Esc>`,
47//! which is universal and never a prefix. It was reverted: vim users
48//! press `<Esc>` reflexively and constantly, so a long-running search
49//! would die to a habitual double-tap that carried no intent to cancel.
50//! Cancellation needs a key the user only presses on purpose.
51
52use lattice_grammar::{CommandInvocation, SourceLocation};
53
54use crate::actions::ActionIds;
55use crate::chord::KeyChord;
56use crate::keymap::BindingMode;
57use crate::keymap_registry::KeymapHandle;
58use crate::keymap_trie::{ChordPattern, KeymapLayer};
59
60/// Every mode `<C-g>` resolves to `action:cancel` in. See the module
61/// docs for why `Visual` / `Select` are absent and why `Command` /
62/// `Search` / `Prompt` each need an entry of their own.
63pub const CANCEL_MODES: &[BindingMode] = &[
64    BindingMode::Normal,
65    BindingMode::Insert,
66    BindingMode::Replace,
67    BindingMode::Command,
68    BindingMode::Search,
69    BindingMode::Prompt,
70];
71
72/// Register `<C-g>` → `action:cancel` under `KeymapLayer::Builtin` for
73/// every mode in [`CANCEL_MODES`].
74pub fn register_cancel_bindings(handle: &KeymapHandle, actions: &ActionIds) {
75    for mode in CANCEL_MODES {
76        handle.bind(
77            KeymapLayer::Builtin,
78            *mode,
79            &[ChordPattern::Literal(KeyChord::ctrl('g'))],
80            CommandInvocation::of(actions.cancel),
81            SourceLocation::builtin_file(file!(), cancel_line()),
82        );
83    }
84}
85
86/// Line reported by `:describe-key <C-g>`. A `const fn` for the same
87/// reason `keymap_replace` uses them: `line!()` inside the `bind` call
88/// would report the argument's line, which drifts on every reformat.
89const fn cancel_line() -> u32 {
90    line!()
91}
92
93#[cfg(test)]
94mod tests {
95    #![allow(clippy::unwrap_used, clippy::panic)]
96    use super::*;
97    use crate::keymap_trie::LookupResult;
98
99    fn shared_actions() -> &'static ActionIds {
100        use std::sync::OnceLock;
101        static A: OnceLock<ActionIds> = OnceLock::new();
102        A.get_or_init(|| {
103            let mut r = lattice_grammar::CommandRegistry::new();
104            let b = lattice_grammar::builtins::populate(&mut r);
105            let _ = lattice_grammar::ex_commands::populate(&mut r);
106            crate::actions::populate(&mut r, &b)
107        })
108    }
109
110    fn handle() -> KeymapHandle {
111        let h = KeymapHandle::new();
112        register_cancel_bindings(&h, shared_actions());
113        h
114    }
115
116    #[test]
117    fn ctrl_g_is_bound_in_every_cancel_mode() {
118        let h = handle();
119        let actions = shared_actions();
120        for mode in CANCEL_MODES {
121            match h.lookup(*mode, &[KeyChord::ctrl('g')]) {
122                LookupResult::Bound { command, .. } => assert_eq!(
123                    command.command.command, actions.cancel,
124                    "<C-g> in {mode:?} must resolve to action:cancel"
125                ),
126                other => panic!("<C-g> unbound in {mode:?}: {other:?}"),
127            }
128        }
129    }
130
131    /// SN.3d owns `<C-g>` in Visual and Select. Their handlers are
132    /// hardcoded ahead of the trie so a stray registration here would
133    /// not actually break the toggle — which is exactly why it needs a
134    /// test: the damage would be silent, surfacing only as a confusing
135    /// `:describe-key` and as a trap for whoever later removes those
136    /// hardcoded arms.
137    #[test]
138    fn visual_and_select_are_left_to_the_sn3d_toggle() {
139        let h = handle();
140        for mode in [BindingMode::Visual, BindingMode::Select] {
141            assert!(
142                !CANCEL_MODES.contains(&mode),
143                "{mode:?} must stay out of the cancel set"
144            );
145            assert!(
146                matches!(
147                    h.lookup(mode, &[KeyChord::ctrl('g')]),
148                    LookupResult::Unbound
149                ),
150                "<C-g> must stay unbound in {mode:?} (SN.3d's toggle)"
151            );
152        }
153    }
154
155    /// `<C-c>` is a mode prefix. A terminal binding at Builtin resolves
156    /// before its own children and would make `<C-c>g` (magit dispatch)
157    /// and friends unreachable — the regression that moved this slice
158    /// off `<C-c>` in the first place.
159    #[test]
160    fn ctrl_c_is_never_claimed_here() {
161        let h = handle();
162        for mode in CANCEL_MODES {
163            assert!(
164                matches!(
165                    h.lookup(*mode, &[KeyChord::ctrl('c')]),
166                    LookupResult::Unbound
167                ),
168                "<C-c> must stay free in {mode:?} — it is a prefix"
169            );
170        }
171    }
172}