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