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