1use crossterm::event::{KeyCode, KeyModifiers};
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::fmt;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum KeyAction {
17 Quit,
19 ScrollDown,
21 ScrollUp,
23 ScrollHalfPageDown,
25 ScrollHalfPageUp,
27 NextMailbox,
29 PrevMailbox,
31 OpenThread,
33 CloseThread,
35 Refresh,
37 Search,
39 BookmarkToggle,
41 ViewRawLog,
43 Help,
45 FocusSidebar,
47 NextComment,
49 PrevComment,
51 NextPage,
53 PrevPage,
55 ViewBaselineLog,
57 ToggleListContent,
59 BookmarkFilter,
61 CycleSort,
63 ReverseSort,
65}
66
67impl KeyAction {
68 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
102pub struct KeyCombo {
103 pub code: KeyCode,
105 pub modifiers: KeyModifiers,
107}
108
109impl KeyCombo {
110 #[must_use]
112 pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
113 Self { code, modifiers }
114 }
115
116 fn parse(s: &str) -> Option<Self> {
121 let mut modifiers = KeyModifiers::NONE;
122 let mut remaining = s;
123
124 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 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
187fn parse_key_code(s: &str) -> Option<KeyCode> {
189 if s.len() == 1 {
191 return Some(KeyCode::Char(s.chars().next()?));
192 }
193
194 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
227fn 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#[derive(Debug, Clone)]
260pub struct KeybindingsConfig {
261 pub bindings: HashMap<KeyCombo, KeyAction>,
263}
264
265impl KeybindingsConfig {
266 #[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 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 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 assert_eq!(
512 KeyCombo::new(KeyCode::F(1), KeyModifiers::NONE).to_string(),
513 "F1"
514 );
515 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 assert_eq!(
526 KeyCombo::new(KeyCode::Char('a'), KeyModifiers::SHIFT).to_string(),
527 "S-a"
528 );
529 assert_eq!(
531 KeyCombo::new(KeyCode::Char('x'), KeyModifiers::ALT).to_string(),
532 "A-x"
533 );
534 let cs = KeyCombo::new(
536 KeyCode::Char('z'),
537 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
538 );
539 assert_eq!(cs.to_string(), "C-S-z");
540 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 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}