Skip to main content

remendo/config/
keys.rs

1//! Keybinding configuration: `KeyAction`, `KeyCombo`, and `KeybindingsConfig`.
2//!
3//! Maps physical key combinations to semantic actions, decoupling
4//! the user interface from hardcoded key assignments per the TRD.
5
6use crossterm::event::{KeyCode, KeyModifiers};
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::fmt;
10
11/// Every semantic action the TUI can perform.
12///
13/// This is the decoupling layer the TRD requires: operations are
14/// agnostic of the keys that trigger them.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum KeyAction {
17    /// Exit the application.
18    Quit,
19    /// Scroll down one line.
20    ScrollDown,
21    /// Scroll up one line.
22    ScrollUp,
23    /// Scroll down half a page.
24    ScrollHalfPageDown,
25    /// Scroll up half a page.
26    ScrollHalfPageUp,
27    /// Switch to the next mailbox/remote.
28    NextMailbox,
29    /// Switch to the previous mailbox/remote.
30    PrevMailbox,
31    /// Open the selected thread/patchset.
32    OpenThread,
33    /// Close the current detail view.
34    CloseThread,
35    /// Refresh the current view.
36    Refresh,
37    /// Activate the search prompt.
38    Search,
39    /// Toggle bookmark on the selected item.
40    BookmarkToggle,
41    /// View the raw review log.
42    ViewRawLog,
43    /// Show the help overlay.
44    Help,
45    /// Focus the sidebar panel.
46    FocusSidebar,
47    /// Jump to the next comment/finding.
48    NextComment,
49    /// Jump to the previous comment/finding.
50    PrevComment,
51    /// Navigate to the next page of results.
52    NextPage,
53    /// Navigate to the previous page of results.
54    PrevPage,
55    /// View the baseline application log in `$EDITOR`.
56    ViewBaselineLog,
57    /// Toggle between patchset and message list views.
58    ToggleListContent,
59    /// Toggle bookmark-only filter in the list view.
60    BookmarkFilter,
61    /// Cycle the sort column in the patchset list.
62    CycleSort,
63    /// Reverse the sort direction in the patchset list.
64    ReverseSort,
65}
66
67impl KeyAction {
68    /// Human-readable label for display in the help overlay.
69    #[must_use]
70    pub fn label(self) -> &'static str {
71        match self {
72            Self::Quit => "Quit",
73            Self::ScrollDown => "Scroll down",
74            Self::ScrollUp => "Scroll up",
75            Self::ScrollHalfPageDown => "Half page down",
76            Self::ScrollHalfPageUp => "Half page up",
77            Self::NextMailbox => "Next mailbox",
78            Self::PrevMailbox => "Prev mailbox",
79            Self::OpenThread => "Open thread",
80            Self::CloseThread => "Close / Back",
81            Self::Refresh => "Refresh",
82            Self::Search => "Search",
83            Self::BookmarkToggle => "Toggle bookmark",
84            Self::ViewRawLog => "View raw log",
85            Self::Help => "Help",
86            Self::FocusSidebar => "Focus sidebar",
87            Self::NextComment => "Next comment",
88            Self::PrevComment => "Prev comment",
89            Self::NextPage => "Next page",
90            Self::PrevPage => "Prev page",
91            Self::ViewBaselineLog => "View baseline log",
92            Self::ToggleListContent => "Toggle messages",
93            Self::BookmarkFilter => "Bookmark filter",
94            Self::CycleSort => "Cycle sort column",
95            Self::ReverseSort => "Reverse sort",
96        }
97    }
98}
99
100/// A physical key combination (key code + modifiers).
101#[derive(Debug, Clone, PartialEq, Eq, Hash)]
102pub struct KeyCombo {
103    /// The key code.
104    pub code: KeyCode,
105    /// Active modifier keys (Ctrl, Shift, Alt).
106    pub modifiers: KeyModifiers,
107}
108
109impl KeyCombo {
110    /// Create a new key combo.
111    #[must_use]
112    pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
113        Self { code, modifiers }
114    }
115
116    /// Parse a key combo from a string like `"C-r"`, `"S-Tab"`, `"q"`.
117    ///
118    /// Modifier prefixes: `C-` (Ctrl), `S-` (Shift), `A-` (Alt).
119    /// Multiple modifiers can be chained: `"C-S-x"`.
120    fn parse(s: &str) -> Option<Self> {
121        let mut modifiers = KeyModifiers::NONE;
122        let mut remaining = s;
123
124        // Parse modifier prefixes
125        loop {
126            if let Some(rest) = remaining.strip_prefix("C-") {
127                modifiers |= KeyModifiers::CONTROL;
128                remaining = rest;
129            } else if let Some(rest) = remaining.strip_prefix("S-") {
130                modifiers |= KeyModifiers::SHIFT;
131                remaining = rest;
132            } else if let Some(rest) = remaining.strip_prefix("A-") {
133                modifiers |= KeyModifiers::ALT;
134                remaining = rest;
135            } else {
136                break;
137            }
138        }
139
140        let code = parse_key_code(remaining)?;
141
142        // Uppercase letters implicitly carry SHIFT — crossterm delivers
143        // Shift+n as KeyCode::Char('N') with KeyModifiers::SHIFT.
144        if let KeyCode::Char(c) = code
145            && c.is_ascii_uppercase()
146        {
147            modifiers |= KeyModifiers::SHIFT;
148        }
149
150        Some(Self { code, modifiers })
151    }
152}
153
154impl fmt::Display for KeyCombo {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        if self.modifiers.contains(KeyModifiers::CONTROL) {
157            write!(f, "C-")?;
158        }
159        if self.modifiers.contains(KeyModifiers::SHIFT) {
160            write!(f, "S-")?;
161        }
162        if self.modifiers.contains(KeyModifiers::ALT) {
163            write!(f, "A-")?;
164        }
165        match self.code {
166            KeyCode::Char(c) => write!(f, "{c}"),
167            KeyCode::Enter => write!(f, "Enter"),
168            KeyCode::Tab => write!(f, "Tab"),
169            KeyCode::BackTab => write!(f, "S-Tab"),
170            KeyCode::Esc => write!(f, "Esc"),
171            KeyCode::Backspace => write!(f, "Backspace"),
172            KeyCode::Delete => write!(f, "Delete"),
173            KeyCode::Up => write!(f, "Up"),
174            KeyCode::Down => write!(f, "Down"),
175            KeyCode::Left => write!(f, "Left"),
176            KeyCode::Right => write!(f, "Right"),
177            KeyCode::Home => write!(f, "Home"),
178            KeyCode::End => write!(f, "End"),
179            KeyCode::PageUp => write!(f, "PageUp"),
180            KeyCode::PageDown => write!(f, "PageDown"),
181            KeyCode::F(n) => write!(f, "F{n}"),
182            _ => write!(f, "?"),
183        }
184    }
185}
186
187/// Parse a key code string into a [`KeyCode`].
188fn parse_key_code(s: &str) -> Option<KeyCode> {
189    // Single character
190    if s.len() == 1 {
191        return Some(KeyCode::Char(s.chars().next()?));
192    }
193
194    // Named keys (case-insensitive)
195    Some(match s.to_lowercase().as_str() {
196        "enter" | "return" | "cr" => KeyCode::Enter,
197        "tab" => KeyCode::Tab,
198        "backtab" => KeyCode::BackTab,
199        "esc" | "escape" => KeyCode::Esc,
200        "space" => KeyCode::Char(' '),
201        "backspace" | "bs" => KeyCode::Backspace,
202        "delete" | "del" => KeyCode::Delete,
203        "up" => KeyCode::Up,
204        "down" => KeyCode::Down,
205        "left" => KeyCode::Left,
206        "right" => KeyCode::Right,
207        "home" => KeyCode::Home,
208        "end" => KeyCode::End,
209        "pageup" | "pgup" => KeyCode::PageUp,
210        "pagedown" | "pgdn" => KeyCode::PageDown,
211        "f1" => KeyCode::F(1),
212        "f2" => KeyCode::F(2),
213        "f3" => KeyCode::F(3),
214        "f4" => KeyCode::F(4),
215        "f5" => KeyCode::F(5),
216        "f6" => KeyCode::F(6),
217        "f7" => KeyCode::F(7),
218        "f8" => KeyCode::F(8),
219        "f9" => KeyCode::F(9),
220        "f10" => KeyCode::F(10),
221        "f11" => KeyCode::F(11),
222        "f12" => KeyCode::F(12),
223        _ => return None,
224    })
225}
226
227/// Parse an action name string into a [`KeyAction`].
228fn parse_action(s: &str) -> Option<KeyAction> {
229    Some(match s {
230        "quit" => KeyAction::Quit,
231        "scroll_down" => KeyAction::ScrollDown,
232        "scroll_up" => KeyAction::ScrollUp,
233        "scroll_half_page_down" => KeyAction::ScrollHalfPageDown,
234        "scroll_half_page_up" => KeyAction::ScrollHalfPageUp,
235        "next_mailbox" => KeyAction::NextMailbox,
236        "prev_mailbox" => KeyAction::PrevMailbox,
237        "open_thread" => KeyAction::OpenThread,
238        "close_thread" => KeyAction::CloseThread,
239        "refresh" => KeyAction::Refresh,
240        "search" => KeyAction::Search,
241        "bookmark_toggle" => KeyAction::BookmarkToggle,
242        "view_raw_log" => KeyAction::ViewRawLog,
243        "help" => KeyAction::Help,
244        "focus_sidebar" => KeyAction::FocusSidebar,
245        "next_comment" => KeyAction::NextComment,
246        "prev_comment" => KeyAction::PrevComment,
247        "next_page" => KeyAction::NextPage,
248        "prev_page" => KeyAction::PrevPage,
249        "view_baseline_log" => KeyAction::ViewBaselineLog,
250        "toggle_list_content" => KeyAction::ToggleListContent,
251        "bookmark_filter" => KeyAction::BookmarkFilter,
252        "cycle_sort" => KeyAction::CycleSort,
253        "reverse_sort" => KeyAction::ReverseSort,
254        _ => return None,
255    })
256}
257
258/// Keybinding configuration mapping physical keys to semantic actions.
259#[derive(Debug, Clone)]
260pub struct KeybindingsConfig {
261    /// The resolved key-to-action map.
262    pub bindings: HashMap<KeyCombo, KeyAction>,
263}
264
265impl KeybindingsConfig {
266    /// Look up the action bound to a key combination.
267    #[must_use]
268    pub fn action_for(&self, combo: &KeyCombo) -> Option<KeyAction> {
269        self.bindings.get(combo).copied()
270    }
271}
272
273impl Default for KeybindingsConfig {
274    fn default() -> Self {
275        let defaults: &[(&str, &str)] = &[
276            ("quit", "q"),
277            ("scroll_down", "j"),
278            ("scroll_up", "k"),
279            ("scroll_half_page_down", "C-d"),
280            ("scroll_half_page_up", "C-u"),
281            ("next_mailbox", "Tab"),
282            ("prev_mailbox", "S-Tab"),
283            ("open_thread", "Enter"),
284            ("close_thread", "Esc"),
285            ("refresh", "C-r"),
286            ("search", "/"),
287            ("bookmark_toggle", "b"),
288            ("view_raw_log", "r"),
289            ("help", "?"),
290            ("focus_sidebar", "C-s"),
291            ("next_comment", "n"),
292            ("prev_comment", "N"),
293            ("next_page", "]"),
294            ("prev_page", "["),
295            ("view_baseline_log", "L"),
296            ("toggle_list_content", "m"),
297            ("bookmark_filter", "B"),
298            ("cycle_sort", "s"),
299            ("reverse_sort", "S"),
300        ];
301
302        let mut bindings = HashMap::new();
303        for &(action_str, key_str) in defaults {
304            if let (Some(action), Some(combo)) =
305                (parse_action(action_str), KeyCombo::parse(key_str))
306            {
307                bindings.insert(combo, action);
308            }
309        }
310
311        Self { bindings }
312    }
313}
314
315impl<'de> Deserialize<'de> for KeybindingsConfig {
316    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
317    where
318        D: serde::Deserializer<'de>,
319    {
320        let raw: HashMap<String, String> = HashMap::deserialize(deserializer)?;
321        let mut config = Self::default();
322
323        for (action_str, key_str) in &raw {
324            let Some(action) = parse_action(action_str) else {
325                tracing::warn!(action = %action_str, "unknown keybinding action, skipping");
326                continue;
327            };
328            let Some(combo) = KeyCombo::parse(key_str) else {
329                tracing::warn!(
330                    key = %key_str,
331                    action = %action_str,
332                    "unparseable key combo, skipping"
333                );
334                continue;
335            };
336            config.bindings.insert(combo, action);
337        }
338
339        Ok(config)
340    }
341}
342
343#[cfg(test)]
344#[allow(clippy::expect_used)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn parse_single_char_key() {
350        let combo = KeyCombo::parse("q").expect("parse 'q'");
351        assert_eq!(combo.code, KeyCode::Char('q'));
352        assert_eq!(combo.modifiers, KeyModifiers::NONE);
353    }
354
355    #[test]
356    fn parse_ctrl_modifier() {
357        let combo = KeyCombo::parse("C-r").expect("parse 'C-r'");
358        assert_eq!(combo.code, KeyCode::Char('r'));
359        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
360    }
361
362    #[test]
363    fn parse_shift_modifier() {
364        let combo = KeyCombo::parse("S-Tab").expect("parse 'S-Tab'");
365        assert_eq!(combo.code, KeyCode::Tab);
366        assert!(combo.modifiers.contains(KeyModifiers::SHIFT));
367    }
368
369    #[test]
370    fn parse_alt_modifier() {
371        let combo = KeyCombo::parse("A-x").expect("parse 'A-x'");
372        assert_eq!(combo.code, KeyCode::Char('x'));
373        assert!(combo.modifiers.contains(KeyModifiers::ALT));
374    }
375
376    #[test]
377    fn parse_named_keys() {
378        assert_eq!(
379            KeyCombo::parse("Enter").expect("Enter").code,
380            KeyCode::Enter
381        );
382        assert_eq!(KeyCombo::parse("Esc").expect("Esc").code, KeyCode::Esc);
383        assert_eq!(
384            KeyCombo::parse("Space").expect("Space").code,
385            KeyCode::Char(' ')
386        );
387        assert_eq!(KeyCombo::parse("F5").expect("F5").code, KeyCode::F(5));
388    }
389
390    #[test]
391    fn parse_unknown_key_returns_none() {
392        assert!(KeyCombo::parse("UnknownKey").is_none());
393    }
394
395    #[test]
396    fn default_keybindings_has_expected_mappings() {
397        let config = KeybindingsConfig::default();
398        let quit_combo = KeyCombo::new(KeyCode::Char('q'), KeyModifiers::NONE);
399        assert_eq!(config.action_for(&quit_combo), Some(KeyAction::Quit));
400
401        let refresh_combo = KeyCombo::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
402        assert_eq!(config.action_for(&refresh_combo), Some(KeyAction::Refresh));
403
404        let tab_combo = KeyCombo::new(KeyCode::Tab, KeyModifiers::NONE);
405        assert_eq!(config.action_for(&tab_combo), Some(KeyAction::NextMailbox));
406    }
407
408    #[test]
409    fn action_for_unknown_combo_returns_none() {
410        let config = KeybindingsConfig::default();
411        let unknown = KeyCombo::new(KeyCode::F(12), KeyModifiers::NONE);
412        assert!(config.action_for(&unknown).is_none());
413    }
414
415    #[test]
416    fn deserialize_custom_keybindings() {
417        let toml_str = r#"
418            quit = "C-q"
419            refresh = "F5"
420        "#;
421        let config: KeybindingsConfig = toml::from_str(toml_str).expect("parse keybindings");
422
423        let ctrl_q = KeyCombo::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
424        assert_eq!(config.action_for(&ctrl_q), Some(KeyAction::Quit));
425
426        let f5 = KeyCombo::new(KeyCode::F(5), KeyModifiers::NONE);
427        assert_eq!(config.action_for(&f5), Some(KeyAction::Refresh));
428    }
429
430    #[test]
431    fn deserialize_unknown_action_is_skipped() {
432        let toml_str = r#"
433            quit = "q"
434            unknown_future_action = "x"
435        "#;
436        let config: KeybindingsConfig =
437            toml::from_str(toml_str).expect("parse with unknown action");
438        // "q" should still work
439        let q = KeyCombo::new(KeyCode::Char('q'), KeyModifiers::NONE);
440        assert_eq!(config.action_for(&q), Some(KeyAction::Quit));
441    }
442
443    #[test]
444    fn key_combo_display() {
445        let combo = KeyCombo::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
446        assert_eq!(combo.to_string(), "C-r");
447
448        let plain = KeyCombo::new(KeyCode::Char('q'), KeyModifiers::NONE);
449        assert_eq!(plain.to_string(), "q");
450
451        let enter = KeyCombo::new(KeyCode::Enter, KeyModifiers::NONE);
452        assert_eq!(enter.to_string(), "Enter");
453    }
454
455    #[test]
456    fn key_combo_display_all_named_keys() {
457        // Cover remaining KeyCode arms in KeyCombo::fmt
458        assert_eq!(
459            KeyCombo::new(KeyCode::Tab, KeyModifiers::NONE).to_string(),
460            "Tab"
461        );
462        assert_eq!(
463            KeyCombo::new(KeyCode::BackTab, KeyModifiers::NONE).to_string(),
464            "S-Tab"
465        );
466        assert_eq!(
467            KeyCombo::new(KeyCode::Esc, KeyModifiers::NONE).to_string(),
468            "Esc"
469        );
470        assert_eq!(
471            KeyCombo::new(KeyCode::Backspace, KeyModifiers::NONE).to_string(),
472            "Backspace"
473        );
474        assert_eq!(
475            KeyCombo::new(KeyCode::Delete, KeyModifiers::NONE).to_string(),
476            "Delete"
477        );
478        assert_eq!(
479            KeyCombo::new(KeyCode::Up, KeyModifiers::NONE).to_string(),
480            "Up"
481        );
482        assert_eq!(
483            KeyCombo::new(KeyCode::Down, KeyModifiers::NONE).to_string(),
484            "Down"
485        );
486        assert_eq!(
487            KeyCombo::new(KeyCode::Left, KeyModifiers::NONE).to_string(),
488            "Left"
489        );
490        assert_eq!(
491            KeyCombo::new(KeyCode::Right, KeyModifiers::NONE).to_string(),
492            "Right"
493        );
494        assert_eq!(
495            KeyCombo::new(KeyCode::Home, KeyModifiers::NONE).to_string(),
496            "Home"
497        );
498        assert_eq!(
499            KeyCombo::new(KeyCode::End, KeyModifiers::NONE).to_string(),
500            "End"
501        );
502        assert_eq!(
503            KeyCombo::new(KeyCode::PageUp, KeyModifiers::NONE).to_string(),
504            "PageUp"
505        );
506        assert_eq!(
507            KeyCombo::new(KeyCode::PageDown, KeyModifiers::NONE).to_string(),
508            "PageDown"
509        );
510        // F-key
511        assert_eq!(
512            KeyCombo::new(KeyCode::F(1), KeyModifiers::NONE).to_string(),
513            "F1"
514        );
515        // Fallback arm
516        assert_eq!(
517            KeyCombo::new(KeyCode::Insert, KeyModifiers::NONE).to_string(),
518            "?"
519        );
520    }
521
522    #[test]
523    fn key_combo_display_modifier_combos() {
524        // Shift modifier
525        assert_eq!(
526            KeyCombo::new(KeyCode::Char('a'), KeyModifiers::SHIFT).to_string(),
527            "S-a"
528        );
529        // Alt modifier
530        assert_eq!(
531            KeyCombo::new(KeyCode::Char('x'), KeyModifiers::ALT).to_string(),
532            "A-x"
533        );
534        // Ctrl+Shift
535        let cs = KeyCombo::new(
536            KeyCode::Char('z'),
537            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
538        );
539        assert_eq!(cs.to_string(), "C-S-z");
540        // Ctrl+Alt
541        let ca = KeyCombo::new(
542            KeyCode::Char('m'),
543            KeyModifiers::CONTROL | KeyModifiers::ALT,
544        );
545        assert_eq!(ca.to_string(), "C-A-m");
546    }
547
548    #[test]
549    fn key_action_label_covers_all_variants() {
550        // Exercises every arm of KeyAction::label (CC=25)
551        let labels = [
552            (KeyAction::Quit, "Quit"),
553            (KeyAction::ScrollDown, "Scroll down"),
554            (KeyAction::ScrollUp, "Scroll up"),
555            (KeyAction::ScrollHalfPageDown, "Half page down"),
556            (KeyAction::ScrollHalfPageUp, "Half page up"),
557            (KeyAction::NextMailbox, "Next mailbox"),
558            (KeyAction::PrevMailbox, "Prev mailbox"),
559            (KeyAction::OpenThread, "Open thread"),
560            (KeyAction::CloseThread, "Close / Back"),
561            (KeyAction::Refresh, "Refresh"),
562            (KeyAction::Search, "Search"),
563            (KeyAction::BookmarkToggle, "Toggle bookmark"),
564            (KeyAction::ViewRawLog, "View raw log"),
565            (KeyAction::Help, "Help"),
566            (KeyAction::FocusSidebar, "Focus sidebar"),
567            (KeyAction::NextComment, "Next comment"),
568            (KeyAction::PrevComment, "Prev comment"),
569            (KeyAction::NextPage, "Next page"),
570            (KeyAction::PrevPage, "Prev page"),
571            (KeyAction::ViewBaselineLog, "View baseline log"),
572            (KeyAction::ToggleListContent, "Toggle messages"),
573            (KeyAction::BookmarkFilter, "Bookmark filter"),
574            (KeyAction::CycleSort, "Cycle sort column"),
575            (KeyAction::ReverseSort, "Reverse sort"),
576        ];
577        for (action, expected) in labels {
578            assert_eq!(action.label(), expected, "label mismatch for {action:?}");
579        }
580    }
581}