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