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}