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