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    /// Returns the display string of the first combo bound to `action`, or `None`.
173    pub fn first_combo_for(&self, action: &ActionShortcuts) -> Option<String> {
174        self.bindings
175            .iter()
176            .find(|(_, a)| *a == action)
177            .map(|(combo, _)| combo.to_string())
178    }
179
180    pub fn to_hashmap(&self) -> HashMap<ActionShortcuts, Vec<KeyCombo>> {
181        let mut bindings: HashMap<ActionShortcuts, Vec<KeyCombo>> = HashMap::new();
182        for (combo, action) in &self.bindings {
183            let entry = bindings.entry(action.to_owned()).or_default();
184            entry.push(combo.to_owned());
185            entry.sort();
186        }
187        bindings
188    }
189
190    pub fn from_hashmap(bindings: HashMap<ActionShortcuts, Vec<KeyCombo>>) -> KeyBindings {
191        let mut kb = KeyBindings::empty();
192        for (action, combos) in &bindings {
193            tracing::debug!("from_hashmap: action={} combos={:?}", action, combos);
194        }
195        for (action, combos) in bindings {
196            for combo in combos {
197                let valid = combo.is_valid_binding();
198                tracing::debug!(
199                    "from_hashmap: combo='{}' key={:?} modifiers={:?} valid={}",
200                    combo,
201                    combo.key,
202                    combo.modifiers,
203                    valid
204                );
205                if valid {
206                    kb.bindings.insert(combo.to_owned(), action.to_owned());
207                } else {
208                    tracing::warn!(
209                        "Skipping invalid key combo '{}' for action '{}': \
210                         only ctrl/alt (with optional shift) + a letter, digit, or \
211                         punctuation key, or bare F1–F12 are supported",
212                        combo,
213                        action
214                    );
215                }
216            }
217        }
218        kb
219    }
220}
221
222/// Canonical default combo for [`ActionShortcuts::Quit`]. Sourced once so the
223/// deserialize safety net and [`crate::settings::default_keybindings`] can't
224/// drift.
225pub fn default_quit_combo() -> KeyCombo {
226    KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyQ)
227}
228
229pub struct KeyBindBatch<'k> {
230    bindings: &'k mut KeyBindings,
231    modifiers: KeyModifiers,
232}
233
234impl<'k> KeyBindBatch<'k> {
235    pub fn with_shift(mut self) -> Self {
236        self.modifiers.with_shift();
237        self
238    }
239    pub fn with_ctrl(mut self) -> Self {
240        self.modifiers.with_ctrl();
241        self
242    }
243    pub fn with_alt(mut self) -> Self {
244        self.modifiers.with_alt();
245        self
246    }
247    /// Same as with_cmd, used for non-macOS
248    pub fn with_meta(mut self) -> Self {
249        self.modifiers.with_meta_cmd();
250        self
251    }
252    pub fn with_cmd(mut self) -> Self {
253        self.modifiers.with_meta_cmd();
254        self
255    }
256    pub fn add(self, key: KeyStrike, action: ActionShortcuts) -> KeyBindBatch<'k> {
257        self.bindings
258            .bindings
259            .insert(KeyCombo::new(self.modifiers, key), action);
260        self
261    }
262}
263
264/// Convert a crossterm [`KeyEvent`] into a [`KeyCombo`] for keybinding lookup.
265///
266/// Returns `None` for key codes that have no [`KeyStrike`] mapping (e.g. media keys).
267/// `BackTab` (Shift+Tab) is normalised to `Tab` with the `shift` modifier set.
268pub fn key_event_to_combo(event: &KeyEvent) -> Option<KeyCombo> {
269    // Some terminals deliver Ctrl+letter as raw control characters (e.g. Ctrl+Q → '\x11')
270    // without setting the CONTROL modifier.  Normalise them here so the rest of the
271    // function sees an ordinary letter + an implied ctrl flag.
272    let mut implied_ctrl = false;
273    let key = match event.code {
274        KeyCode::Char(c) => {
275            let c = if c as u8 >= 1 && c as u8 <= 26 {
276                implied_ctrl = true;
277                (c as u8 + b'a' - 1) as char
278            } else {
279                c
280            };
281            match c.to_ascii_lowercase() {
282                'a' => KeyStrike::KeyA,
283                'b' => KeyStrike::KeyB,
284                'c' => KeyStrike::KeyC,
285                'd' => KeyStrike::KeyD,
286                'e' => KeyStrike::KeyE,
287                'f' => KeyStrike::KeyF,
288                'g' => KeyStrike::KeyG,
289                'h' => KeyStrike::KeyH,
290                'i' => KeyStrike::KeyI,
291                'j' => KeyStrike::KeyJ,
292                'k' => KeyStrike::KeyK,
293                'l' => KeyStrike::KeyL,
294                'm' => KeyStrike::KeyM,
295                'n' => KeyStrike::KeyN,
296                'o' => KeyStrike::KeyO,
297                'p' => KeyStrike::KeyP,
298                'q' => KeyStrike::KeyQ,
299                'r' => KeyStrike::KeyR,
300                's' => KeyStrike::KeyS,
301                't' => KeyStrike::KeyT,
302                'u' => KeyStrike::KeyU,
303                'v' => KeyStrike::KeyV,
304                'w' => KeyStrike::KeyW,
305                'x' => KeyStrike::KeyX,
306                'y' => KeyStrike::KeyY,
307                'z' => KeyStrike::KeyZ,
308                '0' => KeyStrike::Digit0,
309                '1' => KeyStrike::Digit1,
310                '2' => KeyStrike::Digit2,
311                '3' => KeyStrike::Digit3,
312                '4' => KeyStrike::Digit4,
313                '5' => KeyStrike::Digit5,
314                '6' => KeyStrike::Digit6,
315                '7' => KeyStrike::Digit7,
316                '8' => KeyStrike::Digit8,
317                '9' => KeyStrike::Digit9,
318                ',' => KeyStrike::Comma,
319                '.' => KeyStrike::Period,
320                '/' => KeyStrike::Slash,
321                ';' => KeyStrike::Semicolon,
322                '\'' => KeyStrike::Quote,
323                '[' => KeyStrike::BracketLeft,
324                ']' => KeyStrike::BracketRight,
325                '\\' => KeyStrike::Backslash,
326                '`' => KeyStrike::Backquote,
327                '-' => KeyStrike::Minus,
328                '=' => KeyStrike::Equal,
329                _ => return None,
330            }
331        }
332        KeyCode::Enter => KeyStrike::Enter,
333        KeyCode::Backspace => KeyStrike::Backspace,
334        KeyCode::Tab | KeyCode::BackTab => KeyStrike::Tab,
335        KeyCode::Esc => KeyStrike::Escape,
336        KeyCode::Up => KeyStrike::ArrowUp,
337        KeyCode::Down => KeyStrike::ArrowDown,
338        KeyCode::Left => KeyStrike::ArrowLeft,
339        KeyCode::Right => KeyStrike::ArrowRight,
340        KeyCode::Home => KeyStrike::Home,
341        KeyCode::End => KeyStrike::End,
342        KeyCode::PageUp => KeyStrike::PageUp,
343        KeyCode::PageDown => KeyStrike::PageDown,
344        KeyCode::Delete => KeyStrike::Delete,
345        KeyCode::Insert => KeyStrike::Insert,
346        KeyCode::F(n) => match n {
347            1 => KeyStrike::F1,
348            2 => KeyStrike::F2,
349            3 => KeyStrike::F3,
350            4 => KeyStrike::F4,
351            5 => KeyStrike::F5,
352            6 => KeyStrike::F6,
353            7 => KeyStrike::F7,
354            8 => KeyStrike::F8,
355            9 => KeyStrike::F9,
356            10 => KeyStrike::F10,
357            11 => KeyStrike::F11,
358            12 => KeyStrike::F12,
359            _ => return None,
360        },
361        _ => return None,
362    };
363
364    let mut modifiers = KeyModifiers::default();
365    if implied_ctrl || event.modifiers.contains(CKeyMods::CONTROL) {
366        modifiers.with_ctrl();
367    }
368    // BackTab arrives as KeyCode::BackTab (no SHIFT bit set on some terminals).
369    if event.modifiers.contains(CKeyMods::SHIFT) || matches!(event.code, KeyCode::BackTab) {
370        modifiers.with_shift();
371    }
372    if event.modifiers.contains(CKeyMods::ALT) {
373        modifiers.with_alt();
374    }
375    if event.modifiers.contains(CKeyMods::SUPER) || event.modifiers.contains(CKeyMods::META) {
376        modifiers.with_meta_cmd();
377    }
378
379    Some(KeyCombo::new(modifiers, key))
380}
381
382#[cfg(test)]
383mod tests {
384    use super::{
385        KeyBindings,
386        action_shortcuts::{ActionShortcuts, TextAction},
387        key_strike::KeyStrike,
388    };
389
390    #[test]
391    fn serialize_key_binding() {
392        let mut km = KeyBindings::empty();
393        km.batch_add()
394            .with_ctrl()
395            .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
396            .add(KeyStrike::KeyH, ActionShortcuts::Text(TextAction::Bold))
397            .with_alt()
398            .add(
399                KeyStrike::KeyL,
400                ActionShortcuts::Text(TextAction::Header(2)),
401            );
402        let km_str = toml::to_string(&km).unwrap();
403
404        let expected = r#"NewJournal = ["ctrl&N"]
405TextEditor-Bold = ["ctrl&H"]
406TextEditor-Header2 = ["ctrl+alt&L"]
407"#
408        .to_string();
409        assert_eq!(expected, km_str);
410    }
411
412    #[test]
413    fn serialize_key_binding_double_assignment() {
414        let mut km = KeyBindings::empty();
415        km.batch_add()
416            .with_ctrl()
417            .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
418            .add(KeyStrike::KeyH, ActionShortcuts::Text(TextAction::Bold))
419            .with_alt()
420            .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Bold));
421        let km_str = toml::to_string(&km).unwrap();
422
423        let expected = r#"NewJournal = ["ctrl&N"]
424TextEditor-Bold = ["ctrl&H", "ctrl+alt&L"]
425"#
426        .to_string();
427        assert_eq!(expected, km_str);
428    }
429
430    #[test]
431    fn deserialize_key_binding_double_assignment() {
432        let mut expected_km = KeyBindings::empty();
433        expected_km
434            .batch_add()
435            .with_ctrl()
436            .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
437            .add(KeyStrike::KeyH, ActionShortcuts::Text(TextAction::Bold))
438            .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
439            .with_alt()
440            .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Bold));
441
442        let km_str = r#"NewJournal = ["ctrl & N"]
443TextEditor-Bold = ["ctrl & H", "ctrl+alt & L"]
444Quit = ["ctrl & Q"]
445"#
446        .to_string();
447
448        let km = toml::from_str(&km_str).unwrap();
449
450        assert_eq!(expected_km, km);
451    }
452
453    #[test]
454    fn deserialize_skips_entry_with_unknown_action() {
455        let toml_str = r#"NewJournal = ["ctrl & N"]
456NotARealAction = ["ctrl & X"]
457Quit = ["ctrl & Q"]
458"#;
459
460        let km: KeyBindings = toml::from_str(toml_str).expect("should not error");
461
462        let mut expected = KeyBindings::empty();
463        expected
464            .batch_add()
465            .with_ctrl()
466            .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
467            .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
468
469        assert_eq!(expected, km);
470    }
471
472    #[test]
473    fn deserialize_skips_entry_with_malformed_combo() {
474        let toml_str = r#"NewJournal = ["ctrl & N"]
475OpenNote = ["bogus & ZZZZ"]
476Quit = ["ctrl & Q"]
477"#;
478
479        let km: KeyBindings = toml::from_str(toml_str).expect("should not error");
480
481        let mut expected = KeyBindings::empty();
482        expected
483            .batch_add()
484            .with_ctrl()
485            .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
486            .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
487
488        assert_eq!(expected, km);
489    }
490
491    #[test]
492    fn deserialize_injects_default_quit_when_missing() {
493        let toml_str = r#"NewJournal = ["ctrl & N"]
494"#;
495
496        let km: KeyBindings = toml::from_str(toml_str).expect("should not error");
497
498        let mut expected = KeyBindings::empty();
499        expected
500            .batch_add()
501            .with_ctrl()
502            .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
503            .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
504
505        assert_eq!(expected, km);
506    }
507
508    #[test]
509    fn deserialize_errors_when_quit_missing_and_default_taken() {
510        let toml_str = r#"OpenNote = ["ctrl & Q"]
511"#;
512
513        let result: Result<KeyBindings, _> = toml::from_str(toml_str);
514        assert!(result.is_err(), "expected deserialize to fail");
515        let err_msg = result.unwrap_err().to_string();
516        assert!(
517            err_msg.contains("Quit") && err_msg.contains("Ctrl+Q"),
518            "error message should mention Quit and Ctrl+Q, got: {}",
519            err_msg
520        );
521    }
522
523    #[test]
524    fn deserialize_recovers_quit_when_quit_entry_is_malformed() {
525        let toml_str = r#"NewJournal = ["ctrl & N"]
526Quit = ["bogus & ZZZZ"]
527"#;
528
529        let km: KeyBindings = toml::from_str(toml_str).expect("should not error");
530
531        let mut expected = KeyBindings::empty();
532        expected
533            .batch_add()
534            .with_ctrl()
535            .add(KeyStrike::KeyN, ActionShortcuts::NewJournal)
536            .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
537
538        assert_eq!(expected, km);
539    }
540}