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
481fn pager_defaults() -> Vec<(KeyPattern, PagerAction)> {
482    use KeyCode as K;
483    use PagerAction::*;
484    vec![
485        (KeyPattern::ch('q'), Back),
486        (KeyPattern::ch('i'), Back),
487        (KeyPattern::plain(K::Esc), Back),
488        // mutt's pager: Enter/Backspace scroll one line; j/k and the
489        // arrows move between messages (next-/previous-undeleted).
490        (KeyPattern::plain(K::Enter), Down),
491        (KeyPattern::plain(K::Backspace), Up),
492        (KeyPattern::ch('j'), NextUndeleted),
493        (KeyPattern::plain(K::Down), NextUndeleted),
494        (KeyPattern::plain(K::Right), NextUndeleted),
495        (KeyPattern::ch('k'), PrevUndeleted),
496        (KeyPattern::plain(K::Up), PrevUndeleted),
497        (KeyPattern::plain(K::Left), PrevUndeleted),
498        (KeyPattern::ch(' '), PageDown),
499        (KeyPattern::plain(K::PageDown), PageDown),
500        (KeyPattern::ch('-'), PageUp),
501        (KeyPattern::plain(K::PageUp), PageUp),
502        (KeyPattern::ctrl('d'), HalfDown),
503        (KeyPattern::ctrl('u'), HalfUp),
504        (KeyPattern::plain(K::Home), Top),
505        (KeyPattern::plain(K::End), Bottom),
506        (KeyPattern::ch('T'), ToggleQuoted),
507        (KeyPattern::ch('S'), SkipQuoted),
508        (KeyPattern::ch('J'), NextMsg),
509        (KeyPattern::ch('K'), PrevMsg),
510        (KeyPattern::ch('d'), Delete),
511        (KeyPattern::ch('u'), Undelete),
512        (KeyPattern::ch('F'), Flag),
513        (KeyPattern::ch('t'), Tag),
514        // toggle-new has no default key here: mutt's N is rmut's
515        // backwards pager search. `:bind pager <key> toggle-new`.
516        (KeyPattern::ch('z'), Undo),
517        (KeyPattern::ctrl('l'), Redraw),
518        (KeyPattern::ctrl('z'), Suspend),
519        (KeyPattern::ch('h'), Headers),
520        (KeyPattern::ch('/'), Search),
521        (KeyPattern::ch('n'), SearchNext),
522        (KeyPattern::ch('N'), SearchPrev),
523        (KeyPattern::ch('\\'), SearchToggle),
524        (KeyPattern::ch('v'), Attachments),
525        (KeyPattern::ch('m'), Compose),
526        (KeyPattern::ch('r'), Reply),
527        (KeyPattern::ch('g'), GroupReply),
528        (KeyPattern::ch('L'), ListReply),
529        (KeyPattern::ch('f'), Forward),
530        (KeyPattern::ch('p'), Print),
531        (KeyPattern::ch('s'), Save),
532        (KeyPattern::ch('C'), Copy),
533        (KeyPattern::ch('|'), Pipe),
534        (KeyPattern::ch('b'), Bounce),
535        (KeyPattern::ch('e'), Edit),
536        (KeyPattern::alt('e'), Resend),
537        (KeyPattern::ch('a'), CreateAlias),
538        (KeyPattern::ch(':'), EnterCommand),
539        (KeyPattern::ch('?'), Help),
540        (KeyPattern::alt('L'), ListAction),
541    ]
542}
543
544impl Keymap {
545    /// Defaults with config remaps applied: a remap unbinds the action's
546    /// default keys and whatever the new key was bound to. Macros come
547    /// from [macros.index]/[macros.pager], `key = "sequence"`.
548    pub fn with_config(
549        index_over: &HashMap<String, String>,
550        pager_over: &HashMap<String, String>,
551        macros_index: &HashMap<String, String>,
552        macros_pager: &HashMap<String, String>,
553    ) -> (Keymap, Vec<String>) {
554        let mut warnings = Vec::new();
555        let mut index = index_defaults();
556        for (action_name, key_str) in index_over {
557            let (Some(action), Some(key)) = (Function::from_name(action_name), parse_key(key_str))
558            else {
559                warnings.push(format!("bad index binding {action_name} = {key_str:?}"));
560                continue;
561            };
562            index.retain(|(k, a)| *a != action && *k != key);
563            index.push((key, action));
564        }
565        let mut pager = pager_defaults();
566        for (action_name, key_str) in pager_over {
567            let (Some(action), Some(key)) =
568                (PagerAction::from_name(action_name), parse_key(key_str))
569            else {
570                warnings.push(format!("bad pager binding {action_name} = {key_str:?}"));
571                continue;
572            };
573            pager.retain(|(k, a)| *a != action && *k != key);
574            pager.push((key, action));
575        }
576        let mut macros = |table: &HashMap<String, String>, menu: &str| {
577            let mut out = Vec::new();
578            for (key_str, seq_str) in table {
579                let (Some(key), Some(seq)) = (parse_key(key_str), parse_sequence(seq_str)) else {
580                    warnings.push(format!("bad {menu} macro {key_str} = {seq_str:?}"));
581                    continue;
582                };
583                out.push((key, seq, seq_str.clone()));
584            }
585            out
586        };
587        let macros_index = macros(macros_index, "index");
588        let macros_pager = macros(macros_pager, "pager");
589        (
590            Keymap {
591                index,
592                pager,
593                macros_index,
594                macros_pager,
595            },
596            warnings,
597        )
598    }
599
600    /// The macro sequence bound to this key, if any.
601    pub fn lookup_index_macro(&self, key: &KeyEvent) -> Option<&[KeyEvent]> {
602        self.macros_index
603            .iter()
604            .find(|(p, _, _)| p.matches(key))
605            .map(|(_, seq, _)| seq.as_slice())
606    }
607
608    pub fn lookup_pager_macro(&self, key: &KeyEvent) -> Option<&[KeyEvent]> {
609        self.macros_pager
610            .iter()
611            .find(|(p, _, _)| p.matches(key))
612            .map(|(_, seq, _)| seq.as_slice())
613    }
614
615    pub fn lookup_index(&self, key: &KeyEvent) -> Option<Function> {
616        self.index
617            .iter()
618            .find(|(p, _)| p.matches(key))
619            .map(|&(_, a)| a)
620    }
621
622    pub fn lookup_pager(&self, key: &KeyEvent) -> Option<PagerAction> {
623        self.pager
624            .iter()
625            .find(|(p, _)| p.matches(key))
626            .map(|&(_, a)| a)
627    }
628
629    /// Lines for the help screen, grouped and ordered by action.
630    pub fn help_lines(&self) -> Vec<String> {
631        let mut lines = vec!["Index keys".to_string(), String::new()];
632        for &action in Function::all() {
633            let keys: Vec<String> = self
634                .index
635                .iter()
636                .filter(|&&(_, a)| a == action)
637                .map(|(k, _)| k.display())
638                .collect();
639            if !keys.is_empty() {
640                lines.push(format!("  {:<16} {}", keys.join(" "), action.describe()));
641            }
642        }
643        lines.extend([String::new(), "Pager keys".to_string(), String::new()]);
644        for &action in PagerAction::all() {
645            let keys: Vec<String> = self
646                .pager
647                .iter()
648                .filter(|&&(_, a)| a == action)
649                .map(|(k, _)| k.display())
650                .collect();
651            if !keys.is_empty() {
652                lines.push(format!("  {:<16} {}", keys.join(" "), action.describe()));
653            }
654        }
655        for (title, table) in [
656            ("Index macros", &self.macros_index),
657            ("Pager macros", &self.macros_pager),
658        ] {
659            if !table.is_empty() {
660                lines.extend([String::new(), title.to_string(), String::new()]);
661                for (key, _, raw) in table {
662                    lines.push(format!("  {:<16} {raw}", key.display()));
663                }
664            }
665        }
666        lines.extend(
667            [
668                "",
669                "Patterns (limit/search)",
670                "",
671                "  ~f x  from       ~s x  subject     ~b x  body",
672                "  ~t x  to         ~c x  cc          ~C x  to or cc",
673                "  ~e x  sender     ~d spec  date     word  subject or from",
674                "  ~N new   ~U unread   ~F flagged   ~D deleted   ~T tagged",
675                "  ~p addressed to me",
676                "",
677                "  x is a case-insensitive regex; \"quotes\" keep spaces.",
678                "  ~d: 24/12/2026, 1/6/2026-30/6/2026, 24/12-, <1w, >2d, =3d",
679                "  Terms AND; ! negates, | ORs, () groups:",
680                "    !~D (~f jane | ~t jane) ~d <1m",
681            ]
682            .map(String::from),
683        );
684        lines
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[test]
693    fn parse_key_forms() {
694        assert_eq!(parse_key("x"), Some(KeyPattern::ch('x')));
695        assert_eq!(parse_key("X"), Some(KeyPattern::ch('X')));
696        assert_eq!(parse_key("ctrl+f"), Some(KeyPattern::ctrl('f')));
697        assert_eq!(parse_key("Alt+v"), Some(KeyPattern::alt('v')));
698        assert_eq!(parse_key("space"), Some(KeyPattern::ch(' ')));
699        assert_eq!(
700            parse_key("pgdn"),
701            Some(KeyPattern::plain(KeyCode::PageDown))
702        );
703        assert_eq!(parse_key("enter"), Some(KeyPattern::plain(KeyCode::Enter)));
704        assert!(parse_key("bogus-key").is_none());
705    }
706
707    #[test]
708    fn remap_replaces_defaults_and_conflicts() {
709        let mut over = HashMap::new();
710        over.insert("sync".to_string(), "w".to_string());
711        over.insert("delete".to_string(), "ctrl+d".to_string());
712        let (map, warnings) =
713            Keymap::with_config(&over, &HashMap::new(), &HashMap::new(), &HashMap::new());
714        assert!(warnings.is_empty());
715        let ev = |p: KeyPattern| KeyEvent::new(p.code, p.mods);
716        assert_eq!(
717            map.lookup_index(&ev(KeyPattern::ch('w'))),
718            Some(Function::Sync)
719        );
720        assert_eq!(map.lookup_index(&ev(KeyPattern::ch('$'))), None);
721        assert_eq!(
722            map.lookup_index(&ev(KeyPattern::ctrl('d'))),
723            Some(Function::Delete)
724        );
725        assert_eq!(map.lookup_index(&ev(KeyPattern::ch('d'))), None);
726    }
727
728    #[test]
729    fn bad_bindings_warn_and_keep_defaults() {
730        let mut over = HashMap::new();
731        over.insert("frobnicate".to_string(), "z".to_string());
732        let (map, warnings) =
733            Keymap::with_config(&over, &HashMap::new(), &HashMap::new(), &HashMap::new());
734        assert_eq!(warnings.len(), 1);
735        let ev = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
736        assert_eq!(map.lookup_index(&ev), Some(Function::Quit));
737    }
738
739    #[test]
740    fn parse_sequence_forms() {
741        let seq = parse_sequence("l~f jane<enter>").unwrap();
742        assert_eq!(seq.len(), 9);
743        assert_eq!(seq[0].code, KeyCode::Char('l'));
744        assert_eq!(seq[2].code, KeyCode::Char('f'));
745        assert_eq!(seq[3].code, KeyCode::Char(' '));
746        assert_eq!(seq[8].code, KeyCode::Enter);
747        let seq = parse_sequence("<ctrl+x><Esc>").unwrap();
748        assert_eq!(seq[0].code, KeyCode::Char('x'));
749        assert!(seq[0].modifiers.contains(KeyModifiers::CONTROL));
750        assert_eq!(seq[1].code, KeyCode::Esc);
751        assert!(parse_sequence("<bogus>").is_none());
752        assert!(parse_sequence("<unclosed").is_none());
753        assert!(parse_sequence("").unwrap().is_empty());
754    }
755
756    #[test]
757    fn macros_parse_shadow_and_warn() {
758        let mut macros_index = HashMap::new();
759        macros_index.insert("d".to_string(), "l~f jane<enter>".to_string());
760        macros_index.insert("Z".to_string(), "<bogus>".to_string());
761        let (map, warnings) = Keymap::with_config(
762            &HashMap::new(),
763            &HashMap::new(),
764            &macros_index,
765            &HashMap::new(),
766        );
767        assert_eq!(warnings.len(), 1);
768        assert!(warnings[0].contains("bad index macro Z"), "{warnings:?}");
769        let ev = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
770        // The macro exists on d; the app checks it before the delete
771        // binding, so it shadows.
772        assert_eq!(map.lookup_index_macro(&ev).unwrap().len(), 9);
773        assert!(
774            map.lookup_pager_macro(&ev).is_none(),
775            "index macro must not leak into the pager"
776        );
777        // Help lists the macro with its raw sequence.
778        let help = map.help_lines().join("\n");
779        assert!(help.contains("Index macros"), "{help}");
780        assert!(help.contains("l~f jane<enter>"), "{help}");
781    }
782
783    #[test]
784    fn shift_in_event_does_not_block_match() {
785        // Terminals report 'F' as Char('F') + SHIFT.
786        let ev = KeyEvent::new(KeyCode::Char('F'), KeyModifiers::SHIFT);
787        let (map, _) = Keymap::with_config(
788            &HashMap::new(),
789            &HashMap::new(),
790            &HashMap::new(),
791            &HashMap::new(),
792        );
793        assert_eq!(map.lookup_index(&ev), Some(Function::Flag));
794    }
795}