Skip to main content

lattice_mode/modes/
which_key.rs

1//! Which-key — the pending-chord discoverability subsystem.
2//!
3//! Design: `docs/dev/architecture/which-key.md` (§3 ownership, §5
4//! lifecycle, §8 options). Slice plan:
5//! `docs/dev/operations/slice-plans/archive/which-key.md` (WK.6).
6//!
7//! Hold a prefix; after a short idle delay a popup shows what can come
8//! next, derived from the live composite keymap the dispatcher itself
9//! walks — never from the static catalog (design §2, and the bug
10//! `:describe-bindings` still has).
11//!
12//! ## Shape
13//!
14//! Four moving parts, all owned here:
15//!
16//! 1. [`install`] wires the subsystem against the generic
17//!    [`SubsystemBoot`](crate::SubsystemBoot) surface. It adds ZERO
18//!    `Editor::` methods and ZERO host `Action` variants — the
19//!    mode-ownership acid test.
20//! 2. A `PartialChordPending` subscription stashes the payload and arms
21//!    an idle gate.
22//! 3. The gate's handler builds the model + grid and emits
23//!    `Effect::OpenPopup`.
24//! 4. [`WhichKeyMode`] is the popup buffer's major mode; its
25//!    `on_activate` writes the stashed grid into the buffer it was
26//!    activated on.
27//!
28//! ## The popup is passive
29//!
30//! `PopupFocus::Passive` — the document keeps focus, the caret and the
31//! modal state, so **every keystroke continues to flow to the trie
32//! unchanged**. A hint that changed what a chord does would be a vim
33//! deviation nobody asked for, and one that failed differently for every
34//! prefix (a transient-style takeover's `<C-n>` shadows a real `n`
35//! continuation under `<C-w>`, and a real `j` under `g`) is the worst
36//! shape of that failure. See design §7.
37
38use std::sync::{Arc, Mutex};
39
40use lattice_config::ConfigRegistry;
41use lattice_core::ui::popup::{PopupFocus, PopupPlacement};
42use lattice_grammar::CommandRegistryHandle;
43use lattice_grammar::effect::Effect;
44use lattice_keymap::PartialChordPending;
45use lattice_keymap::which_key::{GridOpts, GridSpanKind, RenderedGrid, Sort, layout_grid};
46
47use crate::{
48    BufferStoreHandle, CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
49    SubsystemBoot,
50};
51
52/// The popup buffer's registered name.
53pub const WHICH_KEY_BUFFER_NAME: &str = "*which-key*";
54
55lattice_config::groups! {
56    /// Pending-chord discoverability.
57    pub WhichKey = "which-key";
58}
59
60lattice_config::options! {
61    group = WhichKey;
62
63    /// Show the pending-chord popup at all.
64    #[name("which-key.enabled")]
65    pub WhichKeyEnabled: bool = true;
66
67    /// Milliseconds a prefix must sit pending before the popup appears.
68    /// `0` shows it immediately. The delay is what separates a hint from
69    /// a stutter: a user who knows their chord finishes it well inside
70    /// the window and never sees a frame of popup.
71    #[name("which-key.delay")]
72    pub WhichKeyDelay: i64 = 300;
73
74    /// Maximum content rows. Hard-capped at half the pane regardless.
75    #[name("which-key.max-height")]
76    pub WhichKeyMaxHeight: i64 = 12;
77
78    /// Maximum grid columns.
79    #[name("which-key.max-columns")]
80    pub WhichKeyMaxColumns: i64 = 6;
81
82    /// Row ordering: `key` (digits, lowercase, uppercase, punctuation,
83    /// special, modifier-bearing) or `label`.
84    #[name("which-key.sort")]
85    pub WhichKeySort: String = String::from("key");
86}
87
88/// What the subscription stashes for the gate handler to read. Carrying
89/// the whole event is deliberate — see `PartialChordPending`'s docs for
90/// why the payload rides rather than being read back.
91type Stash = Arc<Mutex<Option<PartialChordPending>>>;
92
93/// The grid the gate handler laid out, for [`WhichKeyMode::on_activate`]
94/// to write into the popup buffer. A mode cannot create the buffer it is
95/// activating on, and the handler cannot write a buffer that does not
96/// exist yet, so the content crosses between them here.
97///
98/// Carries the SPANS as well as the lines (WK.9). They are produced by
99/// the layout, which knows the byte offset it wrote each key at;
100/// recovering them by re-scanning padded rows would be a guess.
101type PendingGrid = Arc<Mutex<RenderedGrid>>;
102
103/// Major mode for `*which-key*`.
104pub struct WhichKeyMode {
105    grid: PendingGrid,
106}
107
108impl WhichKeyMode {
109    /// The canonical id, `"which-key-mode"` — what [`Mode::id`](crate::Mode::id)
110    /// returns. Use it to name this mode without an instance (activation,
111    /// `implies`, keymap layers, tests).
112    pub fn mode_id() -> ModeId {
113        ModeId::new("which-key-mode")
114    }
115}
116
117impl Mode for WhichKeyMode {
118    type Guard = ();
119
120    fn id(&self) -> ModeId {
121        Self::mode_id()
122    }
123
124    fn kind(&self) -> ModeKind {
125        ModeKind::Major
126    }
127
128    /// Read-only and file-less. `ReadOnly` alone gates typing only, so
129    /// `read-only-mode` is implied for the operator gate — declared on
130    /// the MAJOR, since an implied mode is followed from the mode being
131    /// activated.
132    fn implies(&self) -> &[ModeId] {
133        static IMPLIED: std::sync::OnceLock<Vec<ModeId>> = std::sync::OnceLock::new();
134        IMPLIED.get_or_init(|| vec![crate::modes::ReadOnlyMode::mode_id()])
135    }
136
137    fn options(&self) -> lattice_config::OptionOverrideSet {
138        lattice_config::overrides! {
139            lattice_config::ReadOnly = true,
140            lattice_config::NoFile = true,
141            // A hint with a gutter of line numbers reads as a document.
142            lattice_config::Number = false,
143        }
144    }
145
146    fn required_capabilities(&self) -> CapabilitySet {
147        CapabilitySet::empty()
148    }
149
150    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, ()> {
151        let grid = Arc::clone(&self.grid);
152        Box::pin(async move {
153            let (text, spans) = {
154                let g = grid.lock().unwrap_or_else(|e| e.into_inner());
155                (g.lines.join("\n"), g.spans.clone())
156            };
157            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
158            let Some(store) = ctx.service::<BufferStoreHandle>() else {
159                // No buffer store wired (a test harness); the popup opens
160                // empty rather than panicking. Log-and-skip, at debug —
161                // this is keystroke-adjacent.
162                tracing::debug!("which-key: no buffer store; popup left empty");
163                return Ok(());
164            };
165            let Some(handle) = store.handle_for(buffer_id) else {
166                tracing::debug!(?buffer_id, "which-key: popup buffer vanished");
167                return Ok(());
168            };
169            let snap = handle.snapshot();
170            let last_line = snap.buffer.rope_line_count().saturating_sub(1);
171            let last_len = snap.buffer.line(last_line).unwrap_or_default().len() as u32;
172            let range = lattice_protocol::Range::new(
173                lattice_protocol::position::Position::new(0, 0),
174                lattice_protocol::position::Position::new(last_line, last_len),
175            );
176            let _ = handle
177                .apply_edit_batch(vec![lattice_protocol::edit::Edit::replace(range, text)])
178                .await;
179
180            // WK.9: emphasise the keys. Written through the same
181            // `PendingSyntheticHighlights` path magit's buffers use, so it
182            // lands in the buffer's `ExtraHighlights` local and both
183            // renderers paint it with no peer-side change.
184            //
185            // `Style::HelpKey` rather than a which-key-specific element:
186            // it already means "a key or chord you press" and is already
187            // themed everywhere, so a chord looks the same in `:help` as
188            // it does in the hint. A second name for one concept is a
189            // second thing to keep in sync.
190            // `PendingSyntheticHighlights`, NOT the `…Handle` alias: boot
191            // registers the bare type, and the ServiceRegistry keys on the
192            // exact `T`. Asking for the alias compiles, returns `None`, and
193            // leaves the popup permanently unstyled with nothing to show for
194            // it — the Arc/TypeId trap, which is why this names the same type
195            // magit's producers name.
196            if let Some(ph) = ctx.service::<crate::PendingSyntheticHighlights>() {
197                let styled: Vec<Vec<lattice_cells::StyledSpan>> = spans
198                    .iter()
199                    .map(|row| {
200                        row.iter()
201                            .map(|s| lattice_cells::StyledSpan {
202                                start: s.start,
203                                end: s.end,
204                                style: match s.kind {
205                                    GridSpanKind::Key => lattice_cells::Style::HelpKey,
206                                    // Structure, not something to press.
207                                    GridSpanKind::Group => lattice_cells::Style::Markup,
208                                },
209                            })
210                            .collect()
211                    })
212                    .collect();
213                ph.store_and_wake(buffer_id, styled);
214            }
215            Ok(())
216        })
217    }
218}
219
220/// Register `which-key-mode`. Phase B, in the host's install list.
221///
222/// ## Why this is two calls and not one
223///
224/// The design's acid test wants a subsystem to touch the host in exactly
225/// one place. Which-key cannot, and the reason is boot ordering rather
226/// than design: the MODE registry freezes early (`freeze_mode_registry`,
227/// before commands are all registered), while the COMMAND registry
228/// handle this subsystem needs at popup-build time — rungs 2 and 3 of
229/// the label chain — only exists after `freeze_command_registry`, much
230/// later. A single install call would have to sit on one side of that
231/// gap or the other: register the mode and get `None` for the command
232/// service (every label degrading to `<unbound>`, silently), or resolve
233/// the services and panic registering a mode into a frozen registry.
234///
235/// So: [`install`] registers the mode, [`wire`] wires the lifecycle once
236/// the handles exist. Two lines in `editor_boot`, no `Editor::` method
237/// and no host `Action` variant — the part of the acid test that is
238/// actually about ownership still holds.
239pub fn install(boot: &mut impl SubsystemBoot) -> WhichKeyGrid {
240    let grid: PendingGrid = Arc::new(Mutex::new(RenderedGrid::default()));
241    // A duplicate registration is a boot-order bug, not a runtime
242    // condition — log it and carry on rather than unwrapping.
243    if let Err(e) = boot.modes_mut().register(WhichKeyMode {
244        grid: Arc::clone(&grid),
245    }) {
246        tracing::debug!(error = %e, "which-key: mode already registered");
247    }
248    WhichKeyGrid(grid)
249}
250
251/// The grid cell [`install`] created, handed to [`wire`] so both halves
252/// write and read the same one. Opaque: the host only carries it between
253/// the two calls.
254pub struct WhichKeyGrid(PendingGrid);
255
256/// Wire which-key's lifecycle: the idle gate, the `PartialChordPending`
257/// subscription, and the dismissal path. Called after the keymap and
258/// command-registry services are registered — see [`install`] for why
259/// that cannot be the same call.
260pub fn wire(boot: &mut impl SubsystemBoot, grid: WhichKeyGrid) {
261    let grid = grid.0;
262    let stash: Stash = Arc::new(Mutex::new(None));
263
264    // WK.11: whether OUR popup is on screen — set by the gate body when it
265    // actually returns `OpenPopup`, cleared by the dismissal below.
266    //
267    // This used to be a `bool` local to the inbound handler, set when the gate
268    // was ARMED. Arming and opening are not the same event: the gate body has
269    // five paths that open nothing (the prefix evaporated, a service is
270    // missing, the prefix has no continuations, the node is empty, the pane is
271    // too narrow) and it does not run at all when the chord completes inside
272    // the delay. So the flag meant "a prefix was pending", and every two-key
273    // chord typed faster than `which-key.delay` — `zz`, `gg`, `dd`, `ci"` —
274    // ended by dismissing a popup which-key had never opened. Somebody else's,
275    // whatever happened to be showing.
276    //
277    // Shared rather than local because the two halves that know the truth are
278    // different closures: the gate opens, the inbound handler dismisses.
279    let popup_open = Arc::new(std::sync::atomic::AtomicBool::new(false));
280
281    let config = boot.service::<Arc<ConfigRegistry>>();
282    let keymap = boot.service::<lattice_keymap::KeymapHandle>();
283    let commands = boot.service::<CommandRegistryHandle>();
284
285    // The gate's body: the delay elapsed with a prefix still pending.
286    let gate = Arc::new(boot.idle_gate(
287        "which-key",
288        Box::new({
289            let stash = Arc::clone(&stash);
290            let grid = Arc::clone(&grid);
291            let config = config.clone();
292            let popup_open = Arc::clone(&popup_open);
293            move || {
294                let Some(pending) = stash
295                    .lock()
296                    .unwrap_or_else(|e| e.into_inner())
297                    .clone()
298                    .filter(|p| !p.chords.is_empty())
299                else {
300                    // The prefix evaporated during the delay (the chord
301                    // resolved, a mode deactivated, `:map` rebuilt the trie).
302                    // No popup, no complaint.
303                    return Vec::new();
304                };
305                let (Some(keymap), Some(commands)) = (keymap.as_ref(), commands.as_ref()) else {
306                    tracing::debug!("which-key: keymap/command service missing; no popup");
307                    return Vec::new();
308                };
309                let Some(node) = keymap.continuations_with_context(
310                    pending.binding_mode,
311                    &pending.chords,
312                    &pending.active_modes,
313                ) else {
314                    return Vec::new();
315                };
316                if node.is_empty() {
317                    // Bound with nothing beneath it: an empty box would be
318                    // worse than no box.
319                    return Vec::new();
320                }
321                let opts = read_config(config.as_deref().map(|c| &**c));
322                let model = lattice_keymap::which_key::build_model(
323                    node,
324                    &pending.chords,
325                    pending.binding_mode,
326                    &commands.load(),
327                    opts.sort,
328                );
329                let rendered = layout_grid(
330                    &model,
331                    pending.pane_width as usize,
332                    GridOpts {
333                        max_columns: opts.max_columns,
334                        max_height: opts.max_height,
335                    },
336                );
337                if rendered.is_empty() {
338                    // Pane too narrow (§8): a single column of truncated
339                    // labels is worse than nothing.
340                    return Vec::new();
341                }
342                *grid.lock().unwrap_or_else(|e| e.into_inner()) = rendered;
343                // The one place which-key's popup comes into existence, so the
344                // one place that may claim it is open.
345                popup_open.store(true, std::sync::atomic::Ordering::Relaxed);
346                vec![Effect::OpenPopup {
347                    name: WHICH_KEY_BUFFER_NAME.to_string(),
348                    mode_id: WhichKeyMode::mode_id().as_str().to_string(),
349                    placement: PopupPlacement::MinibufferBand,
350                    // State A: the document keeps focus and every keystroke
351                    // still resolves against the trie. See the module docs.
352                    focus: PopupFocus::Passive,
353                }]
354            }
355        }),
356    ));
357
358    // The arming path. `inbound`'s send wakes the editor, and its handler
359    // runs on the actor thread — so arming happens where the gate lives,
360    // and a fired gate repaints without a keystroke.
361    let inbound = boot.inbound({
362        let stash = Arc::clone(&stash);
363        let gate = Arc::clone(&gate);
364        let config = config.clone();
365        let popup_open = Arc::clone(&popup_open);
366        move |ev: PartialChordPending| {
367            let enabled = config
368                .as_ref()
369                .and_then(|c| c.get_typed::<WhichKeyEnabled>())
370                .map(|v| *v)
371                .unwrap_or(true);
372            if ev.chords.is_empty() || !enabled {
373                gate.disarm();
374                *stash.lock().unwrap_or_else(|e| e.into_inner()) = None;
375                if popup_open.swap(false, std::sync::atomic::Ordering::Relaxed) {
376                    return vec![Effect::DismissPopupNamed {
377                        name: WHICH_KEY_BUFFER_NAME.to_string(),
378                    }];
379                }
380                return Vec::new();
381            }
382            let delay = config
383                .as_ref()
384                .and_then(|c| c.get_typed::<WhichKeyDelay>())
385                .map(|v| *v)
386                .unwrap_or(300)
387                .max(0) as u64;
388            *stash.lock().unwrap_or_else(|e| e.into_inner()) = Some(ev);
389            gate.arm(tokio::time::Instant::now() + std::time::Duration::from_millis(delay));
390            Vec::new()
391        }
392    });
393
394    // Bus → inbound. Subscribed synchronously (before the spawn) so no
395    // early event is lost to a task that has not been polled yet — the
396    // same ordering `lattice_dashboard::install_startup_trigger` uses.
397    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<PartialChordPending>();
398    boot.event_bus().subscribe_typed(tx);
399    boot.runtime_handle().spawn(async move {
400        while let Some(ev) = rx.recv().await {
401            if inbound.send(ev).is_err() {
402                break;
403            }
404        }
405    });
406}
407
408/// Resolved option values for one popup build.
409struct ResolvedOpts {
410    max_columns: usize,
411    max_height: usize,
412    sort: Sort,
413}
414
415fn read_config(config: Option<&ConfigRegistry>) -> ResolvedOpts {
416    let int = |c: &ConfigRegistry, d: i64, f: fn(&ConfigRegistry) -> Option<i64>| f(c).unwrap_or(d);
417    let (max_columns, max_height, sort) = match config {
418        Some(c) => (
419            int(c, 6, |c| c.get_typed::<WhichKeyMaxColumns>().map(|v| *v)).max(1) as usize,
420            int(c, 12, |c| c.get_typed::<WhichKeyMaxHeight>().map(|v| *v)).max(1) as usize,
421            c.get_typed::<WhichKeySort>()
422                .and_then(|v| {
423                    let parsed = Sort::parse(v.as_str());
424                    if parsed.is_none() {
425                        // Unknown value: fall back rather than fail, and
426                        // say so once at debug (§8 — this is
427                        // keystroke-adjacent, so never `info!`).
428                        tracing::debug!(value = %*v, "which-key.sort: unknown value; using `key`");
429                    }
430                    parsed
431                })
432                .unwrap_or_default(),
433        ),
434        None => (6, 12, Sort::default()),
435    };
436    ResolvedOpts {
437        max_columns,
438        max_height,
439        sort,
440    }
441}
442
443/// `*which-key*` is a popup buffer, so it never wants a `BufferKind` of
444/// its own — the popup machinery stores it as `BufferData::Help`. This
445/// asserts the mode does not claim a kind, which would route ordinary
446/// buffers to it.
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    fn mode() -> WhichKeyMode {
452        WhichKeyMode {
453            grid: Arc::new(Mutex::new(RenderedGrid::default())),
454        }
455    }
456
457    #[test]
458    fn id_and_kind() {
459        assert_eq!(mode().id().as_str(), "which-key-mode");
460        assert_eq!(mode().kind(), ModeKind::Major);
461        assert_eq!(
462            <WhichKeyMode as Mode>::target_buffer_kind(&mode()),
463            None::<lattice_core::BufferKind>,
464            "the popup buffer is reached by name, not by kind"
465        );
466    }
467
468    /// Read-only takes TWO declarations: the option gates typing, and
469    /// `read-only-mode` carries the invocation runner that refuses
470    /// operators. A hint you can `dd` into is not read-only.
471    #[test]
472    fn read_only_is_declared_twice() {
473        let m = mode();
474        assert!(
475            <WhichKeyMode as Mode>::implies(&m).contains(&crate::modes::ReadOnlyMode::mode_id()),
476            "the option alone gates Insert-mode typing and nothing else"
477        );
478        let opts = <WhichKeyMode as Mode>::options(&m);
479        assert_eq!(opts.iter().count(), 3, "ReadOnly + NoFile + Number");
480    }
481
482    #[test]
483    fn sort_option_parses_and_falls_back() {
484        assert_eq!(Sort::parse("key"), Some(Sort::Key));
485        assert_eq!(Sort::parse("label"), Some(Sort::Label));
486        assert_eq!(Sort::parse("sideways"), None, "unknown → caller defaults");
487    }
488
489    #[test]
490    fn defaults_are_read_when_no_config_is_wired() {
491        let o = read_config(None);
492        assert_eq!(o.max_columns, 6);
493        assert_eq!(o.max_height, 12);
494        assert_eq!(o.sort, Sort::Key);
495    }
496}