Skip to main content

lattice_keymap/
which_key.rs

1//! Which-key's resolver — prefix → continuation model.
2//!
3//! Design: `docs/dev/architecture/which-key.md` §4 (data model), §4.1
4//! (label resolution), §4.2 (ordering). Sequencing:
5//! `docs/dev/operations/slice-plans/archive/which-key.md` (WK.1).
6//!
7//! Pure and synchronous. Everything here is a function of a
8//! [`NodeView`](crate::trie::NodeView) plus the command registry — no
9//! editor state, no renderer type, no I/O — so the whole model is
10//! unit-testable with a hand-built trie and no host at all.
11//!
12//! **Why this lives in `lattice-keymap`.** Heuristic #6: which-key
13//! *extends* the keymap rather than introducing a new mechanism, so it
14//! belongs to the crate that already owns the trie, the layers and the
15//! resolution. And per the substrate-vs-mode-helper rule, the resolver's
16//! only consumer is which-key's own handler — so it is a helper function
17//! in the owning crate, not host machinery and not a trait method.
18
19use std::sync::Arc;
20
21use lattice_grammar::CommandRegistry;
22use lattice_protocol::KeyChord;
23
24use crate::trie::{BoundCommand, ChildView, NodeView};
25use crate::{BindingMode, KeymapLayer};
26
27/// What a row's key leads to.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum EntryKind {
30    /// A binding fires on this key.
31    Terminal,
32    /// The key opens a deeper prefix, carrying `N` bindings beneath it.
33    /// Rendered `+N`, which-key.nvim's convention for an unlabelled
34    /// group — which is why prefix labels can be deferred without
35    /// structural cost: the count already lives on the entry, and a
36    /// label slot can be filled later.
37    Prefix(usize),
38}
39
40/// One row of the popup.
41#[derive(Debug, Clone)]
42pub struct Entry {
43    /// The key to press next. Rendered through `Display for KeyChord`,
44    /// except the wildcard row, which renders `{char}`.
45    pub chord: KeyChord,
46    /// Never blank — the label chain is total by construction (§4.1).
47    pub label: String,
48    /// Whether the key fires a binding or opens a deeper prefix.
49    pub kind: EntryKind,
50    /// Which layer the binding came from. Carried for provenance in
51    /// tests and future per-layer styling; not rendered in v1.
52    pub layer: Option<KeymapLayer>,
53}
54
55impl Entry {
56    /// The key as the grid renders it. The wildcard row has no real
57    /// chord, so it renders `{char}` — the spelling `:keymap` and
58    /// `:describe-key` already use for `f{char}` / `'{mark}`.
59    pub fn key_text(&self, wildcard: bool) -> String {
60        if wildcard {
61            "{char}".to_string()
62        } else {
63            self.chord.to_string()
64        }
65    }
66}
67
68/// Row ordering (§4.2). An explicit total order is required either way,
69/// because a node's children live in a `HashMap` — without one the grid
70/// reshuffles between openings of the same prefix, which is a worse
71/// discoverability surface than no popup at all.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum Sort {
74    /// Collate by key: digits, lowercase, uppercase, punctuation,
75    /// special keys, then modifier-bearing chords.
76    #[default]
77    Key,
78    /// Collate by label, with the key collation as the tiebreak.
79    Label,
80}
81
82impl Sort {
83    /// Parse the `which-key.sort` option value (`"key"` / `"label"`,
84    /// exact). An unknown value returns `None`; the caller logs it and
85    /// falls back to [`Sort::Key`] — log-and-skip, never panic (§8).
86    pub fn parse(s: &str) -> Option<Self> {
87        match s {
88            "key" => Some(Sort::Key),
89            "label" => Some(Sort::Label),
90            _ => None,
91        }
92    }
93}
94
95/// The popup's content, before layout.
96#[derive(Debug, Clone)]
97pub struct WhichKeyModel {
98    /// The chords already pressed, for the header.
99    pub prefix: Vec<KeyChord>,
100    /// The binding mode the continuations were resolved in.
101    pub mode: BindingMode,
102    /// Rows, already collated.
103    pub entries: Vec<Entry>,
104    /// The `{char}` row, if the node has a wildcard descent. Held apart
105    /// so it can be rendered last regardless of collation — a wildcard
106    /// matches *any* key, so sorting it among specific keys would imply
107    /// an ordering it does not have.
108    pub wildcard: Option<Entry>,
109    /// Set when the prefix node is ALSO bound (vim's `d`: an operator
110    /// and a prefix). Carries the bound command's label for the footer,
111    /// rather than a row — pressing nothing more is not a "next key".
112    pub terminal_label: Option<String>,
113}
114
115impl WhichKeyModel {
116    /// Rows in render order: collated entries, wildcard last.
117    pub fn rows(&self) -> impl Iterator<Item = (&Entry, bool)> {
118        self.entries
119            .iter()
120            .map(|e| (e, false))
121            .chain(self.wildcard.iter().map(|e| (e, true)))
122    }
123
124    /// Total row count including the wildcard.
125    pub fn len(&self) -> usize {
126        self.entries.len() + usize::from(self.wildcard.is_some())
127    }
128
129    /// No rows at all — the caller suppresses the popup rather than
130    /// rendering an empty box (§8).
131    pub fn is_empty(&self) -> bool {
132        self.len() == 0
133    }
134
135    /// The header: the prefix in vim notation.
136    pub fn header(&self) -> String {
137        self.prefix.iter().map(|c| c.to_string()).collect()
138    }
139}
140
141/// Build the popup model from a resolved [`NodeView`].
142///
143/// `registry` supplies rungs 2 and 3 of the label chain; pass the live
144/// `CommandRegistry`. `mode` is only carried through for the static
145/// catalog lookup and the model's own field.
146///
147/// Labels resolve, first hit wins: the built-in catalog's doc for the
148/// full chord path in `mode`, then the registry's doc for the bound
149/// command, then its name, then `<unbound>`. An unbound child is a group
150/// labelled `+N`.
151///
152/// # Examples
153///
154/// ```
155/// use std::sync::Arc;
156/// use lattice_grammar::{CommandId, CommandInvocation, CommandRegistry, SourceLocation};
157/// use lattice_keymap::{
158///     BindingMode, BoundCommand, ChordPattern, EntryKind, KeymapLayer, KeymapTrie, Sort, build_model,
159/// };
160/// use lattice_protocol::KeyChord;
161///
162/// let bound = Arc::new(BoundCommand::from_invocation(
163///     CommandInvocation::of(CommandId::new(1)), SourceLocation::synthetic("doc"), KeymapLayer::Builtin,
164/// ));
165/// let lit = |c| ChordPattern::Literal(KeyChord::char(c));
166/// let mut trie = KeymapTrie::new();
167/// trie.insert(&[lit('g'), lit('g')], bound.clone());
168/// trie.insert(&[lit('g'), lit('c'), lit('c')], bound);
169///
170/// let prefix = [KeyChord::char('g')];
171/// let node = trie.node_view(&prefix).unwrap();
172/// let model = build_model(node, &prefix, BindingMode::Normal, &CommandRegistry::new(), Sort::Key);
173///
174/// assert_eq!(model.header(), "g");
175/// let rows: Vec<_> = model.rows().map(|(e, _)| (e.chord, e.label.as_str(), e.kind)).collect();
176/// assert_eq!(rows, vec![
177///     (KeyChord::char('c'), "+1", EntryKind::Prefix(1)),
178///     // `gg` is in the built-in catalog, so its curated doc wins.
179///     (KeyChord::char('g'), "Jump to first line", EntryKind::Terminal),
180/// ]);
181/// ```
182pub fn build_model(
183    node: NodeView,
184    prefix: &[KeyChord],
185    mode: BindingMode,
186    registry: &CommandRegistry,
187    sort: Sort,
188) -> WhichKeyModel {
189    let mut entries: Vec<Entry> = node
190        .children
191        .iter()
192        .map(|child| entry_for(child, prefix, mode, registry, false))
193        .collect();
194
195    sort_entries(&mut entries, sort);
196
197    let wildcard = node
198        .wildcard
199        .as_ref()
200        .map(|child| entry_for(child, prefix, mode, registry, true));
201
202    let terminal_label = node
203        .terminal
204        .as_ref()
205        .map(|bound| label_for(bound, prefix, mode, registry));
206
207    WhichKeyModel {
208        prefix: prefix.to_vec(),
209        mode,
210        entries,
211        wildcard,
212        terminal_label,
213    }
214}
215
216fn entry_for(
217    child: &ChildView,
218    prefix: &[KeyChord],
219    mode: BindingMode,
220    registry: &CommandRegistry,
221    wildcard: bool,
222) -> Entry {
223    let mut full_path: Vec<KeyChord> = prefix.to_vec();
224    if !wildcard {
225        full_path.push(child.chord);
226    }
227    match &child.binding {
228        // A bound child is a row that fires. When it ALSO has a subtree
229        // beneath it, that subtree is unreachable (the trie stops at the
230        // first binding), so it is not counted here — reporting `+N` for
231        // chords that can never fire would be a lie in the one surface
232        // whose whole job is telling the truth about what comes next.
233        Some(bound) => Entry {
234            chord: child.chord,
235            label: label_for(bound, &full_path, mode, registry),
236            kind: EntryKind::Terminal,
237            layer: Some(bound.layer),
238        },
239        // An unbound child is a group. The wildcard case resolves its
240        // label from the wildcard SUBTREE's binding, so `f` renders one
241        // `{char}  find char forward` row rather than an empty grid —
242        // which would read as a broken popup (§4.1).
243        None => Entry {
244            chord: child.chord,
245            label: format!("+{}", child.descendants),
246            kind: EntryKind::Prefix(child.descendants),
247            layer: None,
248        },
249    }
250}
251
252/// The four-rung label chain (§4.1), first hit wins. **Total by
253/// construction** — the last rung always produces a string, so a label
254/// is never blank and the path never panics.
255fn label_for(
256    bound: &Arc<BoundCommand>,
257    full_path: &[KeyChord],
258    mode: BindingMode,
259    registry: &CommandRegistry,
260) -> String {
261    let chord_text: String = full_path.iter().map(|c| c.to_string()).collect();
262    // 1. The curated one-liner from the static catalog, matched on the
263    //    full chord path AND the binding mode.
264    if let Some(entry) = crate::keymap_entry::lookup(&chord_text)
265        .into_iter()
266        .find(|e| e.modes.contains(&mode))
267        && !entry.doc.is_empty()
268    {
269        return entry.doc.to_string();
270    }
271    let id = bound.command.command;
272    // 2. the registry's doc for the bound command, then 3. its name.
273    if let Some(spec) = registry.lookup(id) {
274        if !spec.doc.is_empty() {
275            return spec.doc.clone();
276        }
277        if !spec.name.is_empty() {
278            return spec.name.clone();
279        }
280    }
281    // 4. The terminal rung. A `CommandId` missing from the registry is a
282    //    real possibility (a plugin unloaded between bind and render), and
283    //    it must not blank the row.
284    "<unbound>".to_string()
285}
286
287/// §4.2's collation. Applied to the entry list in place.
288fn sort_entries(entries: &mut [Entry], sort: Sort) {
289    match sort {
290        Sort::Key => entries.sort_by(|a, b| key_order(&a.chord).cmp(&key_order(&b.chord))),
291        Sort::Label => entries.sort_by(|a, b| {
292            a.label
293                .cmp(&b.label)
294                .then_with(|| key_order(&a.chord).cmp(&key_order(&b.chord)))
295        }),
296    }
297}
298
299/// A chord's sort position: digits, lowercase, uppercase, punctuation,
300/// special keys, then modifier-bearing chords. Returned as a tuple so
301/// the derived `Ord` does the work; the second element keeps the order
302/// within a class stable and total.
303fn key_order(chord: &KeyChord) -> (u8, String) {
304    use lattice_protocol::KeyKind;
305    let text = chord.to_string();
306    if !chord.mods.is_empty() {
307        return (5, text);
308    }
309    let class = match chord.key {
310        KeyKind::Char(c) if c.is_ascii_digit() => 0,
311        KeyKind::Char(c) if c.is_lowercase() => 1,
312        KeyKind::Char(c) if c.is_uppercase() => 2,
313        KeyKind::Char(_) => 3,
314        KeyKind::Special(_) => 4,
315    };
316    (class, text)
317}
318
319/// Grid geometry knobs, supplied by which-key's options (§8).
320#[derive(Debug, Clone, Copy)]
321pub struct GridOpts {
322    /// `which-key.max-columns`.
323    pub max_columns: usize,
324    /// `which-key.max-height`, in content rows (the caller has already
325    /// applied the half-pane hard cap).
326    pub max_height: usize,
327}
328
329impl Default for GridOpts {
330    fn default() -> Self {
331        Self {
332            max_columns: 6,
333            max_height: 12,
334        }
335    }
336}
337
338/// Cells within a row: `{key}  {label}`.
339const KEY_LABEL_GAP: usize = 2;
340/// Between one cell and the next.
341const COLUMN_GAP: usize = 2;
342/// One cell of breathing room at each edge.
343const MARGIN: usize = 2;
344/// A label truncated below this is noise; stop shrinking and accept
345/// fewer columns instead.
346const MIN_LABEL: usize = 4;
347/// Below this pane width the popup is suppressed entirely (§8) — a
348/// single column of truncated labels is worse than nothing.
349pub const MIN_USABLE_WIDTH: usize = 20;
350
351/// What a [`GridSpan`] covers. Deliberately semantic rather than a
352/// colour: this crate has no styling dependency, and the consumer maps
353/// these onto the editor's existing style vocabulary.
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum GridSpanKind {
356    /// A key you would press — the emphasised column, and the header's
357    /// pending prefix.
358    Key,
359    /// A `+N` group marker: structure, not a key.
360    Group,
361}
362
363/// A styled byte range within one rendered line.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub struct GridSpan {
366    /// Byte offset of the span's first byte in its line.
367    pub start: usize,
368    /// Byte offset one past the span's last byte (exclusive).
369    pub end: usize,
370    /// What the span covers.
371    pub kind: GridSpanKind,
372}
373
374/// The laid-out popup: lines to write, and where the keys are.
375///
376/// The spans come from the LAYOUT rather than from re-scanning the
377/// rendered text, and that is the point: this function knows the byte
378/// offset it wrote each key at, while a scanner would have to guess
379/// which run of a padded row was a key. `magit/highlight.rs` carries a
380/// note about exactly that hazard — a refs row cannot be scanned back
381/// unambiguously, so its producer emits spans directly. Same rule here,
382/// applied before the ambiguity can arise.
383#[derive(Debug, Clone, Default)]
384pub struct RenderedGrid {
385    /// The popup's text, one entry per line: header, grid rows, then any
386    /// footer lines. Written into the popup buffer verbatim.
387    pub lines: Vec<String>,
388    /// One entry per line in `lines`, same order. Empty vectors for
389    /// lines with nothing to emphasise.
390    pub spans: Vec<Vec<GridSpan>>,
391}
392
393impl RenderedGrid {
394    /// No lines — the model was empty or the pane too narrow, and the
395    /// caller suppresses the popup.
396    pub fn is_empty(&self) -> bool {
397        self.lines.is_empty()
398    }
399}
400
401/// Lay the model out (§6). Pure: no renderer type crosses in, so both
402/// the column algorithm and the span placement are unit-testable with no
403/// renderer at all.
404///
405/// Returns header, grid rows, then any footer lines — plus the byte
406/// ranges of every key. The caller writes the lines into the popup
407/// buffer verbatim and hands the spans to the highlight path, so
408/// everything-is-a-buffer holds and no new render model reaches either
409/// peer.
410///
411/// Returns empty when the model has no rows or the pane is too narrow;
412/// the caller suppresses the popup rather than opening an empty box.
413pub fn layout_grid(model: &WhichKeyModel, width: usize, opts: GridOpts) -> RenderedGrid {
414    if model.is_empty() || width < MIN_USABLE_WIDTH {
415        return RenderedGrid::default();
416    }
417    let cells: Vec<(String, String)> = model
418        .rows()
419        .map(|(entry, wild)| (entry.key_text(wild), entry.label.clone()))
420        .collect();
421
422    let usable = width.saturating_sub(MARGIN);
423    let key_w = cells
424        .iter()
425        .map(|(k, _)| display_width(k))
426        .max()
427        .unwrap_or(0);
428    let natural_label_w = cells
429        .iter()
430        .map(|(_, l)| display_width(l))
431        .max()
432        .unwrap_or(0);
433
434    // How many columns fit at a given label width.
435    let columns_at = |label_w: usize| -> usize {
436        let cell = key_w + KEY_LABEL_GAP + label_w;
437        ((usable + COLUMN_GAP) / (cell + COLUMN_GAP)).clamp(1, opts.max_columns.max(1))
438    };
439
440    // Elastic truncation (§6): when the natural width yields fewer than
441    // two columns, shrink LABELS until two fit — then stop. A key is
442    // never truncated: a wrong key is worse than a missing label.
443    let mut label_w = natural_label_w;
444    if cells.len() > 1 && columns_at(label_w) < 2 {
445        // Width available to one label when two cells share the row.
446        let per_cell = (usable + COLUMN_GAP) / 2;
447        let shrunk = per_cell
448            .saturating_sub(COLUMN_GAP)
449            .saturating_sub(key_w + KEY_LABEL_GAP);
450        if shrunk >= MIN_LABEL {
451            label_w = shrunk;
452        }
453    }
454
455    let columns = columns_at(label_w);
456    let rows_needed = cells.len().div_ceil(columns);
457    let rows = rows_needed.min(opts.max_height.max(1));
458    let capacity = rows * columns;
459    let truncated = cells.len().saturating_sub(capacity);
460    let shown = &cells[..cells.len().min(capacity)];
461
462    let mut out = Vec::with_capacity(rows + 3);
463    let mut spans: Vec<Vec<GridSpan>> = Vec::with_capacity(rows + 3);
464    // The header IS the pending prefix — the keys you have already
465    // pressed — so it is emphasised for the same reason the key column is.
466    let header = model.header();
467    spans.push(vec![GridSpan {
468        start: 0,
469        end: header.len(),
470        kind: GridSpanKind::Key,
471    }]);
472    out.push(header);
473
474    for r in 0..rows {
475        let mut line = String::new();
476        let mut row_spans: Vec<GridSpan> = Vec::new();
477        // COLUMN-MAJOR fill: down, then across. Row-major would place
478        // `a b c` across the top and `d e f` on row two, defeating a
479        // scan for a letter in a sorted list — `ls` and emacs
480        // `which-key` fill column-major for the same reason.
481        for c in 0..columns {
482            let Some((key, label)) = shown.get(c * rows + r) else {
483                continue;
484            };
485            if !line.is_empty() {
486                line.push_str(&" ".repeat(COLUMN_GAP));
487            }
488            let label = truncate_to(label, label_w);
489            // Byte offsets, captured as the row is built — `key` may be
490            // multi-byte (`{char}`, a special-key name) and the padding
491            // that follows must not be inside the span.
492            let key_start = line.len();
493            line.push_str(&pad_to(key, key_w));
494            row_spans.push(GridSpan {
495                start: key_start,
496                end: key_start + key.len(),
497                kind: GridSpanKind::Key,
498            });
499            line.push_str(&" ".repeat(KEY_LABEL_GAP));
500            let label_start = line.len();
501            line.push_str(&pad_to(&label, label_w));
502            // `+N` is a group marker, not a command name: dim structure
503            // rather than another key.
504            if label.starts_with('+') {
505                row_spans.push(GridSpan {
506                    start: label_start,
507                    end: label_start + label.len(),
508                    kind: GridSpanKind::Group,
509                });
510            }
511        }
512        // Trailing padding is trimmed; no span can point past the line
513        // because every span ends at content, never at padding.
514        out.push(line.trim_end().to_string());
515        spans.push(row_spans);
516    }
517
518    if truncated > 0 {
519        out.push(format!("+{truncated} more"));
520        spans.push(Vec::new());
521    }
522    if let Some(label) = &model.terminal_label {
523        // The prefix is bound on its own (vim's `d`). A footer note, not
524        // a row: pressing nothing more is not a "next key".
525        let header = model.header();
526        out.push(format!("{header} alone: {label}"));
527        spans.push(vec![GridSpan {
528            start: 0,
529            end: header.len(),
530            kind: GridSpanKind::Key,
531        }]);
532    }
533    debug_assert_eq!(out.len(), spans.len(), "one span row per rendered line");
534    RenderedGrid { lines: out, spans }
535}
536
537fn display_width(s: &str) -> usize {
538    // Keys and labels are chord notation and docstrings; the codebase
539    // has no unicode-width dependency at this layer, and a chars count
540    // is exact for both. Revisit if labels ever carry CJK.
541    s.chars().count()
542}
543
544fn pad_to(s: &str, w: usize) -> String {
545    let mut out = s.to_string();
546    for _ in display_width(s)..w {
547        out.push(' ');
548    }
549    out
550}
551
552/// Truncate with an ellipsis, never past the ellipsis itself.
553fn truncate_to(s: &str, w: usize) -> String {
554    if display_width(s) <= w || w == 0 {
555        return s.to_string();
556    }
557    let keep = w.saturating_sub(1);
558    let mut out: String = s.chars().take(keep).collect();
559    out.push('…');
560    out
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use crate::trie::{KeymapLayer, KeymapTrie};
567    use crate::{ChordPattern, ModeId};
568    use lattice_grammar::{CommandInvocation, SourceLocation};
569    use lattice_protocol::ids::CommandId;
570
571    fn bound(id: u64, layer: KeymapLayer) -> Arc<BoundCommand> {
572        Arc::new(BoundCommand::from_invocation(
573            CommandInvocation::of(CommandId::new(id)),
574            SourceLocation::synthetic("test"),
575            layer,
576        ))
577    }
578
579    fn lit(c: char) -> ChordPattern {
580        ChordPattern::Literal(KeyChord::char(c))
581    }
582
583    fn press(c: char) -> KeyChord {
584        KeyChord::char(c)
585    }
586
587    /// A trie with `gd`, `gr` (bound) and `gs{a,b}` (a group).
588    fn g_trie() -> KeymapTrie {
589        let mut t = KeymapTrie::new();
590        t.insert(&[lit('g'), lit('d')], bound(1, KeymapLayer::Builtin));
591        t.insert(&[lit('g'), lit('r')], bound(2, KeymapLayer::Builtin));
592        t.insert(
593            &[lit('g'), lit('s'), lit('a')],
594            bound(3, KeymapLayer::Builtin),
595        );
596        t.insert(
597            &[lit('g'), lit('s'), lit('b')],
598            bound(4, KeymapLayer::Builtin),
599        );
600        t
601    }
602
603    #[test]
604    fn node_view_reports_children_and_group_counts() {
605        let view = g_trie().node_view(&[press('g')]).expect("g is a prefix");
606        assert_eq!(view.children.len(), 3, "d, r, s");
607        let by_chord = |c: char| {
608            view.children
609                .iter()
610                .find(|ch| ch.chord == press(c))
611                .expect("child present")
612        };
613        assert!(by_chord('d').binding.is_some(), "gd is bound");
614        assert_eq!(by_chord('d').descendants, 0);
615        assert!(by_chord('s').binding.is_none(), "gs is a group");
616        assert_eq!(by_chord('s').descendants, 2, "gsa + gsb");
617        assert!(view.terminal.is_none(), "g itself is not bound");
618        assert!(view.wildcard.is_none());
619    }
620
621    #[test]
622    fn node_view_reports_a_prefix_that_is_also_bound() {
623        let mut t = g_trie();
624        t.insert(&[lit('g')], bound(9, KeymapLayer::Builtin));
625        let view = t.node_view(&[press('g')]).expect("still a node");
626        assert!(
627            view.terminal.is_some(),
628            "a bound prefix is reported for the footer, not as a row"
629        );
630        assert_eq!(view.children.len(), 3, "…and its children still listed");
631    }
632
633    #[test]
634    fn node_view_is_none_for_an_unknown_prefix() {
635        assert!(g_trie().node_view(&[press('q')]).is_none());
636    }
637
638    #[test]
639    fn node_view_reports_the_wildcard_descent() {
640        let mut t = KeymapTrie::new();
641        t.insert(
642            &[lit('f'), ChordPattern::CharLiteral],
643            bound(1, KeymapLayer::Builtin),
644        );
645        let view = t.node_view(&[press('f')]).expect("f is a prefix");
646        assert!(view.children.is_empty());
647        let wild = view.wildcard.expect("wildcard present");
648        assert!(
649            wild.binding.is_some(),
650            "the label comes from the wildcard subtree's binding — without \
651             it `f` would render an empty grid, which reads as a broken popup"
652        );
653    }
654
655    #[test]
656    fn a_group_entry_carries_its_count() {
657        let view = g_trie().node_view(&[press('g')]).unwrap();
658        let model = build_model(
659            view,
660            &[press('g')],
661            BindingMode::Normal,
662            &CommandRegistry::new(),
663            Sort::Key,
664        );
665        let s = model
666            .entries
667            .iter()
668            .find(|e| e.chord == press('s'))
669            .expect("gs row");
670        assert_eq!(s.kind, EntryKind::Prefix(2));
671        assert_eq!(s.label, "+2", "which-key.nvim's unlabelled-group form");
672    }
673
674    /// The label chain's terminal rung. A `CommandId` the registry does
675    /// not know must not blank the row.
676    #[test]
677    fn the_label_chain_is_total() {
678        let mut t = KeymapTrie::new();
679        t.insert(&[lit('z'), lit('q')], bound(0xDEAD, KeymapLayer::Builtin));
680        let view = t.node_view(&[press('z')]).unwrap();
681        let model = build_model(
682            view,
683            &[press('z')],
684            BindingMode::Normal,
685            &CommandRegistry::new(),
686            Sort::Key,
687        );
688        assert_eq!(model.entries[0].label, "<unbound>");
689        assert!(
690            !model.entries[0].label.is_empty(),
691            "a label is never blank — the chain's last rung always produces one"
692        );
693    }
694
695    #[test]
696    fn the_registry_supplies_the_label_when_the_catalog_does_not() {
697        let mut registry = CommandRegistry::new();
698        let id = registry.register_action(
699            "action:test-jump-somewhere",
700            "Jump somewhere",
701            lattice_grammar::registry::ActionSpec {
702                apply: Arc::new(|_ctx| Ok(lattice_grammar::Effect::None)),
703                args_schema: vec![],
704            },
705        );
706        let mut t = KeymapTrie::new();
707        t.insert(
708            &[lit('z'), lit('q')],
709            Arc::new(BoundCommand::from_invocation(
710                CommandInvocation::of(id),
711                SourceLocation::synthetic("test"),
712                KeymapLayer::Builtin,
713            )),
714        );
715        let view = t.node_view(&[press('z')]).unwrap();
716        let model = build_model(
717            view,
718            &[press('z')],
719            BindingMode::Normal,
720            &registry,
721            Sort::Key,
722        );
723        assert_eq!(model.entries[0].label, "Jump somewhere");
724    }
725
726    /// The collation must be TOTAL and stable, because `children` is a
727    /// `HashMap`: without it the grid reshuffles between openings of the
728    /// same prefix, which is worse than no popup at all.
729    #[test]
730    fn collation_is_stable_across_rebuilds() {
731        let order_of = || {
732            let mut t = KeymapTrie::new();
733            for c in ['b', 'A', '2', 'a', '-', 'B'] {
734                t.insert(&[lit('g'), lit(c)], bound(1, KeymapLayer::Builtin));
735            }
736            t.insert(
737                &[lit('g'), ChordPattern::Literal(KeyChord::ctrl('x'))],
738                bound(1, KeymapLayer::Builtin),
739            );
740            let view = t.node_view(&[press('g')]).unwrap();
741            let model = build_model(
742                view,
743                &[press('g')],
744                BindingMode::Normal,
745                &CommandRegistry::new(),
746                Sort::Key,
747            );
748            model
749                .entries
750                .iter()
751                .map(|e| e.chord.to_string())
752                .collect::<Vec<_>>()
753        };
754        let first = order_of();
755        assert_eq!(
756            first,
757            vec!["2", "a", "b", "A", "B", "-", "<C-x>"],
758            "digits, lowercase, uppercase, punctuation, then modifier-bearing"
759        );
760        for _ in 0..8 {
761            assert_eq!(order_of(), first, "order must not depend on HashMap order");
762        }
763    }
764
765    #[test]
766    fn sort_by_label_falls_back_to_key_order() {
767        let mut t = KeymapTrie::new();
768        t.insert(&[lit('g'), lit('b')], bound(1, KeymapLayer::Builtin));
769        t.insert(&[lit('g'), lit('a')], bound(1, KeymapLayer::Builtin));
770        let view = t.node_view(&[press('g')]).unwrap();
771        let model = build_model(
772            view,
773            &[press('g')],
774            BindingMode::Normal,
775            &CommandRegistry::new(),
776            Sort::Label,
777        );
778        // Both labels are `<unbound>`, so the key collation decides.
779        assert_eq!(
780            model
781                .entries
782                .iter()
783                .map(|e| e.chord.to_string())
784                .collect::<Vec<_>>(),
785            vec!["a", "b"]
786        );
787    }
788
789    #[test]
790    fn the_wildcard_row_renders_as_char_and_sorts_last() {
791        let mut t = KeymapTrie::new();
792        t.insert(&[lit('f'), lit('z')], bound(1, KeymapLayer::Builtin));
793        t.insert(
794            &[lit('f'), ChordPattern::CharLiteral],
795            bound(2, KeymapLayer::Builtin),
796        );
797        let view = t.node_view(&[press('f')]).unwrap();
798        let model = build_model(
799            view,
800            &[press('f')],
801            BindingMode::Normal,
802            &CommandRegistry::new(),
803            Sort::Key,
804        );
805        let rendered: Vec<String> = model
806            .rows()
807            .map(|(e, wild)| e.key_text(wild))
808            .collect::<Vec<_>>();
809        assert_eq!(
810            rendered,
811            vec!["z", "{char}"],
812            "a wildcard matches any key, so it renders last rather than \
813             collated among specific keys"
814        );
815    }
816
817    #[test]
818    fn header_renders_the_prefix_in_vim_notation() {
819        let model = build_model(
820            g_trie().node_view(&[press('g')]).unwrap(),
821            &[press('g')],
822            BindingMode::Normal,
823            &CommandRegistry::new(),
824            Sort::Key,
825        );
826        assert_eq!(model.header(), "g");
827        assert!(!model.is_empty());
828    }
829
830    // ---- The one correctness property (design §2) -------------------
831    //
832    // These go through `KeymapHandle::continuations_with_context` rather
833    // than a bare trie, because the property under test is the FOLD.
834
835    fn handle_with_shadowing_minor() -> (crate::KeymapHandle, ModeId) {
836        use crate::PushLayerKind;
837        use std::collections::HashMap;
838
839        let h = crate::KeymapHandle::new();
840        // Builtin `gd` → command 1.
841        h.bind(
842            KeymapLayer::Builtin,
843            BindingMode::Normal,
844            &[lit('g'), lit('d')],
845            CommandInvocation::of(CommandId::new(1)),
846            SourceLocation::synthetic("builtin"),
847        );
848        // A minor mode shadows `gd` with its own command, and adds `gx`.
849        let mode = ModeId::new("shadowing-mode");
850        let mut trie = KeymapTrie::new();
851        trie.insert(
852            &[lit('g'), lit('d')],
853            bound(2, KeymapLayer::MinorMode(mode)),
854        );
855        trie.insert(
856            &[lit('g'), lit('x')],
857            bound(3, KeymapLayer::MinorMode(mode)),
858        );
859        let mut bindings = HashMap::new();
860        bindings.insert(BindingMode::Normal, trie);
861        h.push_layer(PushLayerKind::MinorMode(mode), "shadowing-mode", bindings);
862        (h, mode)
863    }
864
865    /// The regression test design §2 names explicitly: activate a mode
866    /// that shadows a builtin chord, and assert the popup shows the
867    /// MODE's binding and that the builtin does not also appear.
868    #[test]
869    fn a_shadowing_minor_wins_and_the_builtin_does_not_also_appear() {
870        let (h, mode) = handle_with_shadowing_minor();
871        let view = h
872            .continuations_with_context(BindingMode::Normal, &[press('g')], &[mode])
873            .expect("g is a prefix in the composite");
874        let gd = view
875            .children
876            .iter()
877            .find(|c| c.chord == press('d'))
878            .expect("gd row");
879        assert_eq!(
880            gd.binding.as_ref().map(|b| b.layer),
881            Some(KeymapLayer::MinorMode(mode)),
882            "the composite's winner is the mode's binding, not the builtin's"
883        );
884        assert_eq!(
885            view.children.len(),
886            2,
887            "one row per next key — the shadowed builtin is not a second `d` row"
888        );
889    }
890
891    /// …and with the mode inactive, its chords are absent entirely.
892    #[test]
893    fn an_inactive_mode_contributes_nothing() {
894        let (h, _mode) = handle_with_shadowing_minor();
895        let view = h
896            .continuations_with_context(BindingMode::Normal, &[press('g')], &[])
897            .expect("builtin g still resolves");
898        assert_eq!(view.children.len(), 1, "only the builtin `gd`");
899        assert_eq!(
900            view.children[0].binding.as_ref().map(|b| b.layer),
901            Some(KeymapLayer::Builtin)
902        );
903    }
904
905    #[test]
906    fn continuations_with_context_is_none_for_an_unknown_prefix() {
907        let (h, mode) = handle_with_shadowing_minor();
908        assert!(
909            h.continuations_with_context(BindingMode::Normal, &[press('q')], &[mode])
910                .is_none()
911        );
912    }
913
914    // ---- WK.2: the grid (design §6) ---------------------------------
915
916    /// A model of `n` rows with predictable keys and labels.
917    fn model_of(n: usize, label: &str) -> WhichKeyModel {
918        let entries = (0..n)
919            .map(|i| Entry {
920                chord: KeyChord::char((b'a' + (i as u8 % 26)) as char),
921                label: format!("{label}{i}"),
922                kind: EntryKind::Terminal,
923                layer: Some(KeymapLayer::Builtin),
924            })
925            .collect();
926        WhichKeyModel {
927            prefix: vec![press('g')],
928            mode: BindingMode::Normal,
929            entries,
930            wildcard: None,
931            terminal_label: None,
932        }
933    }
934
935    /// Grid rows only — header and footers stripped.
936    fn grid_rows(grid: &RenderedGrid) -> Vec<String> {
937        grid.lines
938            .iter()
939            .skip(1)
940            .filter(|l| !l.starts_with('+') && !l.contains(" alone: "))
941            .cloned()
942            .collect()
943    }
944
945    #[test]
946    fn column_count_scales_with_width() {
947        let model = model_of(24, "cmd");
948        let cols_at = |w: usize| {
949            let grid = layout_grid(&model, w, GridOpts::default());
950            let rows = grid_rows(&grid);
951            // Columns = ceil(n / rows) given every row is full but the last.
952            24_usize.div_ceil(rows.len())
953        };
954        let (c40, c80, c120, c200) = (cols_at(40), cols_at(80), cols_at(120), cols_at(200));
955        assert!(
956            c40 <= c80 && c80 <= c120 && c120 <= c200,
957            "columns must be monotonic in width: {c40} {c80} {c120} {c200}"
958        );
959        assert!(c40 >= 1 && c200 <= GridOpts::default().max_columns);
960    }
961
962    #[test]
963    fn fill_is_column_major_so_a_sorted_scan_reads_down() {
964        // 6 entries, a width that yields exactly 2 columns → 3 rows.
965        let model = model_of(6, "x");
966        let opts = GridOpts {
967            max_columns: 2,
968            max_height: 12,
969        };
970        let grid = layout_grid(&model, 40, opts);
971        let rows = grid_rows(&grid);
972        assert_eq!(rows.len(), 3, "6 entries / 2 columns");
973        // Column-major: a b c fill column ONE (rows 0,1,2); d e f fill
974        // column two. Row-major would put `a b` on the first row.
975        assert!(rows[0].starts_with('a'), "row 0 col 0 is the first entry");
976        assert!(rows[1].starts_with('b'), "row 1 col 0 is the SECOND entry");
977        assert!(rows[2].starts_with('c'));
978        assert!(
979            rows[0].contains('d'),
980            "the second column starts at the 4th entry, not the 2nd: {:?}",
981            rows[0]
982        );
983    }
984
985    #[test]
986    fn labels_truncate_to_reach_two_columns_but_keys_never_do() {
987        let mut model = model_of(4, "");
988        for (i, e) in model.entries.iter_mut().enumerate() {
989            e.label = format!("an extremely long description number {i}");
990        }
991        let grid = layout_grid(&model, 60, GridOpts::default());
992        let rows = grid_rows(&grid);
993        assert!(
994            rows.iter().any(|r| r.contains('…')),
995            "labels shrink so a second column fits: {rows:?}"
996        );
997        assert_eq!(rows.len(), 2, "4 entries in 2 columns");
998        for (i, row) in rows.iter().enumerate() {
999            let key = (b'a' + i as u8) as char;
1000            assert!(
1001                row.starts_with(key),
1002                "the key column is never truncated — a wrong key is worse \
1003                 than a missing label: {row:?}"
1004            );
1005        }
1006    }
1007
1008    #[test]
1009    fn a_label_is_not_shrunk_below_the_floor() {
1010        let mut model = model_of(2, "");
1011        // Wide keys plus a narrow pane: two columns would leave ~1 char
1012        // for each label, which is the case the floor exists for.
1013        for (i, e) in model.entries.iter_mut().enumerate() {
1014            e.chord = KeyChord::ctrl((b'x' + i as u8) as char);
1015            e.label = "a very long label indeed".to_string();
1016        }
1017        let grid = layout_grid(&model, MIN_USABLE_WIDTH, GridOpts::default());
1018        let rows = grid_rows(&grid);
1019        assert_eq!(
1020            rows.len(),
1021            2,
1022            "one column, readable labels — better than two columns of \
1023             unreadable stubs: {rows:?}"
1024        );
1025        assert!(
1026            rows[0].starts_with("<C-x>"),
1027            "the key survives intact: {rows:?}"
1028        );
1029    }
1030
1031    #[test]
1032    fn overflow_becomes_a_plus_n_more_tail_not_a_scrollbar() {
1033        let model = model_of(40, "cmd");
1034        let opts = GridOpts {
1035            max_columns: 2,
1036            max_height: 4,
1037        };
1038        let grid = layout_grid(&model, 80, opts);
1039        let rows = grid_rows(&grid);
1040        assert_eq!(rows.len(), 4, "capped at max_height");
1041        let tail = grid.lines.last().expect("a tail line");
1042        assert_eq!(
1043            tail, "+32 more",
1044            "40 entries, 4 rows × 2 columns shown: {:?}",
1045            grid.lines
1046        );
1047    }
1048
1049    #[test]
1050    fn a_bound_prefix_is_a_footer_note_not_a_row() {
1051        let mut model = model_of(3, "cmd");
1052        model.terminal_label = Some("delete (operator)".to_string());
1053        let grid = layout_grid(&model, 80, GridOpts::default());
1054        assert_eq!(
1055            grid.lines.last().map(String::as_str),
1056            Some("g alone: delete (operator)"),
1057            "pressing nothing more is not a 'next key': {:?}",
1058            grid.lines
1059        );
1060        assert_eq!(grid_rows(&grid).len(), 1, "3 entries still fit one row");
1061    }
1062
1063    #[test]
1064    fn the_header_names_the_pending_prefix() {
1065        let model = model_of(2, "cmd");
1066        let grid = layout_grid(&model, 80, GridOpts::default());
1067        assert_eq!(grid.lines[0], "g", "the prefix, in vim notation");
1068    }
1069
1070    // ---- WK.9: the spans that make the keys legible -----------------
1071
1072    /// Every key gets a span, and the span covers the KEY only — not the
1073    /// padding that aligns the column. A span that ran to the column
1074    /// width would paint the gap between key and label.
1075    #[test]
1076    fn every_key_is_spanned_and_the_padding_is_not() {
1077        let mut model = model_of(3, "cmd");
1078        model.entries[0].chord = KeyChord::ctrl('x'); // a WIDE key
1079        let grid = layout_grid(&model, 100, GridOpts::default());
1080
1081        // Row 1 is the first grid row (row 0 is the header).
1082        let row = &grid.lines[1];
1083        let row_spans = &grid.spans[1];
1084        assert_eq!(row_spans.len(), 3, "one span per cell in the row");
1085        for span in row_spans {
1086            assert_eq!(span.kind, GridSpanKind::Key);
1087            let text = &row[span.start..span.end];
1088            assert!(
1089                !text.starts_with(' ') && !text.ends_with(' '),
1090                "a key span must cover the key, not its alignment padding: \
1091                 {text:?} in {row:?}"
1092            );
1093        }
1094        assert_eq!(&row[row_spans[0].start..row_spans[0].end], "<C-x>");
1095    }
1096
1097    /// The header is the keys you have already pressed, so it is
1098    /// emphasised the same way.
1099    #[test]
1100    fn the_header_prefix_is_spanned_as_a_key() {
1101        let grid = layout_grid(&model_of(2, "cmd"), 80, GridOpts::default());
1102        assert_eq!(
1103            grid.spans[0],
1104            vec![GridSpan {
1105                start: 0,
1106                end: 1,
1107                kind: GridSpanKind::Key
1108            }],
1109        );
1110    }
1111
1112    /// `+N` is structure, not a key — a distinct kind so a theme can dim
1113    /// it rather than making a group look pressable.
1114    #[test]
1115    fn a_group_marker_is_spanned_as_a_group() {
1116        let mut model = model_of(1, "");
1117        model.entries[0].label = "+4".to_string();
1118        model.entries[0].kind = EntryKind::Prefix(4);
1119        let grid = layout_grid(&model, 80, GridOpts::default());
1120        let kinds: Vec<_> = grid.spans[1].iter().map(|s| s.kind).collect();
1121        assert_eq!(kinds, vec![GridSpanKind::Key, GridSpanKind::Group]);
1122    }
1123
1124    /// A label that merely BEGINS with a plus is not a group marker's
1125    /// business, but it is indistinguishable from one by text alone —
1126    /// which is why the kind is decided at layout time from the entry,
1127    /// not recovered by scanning. Pinning the invariant that matters:
1128    /// spans never point past their line.
1129    #[test]
1130    fn no_span_points_past_its_line() {
1131        for width in [20, 40, 80, 120, 200] {
1132            let mut model = model_of(12, "a longer command label");
1133            model.wildcard = Some(Entry {
1134                chord: KeyChord::char('\0'),
1135                label: "find char".to_string(),
1136                kind: EntryKind::Terminal,
1137                layer: None,
1138            });
1139            model.terminal_label = Some("operator".to_string());
1140            let grid = layout_grid(&model, width, GridOpts::default());
1141            for (line, spans) in grid.lines.iter().zip(&grid.spans) {
1142                for s in spans {
1143                    assert!(
1144                        s.end <= line.len()
1145                            && line.is_char_boundary(s.start)
1146                            && line.is_char_boundary(s.end),
1147                        "span {s:?} out of range for {line:?} at width {width}"
1148                    );
1149                }
1150            }
1151        }
1152    }
1153
1154    #[test]
1155    fn a_pane_too_narrow_suppresses_the_grid_entirely() {
1156        let model = model_of(6, "cmd");
1157        assert!(
1158            layout_grid(&model, MIN_USABLE_WIDTH - 1, GridOpts::default()).is_empty(),
1159            "a single column of truncated labels is worse than nothing"
1160        );
1161    }
1162
1163    #[test]
1164    fn an_empty_model_renders_nothing() {
1165        let model = model_of(0, "cmd");
1166        assert!(layout_grid(&model, 80, GridOpts::default()).is_empty());
1167    }
1168
1169    #[test]
1170    fn the_wildcard_row_appears_in_the_grid() {
1171        let mut model = model_of(1, "cmd");
1172        model.wildcard = Some(Entry {
1173            chord: KeyChord::char('\0'),
1174            label: "find char forward".to_string(),
1175            kind: EntryKind::Terminal,
1176            layer: Some(KeymapLayer::Builtin),
1177        });
1178        let grid = layout_grid(&model, 80, GridOpts::default());
1179        assert!(
1180            grid.lines.iter().any(|l| l.contains("{char}")),
1181            "the wildcard renders as a `{{char}}` row: {:?}",
1182            grid.lines
1183        );
1184    }
1185}