1use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9#[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#[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
262const RESERVED: &[&str] = &["esc", "enter", "tab", "?"];
264
265#[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 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
415pub 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 assert_eq!(map.get("j"), None);
437 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 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 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 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 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 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}