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