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