Skip to main content

datui_lib/
help.rs

1//! The help overlay: one screen's keys from the key registry
2//! ([`datui_cli::keys`]), grouped by task, the keys of every screen last.
3//!
4//! `/` narrows the lines to those whose key, label or description holds what is
5//! typed. Enter closes the help and presses the key on the selected line, as the
6//! next key the app takes: [`Help::key`] answers [`HelpKey::Press`], and the App
7//! hands that key back to the event loop as its follow-up `AppEvent::Key`, so it
8//! takes the path a typed key takes (held while busy, replayed in order) rather
9//! than a parallel set of actions.
10
11use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
12use datui_cli::keys::{self, Chord, Code, Context, Key};
13
14/// What a line of the help holds.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Line {
17    /// A key, which Enter can press.
18    Key(&'static Key),
19    /// An example and what it means: the q summary on the query screen.
20    Note(&'static str, &'static str),
21}
22
23/// One group's lines, under its name.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Block {
26    pub name: &'static str,
27    pub lines: Vec<Line>,
28}
29
30/// The overlay's state: open over a context, or closed.
31#[derive(Debug, Default)]
32pub struct Help {
33    context: Option<Context>,
34    /// What `/` narrowed the lines to.
35    pub(crate) filter: String,
36    /// Whether typed characters go to the filter.
37    pub(crate) filtering: bool,
38    /// The selected key, among the keys shown.
39    pub(crate) selected: usize,
40    /// The first line drawn; the render keeps the selection in view and writes it back.
41    pub(crate) scroll: usize,
42    /// Whether a text field under the help takes typed characters: a plain character
43    /// pressed there would type, so those lines do not run.
44    typing: bool,
45}
46
47/// What a key typed at the help did.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum HelpKey {
50    /// Handled inside the help.
51    Stay,
52    /// The help closed.
53    Closed,
54    /// The help closed; press this key where it was opened.
55    Press(KeyEvent),
56}
57
58impl Help {
59    pub fn is_open(&self) -> bool {
60        self.context.is_some()
61    }
62
63    /// The screen whose keys are shown.
64    pub fn context(&self) -> Option<Context> {
65        self.context
66    }
67
68    /// Open on `context`'s keys; `typing` when a text field there takes characters.
69    pub fn open(&mut self, context: Context, typing: bool) {
70        *self = Help {
71            context: Some(context),
72            typing,
73            ..Help::default()
74        };
75    }
76
77    /// The key Enter presses for `key` here: none for a plain character while a text
78    /// field would type it.
79    pub fn runnable(&self, key: &Key) -> Option<KeyEvent> {
80        let event = key_event(key.action()?);
81        let plain_char = matches!(event.code, KeyCode::Char(_))
82            && !event
83                .modifiers
84                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
85        (!(self.typing && plain_char)).then_some(event)
86    }
87
88    pub fn close(&mut self) {
89        *self = Help::default();
90    }
91
92    /// The overlay's title: `Table Help`.
93    pub fn title(&self) -> String {
94        self.context
95            .map(|c| format!("{} Help", keys::screen(c).title))
96            .unwrap_or_default()
97    }
98
99    /// The blocks shown: the screen's groups, the q summary on the query screen, then
100    /// the keys of every screen; each narrowed by the filter, empty ones left out.
101    pub fn blocks(&self) -> Vec<Block> {
102        let Some(context) = self.context else {
103            return Vec::new();
104        };
105        let needle = self.filter.to_lowercase();
106        let keeps = |text: String| needle.is_empty() || text.to_lowercase().contains(&needle);
107        let mut blocks: Vec<Block> = Vec::new();
108        let mut push = |name: &'static str, lines: Vec<Line>| {
109            if !lines.is_empty() {
110                blocks.push(Block { name, lines });
111            }
112        };
113        let key_lines = |group: &'static keys::Group| -> Vec<Line> {
114            group
115                .keys
116                .iter()
117                .filter(|k| keeps(format!("{} {} {}", k.keys, k.label, k.line)))
118                .map(Line::Key)
119                .collect()
120        };
121        for group in keys::screen(context).groups {
122            push(group.name, key_lines(group));
123        }
124        if context == Context::Query {
125            let notes = keys::Q_SUMMARY
126                .iter()
127                .filter(|(example, meaning)| keeps(format!("{example} {meaning}")))
128                .map(|&(example, meaning)| Line::Note(example, meaning))
129                .collect();
130            push("q syntax", notes);
131        }
132        push(keys::GLOBAL.name, key_lines(&keys::GLOBAL));
133        blocks
134    }
135
136    /// The keys shown, in order: what ↑↓ walk.
137    pub fn shown_keys(&self) -> Vec<&'static Key> {
138        self.blocks()
139            .into_iter()
140            .flat_map(|b| b.lines)
141            .filter_map(|line| match line {
142                Line::Key(key) => Some(key),
143                Line::Note(..) => None,
144            })
145            .collect()
146    }
147
148    /// The selected key, if any is shown.
149    pub fn selected_key(&self) -> Option<&'static Key> {
150        let shown = self.shown_keys();
151        shown
152            .get(self.selected.min(shown.len().saturating_sub(1)))
153            .copied()
154    }
155
156    fn step(&mut self, by: isize) {
157        let count = self.shown_keys().len();
158        if count == 0 {
159            self.selected = 0;
160            return;
161        }
162        self.selected = self
163            .selected
164            .min(count - 1)
165            .saturating_add_signed(by)
166            .min(count - 1);
167    }
168
169    /// Enter: close, and press the selected line's key if it has one.
170    fn run(&mut self) -> HelpKey {
171        let Some(event) = self.selected_key().and_then(|k| self.runnable(k)) else {
172            return HelpKey::Stay;
173        };
174        self.close();
175        HelpKey::Press(event)
176    }
177
178    /// A key typed while the help is open.
179    pub fn key(&mut self, event: &KeyEvent) -> HelpKey {
180        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
181        match event.code {
182            KeyCode::Enter => return self.run(),
183            KeyCode::Up => self.step(-1),
184            KeyCode::Down => self.step(1),
185            KeyCode::PageUp => self.step(-10),
186            KeyCode::PageDown => self.step(10),
187            KeyCode::Char('p') if ctrl => self.step(-1),
188            KeyCode::Char('n') if ctrl => self.step(1),
189            KeyCode::Esc => {
190                if self.filtering || !self.filter.is_empty() {
191                    self.filter.clear();
192                    self.filtering = false;
193                    self.selected = 0;
194                } else {
195                    self.close();
196                    return HelpKey::Closed;
197                }
198            }
199            KeyCode::F(1) => {
200                self.close();
201                return HelpKey::Closed;
202            }
203            _ if self.filtering => match event.code {
204                KeyCode::Backspace => {
205                    self.filter.pop();
206                    self.selected = 0;
207                }
208                KeyCode::Char('u') if ctrl => {
209                    self.filter.clear();
210                    self.selected = 0;
211                }
212                KeyCode::Char(c)
213                    if !event
214                        .modifiers
215                        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
216                {
217                    self.filter.push(c);
218                    self.selected = 0;
219                }
220                _ => {}
221            },
222            KeyCode::Char('/') => self.filtering = true,
223            KeyCode::Char('?') => {
224                self.close();
225                return HelpKey::Closed;
226            }
227            KeyCode::Char('k') => self.step(-1),
228            KeyCode::Char('j') => self.step(1),
229            KeyCode::Home | KeyCode::Char('g') => self.selected = 0,
230            KeyCode::End | KeyCode::Char('G') => self.step(isize::MAX),
231            _ => {}
232        }
233        HelpKey::Stay
234    }
235}
236
237/// The key event a chord stands for, as a terminal reports it.
238pub fn key_event(chord: Chord) -> KeyEvent {
239    let code = match chord.code {
240        Code::Char(c) => KeyCode::Char(c),
241        Code::Enter => KeyCode::Enter,
242        Code::Esc => KeyCode::Esc,
243        Code::Tab => KeyCode::Tab,
244        Code::BackTab => KeyCode::BackTab,
245        Code::Backspace => KeyCode::Backspace,
246        Code::Delete => KeyCode::Delete,
247        Code::Insert => KeyCode::Insert,
248        Code::Up => KeyCode::Up,
249        Code::Down => KeyCode::Down,
250        Code::Left => KeyCode::Left,
251        Code::Right => KeyCode::Right,
252        Code::PageUp => KeyCode::PageUp,
253        Code::PageDown => KeyCode::PageDown,
254        Code::Home => KeyCode::Home,
255        Code::End => KeyCode::End,
256        Code::F(n) => KeyCode::F(n),
257    };
258    let mut modifiers = KeyModifiers::NONE;
259    if chord.ctrl {
260        modifiers |= KeyModifiers::CONTROL;
261    }
262    if chord.alt {
263        modifiers |= KeyModifiers::ALT;
264    }
265    if chord.shift {
266        modifiers |= KeyModifiers::SHIFT;
267    }
268    KeyEvent::new(code, modifiers)
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    impl Help {
276        fn open_for_test(&mut self, context: Context) {
277            self.open(context, false);
278        }
279    }
280
281    /// Under a text field, a plain character does not run: it would type. A chord
282    /// still does.
283    #[test]
284    fn a_text_field_keeps_its_characters() {
285        let mut help = Help::default();
286        help.open(Context::Find, true);
287        press(&mut help, KeyCode::Char('/'));
288        type_text(&mut help, "regex on");
289        assert_eq!(
290            press(&mut help, KeyCode::Enter),
291            HelpKey::Press(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL))
292        );
293        help.open(Context::Export, true);
294        let space = keys::screen(Context::Export).groups[0]
295            .keys
296            .iter()
297            .find(|k| k.keys == "Space")
298            .unwrap();
299        assert_eq!(help.runnable(space), None);
300        help.open(Context::Export, false);
301        assert!(help.runnable(space).is_some());
302    }
303
304    fn press(help: &mut Help, code: KeyCode) -> HelpKey {
305        help.key(&KeyEvent::new(code, KeyModifiers::NONE))
306    }
307
308    fn type_text(help: &mut Help, text: &str) {
309        for c in text.chars() {
310            press(help, KeyCode::Char(c));
311        }
312    }
313
314    #[test]
315    fn every_screen_ends_with_the_keys_of_every_screen() {
316        for screen in keys::SCREENS {
317            let mut help = Help::default();
318            help.open_for_test(screen.context);
319            let blocks = help.blocks();
320            assert_eq!(blocks.last().map(|b| b.name), Some(keys::GLOBAL.name));
321            assert!(blocks.len() > 1, "{}", screen.title);
322        }
323    }
324
325    #[test]
326    fn the_query_screen_carries_the_q_summary() {
327        let mut help = Help::default();
328        help.open_for_test(Context::Query);
329        assert!(help.blocks().iter().any(|b| b.name == "q syntax"));
330        help.open_for_test(Context::Table);
331        assert!(!help.blocks().iter().any(|b| b.name == "q syntax"));
332    }
333
334    #[test]
335    fn slash_narrows_by_key_and_description() {
336        let mut help = Help::default();
337        help.open_for_test(Context::Table);
338        let all = help.shown_keys().len();
339        press(&mut help, KeyCode::Char('/'));
340        type_text(&mut help, "value counts");
341        let shown = help.shown_keys();
342        assert_eq!(shown.len(), 1, "{shown:?}");
343        assert_eq!(shown[0].keys, "F");
344        // Backspace takes back a character.
345        press(&mut help, KeyCode::Backspace);
346        type_text(&mut help, "s");
347        assert_eq!(help.filter, "value counts");
348        // Esc clears the filter, then closes.
349        assert_eq!(press(&mut help, KeyCode::Esc), HelpKey::Stay);
350        assert_eq!(help.shown_keys().len(), all);
351        assert!(help.is_open());
352        assert_eq!(press(&mut help, KeyCode::Esc), HelpKey::Closed);
353        assert!(!help.is_open());
354    }
355
356    #[test]
357    fn a_filter_that_matches_nothing_shows_nothing() {
358        let mut help = Help::default();
359        help.open_for_test(Context::Table);
360        press(&mut help, KeyCode::Char('/'));
361        type_text(&mut help, "zzzzzz");
362        assert!(help.blocks().is_empty());
363        assert_eq!(press(&mut help, KeyCode::Enter), HelpKey::Stay);
364        assert!(help.is_open());
365    }
366
367    #[test]
368    fn enter_closes_and_presses_the_selected_key() {
369        let mut help = Help::default();
370        help.open_for_test(Context::Table);
371        press(&mut help, KeyCode::Char('/'));
372        type_text(&mut help, "value counts");
373        let pressed = press(&mut help, KeyCode::Enter);
374        assert_eq!(
375            pressed,
376            HelpKey::Press(KeyEvent::new(KeyCode::Char('F'), KeyModifiers::SHIFT))
377        );
378        assert!(!help.is_open());
379    }
380
381    #[test]
382    fn arrows_walk_the_keys_and_stop_at_the_ends() {
383        let mut help = Help::default();
384        help.open_for_test(Context::Query);
385        let count = help.shown_keys().len();
386        press(&mut help, KeyCode::Up);
387        assert_eq!(help.selected, 0);
388        press(&mut help, KeyCode::Char('j'));
389        assert_eq!(help.selected, 1);
390        press(&mut help, KeyCode::End);
391        assert_eq!(help.selected, count - 1);
392        press(&mut help, KeyCode::Down);
393        assert_eq!(help.selected, count - 1);
394        press(&mut help, KeyCode::Home);
395        assert_eq!(help.selected, 0);
396    }
397
398    /// A line with nothing to press (typing, the mouse) keeps the help open.
399    #[test]
400    fn enter_on_a_line_with_no_key_stays() {
401        let mut help = Help::default();
402        help.open_for_test(Context::Query);
403        assert_eq!(help.selected_key().map(|k| k.keys), Some("(digits)"));
404        assert_eq!(press(&mut help, KeyCode::Enter), HelpKey::Stay);
405        assert!(help.is_open());
406    }
407}