1use crate::action::Action;
2use crossterm::event::{KeyCode, KeyModifiers};
3use serde::Deserialize;
4use std::collections::HashMap;
5
6#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ViewContext {
29 MailList,
30 MessageView,
31 ThreadView,
32}
33
34pub 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
76pub 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
94pub fn action_from_name(name: &str) -> Option<Action> {
96 match name {
97 "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 "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 "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_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
163pub 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#[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
299pub 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 let ml_defaults = [
340 ("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 ("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 ("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 ("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 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 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 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 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 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}