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