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}
69
70impl ActionId {
71 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 pub fn parse(raw: &str) -> Option<Self> {
107 ALL.iter().find(|a| a.as_str() == raw).copied()
108 }
109
110 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Scope {
148 Global,
150 Board,
152}
153
154impl Scope {
155 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#[derive(Debug, Clone, Copy)]
197pub struct ActionRow {
198 pub id: ActionId,
200 pub scope: Scope,
202 pub default: &'static str,
204 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
379const RESERVED: &[&str] = &["esc", "enter", "tab", "?"];
381
382#[derive(Debug, Clone)]
384pub struct KeyMap {
385 by_chord: BTreeMap<String, ActionId>,
386 pub leader: Option<char>,
388 pub leader_timeout_ms: u64,
390 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 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 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 pub fn get(&self, chord: &str) -> Option<ActionId> {
436 self.by_chord.get(chord).copied()
437 }
438
439 pub fn catalog() -> &'static [ActionRow] {
441 CATALOG
442 }
443
444 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 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 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 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 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 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
585pub 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 assert_eq!(map.get("j"), None);
607 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 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 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 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 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 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}