Skip to main content

mxr_tui/
keybindings.rs

1use crate::action::Action;
2use crossterm::event::{KeyCode, KeyModifiers};
3use serde::Deserialize;
4use std::collections::HashMap;
5
6/// Parsed keybinding configuration.
7#[derive(Debug, Clone)]
8pub struct KeybindingConfig {
9    pub mail_list: HashMap<KeyBinding, String>,
10    pub message_view: HashMap<KeyBinding, String>,
11    pub thread_view: HashMap<KeyBinding, String>,
12}
13
14/// A single key or key combination.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct KeyBinding {
17    pub keys: Vec<KeyPress>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct KeyPress {
22    pub code: KeyCode,
23    pub modifiers: KeyModifiers,
24}
25
26/// View context for resolving keybindings.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ViewContext {
29    MailList,
30    MessageView,
31    ThreadView,
32}
33
34/// Parse a key string like "Ctrl-p", "gg", "G", "/", "Enter" into a KeyBinding.
35pub fn parse_key_string(s: &str) -> Result<KeyBinding, String> {
36    let mut keys = Vec::new();
37
38    if let Some(rest) = s.strip_prefix("Ctrl-") {
39        let ch = rest.chars().next().ok_or("Missing char after Ctrl-")?;
40        keys.push(KeyPress {
41            code: KeyCode::Char(ch),
42            modifiers: KeyModifiers::CONTROL,
43        });
44    } else if s == "Enter" {
45        keys.push(KeyPress {
46            code: KeyCode::Enter,
47            modifiers: KeyModifiers::NONE,
48        });
49    } else if s == "Escape" || s == "Esc" {
50        keys.push(KeyPress {
51            code: KeyCode::Esc,
52            modifiers: KeyModifiers::NONE,
53        });
54    } else if s == "Tab" {
55        keys.push(KeyPress {
56            code: KeyCode::Tab,
57            modifiers: KeyModifiers::NONE,
58        });
59    } else {
60        for ch in s.chars() {
61            let modifiers = if ch.is_uppercase() {
62                KeyModifiers::SHIFT
63            } else {
64                KeyModifiers::NONE
65            };
66            keys.push(KeyPress {
67                code: KeyCode::Char(ch),
68                modifiers,
69            });
70        }
71    }
72
73    Ok(KeyBinding { keys })
74}
75
76/// Resolve a key sequence to an action name.
77pub fn resolve_action(
78    config: &KeybindingConfig,
79    context: ViewContext,
80    key_sequence: &[KeyPress],
81) -> Option<String> {
82    let map = match context {
83        ViewContext::MailList => &config.mail_list,
84        ViewContext::MessageView => &config.message_view,
85        ViewContext::ThreadView => &config.thread_view,
86    };
87
88    let binding = KeyBinding {
89        keys: key_sequence.to_vec(),
90    };
91    map.get(&binding).cloned()
92}
93
94/// Map action name strings to Action enum variants.
95pub fn action_from_name(name: &str) -> Option<Action> {
96    match name {
97        // Navigation (vim-native)
98        "move_down" | "scroll_down" | "next_message" => Some(Action::MoveDown),
99        "move_up" | "scroll_up" | "prev_message" => Some(Action::MoveUp),
100        "jump_top" => Some(Action::JumpTop),
101        "jump_bottom" => Some(Action::JumpBottom),
102        "page_down" => Some(Action::PageDown),
103        "page_up" => Some(Action::PageUp),
104        "visible_top" => Some(Action::ViewportTop),
105        "visible_middle" => Some(Action::ViewportMiddle),
106        "visible_bottom" => Some(Action::ViewportBottom),
107        "center_current" => Some(Action::CenterCurrent),
108        "search" => Some(Action::OpenSearch),
109        "next_search_result" => Some(Action::NextSearchResult),
110        "prev_search_result" => Some(Action::PrevSearchResult),
111        "open" => Some(Action::OpenSelected),
112        "quit_view" => Some(Action::QuitView),
113        "clear_selection" => Some(Action::ClearSelection),
114        "help" => Some(Action::Help),
115        "toggle_mail_list_mode" => Some(Action::ToggleMailListMode),
116        // Email actions (Gmail-native A005)
117        "compose" => Some(Action::Compose),
118        "reply" => Some(Action::Reply),
119        "reply_all" => Some(Action::ReplyAll),
120        "forward" => Some(Action::Forward),
121        "archive" => Some(Action::Archive),
122        "mark_read_archive" => Some(Action::MarkReadAndArchive),
123        "trash" => Some(Action::Trash),
124        "spam" => Some(Action::Spam),
125        "star" => Some(Action::Star),
126        "mark_read" => Some(Action::MarkRead),
127        "mark_unread" => Some(Action::MarkUnread),
128        "apply_label" => Some(Action::ApplyLabel),
129        "move_to_label" => Some(Action::MoveToLabel),
130        "toggle_select" => Some(Action::ToggleSelect),
131        // mxr-specific
132        "unsubscribe" => Some(Action::Unsubscribe),
133        "snooze" => Some(Action::Snooze),
134        "open_in_browser" => Some(Action::OpenInBrowser),
135        "toggle_reader_mode" => Some(Action::ToggleReaderMode),
136        "export_thread" => Some(Action::ExportThread),
137        "command_palette" => Some(Action::OpenCommandPalette),
138        "switch_panes" => Some(Action::SwitchPane),
139        "toggle_fullscreen" => Some(Action::ToggleFullscreen),
140        "visual_line_mode" => Some(Action::VisualLineMode),
141        "attachment_list" => Some(Action::AttachmentList),
142        "open_links" => Some(Action::OpenLinks),
143        "sync" => Some(Action::SyncNow),
144        // Go-to navigation (A005)
145        "go_inbox" => Some(Action::GoToInbox),
146        "go_starred" => Some(Action::GoToStarred),
147        "go_sent" => Some(Action::GoToSent),
148        "go_drafts" => Some(Action::GoToDrafts),
149        "go_all_mail" => Some(Action::GoToAllMail),
150        "go_label" => Some(Action::GoToLabel),
151        "edit_config" => Some(Action::EditConfig),
152        "open_logs" => Some(Action::OpenLogs),
153        "open_tab_1" => Some(Action::OpenTab1),
154        "open_tab_2" => Some(Action::OpenTab2),
155        "open_tab_3" => Some(Action::OpenTab3),
156        "open_tab_4" => Some(Action::OpenTab4),
157        "open_tab_5" => Some(Action::OpenTab5),
158        "toggle_signature" => Some(Action::ToggleSignature),
159        _ => None,
160    }
161}
162
163/// Format a keybinding for display.
164pub fn format_keybinding(kb: &KeyBinding) -> String {
165    kb.keys
166        .iter()
167        .map(|kp| {
168            let mut s = String::new();
169            if kp.modifiers.contains(KeyModifiers::CONTROL) {
170                s.push_str("Ctrl-");
171            }
172            match kp.code {
173                KeyCode::Char(c) => s.push(c),
174                KeyCode::Enter => s.push_str("Enter"),
175                KeyCode::Esc => s.push_str("Esc"),
176                KeyCode::Tab => s.push_str("Tab"),
177                _ => s.push('?'),
178            }
179            s
180        })
181        .collect::<Vec<_>>()
182        .join("")
183}
184
185pub fn display_bindings_for_actions(
186    context: ViewContext,
187    actions: &[&str],
188) -> Vec<(String, String)> {
189    let config = default_keybindings();
190    let map = match context {
191        ViewContext::MailList => &config.mail_list,
192        ViewContext::MessageView => &config.message_view,
193        ViewContext::ThreadView => &config.thread_view,
194    };
195
196    actions
197        .iter()
198        .filter_map(|action| {
199            let mut bindings: Vec<String> = map
200                .iter()
201                .filter(|(_, name)| name == action)
202                .map(|(binding, _)| format_keybinding(binding))
203                .collect();
204            bindings.sort();
205            bindings.dedup();
206
207            (!bindings.is_empty()).then(|| (bindings.join("/"), action_display_name(action)))
208        })
209        .collect()
210}
211
212pub fn all_bindings_for_context(context: ViewContext) -> Vec<(String, String)> {
213    let config = default_keybindings();
214    let map = match context {
215        ViewContext::MailList => &config.mail_list,
216        ViewContext::MessageView => &config.message_view,
217        ViewContext::ThreadView => &config.thread_view,
218    };
219
220    let mut entries: Vec<(String, String)> = map
221        .iter()
222        .map(|(binding, action)| (format_keybinding(binding), action_display_name(action)))
223        .collect();
224    entries.sort_by(|(left_key, left_action), (right_key, right_action)| {
225        left_key
226            .cmp(right_key)
227            .then_with(|| left_action.cmp(right_action))
228    });
229    entries
230}
231
232fn action_display_name(action: &str) -> String {
233    match action {
234        "move_down" => "Down".into(),
235        "move_up" => "Up".into(),
236        "search" => "Search".into(),
237        "open" => "Open".into(),
238        "apply_label" => "Apply Label".into(),
239        "move_to_label" => "Move Label".into(),
240        "command_palette" => "Commands".into(),
241        "help" => "Help".into(),
242        "reply" => "Reply".into(),
243        "reply_all" => "Reply All".into(),
244        "forward" => "Forward".into(),
245        "archive" => "Archive".into(),
246        "mark_read_archive" => "Read + Archive".into(),
247        "star" => "Star".into(),
248        "mark_read" => "Mark Read".into(),
249        "mark_unread" => "Mark Unread".into(),
250        "unsubscribe" => "Unsubscribe".into(),
251        "snooze" => "Snooze".into(),
252        "visual_line_mode" => "Visual Line Mode".into(),
253        "toggle_fullscreen" => "Toggle Fullscreen".into(),
254        "toggle_select" => "Toggle Select".into(),
255        "go_inbox" => "Go Inbox".into(),
256        "edit_config" => "Edit Config".into(),
257        "open_logs" => "Open Logs".into(),
258        "switch_panes" => "Switch Pane".into(),
259        "next_message" => "Next Msg".into(),
260        "prev_message" => "Prev Msg".into(),
261        "attachment_list" => "Attachments".into(),
262        "open_links" => "Open Links".into(),
263        "toggle_reader_mode" => "Reader".into(),
264        "toggle_signature" => "Signature".into(),
265        "export_thread" => "Export".into(),
266        "open_in_browser" => "Browser".into(),
267        "open_tab_1" => "Mailbox".into(),
268        "open_tab_2" => "Search Page".into(),
269        "open_tab_3" => "Rules Page".into(),
270        "open_tab_4" => "Accounts Page".into(),
271        "open_tab_5" => "Diagnostics Page".into(),
272        "quit_view" => "Quit".into(),
273        "clear_selection" => "Clear Sel".into(),
274        _ => action
275            .split('_')
276            .map(|part| {
277                let mut chars = part.chars();
278                match chars.next() {
279                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
280                    None => String::new(),
281                }
282            })
283            .collect::<Vec<_>>()
284            .join(" "),
285    }
286}
287
288/// Raw TOML structure for keys.toml
289#[derive(Debug, Deserialize)]
290pub struct KeysToml {
291    #[serde(default)]
292    pub mail_list: HashMap<String, String>,
293    #[serde(default)]
294    pub message_view: HashMap<String, String>,
295    #[serde(default)]
296    pub thread_view: HashMap<String, String>,
297}
298
299/// Load keybinding config from keys.toml, falling back to defaults.
300pub fn load_keybindings(config_dir: &std::path::Path) -> KeybindingConfig {
301    let keys_path = config_dir.join("keys.toml");
302    let user_config = if keys_path.exists() {
303        std::fs::read_to_string(&keys_path)
304            .ok()
305            .and_then(|s| toml::from_str::<KeysToml>(&s).ok())
306    } else {
307        None
308    };
309
310    let mut config = default_keybindings();
311
312    if let Some(user) = user_config {
313        for (key, action) in &user.mail_list {
314            if let Ok(kb) = parse_key_string(key) {
315                config.mail_list.insert(kb, action.clone());
316            }
317        }
318        for (key, action) in &user.message_view {
319            if let Ok(kb) = parse_key_string(key) {
320                config.message_view.insert(kb, action.clone());
321            }
322        }
323        for (key, action) in &user.thread_view {
324            if let Ok(kb) = parse_key_string(key) {
325                config.thread_view.insert(kb, action.clone());
326            }
327        }
328    }
329
330    config
331}
332
333pub fn default_keybindings() -> KeybindingConfig {
334    let mut mail_list = HashMap::new();
335    let mut message_view = HashMap::new();
336    let mut thread_view = HashMap::new();
337
338    // Mail list defaults — Gmail-native scheme (A005)
339    let ml_defaults = [
340        // Navigation (vim-native)
341        ("j", "move_down"),
342        ("k", "move_up"),
343        ("gg", "jump_top"),
344        ("G", "jump_bottom"),
345        ("Ctrl-d", "page_down"),
346        ("Ctrl-u", "page_up"),
347        ("H", "visible_top"),
348        ("M", "visible_middle"),
349        ("L", "visible_bottom"),
350        ("zz", "center_current"),
351        ("/", "search"),
352        ("n", "next_search_result"),
353        ("N", "prev_search_result"),
354        ("Enter", "open"),
355        ("o", "open"),
356        ("q", "quit_view"),
357        ("?", "help"),
358        // Email actions (Gmail-native A005)
359        ("c", "compose"),
360        ("r", "reply"),
361        ("a", "reply_all"),
362        ("f", "forward"),
363        ("e", "archive"),
364        ("m", "mark_read_archive"),
365        ("#", "trash"),
366        ("!", "spam"),
367        ("s", "star"),
368        ("I", "mark_read"),
369        ("U", "mark_unread"),
370        ("l", "apply_label"),
371        ("v", "move_to_label"),
372        ("x", "toggle_select"),
373        // mxr-specific
374        ("D", "unsubscribe"),
375        ("Z", "snooze"),
376        ("O", "open_in_browser"),
377        ("R", "toggle_reader_mode"),
378        ("S", "toggle_signature"),
379        ("E", "export_thread"),
380        ("V", "visual_line_mode"),
381        ("Ctrl-p", "command_palette"),
382        ("Tab", "switch_panes"),
383        ("F", "toggle_fullscreen"),
384        ("1", "open_tab_1"),
385        ("2", "open_tab_2"),
386        ("3", "open_tab_3"),
387        ("4", "open_tab_4"),
388        ("5", "open_tab_5"),
389        // Gmail go-to (A005)
390        ("gi", "go_inbox"),
391        ("gs", "go_starred"),
392        ("gt", "go_sent"),
393        ("gd", "go_drafts"),
394        ("ga", "go_all_mail"),
395        ("gl", "go_label"),
396        ("gc", "edit_config"),
397        ("gL", "open_logs"),
398    ];
399    for (key, action) in ml_defaults {
400        if let Ok(kb) = parse_key_string(key) {
401            mail_list.insert(kb, action.to_string());
402        }
403    }
404
405    // Message view defaults
406    let mv_defaults = [
407        ("j", "scroll_down"),
408        ("k", "scroll_up"),
409        ("R", "toggle_reader_mode"),
410        ("O", "open_in_browser"),
411        ("A", "attachment_list"),
412        ("L", "open_links"),
413        ("r", "reply"),
414        ("a", "reply_all"),
415        ("f", "forward"),
416        ("e", "archive"),
417        ("m", "mark_read_archive"),
418        ("#", "trash"),
419        ("!", "spam"),
420        ("s", "star"),
421        ("I", "mark_read"),
422        ("U", "mark_unread"),
423        ("D", "unsubscribe"),
424        ("S", "toggle_signature"),
425        ("1", "open_tab_1"),
426        ("2", "open_tab_2"),
427        ("3", "open_tab_3"),
428        ("4", "open_tab_4"),
429        ("5", "open_tab_5"),
430        ("gc", "edit_config"),
431        ("gL", "open_logs"),
432    ];
433    for (key, action) in mv_defaults {
434        if let Ok(kb) = parse_key_string(key) {
435            message_view.insert(kb, action.to_string());
436        }
437    }
438
439    // Thread view defaults
440    let tv_defaults = [
441        ("j", "next_message"),
442        ("k", "prev_message"),
443        ("r", "reply"),
444        ("a", "reply_all"),
445        ("f", "forward"),
446        ("A", "attachment_list"),
447        ("L", "open_links"),
448        ("R", "toggle_reader_mode"),
449        ("E", "export_thread"),
450        ("O", "open_in_browser"),
451        ("e", "archive"),
452        ("m", "mark_read_archive"),
453        ("#", "trash"),
454        ("!", "spam"),
455        ("s", "star"),
456        ("I", "mark_read"),
457        ("U", "mark_unread"),
458        ("D", "unsubscribe"),
459        ("S", "toggle_signature"),
460        ("1", "open_tab_1"),
461        ("2", "open_tab_2"),
462        ("3", "open_tab_3"),
463        ("4", "open_tab_4"),
464        ("5", "open_tab_5"),
465        ("gc", "edit_config"),
466        ("gL", "open_logs"),
467    ];
468    for (key, action) in tv_defaults {
469        if let Ok(kb) = parse_key_string(key) {
470            thread_view.insert(kb, action.to_string());
471        }
472    }
473
474    KeybindingConfig {
475        mail_list,
476        message_view,
477        thread_view,
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn parse_key_string_single_char() {
487        let kb = parse_key_string("j").unwrap();
488        assert_eq!(kb.keys.len(), 1);
489        assert_eq!(kb.keys[0].code, KeyCode::Char('j'));
490        assert_eq!(kb.keys[0].modifiers, KeyModifiers::NONE);
491    }
492
493    #[test]
494    fn parse_key_string_ctrl_p() {
495        let kb = parse_key_string("Ctrl-p").unwrap();
496        assert_eq!(kb.keys.len(), 1);
497        assert_eq!(kb.keys[0].code, KeyCode::Char('p'));
498        assert_eq!(kb.keys[0].modifiers, KeyModifiers::CONTROL);
499    }
500
501    #[test]
502    fn parse_key_string_gg() {
503        let kb = parse_key_string("gg").unwrap();
504        assert_eq!(kb.keys.len(), 2);
505        assert_eq!(kb.keys[0].code, KeyCode::Char('g'));
506        assert_eq!(kb.keys[1].code, KeyCode::Char('g'));
507    }
508
509    #[test]
510    fn parse_key_string_enter() {
511        let kb = parse_key_string("Enter").unwrap();
512        assert_eq!(kb.keys.len(), 1);
513        assert_eq!(kb.keys[0].code, KeyCode::Enter);
514    }
515
516    #[test]
517    fn parse_key_string_shift() {
518        let kb = parse_key_string("G").unwrap();
519        assert_eq!(kb.keys.len(), 1);
520        assert_eq!(kb.keys[0].code, KeyCode::Char('G'));
521        assert_eq!(kb.keys[0].modifiers, KeyModifiers::SHIFT);
522    }
523
524    #[test]
525    fn default_keybindings_contain_gmail_native() {
526        let config = default_keybindings();
527
528        // Check that key actions are present
529        let actions: Vec<&str> = config.mail_list.values().map(|s| s.as_str()).collect();
530        assert!(actions.contains(&"compose"));
531        assert!(actions.contains(&"reply"));
532        assert!(actions.contains(&"reply_all"));
533        assert!(actions.contains(&"archive"));
534        assert!(actions.contains(&"mark_read_archive"));
535        assert!(actions.contains(&"trash"));
536        assert!(actions.contains(&"spam"));
537        assert!(actions.contains(&"star"));
538        assert!(actions.contains(&"mark_read"));
539        assert!(actions.contains(&"mark_unread"));
540        assert!(actions.contains(&"toggle_select"));
541        assert!(actions.contains(&"unsubscribe"));
542        assert!(actions.contains(&"snooze"));
543        assert!(actions.contains(&"visual_line_mode"));
544    }
545
546    #[test]
547    fn action_from_name_coverage() {
548        // Test that all important actions are mapped
549        assert!(action_from_name("compose").is_some());
550        assert!(action_from_name("reply").is_some());
551        assert!(action_from_name("reply_all").is_some());
552        assert!(action_from_name("forward").is_some());
553        assert!(action_from_name("archive").is_some());
554        assert!(action_from_name("mark_read_archive").is_some());
555        assert!(action_from_name("trash").is_some());
556        assert!(action_from_name("spam").is_some());
557        assert!(action_from_name("star").is_some());
558        assert!(action_from_name("mark_read").is_some());
559        assert!(action_from_name("mark_unread").is_some());
560        assert!(action_from_name("unsubscribe").is_some());
561        assert!(action_from_name("snooze").is_some());
562        assert!(action_from_name("toggle_reader_mode").is_some());
563        assert!(action_from_name("toggle_select").is_some());
564        assert!(action_from_name("visual_line_mode").is_some());
565        assert!(action_from_name("go_inbox").is_some());
566        assert!(action_from_name("go_starred").is_some());
567        assert!(action_from_name("edit_config").is_some());
568        assert!(action_from_name("open_logs").is_some());
569        assert!(action_from_name("nonexistent").is_none());
570    }
571
572    #[test]
573    fn resolve_action_finds_match() {
574        let config = default_keybindings();
575        let j = KeyPress {
576            code: KeyCode::Char('j'),
577            modifiers: KeyModifiers::NONE,
578        };
579        let result = resolve_action(&config, ViewContext::MailList, &[j]);
580        assert_eq!(result, Some("move_down".to_string()));
581    }
582
583    #[test]
584    fn format_keybinding_basic() {
585        let kb = parse_key_string("Ctrl-p").unwrap();
586        assert_eq!(format_keybinding(&kb), "Ctrl-p");
587    }
588
589    #[test]
590    fn all_bindings_for_mail_list_include_full_action_set() {
591        let bindings = all_bindings_for_context(ViewContext::MailList);
592        let labels: Vec<String> = bindings.into_iter().map(|(_, label)| label).collect();
593        assert!(labels.contains(&"Apply Label".to_string()));
594        assert!(labels.contains(&"Toggle Fullscreen".to_string()));
595        assert!(labels.contains(&"Visual Line Mode".to_string()));
596        assert!(labels.contains(&"Go Inbox".to_string()));
597        assert!(labels.contains(&"Edit Config".to_string()));
598    }
599
600    #[test]
601    fn display_bindings_for_actions_joins_aliases_stably() {
602        let bindings = display_bindings_for_actions(ViewContext::MailList, &["open"]);
603        assert_eq!(bindings, vec![("Enter/o".to_string(), "Open".to_string())]);
604    }
605
606    #[test]
607    fn user_override_replaces_default() {
608        let mut config = default_keybindings();
609
610        // Override 'j' to do something different
611        let j_key = parse_key_string("j").unwrap();
612        config
613            .mail_list
614            .insert(j_key.clone(), "page_down".to_string());
615
616        let j_press = KeyPress {
617            code: KeyCode::Char('j'),
618            modifiers: KeyModifiers::NONE,
619        };
620        let result = resolve_action(&config, ViewContext::MailList, &[j_press]);
621        assert_eq!(result, Some("page_down".to_string()));
622    }
623}