1use std::{collections::HashMap, fmt::Display};
2
3use action_shortcuts::ActionShortcuts;
4use itertools::Itertools;
5use key_combo::{KeyCombo, KeyModifiers};
6use key_strike::KeyStrike;
7use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers as CKeyMods};
8use serde::{Deserialize, Serialize, de::Visitor, ser::SerializeMap};
9
10pub mod action_shortcuts;
11pub mod key_combo;
12pub mod key_strike;
13pub mod leader;
14pub mod reachability;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct KeyBindings {
18 bindings: HashMap<KeyCombo, ActionShortcuts>,
19}
20
21impl Serialize for KeyBindings {
22 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
23 where
24 S: serde::Serializer,
25 {
26 let kb_map = self.to_hashmap();
27 let mut map = serializer.serialize_map(Some(kb_map.len()))?;
28 for (k, v) in kb_map
29 .iter()
30 .sorted_by_key(|(action, _combo)| action.to_owned())
31 {
32 map.serialize_entry(&k, &v)?;
33 }
34 map.end()
35 }
36}
37
38struct DeserializeKeyBindingsVisitor;
39impl<'de> Visitor<'de> for DeserializeKeyBindingsVisitor {
40 type Value = KeyBindings;
41
42 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
43 formatter.write_str("a keybindings map of action names to lists of key combos")
44 }
45 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
46 where
47 A: serde::de::MapAccess<'de>,
48 {
49 use serde::de::{Error, IgnoredAny, IntoDeserializer};
50
51 let mut bindings: HashMap<ActionShortcuts, Vec<KeyCombo>> =
52 HashMap::with_capacity(map.size_hint().unwrap_or(0));
53
54 loop {
55 let key_str: String = match map.next_key::<String>() {
57 Ok(Some(s)) => s,
58 Ok(None) => break,
59 Err(e) => return Err(e),
60 };
61
62 let action = match ActionShortcuts::deserialize(key_str.clone().into_deserializer()) {
65 Ok(a) => a,
66 Err(e) => {
67 let e: serde::de::value::Error = e;
68 let _ = map.next_value::<IgnoredAny>();
69 tracing::warn!(
70 "Skipping unknown action '{}' in keybindings config: {}",
71 key_str,
72 e
73 );
74 continue;
75 }
76 };
77
78 match map.next_value::<Vec<KeyCombo>>() {
79 Ok(value) => {
80 bindings.insert(action, value);
81 }
82 Err(e) => {
83 tracing::warn!("Skipping keybindings entry for action '{}': {}", action, e);
84 }
85 }
86 }
87
88 if !bindings.contains_key(&ActionShortcuts::Quit) {
90 let quit_combo = default_quit_combo();
91
92 let conflicting_action = bindings
93 .iter()
94 .find(|(_, combos)| combos.iter().any(|c| c == &quit_combo))
95 .map(|(action, _)| action.clone());
96
97 if let Some(other) = conflicting_action {
98 return Err(A::Error::custom(format!(
99 "Quit action has no binding and the default combo Ctrl+Q is already mapped to '{}'. \
100 Add a valid Quit binding to your keybindings config.",
101 other
102 )));
103 }
104
105 tracing::warn!("Quit action missing from keybindings; restoring default Ctrl+Q");
106 bindings.insert(ActionShortcuts::Quit, vec![quit_combo]);
107 }
108
109 Ok(KeyBindings::from_hashmap(bindings))
110 }
111}
112
113impl<'de> Deserialize<'de> for KeyBindings {
114 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
115 where
116 D: serde::Deserializer<'de>,
117 {
118 deserializer.deserialize_map(DeserializeKeyBindingsVisitor)
119 }
120}
121
122impl Display for KeyBindings {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 let mut bindings: Vec<(ActionShortcuts, Vec<KeyCombo>)> = vec![];
125 for (key, value) in &self.bindings {
126 if let Some((_, combos)) = bindings
127 .iter_mut()
128 .find(|(shortcut, _combos)| shortcut.eq(value))
129 {
130 combos.push(key.to_owned());
131 combos.sort();
132 } else {
133 bindings.push((value.to_owned(), vec![key.to_owned()]));
134 }
135 }
136
137 bindings.sort_by_key(|(a, _v)| a.to_owned());
138 for (key, value) in &bindings {
139 writeln!(
140 f,
141 "{}: {}",
142 key,
143 value
144 .iter()
145 .map(|kc| kc.to_string())
146 .collect::<Vec<String>>()
147 .join(", ")
148 )?;
149 }
150
151 Ok(())
152 }
153}
154
155impl KeyBindings {
156 pub fn empty() -> Self {
157 KeyBindings {
158 bindings: HashMap::default(),
159 }
160 }
161
162 pub fn batch_add(&mut self) -> KeyBindBatch<'_> {
163 KeyBindBatch {
164 bindings: self,
165 modifiers: KeyModifiers::default(),
166 }
167 }
168
169 pub fn get_action(&self, combo: &KeyCombo) -> Option<ActionShortcuts> {
170 self.bindings.get(combo).map(|a| a.to_owned())
171 }
172
173 pub fn combos_for(&self, action: &ActionShortcuts) -> Vec<KeyCombo> {
177 self.bindings
178 .iter()
179 .filter(|(_, a)| *a == action)
180 .map(|(combo, _)| *combo)
181 .collect()
182 }
183
184 pub fn first_combo_for(&self, action: &ActionShortcuts) -> Option<String> {
193 self.bindings
194 .iter()
195 .filter(|(_, a)| *a == action)
196 .map(|(combo, _)| combo)
197 .min()
198 .map(|combo| combo.to_string())
199 }
200
201 pub fn to_hashmap(&self) -> HashMap<ActionShortcuts, Vec<KeyCombo>> {
202 let mut bindings: HashMap<ActionShortcuts, Vec<KeyCombo>> = HashMap::new();
203 for (combo, action) in &self.bindings {
204 let entry = bindings.entry(action.to_owned()).or_default();
205 entry.push(combo.to_owned());
206 entry.sort();
207 }
208 bindings
209 }
210
211 pub fn from_hashmap(bindings: HashMap<ActionShortcuts, Vec<KeyCombo>>) -> KeyBindings {
212 let mut kb = KeyBindings::empty();
213 for (action, combos) in &bindings {
214 tracing::debug!("from_hashmap: action={} combos={:?}", action, combos);
215 }
216 for (action, combos) in bindings {
217 for combo in combos {
218 let valid = combo.is_valid_binding();
219 tracing::debug!(
220 "from_hashmap: combo='{}' key={:?} modifiers={:?} valid={}",
221 combo,
222 combo.key,
223 combo.modifiers,
224 valid
225 );
226 if valid {
227 kb.bindings.insert(combo.to_owned(), action.to_owned());
228 } else {
229 tracing::warn!(
230 "Skipping invalid key combo '{}' for action '{}': \
231 only ctrl/alt (with optional shift) + a letter, digit, or \
232 punctuation key, or bare F1–F12 are supported",
233 combo,
234 action
235 );
236 }
237 }
238 }
239 kb
240 }
241}
242
243pub fn default_quit_combo() -> KeyCombo {
247 KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyQ)
248}
249
250pub fn default_yank_combo() -> KeyCombo {
255 KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyY)
256}
257
258pub struct KeyBindBatch<'k> {
259 bindings: &'k mut KeyBindings,
260 modifiers: KeyModifiers,
261}
262
263impl<'k> KeyBindBatch<'k> {
264 pub fn with_shift(mut self) -> Self {
265 self.modifiers.with_shift();
266 self
267 }
268 pub fn with_ctrl(mut self) -> Self {
269 self.modifiers.with_ctrl();
270 self
271 }
272 pub fn with_alt(mut self) -> Self {
273 self.modifiers.with_alt();
274 self
275 }
276 pub fn with_meta(mut self) -> Self {
278 self.modifiers.with_meta_cmd();
279 self
280 }
281 pub fn with_cmd(mut self) -> Self {
282 self.modifiers.with_meta_cmd();
283 self
284 }
285 pub fn add(self, key: KeyStrike, action: ActionShortcuts) -> KeyBindBatch<'k> {
297 let combo = KeyCombo::new(self.modifiers, key);
298 debug_assert!(
299 !self.bindings.bindings.contains_key(&combo),
300 "{combo} is already bound to {:?}, so binding it to {action:?} would drop that silently",
301 self.bindings.bindings.get(&combo)
302 );
303 self.bindings.bindings.insert(combo, action);
304 self
305 }
306}
307
308pub fn key_event_to_combo(event: &KeyEvent) -> Option<KeyCombo> {
313 let mut implied_ctrl = false;
317 let key = match event.code {
318 KeyCode::Char(c) => {
319 let code = u32::from(c);
323 let c = if (1..=26).contains(&code) {
324 implied_ctrl = true;
325 char::from_u32(code + u32::from('a') - 1)
326 .expect("ASCII control code 1..=26 always maps to a letter")
327 } else {
328 c
329 };
330 match c.to_ascii_lowercase() {
331 'a' => KeyStrike::KeyA,
332 'b' => KeyStrike::KeyB,
333 'c' => KeyStrike::KeyC,
334 'd' => KeyStrike::KeyD,
335 'e' => KeyStrike::KeyE,
336 'f' => KeyStrike::KeyF,
337 'g' => KeyStrike::KeyG,
338 'h' => KeyStrike::KeyH,
339 'i' => KeyStrike::KeyI,
340 'j' => KeyStrike::KeyJ,
341 'k' => KeyStrike::KeyK,
342 'l' => KeyStrike::KeyL,
343 'm' => KeyStrike::KeyM,
344 'n' => KeyStrike::KeyN,
345 'o' => KeyStrike::KeyO,
346 'p' => KeyStrike::KeyP,
347 'q' => KeyStrike::KeyQ,
348 'r' => KeyStrike::KeyR,
349 's' => KeyStrike::KeyS,
350 't' => KeyStrike::KeyT,
351 'u' => KeyStrike::KeyU,
352 'v' => KeyStrike::KeyV,
353 'w' => KeyStrike::KeyW,
354 'x' => KeyStrike::KeyX,
355 'y' => KeyStrike::KeyY,
356 'z' => KeyStrike::KeyZ,
357 '0' => KeyStrike::Digit0,
358 '1' => KeyStrike::Digit1,
359 '2' => KeyStrike::Digit2,
360 '3' => KeyStrike::Digit3,
361 '4' => KeyStrike::Digit4,
362 '5' => KeyStrike::Digit5,
363 '6' => KeyStrike::Digit6,
364 '7' => KeyStrike::Digit7,
365 '8' => KeyStrike::Digit8,
366 '9' => KeyStrike::Digit9,
367 ',' => KeyStrike::Comma,
368 '.' => KeyStrike::Period,
369 '/' => KeyStrike::Slash,
370 ';' => KeyStrike::Semicolon,
371 '\'' => KeyStrike::Quote,
372 '[' => KeyStrike::BracketLeft,
373 ']' => KeyStrike::BracketRight,
374 '\\' => KeyStrike::Backslash,
375 '`' => KeyStrike::Backquote,
376 '-' => KeyStrike::Minus,
377 '=' => KeyStrike::Equal,
378 _ => return None,
379 }
380 }
381 KeyCode::Enter => KeyStrike::Enter,
382 KeyCode::Backspace => KeyStrike::Backspace,
383 KeyCode::Tab | KeyCode::BackTab => KeyStrike::Tab,
384 KeyCode::Esc => KeyStrike::Escape,
385 KeyCode::Up => KeyStrike::ArrowUp,
386 KeyCode::Down => KeyStrike::ArrowDown,
387 KeyCode::Left => KeyStrike::ArrowLeft,
388 KeyCode::Right => KeyStrike::ArrowRight,
389 KeyCode::Home => KeyStrike::Home,
390 KeyCode::End => KeyStrike::End,
391 KeyCode::PageUp => KeyStrike::PageUp,
392 KeyCode::PageDown => KeyStrike::PageDown,
393 KeyCode::Delete => KeyStrike::Delete,
394 KeyCode::Insert => KeyStrike::Insert,
395 KeyCode::F(n) => match n {
396 1 => KeyStrike::F1,
397 2 => KeyStrike::F2,
398 3 => KeyStrike::F3,
399 4 => KeyStrike::F4,
400 5 => KeyStrike::F5,
401 6 => KeyStrike::F6,
402 7 => KeyStrike::F7,
403 8 => KeyStrike::F8,
404 9 => KeyStrike::F9,
405 10 => KeyStrike::F10,
406 11 => KeyStrike::F11,
407 12 => KeyStrike::F12,
408 13 => KeyStrike::F13,
409 14 => KeyStrike::F14,
410 15 => KeyStrike::F15,
411 16 => KeyStrike::F16,
412 17 => KeyStrike::F17,
413 18 => KeyStrike::F18,
414 19 => KeyStrike::F19,
415 20 => KeyStrike::F20,
416 21 => KeyStrike::F21,
417 22 => KeyStrike::F22,
418 23 => KeyStrike::F23,
419 24 => KeyStrike::F24,
420 25 => KeyStrike::F25,
421 _ => return None,
422 },
423 _ => return None,
424 };
425
426 let mut modifiers = KeyModifiers::default();
427 if implied_ctrl || event.modifiers.contains(CKeyMods::CONTROL) {
428 modifiers.with_ctrl();
429 }
430 if event.modifiers.contains(CKeyMods::SHIFT) || matches!(event.code, KeyCode::BackTab) {
432 modifiers.with_shift();
433 }
434 if event.modifiers.contains(CKeyMods::ALT) {
435 modifiers.with_alt();
436 }
437 if event.modifiers.contains(CKeyMods::SUPER) || event.modifiers.contains(CKeyMods::META) {
438 modifiers.with_meta_cmd();
439 }
440
441 Some(KeyCombo::new(modifiers, key))
442}
443
444#[cfg(test)]
445mod tests {
446 use super::{
447 KeyBindings,
448 action_shortcuts::{ActionShortcuts, TextAction},
449 key_strike::KeyStrike,
450 };
451
452 #[test]
458 fn first_combo_for_is_stable_when_an_action_has_two_chords() {
459 let mut kb = KeyBindings::empty();
460 kb.batch_add()
461 .with_ctrl()
462 .add(KeyStrike::KeyB, ActionShortcuts::FocusSidebar);
463 kb.batch_add()
464 .with_alt()
465 .add(KeyStrike::KeyY, ActionShortcuts::FocusSidebar);
466
467 let first = kb
468 .first_combo_for(&ActionShortcuts::FocusSidebar)
469 .expect("bound twice");
470 for _ in 0..50 {
471 assert_eq!(
472 kb.first_combo_for(&ActionShortcuts::FocusSidebar)
473 .as_deref(),
474 Some(first.as_str())
475 );
476 }
477 let mut combos = kb.combos_for(&ActionShortcuts::FocusSidebar);
480 combos.sort();
481 assert_eq!(first, combos[0].to_string());
482 }
483
484 #[test]
489 #[should_panic(expected = "would drop that silently")]
490 fn a_duplicate_combo_in_one_table_panics() {
491 KeyBindings::empty()
492 .batch_add()
493 .with_ctrl()
494 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
495 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor);
496 }
497
498 #[test]
501 fn a_duplicate_combo_in_a_user_config_does_not() {
502 let combo = super::KeyCombo::new(super::KeyModifiers::new().and_ctrl(), KeyStrike::KeyL);
503 let kb = KeyBindings::from_hashmap(std::collections::HashMap::from([
504 (ActionShortcuts::FocusEditor, vec![combo]),
505 (ActionShortcuts::Text(TextAction::Link), vec![combo]),
506 ]));
507 assert!(
508 kb.get_action(&combo).is_some(),
509 "one of the two must win, and the app must still start"
510 );
511 }
512
513 #[test]
517 fn combos_for_follows_a_rebinding() {
518 let default = crate::settings::AppSettings::default().key_bindings;
519 assert_eq!(
520 default.combos_for(&ActionShortcuts::YankRow),
521 vec![super::default_yank_combo()],
522 "the default binding must be the shared literal"
523 );
524
525 let mut rebound = KeyBindings::empty();
526 rebound
527 .batch_add()
528 .with_ctrl()
529 .add(KeyStrike::KeyD, ActionShortcuts::YankRow);
530 let combos = rebound.combos_for(&ActionShortcuts::YankRow);
531 assert_eq!(combos.len(), 1);
532 assert_eq!(combos[0].key, KeyStrike::KeyD);
533
534 assert!(
535 KeyBindings::empty()
536 .combos_for(&ActionShortcuts::YankRow)
537 .is_empty(),
538 "unbinding must yield no chords, not a silent fallback to the default"
539 );
540 }
541
542 #[test]
546 fn non_ascii_chars_are_not_mistaken_for_control_codes() {
547 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers as CKeyMods};
548
549 for c in ['理', '成', 'あ', 'é'] {
550 let event = KeyEvent::new(KeyCode::Char(c), CKeyMods::NONE);
551 assert_eq!(
552 super::key_event_to_combo(&event),
553 None,
554 "{c:?} is not a shortcut and must not resolve to one"
555 );
556 }
557 }
558
559 #[test]
562 fn ascii_control_chars_still_imply_ctrl() {
563 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers as CKeyMods};
564
565 for (raw, letter) in [('\x06', 'f'), ('\x10', 'p')] {
566 let raw_event = KeyEvent::new(KeyCode::Char(raw), CKeyMods::NONE);
567 let letter_event = KeyEvent::new(KeyCode::Char(letter), CKeyMods::CONTROL);
568 assert_eq!(
569 super::key_event_to_combo(&raw_event),
570 super::key_event_to_combo(&letter_event),
571 "raw ASCII control char {raw:?} must resolve like Ctrl+{letter}"
572 );
573 }
574 }
575
576 #[test]
577 fn serialize_key_binding() {
578 let mut km = KeyBindings::empty();
579 km.batch_add()
580 .with_ctrl()
581 .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
582 .add(KeyStrike::KeyH, ActionShortcuts::Text(TextAction::Bold))
583 .with_alt()
584 .add(
585 KeyStrike::KeyL,
586 ActionShortcuts::Text(TextAction::Header(2)),
587 );
588 let km_str = toml::to_string(&km).unwrap();
589
590 let expected = r#"NewJournal = ["ctrl&N"]
591TextEditor-Bold = ["ctrl&H"]
592TextEditor-Header2 = ["ctrl+alt&L"]
593"#
594 .to_string();
595 assert_eq!(expected, km_str);
596 }
597
598 #[test]
599 fn serialize_key_binding_double_assignment() {
600 let mut km = KeyBindings::empty();
601 km.batch_add()
602 .with_ctrl()
603 .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
604 .add(KeyStrike::KeyH, ActionShortcuts::Text(TextAction::Bold))
605 .with_alt()
606 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Bold));
607 let km_str = toml::to_string(&km).unwrap();
608
609 let expected = r#"NewJournal = ["ctrl&N"]
610TextEditor-Bold = ["ctrl&H", "ctrl+alt&L"]
611"#
612 .to_string();
613 assert_eq!(expected, km_str);
614 }
615
616 #[test]
617 fn deserialize_key_binding_double_assignment() {
618 let mut expected_km = KeyBindings::empty();
619 expected_km
620 .batch_add()
621 .with_ctrl()
622 .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
623 .add(KeyStrike::KeyH, ActionShortcuts::Text(TextAction::Bold))
624 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
625 .with_alt()
626 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Bold));
627
628 let km_str = r#"NewJournal = ["ctrl & N"]
629TextEditor-Bold = ["ctrl & H", "ctrl+alt & L"]
630Quit = ["ctrl & Q"]
631"#
632 .to_string();
633
634 let km = toml::from_str(&km_str).unwrap();
635
636 assert_eq!(expected_km, km);
637 }
638
639 #[test]
640 fn deserialize_skips_entry_with_unknown_action() {
641 let toml_str = r#"NewJournal = ["ctrl & N"]
642NotARealAction = ["ctrl & X"]
643Quit = ["ctrl & Q"]
644"#;
645
646 let km: KeyBindings = toml::from_str(toml_str).expect("should not error");
647
648 let mut expected = KeyBindings::empty();
649 expected
650 .batch_add()
651 .with_ctrl()
652 .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
653 .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
654
655 assert_eq!(expected, km);
656 }
657
658 #[test]
659 fn deserialize_skips_entry_with_malformed_combo() {
660 let toml_str = r#"NewJournal = ["ctrl & N"]
661OpenNote = ["bogus & ZZZZ"]
662Quit = ["ctrl & Q"]
663"#;
664
665 let km: KeyBindings = toml::from_str(toml_str).expect("should not error");
666
667 let mut expected = KeyBindings::empty();
668 expected
669 .batch_add()
670 .with_ctrl()
671 .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
672 .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
673
674 assert_eq!(expected, km);
675 }
676
677 #[test]
678 fn deserialize_injects_default_quit_when_missing() {
679 let toml_str = r#"NewJournal = ["ctrl & N"]
680"#;
681
682 let km: KeyBindings = toml::from_str(toml_str).expect("should not error");
683
684 let mut expected = KeyBindings::empty();
685 expected
686 .batch_add()
687 .with_ctrl()
688 .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
689 .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
690
691 assert_eq!(expected, km);
692 }
693
694 #[test]
695 fn deserialize_errors_when_quit_missing_and_default_taken() {
696 let toml_str = r#"OpenNote = ["ctrl & Q"]
697"#;
698
699 let result: Result<KeyBindings, _> = toml::from_str(toml_str);
700 assert!(result.is_err(), "expected deserialize to fail");
701 let err_msg = result.unwrap_err().to_string();
702 assert!(
703 err_msg.contains("Quit") && err_msg.contains("Ctrl+Q"),
704 "error message should mention Quit and Ctrl+Q, got: {}",
705 err_msg
706 );
707 }
708
709 #[test]
710 fn deserialize_recovers_quit_when_quit_entry_is_malformed() {
711 let toml_str = r#"NewJournal = ["ctrl & N"]
712Quit = ["bogus & ZZZZ"]
713"#;
714
715 let km: KeyBindings = toml::from_str(toml_str).expect("should not error");
716
717 let mut expected = KeyBindings::empty();
718 expected
719 .batch_add()
720 .with_ctrl()
721 .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
722 .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
723
724 assert_eq!(expected, km);
725 }
726}