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;
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            // Read the key as a raw String so a bad action name is recoverable.
55            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            // Parse the action name; on failure, discard the value and continue.
62            // The explicit error type pins the generic on `IntoDeserializer`.
63            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        // Essential-action safety net: Quit must always have a binding.
88        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    /// Every combo bound to `action`. Empty when the user has unbound it —
173    /// callers must treat that as "this chord does nothing", not as a cue to
174    /// fall back to the default.
175    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    /// Returns the display string of the first combo bound to `action`, or `None`.
184    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
233/// Canonical default combo for [`ActionShortcuts::Quit`]. Sourced once so the
234/// deserialize safety net and `settings::default_keybindings` can't
235/// drift.
236pub fn default_quit_combo() -> KeyCombo {
237    KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyQ)
238}
239
240/// Canonical default combo for [`ActionShortcuts::YankRow`]. Sourced once for
241/// the same reason as [`default_quit_combo`], plus one more: `SearchList` claims
242/// this chord itself so every list surface yanks without per-panel wiring,
243/// and it must be the same chord the shortcut tier advertises.
244pub 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    /// Same as with_cmd, used for non-macOS
267    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
283/// Convert a crossterm [`KeyEvent`] into a [`KeyCombo`] for keybinding lookup.
284///
285/// Returns `None` for key codes that have no [`KeyStrike`] mapping (e.g. media keys).
286/// `BackTab` (Shift+Tab) is normalised to `Tab` with the `shift` modifier set.
287pub fn key_event_to_combo(event: &KeyEvent) -> Option<KeyCombo> {
288    // Some terminals deliver Ctrl+letter as raw control characters (e.g. Ctrl+Q → '\x11')
289    // without setting the CONTROL modifier.  Normalise them here so the rest of the
290    // function sees an ordinary letter + an implied ctrl flag.
291    let mut implied_ctrl = false;
292    let key = match event.code {
293        KeyCode::Char(c) => {
294            // Compare on the full code point: `c as u8` truncates to the low byte,
295            // so a non-ASCII char such as '理' (U+7406) would look like '\x06' and
296            // fire Ctrl+F while typing.
297            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    // BackTab arrives as KeyCode::BackTab (no SHIFT bit set on some terminals).
406    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    /// `SearchList` resolves its yank chords through this, so a rebinding has to
428    /// come back out of it — otherwise the help dialog advertises one chord
429    /// while the lists answer to another.
430    #[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    /// Regression: `c as u8` truncated the code point, so Japanese (and any
457    /// other) non-ASCII input was read as an ASCII control character and fired
458    /// shortcuts — typing '理' (U+7406) sent Ctrl+F, '成' (U+6210) sent Ctrl+P.
459    #[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    /// The raw control characters this normalisation exists for must keep
474    /// implying Ctrl on terminals that deliver them without the modifier bit.
475    #[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}