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