Skip to main content

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>", &registry());
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>", &reg);
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>", &registry());
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-", &registry());
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>", &registry());
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}