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;
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            // Read the key as a raw String so a bad action name is recoverable.
54            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            // Parse the action name; on failure, discard the value and continue.
61            // The explicit error type pins the generic on `IntoDeserializer`.
62            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        // Essential-action safety net: Quit must always have a binding.
87        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    /// Returns the display string of the first combo bound to `action`, or `None`.
172    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
220/// Canonical default combo for [`ActionShortcuts::Quit`]. Sourced once so the
221/// deserialize safety net and [`crate::settings::default_keybindings`] can't
222/// drift.
223pub 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    /// Same as with_cmd, used for non-macOS
246    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
262/// Convert a crossterm [`KeyEvent`] into a [`KeyCombo`] for keybinding lookup.
263///
264/// Returns `None` for key codes that have no [`KeyStrike`] mapping (e.g. media keys).
265/// `BackTab` (Shift+Tab) is normalised to `Tab` with the `shift` modifier set.
266pub fn key_event_to_combo(event: &KeyEvent) -> Option<KeyCombo> {
267    // Some terminals deliver Ctrl+letter as raw control characters (e.g. Ctrl+Q → '\x11')
268    // without setting the CONTROL modifier.  Normalise them here so the rest of the
269    // function sees an ordinary letter + an implied ctrl flag.
270    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    // BackTab arrives as KeyCode::BackTab (no SHIFT bit set on some terminals).
367    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}