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