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}
61
62impl ActionId {
63 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 pub fn parse(raw: &str) -> Option<Self> {
95 ALL.iter().find(|a| a.as_str() == raw).copied()
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum Scope {
102 Global,
104 Board,
106}
107
108impl Scope {
109 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#[derive(Debug, Clone, Copy)]
147pub struct ActionRow {
148 pub id: ActionId,
150 pub scope: Scope,
152 pub default: &'static str,
154 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
305const RESERVED: &[&str] = &["esc", "enter", "tab", "?"];
307
308#[derive(Debug, Clone)]
310pub struct KeyMap {
311 by_chord: BTreeMap<String, ActionId>,
312 pub leader: Option<char>,
314 pub leader_timeout_ms: u64,
316 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 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 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 pub fn get(&self, chord: &str) -> Option<ActionId> {
362 self.by_chord.get(chord).copied()
363 }
364
365 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 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 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 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
472pub 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 assert_eq!(map.get("j"), None);
494 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 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 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 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 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 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}