Skip to main content

rmut_front/
keymap.rs

1//! Default keybindings plus config remaps ([keys.index] / [keys.pager],
2//! `action = "key"`). Key syntax: a single character, or `ctrl+x` /
3//! `alt+x`, or a name: enter, esc, space, tab, backspace, up, down,
4//! left, right, pgup, pgdn, home, end.
5
6use std::collections::HashMap;
7
8use crate::key::{KeyCode, KeyEvent, KeyModifiers};
9use rmut_session::Function;
10
11#[derive(Clone, Copy, PartialEq, Eq, Debug)]
12pub struct KeyPattern {
13    pub code: KeyCode,
14    pub mods: KeyModifiers,
15}
16
17impl KeyPattern {
18    fn plain(code: KeyCode) -> Self {
19        KeyPattern {
20            code,
21            mods: KeyModifiers::NONE,
22        }
23    }
24
25    fn ch(c: char) -> Self {
26        Self::plain(KeyCode::Char(c))
27    }
28
29    fn ctrl(c: char) -> Self {
30        KeyPattern {
31            code: KeyCode::Char(c),
32            mods: KeyModifiers::CONTROL,
33        }
34    }
35
36    fn alt(c: char) -> Self {
37        KeyPattern {
38            code: KeyCode::Char(c),
39            mods: KeyModifiers::ALT,
40        }
41    }
42
43    pub fn matches(&self, key: &KeyEvent) -> bool {
44        self.code == key.code
45            && key.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT) == self.mods
46    }
47
48    pub fn display(&self) -> String {
49        let base = match self.code {
50            KeyCode::Char(' ') => "Space".to_string(),
51            KeyCode::Char(c) => c.to_string(),
52            KeyCode::Enter => "Enter".into(),
53            KeyCode::Esc => "Esc".into(),
54            KeyCode::Tab => "Tab".into(),
55            KeyCode::Backspace => "Backspace".into(),
56            KeyCode::Up => "Up".into(),
57            KeyCode::Down => "Down".into(),
58            KeyCode::Left => "Left".into(),
59            KeyCode::Right => "Right".into(),
60            KeyCode::Delete => "Delete".into(),
61            KeyCode::PageUp => "PgUp".into(),
62            KeyCode::PageDown => "PgDn".into(),
63            KeyCode::Home => "Home".into(),
64            KeyCode::End => "End".into(),
65            other => format!("{other:?}"),
66        };
67        if self.mods.contains(KeyModifiers::CONTROL) {
68            format!("Ctrl+{base}")
69        } else if self.mods.contains(KeyModifiers::ALT) {
70            format!("Alt+{base}")
71        } else {
72            base
73        }
74    }
75}
76
77fn strip_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
78    (s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix))
79        .then(|| &s[prefix.len()..])
80}
81
82pub fn parse_key(input: &str) -> Option<KeyPattern> {
83    let mut mods = KeyModifiers::NONE;
84    let mut rest = input.trim();
85    loop {
86        if let Some(r) = strip_ci(rest, "ctrl+") {
87            mods |= KeyModifiers::CONTROL;
88            rest = r;
89        } else if let Some(r) = strip_ci(rest, "alt+") {
90            mods |= KeyModifiers::ALT;
91            rest = r;
92        } else {
93            break;
94        }
95    }
96    let code = match rest.to_lowercase().as_str() {
97        "enter" | "return" => KeyCode::Enter,
98        "esc" | "escape" => KeyCode::Esc,
99        "space" => KeyCode::Char(' '),
100        "tab" => KeyCode::Tab,
101        "backspace" => KeyCode::Backspace,
102        "delete" | "del" => KeyCode::Delete,
103        "up" => KeyCode::Up,
104        "down" => KeyCode::Down,
105        "left" => KeyCode::Left,
106        "right" => KeyCode::Right,
107        "pgup" | "pageup" => KeyCode::PageUp,
108        "pgdn" | "pagedown" => KeyCode::PageDown,
109        "home" => KeyCode::Home,
110        "end" => KeyCode::End,
111        _ => {
112            let mut chars = rest.chars();
113            let c = chars.next()?;
114            if chars.next().is_some() {
115                return None;
116            }
117            KeyCode::Char(c)
118        }
119    };
120    Some(KeyPattern { code, mods })
121}
122
123#[derive(Clone, Copy, PartialEq, Eq, Debug)]
124pub enum PagerAction {
125    Back,
126    Down,
127    Up,
128    PageDown,
129    PageUp,
130    HalfDown,
131    HalfUp,
132    Top,
133    Bottom,
134    ToggleQuoted,
135    SkipQuoted,
136    NextMsg,
137    PrevMsg,
138    NextUndeleted,
139    PrevUndeleted,
140    Delete,
141    Undelete,
142    Flag,
143    ToggleNew,
144    Tag,
145    Undo,
146    Redraw,
147    Suspend,
148    Headers,
149    Search,
150    SearchNext,
151    SearchPrev,
152    SearchToggle,
153    Attachments,
154    Compose,
155    Reply,
156    GroupReply,
157    ListReply,
158    Forward,
159    Print,
160    Save,
161    Copy,
162    Pipe,
163    Bounce,
164    Resend,
165    Edit,
166    CreateAlias,
167    EnterCommand,
168    Help,
169    ListAction,
170    ErrorHistory,
171    WhatKey,
172    Urls,
173}
174
175impl PagerAction {
176    pub fn name(self) -> &'static str {
177        use PagerAction::*;
178        match self {
179            Back => "back",
180            Down => "down",
181            Up => "up",
182            PageDown => "page-down",
183            PageUp => "page-up",
184            HalfDown => "half-down",
185            HalfUp => "half-up",
186            Top => "top",
187            Bottom => "bottom",
188            ToggleQuoted => "toggle-quoted",
189            SkipQuoted => "skip-quoted",
190            NextMsg => "next",
191            PrevMsg => "previous",
192            NextUndeleted => "next-undeleted",
193            PrevUndeleted => "previous-undeleted",
194            Delete => "delete",
195            Undelete => "undelete",
196            Flag => "flag",
197            ToggleNew => "toggle-new",
198            Tag => "tag",
199            Undo => "undo",
200            Redraw => "refresh",
201            Suspend => "suspend",
202            Headers => "headers",
203            Search => "search",
204            SearchNext => "search-next",
205            SearchPrev => "search-prev",
206            SearchToggle => "search-toggle",
207            Attachments => "attachments",
208            Compose => "compose",
209            Reply => "reply",
210            GroupReply => "group-reply",
211            ListReply => "list-reply",
212            Forward => "forward",
213            Print => "print",
214            Save => "save",
215            Copy => "copy",
216            Pipe => "pipe",
217            Bounce => "bounce",
218            Resend => "resend",
219            Edit => "edit",
220            CreateAlias => "create-alias",
221            EnterCommand => "enter-command",
222            Help => "help",
223            ListAction => "list-action",
224            ErrorHistory => "error-history",
225            WhatKey => "what-key",
226            Urls => "urls",
227        }
228    }
229
230    pub fn describe(self) -> &'static str {
231        use PagerAction::*;
232        match self {
233            Back => "back to the index",
234            Down => "scroll down one line",
235            Up => "scroll up one line",
236            PageDown => "page down",
237            PageUp => "page up",
238            HalfDown => "scroll down half a page",
239            HalfUp => "scroll up half a page",
240            Top => "jump to the top",
241            Bottom => "jump to the bottom",
242            ToggleQuoted => "show/hide quoted text",
243            SkipQuoted => "skip past the quoted text below",
244            NextMsg => "open next message",
245            PrevMsg => "open previous message",
246            NextUndeleted => "open next undeleted message",
247            PrevUndeleted => "open previous undeleted message",
248            Delete => "delete and advance",
249            Undelete => "unmark deletion",
250            Flag => "toggle flagged mark",
251            ToggleNew => "toggle read/unread (unbound here: N is the backwards search)",
252            Tag => "toggle the tag on this message",
253            Undo => "cancel a held send, or undo the last mark change",
254            Redraw => "repaint the screen",
255            Suspend => "suspend rmut (fg brings it back)",
256            Headers => "toggle full headers",
257            Search => "search the displayed text (unlike the index /, which matches messages)",
258            SearchNext => "next match of the pager search",
259            SearchPrev => "previous match of the pager search",
260            SearchToggle => "toggle the search highlighting",
261            Attachments => "list message parts",
262            Compose => "compose a new message",
263            Reply => "reply to sender",
264            GroupReply => "reply to all",
265            ListReply => "reply to the mailing list only",
266            Forward => "forward message",
267            Print => "pipe message to the print command",
268            Save => "save (copy + mark deleted) to a mailbox",
269            Copy => "copy to a mailbox (original stays)",
270            Pipe => "pipe raw message to a shell command",
271            Bounce => "bounce (resend) message to new recipients",
272            Resend => "edit the message as a new draft",
273            Edit => "edit the raw message and replace it",
274            CreateAlias => "add the sender to the alias file",
275            EnterCommand => "run a config command (set/bind/macro/color/...)",
276            Help => "this help",
277            ListAction => "act on the message's List-* headers (subscribe, help, ...)",
278            ErrorHistory => "show the recent errors",
279            WhatKey => "say what a key is (Ctrl+G ends it)",
280            Urls => "list the message's links, to open or copy one",
281        }
282    }
283
284    fn all() -> &'static [PagerAction] {
285        use PagerAction::*;
286        &[
287            Back,
288            Down,
289            Up,
290            PageDown,
291            PageUp,
292            HalfDown,
293            HalfUp,
294            Top,
295            Bottom,
296            ToggleQuoted,
297            SkipQuoted,
298            NextMsg,
299            PrevMsg,
300            NextUndeleted,
301            PrevUndeleted,
302            Delete,
303            Undelete,
304            Flag,
305            ToggleNew,
306            Tag,
307            Undo,
308            Redraw,
309            Suspend,
310            Headers,
311            Search,
312            SearchNext,
313            SearchPrev,
314            SearchToggle,
315            Attachments,
316            Compose,
317            Reply,
318            GroupReply,
319            ListReply,
320            Forward,
321            Print,
322            Save,
323            Copy,
324            Pipe,
325            Bounce,
326            Resend,
327            Edit,
328            CreateAlias,
329            EnterCommand,
330            Help,
331            ListAction,
332            ErrorHistory,
333            WhatKey,
334            Urls,
335        ]
336    }
337
338    pub fn from_name(name: &str) -> Option<PagerAction> {
339        // mutt's pager calls toggle-new "mark-as-new".
340        let name = match name {
341            "mark-as-new" => "toggle-new",
342            other => other,
343        };
344        PagerAction::all()
345            .iter()
346            .copied()
347            .find(|a| a.name() == name)
348    }
349}
350
351/// A key sequence for macros: literal characters plus key names in
352/// angle brackets (`<enter>`, `<esc>`, `<ctrl+x>`, everything
353/// `parse_key` accepts). None on an unknown name or an unclosed `<`.
354pub fn parse_sequence(input: &str) -> Option<Vec<KeyEvent>> {
355    let mut out = Vec::new();
356    let mut chars = input.chars();
357    while let Some(c) = chars.next() {
358        if c == '<' {
359            let mut name = String::new();
360            loop {
361                match chars.next() {
362                    Some('>') => break,
363                    Some(c) => name.push(c),
364                    None => return None,
365                }
366            }
367            let p = parse_key(&name)?;
368            out.push(KeyEvent::new(p.code, p.mods));
369        } else {
370            out.push(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
371        }
372    }
373    Some(out)
374}
375
376pub struct Keymap {
377    pub index: Vec<(KeyPattern, Function)>,
378    pub pager: Vec<(KeyPattern, PagerAction)>,
379    /// Macros: trigger → (replayed events, the sequence as written,
380    /// kept for the help screen). Checked before the action bindings,
381    /// so a macro shadows a binding on the same key (like mutt).
382    pub macros_index: Vec<(KeyPattern, Vec<KeyEvent>, String)>,
383    pub macros_pager: Vec<(KeyPattern, Vec<KeyEvent>, String)>,
384}
385
386fn index_defaults() -> Vec<(KeyPattern, Function)> {
387    use Function::*;
388    use KeyCode as K;
389    vec![
390        (KeyPattern::ch('q'), Quit),
391        (KeyPattern::ch('x'), Abort),
392        (KeyPattern::ch('j'), Down),
393        (KeyPattern::plain(K::Down), Down),
394        (KeyPattern::ch('k'), Up),
395        (KeyPattern::plain(K::Up), Up),
396        (KeyPattern::plain(K::PageDown), PageDown),
397        (KeyPattern::ctrl('f'), PageDown),
398        (KeyPattern::plain(K::PageUp), PageUp),
399        (KeyPattern::ctrl('b'), PageUp),
400        (KeyPattern::ch(' '), PageDown),
401        (KeyPattern::ch('='), First),
402        (KeyPattern::plain(K::Home), First),
403        (KeyPattern::ch('*'), Last),
404        (KeyPattern::plain(K::End), Last),
405        (KeyPattern::plain(K::Enter), View),
406        (KeyPattern::ch('d'), Delete),
407        (KeyPattern::ch('u'), Undelete),
408        (KeyPattern::ch('F'), Flag),
409        (KeyPattern::ch('N'), ToggleNew),
410        (KeyPattern::alt('a'), MarkAllRead),
411        (KeyPattern::ch('$'), Sync),
412        (KeyPattern::ch('m'), Compose),
413        (KeyPattern::ch('r'), Reply),
414        (KeyPattern::ch('g'), GroupReply),
415        (KeyPattern::ch('L'), ListReply),
416        (KeyPattern::ch('f'), Forward),
417        (KeyPattern::ch('o'), Sort),
418        (KeyPattern::ch('l'), Limit),
419        (KeyPattern::ch('/'), Search),
420        (KeyPattern::alt('/'), SearchReverse),
421        (KeyPattern::ch('n'), SearchNext),
422        (KeyPattern::plain(K::Tab), NextNew),
423        (
424            KeyPattern {
425                code: K::Tab,
426                mods: KeyModifiers::ALT,
427            },
428            PrevNew,
429        ),
430        (KeyPattern::ch('c'), ChangeMailbox),
431        (KeyPattern::alt('c'), ChangeMailboxReadOnly),
432        (KeyPattern::ch('y'), Folders),
433        (KeyPattern::ch('v'), Attachments),
434        (KeyPattern::alt('v'), FoldThread),
435        (KeyPattern::alt('V'), FoldAll),
436        (KeyPattern::ch('p'), Print),
437        (KeyPattern::ch('t'), Tag),
438        (KeyPattern::ch(';'), TagPrefix),
439        (KeyPattern::alt('d'), DeleteThread),
440        (KeyPattern::alt('u'), UndeleteThread),
441        (KeyPattern::alt('t'), TagThread),
442        (KeyPattern::ctrl('d'), DeleteSubthread),
443        (KeyPattern::ctrl('u'), UndeleteSubthread),
444        (KeyPattern::alt('n'), NextThread),
445        (KeyPattern::alt('p'), PrevThread),
446        (KeyPattern::ch('#'), BreakThread),
447        (KeyPattern::ch('&'), LinkThreads),
448        (KeyPattern::ctrl('r'), ReadThread),
449        (KeyPattern::alt('r'), ReadSubthread),
450        (KeyPattern::ch('P'), ParentMessage),
451        (KeyPattern::ch('Y'), EditLabel),
452        (KeyPattern::ch('V'), ShowVersion),
453        (KeyPattern::alt('l'), ShowLimit),
454        (KeyPattern::ch('@'), DisplayAddress),
455        (KeyPattern::ch('%'), ToggleWrite),
456        (KeyPattern::ch('H'), PageTop),
457        (KeyPattern::ch('M'), PageMiddle),
458        (KeyPattern::ch('z'), Undo),
459        (KeyPattern::ch('D'), DeletePattern),
460        (KeyPattern::ch('U'), UndeletePattern),
461        (KeyPattern::ch('T'), TagPattern),
462        (KeyPattern::ctrl('t'), UntagPattern),
463        (KeyPattern::ch('G'), FetchMail),
464        (KeyPattern::ch('s'), Save),
465        (KeyPattern::ch('C'), Copy),
466        (KeyPattern::alt('s'), DecodeSave),
467        (KeyPattern::alt('C'), DecodeCopy),
468        (KeyPattern::ch('|'), Pipe),
469        (KeyPattern::ch('b'), Bounce),
470        (KeyPattern::ch('e'), Edit),
471        (KeyPattern::alt('e'), Resend),
472        (KeyPattern::ch('B'), SidebarToggle),
473        (KeyPattern::ctrl('n'), SidebarNext),
474        (KeyPattern::ctrl('p'), SidebarPrev),
475        (KeyPattern::ctrl('o'), SidebarOpen),
476        (KeyPattern::ch('a'), CreateAlias),
477        (KeyPattern::ch('Q'), Query),
478        (KeyPattern::ch('X'), Notmuch),
479        (KeyPattern::ch(':'), EnterCommand),
480        (KeyPattern::ch('!'), Shell),
481        (KeyPattern::ctrl('l'), Redraw),
482        (KeyPattern::ctrl('z'), Suspend),
483        (KeyPattern::ch('?'), Help),
484        (KeyPattern::ch('~'), MarkMessage),
485        (KeyPattern::alt('L'), ListAction),
486    ]
487}
488
489/// An rmut action name or a mutt function name, resolved to the rmut
490/// name the key tables use. None when the menu has no such function.
491/// Both front ends bind keys through this, so `:bind` and `:macro`
492/// accept the same names in the terminal and in the window.
493pub fn resolve_function(menu: rmut_core::command::Menu, name: &str) -> Option<String> {
494    if menu == rmut_core::command::Menu::Index {
495        if Function::from_name(name).is_some() {
496            return Some(name.to_string());
497        }
498        let mapped = rmut_core::muttrc::index_function(name)?;
499        Function::from_name(mapped).map(|_| mapped.to_string())
500    } else {
501        if PagerAction::from_name(name).is_some() {
502            return Some(name.to_string());
503        }
504        let mapped = rmut_core::muttrc::pager_function(name)?;
505        PagerAction::from_name(mapped).map(|_| mapped.to_string())
506    }
507}
508
509fn pager_defaults() -> Vec<(KeyPattern, PagerAction)> {
510    use KeyCode as K;
511    use PagerAction::*;
512    vec![
513        (KeyPattern::ch('q'), Back),
514        (KeyPattern::ch('i'), Back),
515        (KeyPattern::plain(K::Esc), Back),
516        // mutt's pager: Enter/Backspace scroll one line; j/k and the
517        // arrows move between messages (next-/previous-undeleted).
518        (KeyPattern::plain(K::Enter), Down),
519        (KeyPattern::plain(K::Backspace), Up),
520        (KeyPattern::ch('j'), NextUndeleted),
521        (KeyPattern::plain(K::Down), NextUndeleted),
522        (KeyPattern::plain(K::Right), NextUndeleted),
523        (KeyPattern::ch('k'), PrevUndeleted),
524        (KeyPattern::plain(K::Up), PrevUndeleted),
525        (KeyPattern::plain(K::Left), PrevUndeleted),
526        (KeyPattern::ch(' '), PageDown),
527        (KeyPattern::plain(K::PageDown), PageDown),
528        (KeyPattern::ch('-'), PageUp),
529        (KeyPattern::plain(K::PageUp), PageUp),
530        (KeyPattern::ctrl('d'), HalfDown),
531        (KeyPattern::ctrl('u'), HalfUp),
532        (KeyPattern::plain(K::Home), Top),
533        (KeyPattern::plain(K::End), Bottom),
534        (KeyPattern::ch('T'), ToggleQuoted),
535        (KeyPattern::ch('S'), SkipQuoted),
536        (KeyPattern::ch('J'), NextMsg),
537        (KeyPattern::ch('K'), PrevMsg),
538        (KeyPattern::ch('d'), Delete),
539        (KeyPattern::ch('u'), Undelete),
540        (KeyPattern::ch('F'), Flag),
541        (KeyPattern::ch('t'), Tag),
542        // toggle-new has no default key here: mutt's N is rmut's
543        // backwards pager search. `:bind pager <key> toggle-new`.
544        (KeyPattern::ch('z'), Undo),
545        (KeyPattern::ctrl('l'), Redraw),
546        (KeyPattern::ctrl('z'), Suspend),
547        (KeyPattern::ch('h'), Headers),
548        (KeyPattern::ch('/'), Search),
549        (KeyPattern::ch('n'), SearchNext),
550        (KeyPattern::ch('N'), SearchPrev),
551        (KeyPattern::ch('\\'), SearchToggle),
552        (KeyPattern::ch('v'), Attachments),
553        (KeyPattern::ch('m'), Compose),
554        (KeyPattern::ch('r'), Reply),
555        (KeyPattern::ch('g'), GroupReply),
556        (KeyPattern::ch('L'), ListReply),
557        (KeyPattern::ch('f'), Forward),
558        (KeyPattern::ch('p'), Print),
559        (KeyPattern::ch('s'), Save),
560        (KeyPattern::ch('C'), Copy),
561        (KeyPattern::ch('|'), Pipe),
562        (KeyPattern::ch('b'), Bounce),
563        (KeyPattern::ch('e'), Edit),
564        (KeyPattern::alt('e'), Resend),
565        (KeyPattern::ch('a'), CreateAlias),
566        (KeyPattern::ch(':'), EnterCommand),
567        (KeyPattern::ch('?'), Help),
568        (KeyPattern::alt('L'), ListAction),
569        // urlview's customary key in a mutt setup; the index has
570        // Ctrl+B for page-up, so `urls` has no key there.
571        (KeyPattern::ctrl('b'), Urls),
572    ]
573}
574
575impl Keymap {
576    /// Defaults with config remaps applied: a remap unbinds the action's
577    /// default keys and whatever the new key was bound to. Macros come
578    /// from [macros.index]/[macros.pager], `key = "sequence"`.
579    pub fn with_config(
580        index_over: &HashMap<String, String>,
581        pager_over: &HashMap<String, String>,
582        macros_index: &HashMap<String, String>,
583        macros_pager: &HashMap<String, String>,
584    ) -> (Keymap, Vec<String>) {
585        let mut warnings = Vec::new();
586        let mut index = index_defaults();
587        for (action_name, key_str) in index_over {
588            let (Some(action), Some(key)) = (Function::from_name(action_name), parse_key(key_str))
589            else {
590                warnings.push(format!("bad index binding {action_name} = {key_str:?}"));
591                continue;
592            };
593            index.retain(|(k, a)| *a != action && *k != key);
594            index.push((key, action));
595        }
596        let mut pager = pager_defaults();
597        for (action_name, key_str) in pager_over {
598            let (Some(action), Some(key)) =
599                (PagerAction::from_name(action_name), parse_key(key_str))
600            else {
601                warnings.push(format!("bad pager binding {action_name} = {key_str:?}"));
602                continue;
603            };
604            pager.retain(|(k, a)| *a != action && *k != key);
605            pager.push((key, action));
606        }
607        let mut macros = |table: &HashMap<String, String>, menu: &str| {
608            let mut out = Vec::new();
609            for (key_str, seq_str) in table {
610                let (Some(key), Some(seq)) = (parse_key(key_str), parse_sequence(seq_str)) else {
611                    warnings.push(format!("bad {menu} macro {key_str} = {seq_str:?}"));
612                    continue;
613                };
614                out.push((key, seq, seq_str.clone()));
615            }
616            out
617        };
618        let macros_index = macros(macros_index, "index");
619        let macros_pager = macros(macros_pager, "pager");
620        (
621            Keymap {
622                index,
623                pager,
624                macros_index,
625                macros_pager,
626            },
627            warnings,
628        )
629    }
630
631    /// The macro sequence bound to this key, if any.
632    pub fn lookup_index_macro(&self, key: &KeyEvent) -> Option<&[KeyEvent]> {
633        self.macros_index
634            .iter()
635            .find(|(p, _, _)| p.matches(key))
636            .map(|(_, seq, _)| seq.as_slice())
637    }
638
639    pub fn lookup_pager_macro(&self, key: &KeyEvent) -> Option<&[KeyEvent]> {
640        self.macros_pager
641            .iter()
642            .find(|(p, _, _)| p.matches(key))
643            .map(|(_, seq, _)| seq.as_slice())
644    }
645
646    pub fn lookup_index(&self, key: &KeyEvent) -> Option<Function> {
647        self.index
648            .iter()
649            .find(|(p, _)| p.matches(key))
650            .map(|&(_, a)| a)
651    }
652
653    pub fn lookup_pager(&self, key: &KeyEvent) -> Option<PagerAction> {
654        self.pager
655            .iter()
656            .find(|(p, _)| p.matches(key))
657            .map(|&(_, a)| a)
658    }
659
660    /// Lines for the help screen, grouped and ordered by action.
661    pub fn help_lines(&self) -> Vec<String> {
662        let mut lines = vec!["Index keys".to_string(), String::new()];
663        for &action in Function::all() {
664            let keys: Vec<String> = self
665                .index
666                .iter()
667                .filter(|&&(_, a)| a == action)
668                .map(|(k, _)| k.display())
669                .collect();
670            if !keys.is_empty() {
671                lines.push(format!("  {:<16} {}", keys.join(" "), action.describe()));
672            }
673        }
674        lines.extend([String::new(), "Pager keys".to_string(), String::new()]);
675        for &action in PagerAction::all() {
676            let keys: Vec<String> = self
677                .pager
678                .iter()
679                .filter(|&&(_, a)| a == action)
680                .map(|(k, _)| k.display())
681                .collect();
682            if !keys.is_empty() {
683                lines.push(format!("  {:<16} {}", keys.join(" "), action.describe()));
684            }
685        }
686        for (title, table) in [
687            ("Index macros", &self.macros_index),
688            ("Pager macros", &self.macros_pager),
689        ] {
690            if !table.is_empty() {
691                lines.extend([String::new(), title.to_string(), String::new()]);
692                for (key, _, raw) in table {
693                    lines.push(format!("  {:<16} {raw}", key.display()));
694                }
695            }
696        }
697        lines.extend(
698            [
699                "",
700                "Patterns (limit/search)",
701                "",
702                "  ~f x  from       ~s x  subject     ~b x  body",
703                "  ~t x  to         ~c x  cc          ~C x  to or cc",
704                "  ~e x  sender     ~d spec  date     word  subject or from",
705                "  ~N new   ~U unread   ~F flagged   ~D deleted   ~T tagged",
706                "  ~p addressed to me",
707                "",
708                "  x is a case-insensitive regex; \"quotes\" keep spaces.",
709                "  ~d: 24/12/2026, 1/6/2026-30/6/2026, 24/12-, <1w, >2d, =3d",
710                "  Terms AND; ! negates, | ORs, () groups:",
711                "    !~D (~f jane | ~t jane) ~d <1m",
712            ]
713            .map(String::from),
714        );
715        lines
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    #[test]
724    fn parse_key_forms() {
725        assert_eq!(parse_key("x"), Some(KeyPattern::ch('x')));
726        assert_eq!(parse_key("X"), Some(KeyPattern::ch('X')));
727        assert_eq!(parse_key("ctrl+f"), Some(KeyPattern::ctrl('f')));
728        let del = parse_key("delete").unwrap();
729        assert_eq!(del.code, KeyCode::Delete);
730        assert_eq!(parse_key("Del"), Some(del));
731        assert_eq!(del.display(), "Delete");
732        assert_eq!(
733            parse_sequence("x<delete>").unwrap()[1].code,
734            KeyCode::Delete
735        );
736        assert_eq!(parse_key("Alt+v"), Some(KeyPattern::alt('v')));
737        assert_eq!(parse_key("space"), Some(KeyPattern::ch(' ')));
738        assert_eq!(
739            parse_key("pgdn"),
740            Some(KeyPattern::plain(KeyCode::PageDown))
741        );
742        assert_eq!(parse_key("enter"), Some(KeyPattern::plain(KeyCode::Enter)));
743        assert!(parse_key("bogus-key").is_none());
744    }
745
746    #[test]
747    fn remap_replaces_defaults_and_conflicts() {
748        let mut over = HashMap::new();
749        over.insert("sync".to_string(), "w".to_string());
750        over.insert("delete".to_string(), "ctrl+d".to_string());
751        let (map, warnings) =
752            Keymap::with_config(&over, &HashMap::new(), &HashMap::new(), &HashMap::new());
753        assert!(warnings.is_empty());
754        let ev = |p: KeyPattern| KeyEvent::new(p.code, p.mods);
755        assert_eq!(
756            map.lookup_index(&ev(KeyPattern::ch('w'))),
757            Some(Function::Sync)
758        );
759        assert_eq!(map.lookup_index(&ev(KeyPattern::ch('$'))), None);
760        assert_eq!(
761            map.lookup_index(&ev(KeyPattern::ctrl('d'))),
762            Some(Function::Delete)
763        );
764        assert_eq!(map.lookup_index(&ev(KeyPattern::ch('d'))), None);
765    }
766
767    #[test]
768    fn bad_bindings_warn_and_keep_defaults() {
769        let mut over = HashMap::new();
770        over.insert("frobnicate".to_string(), "z".to_string());
771        let (map, warnings) =
772            Keymap::with_config(&over, &HashMap::new(), &HashMap::new(), &HashMap::new());
773        assert_eq!(warnings.len(), 1);
774        let ev = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
775        assert_eq!(map.lookup_index(&ev), Some(Function::Quit));
776    }
777
778    #[test]
779    fn parse_sequence_forms() {
780        let seq = parse_sequence("l~f jane<enter>").unwrap();
781        assert_eq!(seq.len(), 9);
782        assert_eq!(seq[0].code, KeyCode::Char('l'));
783        assert_eq!(seq[2].code, KeyCode::Char('f'));
784        assert_eq!(seq[3].code, KeyCode::Char(' '));
785        assert_eq!(seq[8].code, KeyCode::Enter);
786        let seq = parse_sequence("<ctrl+x><Esc>").unwrap();
787        assert_eq!(seq[0].code, KeyCode::Char('x'));
788        assert!(seq[0].modifiers.contains(KeyModifiers::CONTROL));
789        assert_eq!(seq[1].code, KeyCode::Esc);
790        assert!(parse_sequence("<bogus>").is_none());
791        assert!(parse_sequence("<unclosed").is_none());
792        assert!(parse_sequence("").unwrap().is_empty());
793    }
794
795    #[test]
796    fn macros_parse_shadow_and_warn() {
797        let mut macros_index = HashMap::new();
798        macros_index.insert("d".to_string(), "l~f jane<enter>".to_string());
799        macros_index.insert("Z".to_string(), "<bogus>".to_string());
800        let (map, warnings) = Keymap::with_config(
801            &HashMap::new(),
802            &HashMap::new(),
803            &macros_index,
804            &HashMap::new(),
805        );
806        assert_eq!(warnings.len(), 1);
807        assert!(warnings[0].contains("bad index macro Z"), "{warnings:?}");
808        let ev = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
809        // The macro exists on d; the app checks it before the delete
810        // binding, so it shadows.
811        assert_eq!(map.lookup_index_macro(&ev).unwrap().len(), 9);
812        assert!(
813            map.lookup_pager_macro(&ev).is_none(),
814            "index macro must not leak into the pager"
815        );
816        // Help lists the macro with its raw sequence.
817        let help = map.help_lines().join("\n");
818        assert!(help.contains("Index macros"), "{help}");
819        assert!(help.contains("l~f jane<enter>"), "{help}");
820    }
821
822    #[test]
823    fn shift_in_event_does_not_block_match() {
824        // Terminals report 'F' as Char('F') + SHIFT.
825        let ev = KeyEvent::new(KeyCode::Char('F'), KeyModifiers::SHIFT);
826        let (map, _) = Keymap::with_config(
827            &HashMap::new(),
828            &HashMap::new(),
829            &HashMap::new(),
830            &HashMap::new(),
831        );
832        assert_eq!(map.lookup_index(&ev), Some(Function::Flag));
833    }
834}