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}