Skip to main content

vissue_core/
keys.rs

1//! Action catalog and keys.toml overlay.
2//!
3//! Defaults live in code. Operator diffs live in `$VISSUE_KEYS` or
4//! `~/.config/vissue/keys.toml`. Invalid overlay is refused.
5
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9/// Dotted action id. HUD-shaped, remappable except reserved chords.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11pub enum ActionId {
12    /// Move the list cursor down.
13    ListDown,
14    /// Move the list cursor up.
15    ListUp,
16    /// Open the selected issue.
17    ListSelect,
18    /// Toggle done on the selected issue.
19    ListDone,
20    /// Switch to the ready pane.
21    PaneReady,
22    /// Switch to the full list pane.
23    PaneList,
24    /// Switch to the claims pane.
25    PaneClaims,
26    /// Switch to the agenda pane.
27    PaneAgenda,
28    /// Switch to the search pane.
29    PaneSearch,
30    /// Cycle to the next pane.
31    PaneNext,
32    /// Cycle the detail card.
33    DetailCycle,
34    /// Cycle the project filter.
35    ProjectCycle,
36    /// Focus the board search box.
37    Search,
38    /// Create an issue.
39    Add,
40    /// Claim the selected issue.
41    Claim,
42    /// Append a note to the selected issue.
43    Note,
44    /// Cite a deed on the selected issue.
45    Deed,
46    /// Cycle the selected issue's state.
47    StateCycle,
48    /// Confirm marking the selected issue done.
49    ConfirmDone,
50    /// Confirm cancelling the selected issue.
51    ConfirmCancel,
52    /// Open the selected issue in an editor.
53    Open,
54    /// Copy the selected issue id.
55    CopyId,
56    /// Reload the board from disk.
57    Reload,
58    /// Show the key help overlay.
59    Help,
60    /// Open the command palette.
61    Palette,
62    /// Hide or show the detail preview.
63    PreviewToggle,
64    /// Scroll the detail preview down.
65    PreviewDown,
66    /// Scroll the detail preview up.
67    PreviewUp,
68    /// Open the board in a decorated window of its own; the overlay hides.
69    WindowPopOut,
70}
71
72impl ActionId {
73    /// Stable dotted id used in `keys.toml` and help text.
74    pub fn as_str(self) -> &'static str {
75        match self {
76            Self::ListDown => "list.down",
77            Self::ListUp => "list.up",
78            Self::ListSelect => "list.select",
79            Self::ListDone => "list.done",
80            Self::PaneReady => "pane.ready",
81            Self::PaneList => "pane.list",
82            Self::PaneClaims => "pane.claims",
83            Self::PaneAgenda => "pane.agenda",
84            Self::PaneSearch => "pane.search",
85            Self::PaneNext => "pane.next",
86            Self::DetailCycle => "detail.cycle",
87            Self::ProjectCycle => "project.cycle",
88            Self::Search => "board.search",
89            Self::Add => "issue.add",
90            Self::Claim => "issue.claim",
91            Self::Note => "issue.note",
92            Self::Deed => "issue.deed",
93            Self::StateCycle => "issue.state",
94            Self::ConfirmDone => "issue.done",
95            Self::ConfirmCancel => "issue.cancel",
96            Self::Open => "issue.open",
97            Self::CopyId => "issue.copy",
98            Self::Reload => "board.reload",
99            Self::Help => "board.help",
100            Self::Palette => "board.palette",
101            Self::PreviewToggle => "preview.toggle",
102            Self::PreviewDown => "preview.down",
103            Self::PreviewUp => "preview.up",
104            Self::WindowPopOut => "window.popout",
105        }
106    }
107
108    /// Parse a dotted id such as `list.down`. Unknown names yield `None`.
109    pub fn parse(raw: &str) -> Option<Self> {
110        ALL.iter().find(|a| a.as_str() == raw).copied()
111    }
112
113    /// Short title for help and the command palette.
114    pub fn title(self) -> &'static str {
115        match self {
116            Self::ListDown => "Move down",
117            Self::ListUp => "Move up",
118            Self::ListSelect => "Open the selected row",
119            Self::ListDone => "Toggle done",
120            Self::PaneReady => "Ready pane",
121            Self::PaneList => "List pane",
122            Self::PaneClaims => "Claims pane",
123            Self::PaneAgenda => "Agenda pane",
124            Self::PaneSearch => "Search pane",
125            Self::PaneNext => "Next pane",
126            Self::DetailCycle => "Cycle detail",
127            Self::ProjectCycle => "Next project",
128            Self::Search => "Search this project",
129            Self::Add => "Add a task",
130            Self::Claim => "Claim the selected issue",
131            Self::Note => "Note the selected issue",
132            Self::Deed => "Cite a deed",
133            Self::StateCycle => "Cycle TODO / STARTED / BLOCKED",
134            Self::ConfirmDone => "Mark done",
135            Self::ConfirmCancel => "Cancel the selected issue",
136            Self::Open => "Open heading",
137            Self::CopyId => "Copy id",
138            Self::Reload => "Reload",
139            Self::Help => "Show help",
140            Self::Palette => "Command palette",
141            Self::PreviewToggle => "Hide or show the preview",
142            Self::PreviewDown => "Scroll the preview down",
143            Self::PreviewUp => "Scroll the preview up",
144            Self::WindowPopOut => "Pop out into a window",
145        }
146    }
147}
148
149/// Where an action is bound: always, or only on the board.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum Scope {
152    /// Available in every HUD context.
153    Global,
154    /// Available while the issue board has focus.
155    Board,
156}
157
158impl Scope {
159    /// Stable scope name used in the key table.
160    pub fn as_str(self) -> &'static str {
161        match self {
162            Self::Global => "global",
163            Self::Board => "board",
164        }
165    }
166}
167
168const ALL: &[ActionId] = &[
169    ActionId::ListDown,
170    ActionId::ListUp,
171    ActionId::ListSelect,
172    ActionId::ListDone,
173    ActionId::PaneReady,
174    ActionId::PaneList,
175    ActionId::PaneClaims,
176    ActionId::PaneAgenda,
177    ActionId::PaneSearch,
178    ActionId::PaneNext,
179    ActionId::DetailCycle,
180    ActionId::ProjectCycle,
181    ActionId::Search,
182    ActionId::Add,
183    ActionId::Claim,
184    ActionId::Note,
185    ActionId::Deed,
186    ActionId::StateCycle,
187    ActionId::ConfirmDone,
188    ActionId::ConfirmCancel,
189    ActionId::Open,
190    ActionId::CopyId,
191    ActionId::Reload,
192    ActionId::Help,
193    ActionId::Palette,
194    ActionId::PreviewToggle,
195    ActionId::PreviewDown,
196    ActionId::PreviewUp,
197    ActionId::WindowPopOut,
198];
199
200/// One catalog row. Defaults stay in this table.
201#[derive(Debug, Clone, Copy)]
202pub struct ActionRow {
203    /// Action this row describes.
204    pub id: ActionId,
205    /// Context the default chord is bound in.
206    pub scope: Scope,
207    /// Compiled-in chord token.
208    pub default: &'static str,
209    /// Whether an overlay may rebind this action.
210    pub remappable: bool,
211}
212
213const CATALOG: &[ActionRow] = &[
214    ActionRow {
215        id: ActionId::ListDown,
216        scope: Scope::Board,
217        default: "j",
218        remappable: true,
219    },
220    ActionRow {
221        id: ActionId::ListUp,
222        scope: Scope::Board,
223        default: "k",
224        remappable: true,
225    },
226    ActionRow {
227        id: ActionId::ListSelect,
228        scope: Scope::Board,
229        default: "enter",
230        remappable: false,
231    },
232    ActionRow {
233        id: ActionId::ListDone,
234        scope: Scope::Board,
235        default: "space",
236        remappable: true,
237    },
238    ActionRow {
239        id: ActionId::PaneReady,
240        scope: Scope::Board,
241        default: "1",
242        remappable: true,
243    },
244    ActionRow {
245        id: ActionId::PaneList,
246        scope: Scope::Board,
247        default: "2",
248        remappable: true,
249    },
250    ActionRow {
251        id: ActionId::PaneClaims,
252        scope: Scope::Board,
253        default: "3",
254        remappable: true,
255    },
256    ActionRow {
257        id: ActionId::PaneAgenda,
258        scope: Scope::Board,
259        default: "4",
260        remappable: true,
261    },
262    ActionRow {
263        id: ActionId::PaneSearch,
264        scope: Scope::Board,
265        default: "5",
266        remappable: true,
267    },
268    ActionRow {
269        id: ActionId::PaneNext,
270        scope: Scope::Board,
271        default: "tab",
272        remappable: false,
273    },
274    ActionRow {
275        id: ActionId::DetailCycle,
276        scope: Scope::Board,
277        default: "enter",
278        remappable: false,
279    },
280    ActionRow {
281        id: ActionId::ProjectCycle,
282        scope: Scope::Board,
283        default: "p",
284        remappable: true,
285    },
286    ActionRow {
287        id: ActionId::Search,
288        scope: Scope::Board,
289        default: "/",
290        remappable: true,
291    },
292    ActionRow {
293        id: ActionId::Add,
294        scope: Scope::Board,
295        default: "a",
296        remappable: true,
297    },
298    ActionRow {
299        id: ActionId::Claim,
300        scope: Scope::Board,
301        default: "c",
302        remappable: true,
303    },
304    ActionRow {
305        id: ActionId::Deed,
306        scope: Scope::Board,
307        default: "d",
308        remappable: true,
309    },
310    ActionRow {
311        id: ActionId::Note,
312        scope: Scope::Board,
313        default: "n",
314        remappable: true,
315    },
316    ActionRow {
317        id: ActionId::StateCycle,
318        scope: Scope::Board,
319        default: "s",
320        remappable: true,
321    },
322    ActionRow {
323        id: ActionId::ConfirmDone,
324        scope: Scope::Board,
325        default: "D",
326        remappable: true,
327    },
328    ActionRow {
329        id: ActionId::ConfirmCancel,
330        scope: Scope::Board,
331        default: "X",
332        remappable: true,
333    },
334    ActionRow {
335        id: ActionId::Open,
336        scope: Scope::Board,
337        default: "o",
338        remappable: true,
339    },
340    ActionRow {
341        id: ActionId::CopyId,
342        scope: Scope::Board,
343        default: "y",
344        remappable: true,
345    },
346    ActionRow {
347        id: ActionId::Reload,
348        scope: Scope::Board,
349        default: "R",
350        remappable: true,
351    },
352    ActionRow {
353        id: ActionId::Help,
354        scope: Scope::Global,
355        default: "?",
356        remappable: false,
357    },
358    ActionRow {
359        id: ActionId::Palette,
360        scope: Scope::Global,
361        default: ":",
362        remappable: true,
363    },
364    ActionRow {
365        id: ActionId::PreviewToggle,
366        scope: Scope::Board,
367        default: "z",
368        remappable: true,
369    },
370    ActionRow {
371        id: ActionId::PreviewDown,
372        scope: Scope::Board,
373        default: "J",
374        remappable: true,
375    },
376    ActionRow {
377        id: ActionId::PreviewUp,
378        scope: Scope::Board,
379        default: "K",
380        remappable: true,
381    },
382    ActionRow {
383        id: ActionId::WindowPopOut,
384        scope: Scope::Global,
385        default: "P",
386        remappable: true,
387    },
388];
389
390/// Reserved chords the overlay may not steal.
391const RESERVED: &[&str] = &["esc", "enter", "tab", "?"];
392
393/// Resolved map: chord -> action (board scope).
394#[derive(Debug, Clone)]
395pub struct KeyMap {
396    by_chord: BTreeMap<String, ActionId>,
397    /// Optional leader character from the overlay.
398    pub leader: Option<char>,
399    /// How long a leader chord stays armed, in milliseconds.
400    pub leader_timeout_ms: u64,
401    /// Why the overlay was refused, when defaults were kept instead.
402    pub overlay_error: Option<String>,
403}
404
405impl Default for KeyMap {
406    fn default() -> Self {
407        Self::from_defaults()
408    }
409}
410
411impl KeyMap {
412    /// Compiled-in chords, no overlay.
413    pub fn from_defaults() -> Self {
414        let mut by_chord = BTreeMap::new();
415        for row in CATALOG {
416            by_chord.insert(row.default.to_string(), row.id);
417        }
418        Self {
419            by_chord,
420            leader: None,
421            leader_timeout_ms: 800,
422            overlay_error: None,
423        }
424    }
425
426    /// Load `$VISSUE_KEYS` or `~/.config/vissue/keys.toml` over the defaults.
427    ///
428    /// A missing file keeps the defaults. A broken overlay also keeps the
429    /// defaults and records the reason in [`Self::overlay_error`].
430    pub fn load() -> Self {
431        let path = overlay_path();
432        match path {
433            Some(p) if p.is_file() => match load_overlay(&p) {
434                Ok(map) => map,
435                Err(err) => {
436                    let mut map = Self::from_defaults();
437                    map.overlay_error = Some(err);
438                    map
439                }
440            },
441            _ => Self::from_defaults(),
442        }
443    }
444
445    /// Action bound to `chord` in board scope, if any.
446    pub fn get(&self, chord: &str) -> Option<ActionId> {
447        self.by_chord.get(chord).copied()
448    }
449
450    /// The compiled catalog. Help and the command palette read this table.
451    pub fn catalog() -> &'static [ActionRow] {
452        CATALOG
453    }
454
455    /// Resolved chord for `id`, or the compiled default.
456    pub fn chord_for(&self, id: ActionId) -> &str {
457        self.by_chord
458            .iter()
459            .find(|(_, bound)| **bound == id)
460            .map(|(c, _)| c.as_str())
461            .or_else(|| {
462                CATALOG
463                    .iter()
464                    .find(|row| row.id == id)
465                    .map(|row| row.default)
466            })
467            .unwrap_or("")
468    }
469
470    /// Markdown help generated from the catalog, not a second key list.
471    pub fn help_markdown(&self) -> String {
472        let mut out = String::from(
473            "# vissue hud\n\n\
474             Home is the project list. Enter opens one.\n\
475             Esc from a project returns to that list.\n\n\
476             Body edits stay in the file.\n\n",
477        );
478        for row in CATALOG {
479            out.push_str(&format!(
480                "- `{}` — {} (`{}`)\n",
481                self.chord_for(row.id),
482                row.id.title(),
483                row.id.as_str()
484            ));
485        }
486        out
487    }
488
489    /// Help overlay rows: resolved chord, then the dotted action id.
490    pub fn help_lines(&self) -> Vec<String> {
491        CATALOG
492            .iter()
493            .map(|row| {
494                let chord = self
495                    .by_chord
496                    .iter()
497                    .find(|(_, id)| **id == row.id)
498                    .map(|(c, _)| c.as_str())
499                    .unwrap_or(row.default);
500                format!("{chord:8}  {}", row.id.as_str())
501            })
502            .collect()
503    }
504
505    /// Every bound chord and the dotted action id it maps to.
506    pub fn occupancy(&self) -> Vec<(String, String)> {
507        self.by_chord
508            .iter()
509            .map(|(c, id)| (c.clone(), id.as_str().to_string()))
510            .collect()
511    }
512
513    /// One line per action: scope, id, resolved chord.
514    pub fn table_lines(&self) -> Vec<String> {
515        CATALOG
516            .iter()
517            .map(|row| {
518                let chord = self
519                    .by_chord
520                    .iter()
521                    .find(|(_, id)| **id == row.id)
522                    .map(|(c, _)| c.as_str())
523                    .unwrap_or(row.default);
524                format!("{:<8} {:<16} {chord}", row.scope.as_str(), row.id.as_str())
525            })
526            .collect()
527    }
528}
529
530fn overlay_path() -> Option<PathBuf> {
531    if let Ok(raw) = std::env::var("VISSUE_KEYS") {
532        let t = raw.trim();
533        if !t.is_empty() {
534            return Some(PathBuf::from(t));
535        }
536    }
537    let base = std::env::var_os("XDG_CONFIG_HOME")
538        .map(PathBuf::from)
539        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
540    Some(base.join("vissue/keys.toml"))
541}
542
543fn load_overlay(path: &Path) -> Result<KeyMap, String> {
544    let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
545    KeyMap::from_overlay(&text)
546}
547
548impl KeyMap {
549    /// The defaults with one overlay document applied: the text of a
550    /// `keys.toml`, wherever it came from.
551    ///
552    /// # Errors
553    ///
554    /// The document is not TOML, names an unknown or unremappable action,
555    /// steals a reserved chord, or binds one chord twice.
556    pub fn from_overlay(text: &str) -> Result<Self, String> {
557        overlay_over_defaults(text)
558    }
559}
560
561fn overlay_over_defaults(text: &str) -> Result<KeyMap, String> {
562    // A document, not a value. `str::parse` into a `Value` reads one TOML
563    // value, so an overlay opening with a table header parses as far as the
564    // bracket and then reports the rest as unexpected.
565    let value: toml::Value = toml::from_str(text).map_err(|e| format!("keys.toml: {e}"))?;
566    let mut map = KeyMap::from_defaults();
567    if let Some(leader) = value.get("leader").and_then(|v| v.as_str()) {
568        let mut chars = leader.chars();
569        let ch = chars.next();
570        if ch.is_none() || chars.next().is_some() {
571            return Err("leader must be one character".into());
572        }
573        map.leader = ch;
574    }
575    if let Some(ms) = value.get("leader_timeout_ms").and_then(|v| v.as_integer())
576        && ms > 0
577    {
578        map.leader_timeout_ms = ms as u64;
579    }
580    let table = value.get("board").and_then(|v| v.as_table());
581    if let Some(table) = table {
582        let mut pending: Vec<(ActionId, String)> = Vec::new();
583        for (id, chord) in table {
584            let Some(action) = ActionId::parse(id) else {
585                return Err(format!("unknown action {id}"));
586            };
587            let Some(row) = CATALOG.iter().find(|r| r.id == action) else {
588                return Err(format!("unknown action {id}"));
589            };
590            let Some(chord) = chord.as_str() else {
591                return Err(format!("{id} chord must be a string"));
592            };
593            if !row.remappable {
594                return Err(format!("{id} is not remappable"));
595            }
596            if RESERVED.contains(&chord.to_ascii_lowercase().as_str()) {
597                return Err(format!("cannot steal reserved chord {chord}"));
598            }
599            pending.push((action, chord.to_string()));
600        }
601        for (action, _) in &pending {
602            map.by_chord.retain(|_, id| id != action);
603        }
604        for (action, chord) in pending {
605            if let Some(prev) = map.by_chord.insert(chord.clone(), action) {
606                return Err(format!("chord {chord} already bound to {}", prev.as_str()));
607            }
608        }
609    }
610    Ok(map)
611}
612
613/// Map a typed character (no modifiers) or a named key to a chord token.
614pub fn chord_from_char(c: char) -> String {
615    c.to_string()
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn help_follows_an_overlay_remap() {
624        let map = KeyMap::from_overlay("[board]\n\"list.down\" = \"e\"\n").unwrap();
625        assert_eq!(map.chord_for(ActionId::ListDown), "e");
626        let help = map.help_markdown();
627        assert!(help.contains("- `e` — Move down (`list.down`)"), "{help}");
628        assert!(
629            !KeyMap::from_defaults()
630                .help_markdown()
631                .contains("- `e` — Move down")
632        );
633    }
634
635    fn overlay(body: &str) -> Result<KeyMap, String> {
636        let dir = tempfile::tempdir().expect("tempdir");
637        let path = dir.path().join("keys.toml");
638        std::fs::write(&path, body).expect("write");
639        load_overlay(&path)
640    }
641
642    #[test]
643    fn an_overlay_rebinds_only_what_it_names() {
644        let map = overlay("[board]\n\"list.down\" = \"e\"\n").expect("overlay");
645        assert_eq!(map.get("e"), Some(ActionId::ListDown));
646        // The old chord is freed rather than left pointing at the action.
647        assert_eq!(map.get("j"), None);
648        // Everything it did not mention keeps its default.
649        assert_eq!(map.get("k"), Some(ActionId::ListUp));
650        assert_eq!(map.get("c"), Some(ActionId::Claim));
651    }
652
653    #[test]
654    fn two_actions_may_swap_chords_in_one_overlay() {
655        // Neither assignment is a conflict, because both old chords are
656        // released before either new one is placed.
657        let map =
658            overlay("[board]\n\"list.down\" = \"k\"\n\"list.up\" = \"j\"\n").expect("overlay");
659        assert_eq!(map.get("k"), Some(ActionId::ListDown));
660        assert_eq!(map.get("j"), Some(ActionId::ListUp));
661    }
662
663    #[test]
664    fn a_leader_is_one_character() {
665        let map = overlay("leader = \",\"\n").expect("overlay");
666        assert_eq!(map.leader, Some(','));
667        assert!(overlay("leader = \"\"\n").is_err());
668        assert!(overlay("leader = \"gg\"\n").is_err());
669    }
670
671    #[test]
672    fn the_leader_timeout_takes_a_positive_number_only() {
673        let map = overlay("leader_timeout_ms = 250\n").expect("overlay");
674        assert_eq!(map.leader_timeout_ms, 250);
675        // A nonsense value leaves the default standing rather than failing.
676        let map = overlay("leader_timeout_ms = 0\n").expect("overlay");
677        assert_eq!(
678            map.leader_timeout_ms,
679            KeyMap::from_defaults().leader_timeout_ms
680        );
681    }
682
683    #[test]
684    fn an_overlay_that_cannot_be_understood_says_which_part() {
685        let unknown = overlay("[board]\n\"list.sideways\" = \"z\"\n").unwrap_err();
686        assert!(unknown.contains("list.sideways"), "{unknown}");
687
688        let not_a_string = overlay("[board]\n\"list.down\" = 3\n").unwrap_err();
689        assert!(not_a_string.contains("list.down"), "{not_a_string}");
690
691        let broken = overlay("[board\n").unwrap_err();
692        assert!(broken.contains("keys.toml"), "{broken}");
693    }
694
695    #[test]
696    fn the_keys_a_reader_needs_cannot_be_taken_away() {
697        // Enter, tab, escape and ? are how someone gets out of a pane, so
698        // neither the action nor the chord may be reassigned.
699        let fixed = overlay("[board]\n\"list.select\" = \"z\"\n").unwrap_err();
700        assert!(fixed.contains("not remappable"), "{fixed}");
701
702        for reserved in ["enter", "tab", "esc", "?"] {
703            let err = overlay(&format!("[board]\n\"list.down\" = \"{reserved}\"\n")).unwrap_err();
704            assert!(err.contains("reserved"), "{reserved}: {err}");
705        }
706    }
707
708    #[test]
709    fn two_actions_may_not_share_one_chord() {
710        let err = overlay("[board]\n\"list.down\" = \"c\"\n").unwrap_err();
711        assert!(err.contains("already bound"), "{err}");
712        assert!(err.contains("issue.claim"), "{err}");
713    }
714
715    #[test]
716    fn a_broken_overlay_leaves_the_defaults_and_says_why() {
717        let dir = tempfile::tempdir().expect("tempdir");
718        let path = dir.path().join("keys.toml");
719        std::fs::write(&path, "[board]\n\"list.down\" = \"enter\"\n").expect("write");
720
721        // `load` never fails: a seat with a bad overlay still gets a board.
722        let map = match load_overlay(&path) {
723            Ok(_) => panic!("a reserved chord was accepted"),
724            Err(err) => {
725                let mut map = KeyMap::from_defaults();
726                map.overlay_error = Some(err);
727                map
728            }
729        };
730        assert_eq!(map.get("j"), Some(ActionId::ListDown));
731        assert!(map.overlay_error.is_some());
732    }
733
734    #[test]
735    fn a_missing_overlay_is_not_an_error_worth_reporting() {
736        let dir = tempfile::tempdir().expect("tempdir");
737        assert!(load_overlay(&dir.path().join("absent.toml")).is_err());
738        // ... and `load` falls back rather than surfacing it.
739        assert_eq!(KeyMap::from_defaults().get("j"), Some(ActionId::ListDown));
740    }
741
742    #[test]
743    fn the_help_and_the_table_describe_every_action() {
744        let map = KeyMap::from_defaults();
745        let help = map.help_lines();
746        let table = map.table_lines();
747        assert_eq!(help.len(), CATALOG.len());
748        assert_eq!(table.len(), CATALOG.len());
749        for row in CATALOG {
750            assert!(
751                help.iter().any(|l| l.contains(row.id.as_str())),
752                "{} missing from help",
753                row.id.as_str()
754            );
755            assert!(
756                table.iter().any(|l| l.contains(row.id.as_str())),
757                "{} missing from the table",
758                row.id.as_str()
759            );
760        }
761    }
762
763    #[test]
764    fn the_help_shows_the_rebound_chord_rather_than_the_default() {
765        let map = overlay("[board]\n\"list.down\" = \"e\"\n").expect("overlay");
766        let line = map
767            .help_lines()
768            .into_iter()
769            .find(|l| l.contains("list.down"))
770            .expect("a line for list.down");
771        assert!(line.starts_with('e'), "{line}");
772    }
773
774    #[test]
775    fn occupancy_reports_one_entry_per_bound_chord() {
776        let map = KeyMap::from_defaults();
777        let occupancy = map.occupancy();
778        assert_eq!(occupancy.len(), map.by_chord.len());
779        for (chord, id) in &occupancy {
780            assert!(!chord.is_empty());
781            assert!(!id.is_empty());
782        }
783    }
784
785    #[test]
786    fn a_typed_character_is_its_own_chord() {
787        assert_eq!(chord_from_char('j'), "j");
788        assert_eq!(chord_from_char('?'), "?");
789    }
790
791    #[test]
792    fn every_action_has_a_unique_id() {
793        let mut seen = BTreeMap::new();
794        for row in CATALOG {
795            assert!(
796                seen.insert(row.id.as_str(), row.id).is_none(),
797                "duplicate {}",
798                row.id.as_str()
799            );
800            assert_eq!(ActionId::parse(row.id.as_str()), Some(row.id));
801        }
802        assert_eq!(seen.len(), ALL.len());
803        for row in CATALOG {
804            assert!(!row.id.title().is_empty(), "{}", row.id.as_str());
805        }
806        let md = KeyMap::from_defaults().help_markdown();
807        assert!(md.contains("issue.deed"), "{md}");
808        assert!(md.contains("board.palette"), "{md}");
809    }
810
811    #[test]
812    fn defaults_resolve_j_and_n() {
813        let map = KeyMap::from_defaults();
814        assert_eq!(map.get("j"), Some(ActionId::ListDown));
815        assert_eq!(map.get("n"), Some(ActionId::Note));
816        assert_eq!(map.get("?"), Some(ActionId::Help));
817    }
818
819    #[test]
820    fn overlay_rejects_reserved_and_unknown() {
821        let dir = tempfile::tempdir().unwrap();
822        let path = dir.path().join("keys.toml");
823        std::fs::write(&path, "[board]\n\"issue.note\" = \"esc\"\n").unwrap();
824        let err = load_overlay(&path).unwrap_err();
825        assert!(err.contains("reserved"), "{err}");
826        std::fs::write(&path, "[board]\n\"no.such\" = \"z\"\n").unwrap();
827        let err = load_overlay(&path).unwrap_err();
828        assert!(err.contains("unknown"), "{err}");
829    }
830
831    #[test]
832    fn overlay_remaps_list_down() {
833        let dir = tempfile::tempdir().unwrap();
834        let path = dir.path().join("keys.toml");
835        std::fs::write(
836            &path,
837            "leader = \";\"\n[board]\n\"list.down\" = \"n\"\n\"issue.note\" = \"leader+n\"\n",
838        )
839        .unwrap();
840        let map = load_overlay(&path).unwrap();
841        assert_eq!(map.leader, Some(';'));
842        assert_eq!(map.get("n"), Some(ActionId::ListDown));
843        assert_eq!(map.get("j"), None);
844    }
845}