lattice_mode/emacs_keys_mode.rs
1//! `emacs-keys-mode` — a default-on builtin minor mode contributing an
2//! emacs-style `<C-x>` leader (a tribute layer). Design:
3//! `docs/dev/architecture/emacs-keys.md`; sequencing:
4//! `docs/dev/operations/slice-plans/emacs-keys.md`.
5//!
6//! ## Home (BC.5, 2026-06-23)
7//!
8//! Moved here from `lattice-host` and reclassified as a **builtin** mode: it
9//! is default-on + `Universal`, has no owning feature crate, and is
10//! renderer-agnostic — so it belongs with the foundation modes in
11//! `lattice-mode` (registered via [`register_foundation_modes`](crate::register_foundation_modes)). All the
12//! keymap-trie types its layer builder needs (`KeymapTrie` / `BoundCommand` /
13//! `KeymapLayer` from `lattice-keymap`, `ChordPattern` from `lattice-protocol`,
14//! `CommandRegistry` / `CommandInvocation` from `lattice-grammar`) live below
15//! `lattice-mode`, so the *whole* module moves. The host retains only the
16//! keymap-layer **push** (it owns the live `KeymapHandle` + reads `config` for
17//! the prefix / enable flag), calling [`emacs_keys_layer_bindings`] here.
18//!
19//! ## Shape
20//!
21//! A marker minor mode (`Guard = ()`) with `ActivationPolicy::Universal`, so
22//! it auto-activates on every buffer. Its keymap layer is pushed once at boot
23//! under `MinorMode(emacs-keys-mode)`; K.1.c's per-keystroke filter gates the
24//! chords to buffers where the mode is active — the `diff-mode` pattern.
25//!
26//! ## Configurable prefix
27//!
28//! Every binding's chord is `prefix + suffix`, parsed as one key sequence
29//! ([`lattice_protocol::parse_chord_sequence`]). The prefix defaults to
30//! `"<C-x>"`; rebuilding the layer with a different prefix re-targets the
31//! whole tribute. A malformed prefix (user config) or an unknown command
32//! name skips that one binding with a warning — never a panic on the boot
33//! path (graceful-degradation contract).
34
35use crate::registry::ModeRegistry;
36use crate::{ActivationPolicy, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind};
37
38/// The `emacs-keys` minor mode. A marker mode: it owns a keymap layer and
39/// an activation policy, but allocates no per-buffer resources.
40pub struct EmacsKeysMode;
41
42impl EmacsKeysMode {
43 /// The canonical id, `"emacs-keys-mode"` — also the key of the
44 /// `MinorMode` keymap layer the host pushes. Distinct from the
45 /// user-facing option name `emacs-keys`.
46 pub fn mode_id() -> ModeId {
47 // The mode id carries the conventional `-mode` suffix (like
48 // `snippet-mode`, `diff-mode`, …) so it reads as `emacs-keys-mode`
49 // in `:describe-mode` / mode listings. The user-facing *option*
50 // is the bare `emacs-keys` (`:set emacs-keys`) — a distinct name.
51 ModeId::new("emacs-keys-mode")
52 }
53}
54
55impl Mode for EmacsKeysMode {
56 type Guard = ();
57
58 fn id(&self) -> ModeId {
59 Self::mode_id()
60 }
61
62 fn kind(&self) -> ModeKind {
63 ModeKind::Minor
64 }
65
66 /// Default-on in **every** buffer — the tribute is universal, so the
67 /// `<C-x>` navigation chords (switch buffer, switch pane, quit) work
68 /// everywhere the user can focus, including synthetic buffers like
69 /// `*messages*` / help / file-tree (mirroring emacs, whose `C-x` map
70 /// is live in `*Messages*`). Hence `Universal`, not `Global`
71 /// (`Global` is document-only — the right scope for content modes
72 /// like snippets, not a universal leader).
73 ///
74 /// The enable toggle (`:set noemacs-keys`) is NOT gated here: the
75 /// mode stays unconditionally active and the *layer* carries the
76 /// gate — `enabled=false` rebuilds the leader map empty (see
77 /// `emacs_keys_layer_bindings`), so disabling reclaims `<C-x>`
78 /// without churning the per-buffer mode set. The layer is
79 /// Normal-mode-only, so Terminal-Insert keystroke passthrough is
80 /// unaffected and the leader never shadows a synthetic buffer's own
81 /// Normal-mode chords (Help's Esc/Enter/`-` are distinct keys).
82 fn activation_policy(&self) -> ActivationPolicy {
83 ActivationPolicy::Universal
84 }
85
86 fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
87 Box::pin(async { Ok(()) })
88 }
89}
90
91/// Register `emacs-keys-mode` against `registry`. Called from
92/// [`register_foundation_modes`](crate::register_foundation_modes) — it is a
93/// builtin, so there is no separate boot call.
94pub fn register_emacs_keys_mode(registry: &mut ModeRegistry) {
95 registry
96 .register(EmacsKeysMode)
97 .expect("emacs-keys-mode must register without conflict");
98}
99
100/// The default leader-map. `(suffix, command canonical name)`; the chord
101/// bound is `prefix + suffix`. Every target is an existing command (ex or
102/// action) resolved by name at build time — no new command is introduced
103/// by S1/S2.
104///
105/// Tier-1 (S1) — buffer / file / save. emacs convention: `b` switches
106/// buffers (the picker), `<C-b>` lists them — distinct chords, mirroring
107/// `switch-to-buffer` vs `list-buffers`.
108const TIER1_BINDINGS: &[(&str, &str)] = &[
109 ("<C-f>", "ex:files"), // C-x C-f — find file (picker)
110 ("<C-s>", "ex:write"), // C-x C-s — save buffer
111 ("b", "ex:buffer-picker"), // C-x b — switch buffer
112 ("<C-b>", "ex:buffers"), // C-x C-b — list buffers
113 ("k", "ex:bdelete"), // C-x k — kill buffer
114 // S3a: emacs `C-x C-c` = save-buffers-kill-emacs. Targets the
115 // dirty-guarded `:qa` (quit every pane + tab), not the brute
116 // `<C-c>` quit — so unsaved changes are honored, mirroring emacs.
117 ("<C-c>", "ex:quit-all"), // C-x C-c — quit all (dirty-guarded)
118];
119
120/// Tier-2 (S2) — pane / window. Targets the pre-registered `action:*`
121/// pane commands (`crates/lattice-host/src/actions.rs`), reused verbatim;
122/// the leader is just a second entry point to the same `CommandId`s the
123/// `<C-w>` family already binds. The digit suffixes are matched as literal
124/// second chords after the `<C-x>` partial, so they never enter count
125/// accumulation (mirrors emacs `C-x 2` / `C-x 3` / `C-x 0`).
126///
127/// emacs: `2` splits below, `3` splits right, `0` deletes this window,
128/// `1` deletes other windows, `o` cycles focus. Lattice's split axis
129/// names are locked per the slice plan (`2`→horizontal, `3`→vertical).
130const TIER2_BINDINGS: &[(&str, &str)] = &[
131 ("2", "action:split-pane-horizontal"), // C-x 2 — split below
132 ("3", "action:split-pane-vertical"), // C-x 3 — split right
133 ("0", "action:close-pane"), // C-x 0 — delete this pane
134 ("1", "action:only-pane"), // C-x 1 — delete other panes (S3b)
135 ("o", "action:next-pane"), // C-x o — focus other pane
136];
137
138/// Build the `emacs-keys` keymap layer for the given `enabled` flag and
139/// `prefix`, resolving each binding's command name against `registry`.
140/// Returns the per-mode trie map the host pushes under
141/// `MinorMode(emacs-keys-mode)`.
142///
143/// `enabled` is the `:set emacs-keys` toggle: `false` yields an EMPTY
144/// Normal trie. The layer is the gate (the mode itself stays a marker),
145/// so re-pushing an empty layer on `:set noemacs-keys` clears the leader
146/// live — `<C-x>` falls through to plain Normal-mode resolution.
147///
148/// Graceful degradation: an unparseable `prefix + suffix` chord (bad user
149/// config) or an unregistered command name skips that binding with a
150/// `warn!` rather than aborting. A wholly-malformed prefix therefore also
151/// yields an empty tribute instead of a panic.
152pub fn emacs_keys_layer_bindings(
153 enabled: bool,
154 prefix: &str,
155 registry: &lattice_grammar::CommandRegistry,
156) -> std::collections::HashMap<crate::BindingMode, lattice_keymap::KeymapTrie> {
157 use crate::BindingMode;
158 use lattice_grammar::CommandInvocation;
159 use lattice_grammar::source::SourceLocation;
160 use lattice_keymap::{BoundCommand, KeymapLayer, KeymapTrie};
161 use lattice_protocol::chord::ChordPattern;
162 use std::collections::HashMap;
163 use std::sync::Arc;
164
165 let layer = KeymapLayer::MinorMode(EmacsKeysMode::mode_id());
166 let mut trie = KeymapTrie::new();
167
168 // Disabled => publish an empty Normal trie so a re-push clears any
169 // prior bindings. `<C-x>` then resolves as plain Normal-mode input.
170 let bindings: &[&[(&str, &str)]] = if enabled {
171 &[TIER1_BINDINGS, TIER2_BINDINGS]
172 } else {
173 &[]
174 };
175 for (suffix, command) in bindings.iter().copied().flatten() {
176 let chord_str = format!("{prefix}{suffix}");
177 let seq = match lattice_protocol::parse_chord_sequence(&chord_str) {
178 Ok(seq) => seq,
179 Err(err) => {
180 tracing::warn!(
181 chord = %chord_str,
182 ?err,
183 "emacs-keys: skipping binding -- unparseable chord (check `emacs-keys-prefix`)"
184 );
185 continue;
186 }
187 };
188 let Some(id) = registry.id_by_name(command) else {
189 tracing::warn!(
190 command,
191 "emacs-keys: skipping binding -- command not registered"
192 );
193 continue;
194 };
195 let pattern: Vec<ChordPattern> = seq.into_iter().map(ChordPattern::Literal).collect();
196 trie.insert(
197 &pattern,
198 Arc::new(BoundCommand::from_invocation(
199 CommandInvocation::of(id),
200 SourceLocation::builtin_file(file!(), line!()),
201 layer,
202 )),
203 );
204 }
205
206 let mut modes = HashMap::new();
207
208 modes.insert(BindingMode::Normal, trie);
209 modes
210}
211
212#[cfg(test)]
213mod tests {
214 #![allow(clippy::unwrap_used)]
215 use std::sync::Arc;
216
217 use super::*;
218 use crate::BindingMode;
219 use lattice_keymap::LookupResult;
220
221 fn registry() -> lattice_grammar::CommandRegistry {
222 let mut r = lattice_grammar::CommandRegistry::new();
223 let _builtins = lattice_grammar::builtins::populate(&mut r);
224 let _ = lattice_grammar::ex_commands::populate(&mut r);
225 // Tier-2 resolves the host `action:*` pane commands. The host's
226 // `actions::populate` is not reachable here (it lives in
227 // `lattice-host`), so register the five pane-action names as minimal
228 // no-op action commands — `emacs_keys_layer_bindings` only needs
229 // `id_by_name` to resolve them.
230 for name in [
231 "action:split-pane-horizontal",
232 "action:split-pane-vertical",
233 "action:close-pane",
234 "action:only-pane",
235 "action:next-pane",
236 ] {
237 r.register_action(
238 name,
239 "test pane action",
240 lattice_grammar::registry::ActionSpec {
241 apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
242 args_schema: vec![],
243 },
244 );
245 }
246 r
247 }
248
249 fn seq(s: &str) -> Vec<lattice_protocol::chord::KeyChord> {
250 lattice_protocol::parse_chord_sequence(s).unwrap()
251 }
252
253 #[test]
254 fn mode_id_uses_the_mode_suffix() {
255 // Convention (mode_id.rs): every mode id ends in `-mode`. The
256 // emacs-keys mode is `emacs-keys-mode`, distinct from the
257 // `emacs-keys` *option* (`:set emacs-keys`). Guards the rename.
258 assert_eq!(EmacsKeysMode::mode_id().as_str(), "emacs-keys-mode");
259 assert!(EmacsKeysMode::mode_id().as_str().ends_with("-mode"));
260 }
261
262 #[test]
263 fn default_prefix_binds_every_tier1_chord() {
264 let modes = emacs_keys_layer_bindings(true, "<C-x>", ®istry());
265 let trie = modes.get(&BindingMode::Normal).unwrap();
266 // Each full chord resolves to a terminal binding.
267 for full in [
268 "<C-x><C-f>",
269 "<C-x><C-s>",
270 "<C-x>b",
271 "<C-x><C-b>",
272 "<C-x>k",
273 "<C-x><C-c>", // S3a: quit-all
274 ] {
275 assert!(
276 matches!(trie.lookup(&seq(full)), LookupResult::Bound { .. }),
277 "expected `{full}` to be bound"
278 );
279 }
280 // The bare prefix is a pending partial (waits for the suffix).
281 assert!(matches!(trie.lookup(&seq("<C-x>")), LookupResult::Partial));
282 // An unmapped suffix under the prefix is unbound (falls through).
283 assert!(matches!(trie.lookup(&seq("<C-x>z")), LookupResult::Unbound));
284 }
285
286 #[test]
287 fn default_prefix_binds_every_tier2_pane_chord() {
288 let reg = registry();
289 let modes = emacs_keys_layer_bindings(true, "<C-x>", ®);
290 let trie = modes.get(&BindingMode::Normal).unwrap();
291 // The digit suffixes (`2` / `3` / `0`) are matched as literal
292 // second chords after the `<C-x>` partial -- they never enter
293 // count accumulation -- alongside the `o` letter suffix.
294 for full in ["<C-x>2", "<C-x>3", "<C-x>0", "<C-x>1", "<C-x>o"] {
295 assert!(
296 matches!(trie.lookup(&seq(full)), LookupResult::Bound { .. }),
297 "expected pane chord `{full}` to be bound"
298 );
299 }
300 // The split-axis wiring is correct (catches a name swap):
301 // `<C-x>2` targets the horizontal split specifically.
302 let LookupResult::Bound { command, .. } = trie.lookup(&seq("<C-x>2")) else {
303 panic!("`<C-x>2` should be bound");
304 };
305 assert_eq!(
306 command.command.command,
307 reg.id_by_name("action:split-pane-horizontal").unwrap(),
308 "`<C-x>2` must target action:split-pane-horizontal"
309 );
310 }
311
312 #[test]
313 fn alternate_prefix_retargets_the_whole_map() {
314 let modes = emacs_keys_layer_bindings(true, "<C-c>", ®istry());
315 let trie = modes.get(&BindingMode::Normal).unwrap();
316 // The new prefix is live...
317 assert!(matches!(
318 trie.lookup(&seq("<C-c><C-f>")),
319 LookupResult::Bound { .. }
320 ));
321 // ...and the old one is gone.
322 assert!(matches!(
323 trie.lookup(&seq("<C-x><C-f>")),
324 LookupResult::Unbound
325 ));
326 }
327
328 #[test]
329 fn malformed_prefix_degrades_to_empty_no_panic() {
330 // A garbage prefix can't parse into a chord; every binding skips,
331 // leaving an empty tribute rather than panicking on boot.
332 let modes = emacs_keys_layer_bindings(true, "<C-", ®istry());
333 let trie = modes.get(&BindingMode::Normal).unwrap();
334 assert!(matches!(
335 trie.lookup(&seq("<C-x><C-f>")),
336 LookupResult::Unbound
337 ));
338 }
339
340 #[test]
341 fn disabled_yields_empty_layer() {
342 // `:set noemacs-keys` => enabled=false => the Normal trie is
343 // present but empty, so `<C-x>` and `<C-x><C-f>` both fall
344 // through (Unbound). Re-pushing this empty layer is how the live
345 // toggle reclaims `<C-x>`.
346 let modes = emacs_keys_layer_bindings(false, "<C-x>", ®istry());
347 let trie = modes.get(&BindingMode::Normal).unwrap();
348 assert!(matches!(
349 trie.lookup(&seq("<C-x><C-f>")),
350 LookupResult::Unbound
351 ));
352 assert!(matches!(trie.lookup(&seq("<C-x>")), LookupResult::Unbound));
353 }
354}