Skip to main content

kimun_notes/keys/
mod.rs

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            // Read the key as a raw String so a bad action name is recoverable.
56            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            // Parse the action name; on failure, discard the value and continue.
63            // The explicit error type pins the generic on `IntoDeserializer`.
64            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        // Essential-action safety net: Quit must always have a binding.
89        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    /// Every combo bound to `action`. Empty when the user has unbound it —
174    /// callers must treat that as "this chord does nothing", not as a cue to
175    /// fall back to the default.
176    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    /// The display string of the combo bound to `action`, or `None`.
185    ///
186    /// `min`, not `find`: this feeds the footer hints, the F1 help and the
187    /// cheatsheet, and a `HashMap` iterates in no particular order — so with
188    /// two combos on one action, `find` returned a different chord from frame
189    /// to frame and the footer flickered between them. Lowest [`KeyCombo`]
190    /// wins, which is arbitrary but stable, and matches the order
191    /// [`Self::to_hashmap`] already sorts each action's combos into.
192    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
243/// Canonical default combo for [`ActionShortcuts::Quit`]. Sourced once so the
244/// deserialize safety net and `settings::default_keybindings` can't
245/// drift.
246pub fn default_quit_combo() -> KeyCombo {
247    KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyQ)
248}
249
250/// Canonical default combo for [`ActionShortcuts::YankRow`]. Sourced once for
251/// the same reason as [`default_quit_combo`], plus one more: `SearchList` claims
252/// this chord itself so every list surface yanks without per-panel wiring,
253/// and it must be the same chord the shortcut tier advertises.
254pub 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    /// Same as with_cmd, used for non-macOS
277    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    /// Bind `key` plus this batch's modifiers to `action`.
286    ///
287    /// The combo must still be free. Two `add` calls for one combo were
288    /// last-wins and silent, which is how `Ctrl+L` came to be bound to both
289    /// `Text(Link)` and `FocusEditor`: the first simply vanished, with no
290    /// warning, and `combos_for(Text(Link))` answering "nothing" was the only
291    /// trace. Every caller of this builder is a hand-written table, so a
292    /// duplicate is a mistake *in that table* and worth a panic in tests. A
293    /// user's config does not come through here — [`KeyBindings::from_hashmap`]
294    /// inserts directly and keeps last-wins, because their keymap is theirs to
295    /// contradict.
296    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
308/// Convert a crossterm [`KeyEvent`] into a [`KeyCombo`] for keybinding lookup.
309///
310/// Returns `None` for key codes that have no [`KeyStrike`] mapping (e.g. media keys).
311/// `BackTab` (Shift+Tab) is normalised to `Tab` with the `shift` modifier set.
312pub fn key_event_to_combo(event: &KeyEvent) -> Option<KeyCombo> {
313    // Some terminals deliver Ctrl+letter as raw control characters (e.g. Ctrl+Q → '\x11')
314    // without setting the CONTROL modifier.  Normalise them here so the rest of the
315    // function sees an ordinary letter + an implied ctrl flag.
316    let mut implied_ctrl = false;
317    let key = match event.code {
318        KeyCode::Char(c) => {
319            // Compare on the full code point: `c as u8` truncates to the low byte,
320            // so a non-ASCII char such as '理' (U+7406) would look like '\x06' and
321            // fire Ctrl+F while typing.
322            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    // BackTab arrives as KeyCode::BackTab (no SHIFT bit set on some terminals).
431    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    /// `first_combo_for` feeds the footer, F1 and the cheatsheet, so it has
453    /// to answer the same thing every time it is asked. It reads a `HashMap`,
454    /// whose iteration order varies run to run and even call to call, so
455    /// picking the first match made a two-chord action flicker between its
456    /// chords mid-session.
457    #[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        // And it is the lowest combo, not merely a stable one: `combos_for`
478        // and `to_hashmap` sort the same way, so the three agree.
479        let mut combos = kb.combos_for(&ActionShortcuts::FocusSidebar);
480        combos.sort();
481        assert_eq!(first, combos[0].to_string());
482    }
483
484    /// The guard on the builder. Without it a second `add` for one combo
485    /// overwrote the first and said nothing — the bug that left `Text(Link)`
486    /// and `Text(ToggleHeader)` with no combo at all for as long as they were
487    /// in the default table.
488    #[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    /// A user's keymap is theirs to contradict: two actions claiming one chord
499    /// in a config resolves to one of them rather than killing the app.
500    #[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    /// `SearchList` resolves its yank chords through this, so a rebinding has to
514    /// come back out of it — otherwise the help dialog advertises one chord
515    /// while the lists answer to another.
516    #[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    /// Regression: `c as u8` truncated the code point, so Japanese (and any
543    /// other) non-ASCII input was read as an ASCII control character and fired
544    /// shortcuts — typing '理' (U+7406) sent Ctrl+F, '成' (U+6210) sent Ctrl+P.
545    #[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    /// The raw control characters this normalisation exists for must keep
560    /// implying Ctrl on terminals that deliver them without the modifier bit.
561    #[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}