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