Skip to main content

minus/
help.rs

1//! Help text and related definitions for the pager.
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4use std::fmt::Write as _;
5
6/// Format a [`KeyEvent`] into a human-readable representation (e.g. `"Ctrl-c"`, `"Alt-h"`).
7#[must_use]
8pub fn format_key(ke: &KeyEvent) -> String {
9    let mut s = String::new();
10    if ke.modifiers.contains(KeyModifiers::CONTROL) {
11        s.push_str("Ctrl-");
12    }
13    if ke.modifiers.contains(KeyModifiers::ALT) {
14        s.push_str("Alt-");
15    }
16    if ke.modifiers.contains(KeyModifiers::SHIFT) {
17        if let KeyCode::Char(c) = ke.code {
18            if !c.is_ascii_uppercase() {
19                s.push_str("Shift-");
20            }
21        } else {
22            s.push_str("Shift-");
23        }
24    }
25
26    match ke.code {
27        KeyCode::Char(c) => s.push(c),
28        KeyCode::Enter => s.push_str("Enter"),
29        KeyCode::Tab => s.push_str("Tab"),
30        KeyCode::BackTab => s.push_str("BackTab"),
31        KeyCode::Backspace => s.push_str("Backspace"),
32        KeyCode::Esc => s.push_str("Esc"),
33        KeyCode::Up => s.push_str("Up"),
34        KeyCode::Down => s.push_str("Down"),
35        KeyCode::Left => s.push_str("Left"),
36        KeyCode::Right => s.push_str("Right"),
37        KeyCode::PageUp => s.push_str("PageUp"),
38        KeyCode::PageDown => s.push_str("PageDown"),
39        KeyCode::Home => s.push_str("Home"),
40        KeyCode::End => s.push_str("End"),
41        KeyCode::Delete => s.push_str("Delete"),
42        KeyCode::Insert => s.push_str("Insert"),
43        KeyCode::F(n) => {
44            let _ = write!(s, "F{n}");
45        }
46        KeyCode::Null => s.push_str("Null"),
47        _ => s.push_str("Unknown"),
48    }
49    s
50}
51
52/// Format dynamic help table from key event entries and their descriptions.
53///
54/// Empty descriptions are omitted.
55#[must_use]
56pub fn format_help_table_from_entries<'a, I>(entries: I) -> String
57where
58    I: IntoIterator<Item = (&'a KeyEvent, &'a str)>,
59{
60    let mut groups: Vec<(&'a str, Vec<String>)> = Vec::new();
61    for (key, desc) in entries {
62        let trimmed_desc = desc.trim();
63        if trimmed_desc.is_empty() {
64            continue;
65        }
66        let key_str = format_key(key);
67        if let Some((_, keys)) = groups.iter_mut().find(|(d, _)| *d == trimmed_desc) {
68            if !keys.contains(&key_str) {
69                keys.push(key_str);
70            }
71        } else {
72            groups.push((trimmed_desc, vec![key_str]));
73        }
74    }
75
76    if groups.is_empty() {
77        return String::new();
78    }
79
80    let mut out = String::new();
81    out.push_str("                         COMMAND SUMMARY\n\n");
82    out.push_str("  Key(s)                         Action\n");
83    out.push_str("  ------                         ------\n");
84
85    for (desc, keys) in groups {
86        let keys_str = keys.join(", ");
87        let _ = writeln!(out, "  {keys_str:<30} {desc}");
88    }
89
90    out
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crossterm::event::KeyEventState;
97
98    #[test]
99    fn test_format_key() {
100        let k1 = KeyEvent {
101            code: KeyCode::Char('q'),
102            modifiers: KeyModifiers::NONE,
103            kind: crossterm::event::KeyEventKind::Press,
104            state: KeyEventState::NONE,
105        };
106        assert_eq!(format_key(&k1), "q");
107
108        let k2 = KeyEvent {
109            code: KeyCode::Char('c'),
110            modifiers: KeyModifiers::CONTROL,
111            kind: crossterm::event::KeyEventKind::Press,
112            state: KeyEventState::NONE,
113        };
114        assert_eq!(format_key(&k2), "Ctrl-c");
115
116        let k3 = KeyEvent {
117            code: KeyCode::Up,
118            modifiers: KeyModifiers::ALT,
119            kind: crossterm::event::KeyEventKind::Press,
120            state: KeyEventState::NONE,
121        };
122        assert_eq!(format_key(&k3), "Alt-Up");
123    }
124
125    #[test]
126    fn test_format_help_table_from_entries() {
127        let k1 = KeyEvent {
128            code: KeyCode::Char('q'),
129            modifiers: KeyModifiers::NONE,
130            kind: crossterm::event::KeyEventKind::Press,
131            state: KeyEventState::NONE,
132        };
133        let k2 = KeyEvent {
134            code: KeyCode::Char('c'),
135            modifiers: KeyModifiers::CONTROL,
136            kind: crossterm::event::KeyEventKind::Press,
137            state: KeyEventState::NONE,
138        };
139        let k3 = KeyEvent {
140            code: KeyCode::Char('x'),
141            modifiers: KeyModifiers::NONE,
142            kind: crossterm::event::KeyEventKind::Press,
143            state: KeyEventState::NONE,
144        };
145
146        let entries = vec![(&k1, "quit"), (&k2, "quit"), (&k3, "")];
147        let table = format_help_table_from_entries(entries);
148        assert!(table.contains("COMMAND SUMMARY"));
149        assert!(table.contains("q, Ctrl-c"));
150        assert!(table.contains("quit"));
151        assert!(!table.contains(" x "));
152
153        let empty_table = format_help_table_from_entries(Vec::<(&KeyEvent, &str)>::new());
154        assert!(empty_table.is_empty());
155    }
156}