1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum EntryKind {
30 Terminal,
32 Prefix(usize),
38}
39
40#[derive(Debug, Clone)]
42pub struct Entry {
43 pub chord: KeyChord,
46 pub label: String,
48 pub kind: EntryKind,
50 pub layer: Option<KeymapLayer>,
53}
54
55impl Entry {
56 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum Sort {
74 #[default]
77 Key,
78 Label,
80}
81
82impl Sort {
83 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#[derive(Debug, Clone)]
97pub struct WhichKeyModel {
98 pub prefix: Vec<KeyChord>,
100 pub mode: BindingMode,
102 pub entries: Vec<Entry>,
104 pub wildcard: Option<Entry>,
109 pub terminal_label: Option<String>,
113}
114
115impl WhichKeyModel {
116 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 pub fn len(&self) -> usize {
126 self.entries.len() + usize::from(self.wildcard.is_some())
127 }
128
129 pub fn is_empty(&self) -> bool {
132 self.len() == 0
133 }
134
135 pub fn header(&self) -> String {
137 self.prefix.iter().map(|c| c.to_string()).collect()
138 }
139}
140
141pub 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 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 None => Entry {
244 chord: child.chord,
245 label: format!("+{}", child.descendants),
246 kind: EntryKind::Prefix(child.descendants),
247 layer: None,
248 },
249 }
250}
251
252fn 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 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 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 "<unbound>".to_string()
285}
286
287fn 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
299fn 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#[derive(Debug, Clone, Copy)]
321pub struct GridOpts {
322 pub max_columns: usize,
324 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
338const KEY_LABEL_GAP: usize = 2;
340const COLUMN_GAP: usize = 2;
342const MARGIN: usize = 2;
344const MIN_LABEL: usize = 4;
347pub const MIN_USABLE_WIDTH: usize = 20;
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum GridSpanKind {
356 Key,
359 Group,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub struct GridSpan {
366 pub start: usize,
368 pub end: usize,
370 pub kind: GridSpanKind,
372}
373
374#[derive(Debug, Clone, Default)]
384pub struct RenderedGrid {
385 pub lines: Vec<String>,
388 pub spans: Vec<Vec<GridSpan>>,
391}
392
393impl RenderedGrid {
394 pub fn is_empty(&self) -> bool {
397 self.lines.is_empty()
398 }
399}
400
401pub 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 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 let mut label_w = natural_label_w;
444 if cells.len() > 1 && columns_at(label_w) < 2 {
445 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 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 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 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 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 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 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 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
552fn 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 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 #[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 ®istry,
721 Sort::Key,
722 );
723 assert_eq!(model.entries[0].label, "Jump somewhere");
724 }
725
726 #[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 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 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 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 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 #[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 #[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 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 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 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 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 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 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 #[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'); let grid = layout_grid(&model, 100, GridOpts::default());
1080
1081 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 #[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 #[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 #[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}