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