Skip to main content

kimun_notes/components/dialogs/
help_dialog.rs

1use std::collections::BTreeMap;
2
3use ratatui::Frame;
4use ratatui::crossterm::event::{KeyCode, MouseEvent, MouseEventKind};
5use ratatui::layout::{Constraint, Direction, Layout, Rect};
6use ratatui::style::{Modifier, Style};
7use ratatui::widgets::Paragraph;
8
9use super::ModalShell;
10use crate::components::Component;
11use crate::components::event_state::EventState;
12use crate::components::events::{AppEvent, AppTx, InputEvent};
13use crate::components::hint_row::HintRow;
14use crate::components::panel::{ModalSpec, modal_chrome};
15use crate::keys::KeyBindings;
16use crate::keys::action_shortcuts::ShortcutCategory;
17use crate::settings::themes::Theme;
18
19// ---------------------------------------------------------------------------
20// HelpRow
21// ---------------------------------------------------------------------------
22
23pub enum HelpRow {
24    Header(String),
25    Separator,
26    Binding { keys: String, label: String },
27    Blank,
28}
29
30// ---------------------------------------------------------------------------
31// HelpDialog
32// ---------------------------------------------------------------------------
33
34pub struct HelpDialog {
35    pub rows: Vec<HelpRow>,
36    /// Window title — distinguishes the flat F1 help from the leader-tree
37    /// cheatsheet, which share this widget.
38    title: &'static str,
39    scroll: usize,
40    /// Cached body height from last render, used for PageUp/PageDown page size.
41    last_body_height: u16,
42    /// Dismiss-on-outside-press and hint-chip clicks (see [`ModalShell`]).
43    shell: ModalShell,
44    footer: HintRow,
45}
46
47impl HelpDialog {
48    pub fn new(key_bindings: &KeyBindings, tree: &crate::keys::leader::LeaderNode) -> Self {
49        use crate::keys::action_shortcuts::ActionShortcuts;
50        use crate::keys::leader::{LeaderAction, LeaderNode};
51
52        let mut by_category: BTreeMap<ShortcutCategory, Vec<(String, String)>> = BTreeMap::new();
53
54        let map = key_bindings.to_hashmap();
55        let mut entries: Vec<_> = map.into_iter().collect();
56        entries.sort_by_key(|(action, _)| action.to_string());
57
58        for (action, mut combos) in entries {
59            combos.sort();
60            let keys = combos
61                .iter()
62                .map(|c| c.to_string())
63                .collect::<Vec<_>>()
64                .join(" / ");
65            let label = action.label();
66            by_category
67                .entry(action.category())
68                .or_default()
69                .push((keys, label));
70        }
71
72        // Formatting carries no default chord — it lives on the leader's
73        // `+text` group, because `Ctrl+I` is Tab's byte outside the kitty
74        // keyboard protocol. This list is built from the binding map, so with
75        // nothing bound the whole section would vanish and formatting would
76        // read as removed rather than moved. Name the route, and read it off
77        // the tree the cheatsheet renders from, so a rebound or relabelled
78        // group is described as it is. Listed beside any chord the user
79        // bound: the leader is the route that always works.
80        //
81        // Both conditions matter. With the gateway unbound there is no
82        // `<leader> t` to reach, so there is nothing truthful to say — which
83        // is why the gateway is read as an Option rather than defaulted to a
84        // placeholder. And a tree with no group holding `TextBold` (an
85        // override removed it) has no route to name.
86        let text_group = tree.children().iter().find(|(_, node)| {
87            node.children().iter().any(|(_, leaf)| {
88                matches!(
89                    leaf,
90                    LeaderNode::Leaf {
91                        action: LeaderAction::TextBold,
92                        ..
93                    }
94                )
95            })
96        });
97        if let Some(leader) = key_bindings.first_combo_for(&ActionShortcuts::Leader)
98            && let Some((key, group)) = text_group
99        {
100            let leaves = group
101                .children()
102                .iter()
103                .map(|(_, leaf)| leaf.label())
104                .collect::<Vec<_>>()
105                .join(" / ");
106            by_category
107                .entry(ShortcutCategory::TextEditing)
108                .or_default()
109                .push((
110                    format!("{leader} {key}"),
111                    format!("{leaves} ({})", group.label()),
112                ));
113        }
114
115        let mut rows: Vec<HelpRow> = Vec::new();
116        for (category, bindings) in by_category {
117            if bindings.is_empty() {
118                continue;
119            }
120            rows.push(HelpRow::Blank);
121            rows.push(HelpRow::Header(category.to_string()));
122            rows.push(HelpRow::Separator);
123            for (keys, label) in bindings {
124                rows.push(HelpRow::Binding { keys, label });
125            }
126        }
127        rows.push(HelpRow::Blank);
128
129        Self {
130            rows,
131            title: " Keyboard Shortcuts ",
132            scroll: 0,
133            last_body_height: 20,
134            shell: ModalShell::default(),
135            footer: footer_hints(),
136        }
137    }
138
139    /// The full leader-tree cheatsheet (leader `?`): every sequence in the
140    /// tree as `gateway keys → description`, grouped per top-level group,
141    /// followed by the flat Tier-0 bindings. Built from the same
142    /// `leader_tree()` the engine and the which-key overlay walk — one
143    /// source, three surfaces.
144    pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
145        use crate::keys::action_shortcuts::ActionShortcuts;
146        use crate::keys::leader::LeaderNode;
147
148        let key_bindings = &settings.key_bindings;
149        let gateway = key_bindings
150            .first_combo_for(&ActionShortcuts::Leader)
151            .unwrap_or_else(|| "leader".to_string());
152
153        fn walk(node: &LeaderNode, prefix: &str, rows: &mut Vec<HelpRow>) {
154            for (key, child) in node.children() {
155                let keys = format!("{prefix} {key}");
156                match child {
157                    LeaderNode::Leaf { label, .. } => rows.push(HelpRow::Binding {
158                        keys,
159                        label: (*label).to_string(),
160                    }),
161                    LeaderNode::Group { .. } => walk(child, &keys, rows),
162                }
163            }
164        }
165
166        let tree = settings.leader_tree();
167        let mut rows: Vec<HelpRow> = Vec::new();
168        // Current configuration up top (surfaces the theme and keys).
169        rows.push(HelpRow::Header("Configuration".to_string()));
170        rows.push(HelpRow::Separator);
171        rows.push(HelpRow::Binding {
172            keys: settings.get_theme().name,
173            label: "active theme (leader v c to switch)".to_string(),
174        });
175        rows.push(HelpRow::Binding {
176            keys: gateway.clone(),
177            label: "leader gateway".to_string(),
178        });
179        rows.push(HelpRow::Binding {
180            keys: format!("{} ms", settings.leader_timeout_ms),
181            label: "which-key reveal timeout".to_string(),
182        });
183        rows.push(HelpRow::Binding {
184            keys: "F1 in Find".to_string(),
185            label: "search query syntax".to_string(),
186        });
187        for row in tree.display_children() {
188            if row.is_group {
189                // Group: find the child node by its single key and walk it.
190                let key = row.keys.chars().next().expect("group keys are one char");
191                let child = tree.child(key).expect("display row names a real child");
192                rows.push(HelpRow::Blank);
193                rows.push(HelpRow::Header(format!("{gateway} {key}  {}", row.label)));
194                rows.push(HelpRow::Separator);
195                walk(child, &format!("{gateway} {key}"), &mut rows);
196            } else {
197                rows.push(HelpRow::Blank);
198                rows.push(HelpRow::Binding {
199                    keys: format!("{gateway} {}", row.keys),
200                    label: row.label,
201                });
202            }
203        }
204
205        // Tier-0: the flat always-on bindings, from the same help builder.
206        let flat = Self::new(key_bindings, &tree);
207        rows.push(HelpRow::Blank);
208        rows.push(HelpRow::Header("Always-on shortcuts".to_string()));
209        rows.push(HelpRow::Separator);
210        rows.extend(
211            flat.rows
212                .into_iter()
213                .filter(|r| matches!(r, HelpRow::Binding { .. })),
214        );
215        rows.push(HelpRow::Blank);
216
217        Self {
218            rows,
219            title: " Cheatsheet — leader keys ",
220            scroll: 0,
221            last_body_height: 20,
222            shell: ModalShell::default(),
223            footer: footer_hints(),
224        }
225    }
226
227    /// Reference card for the search query language (F1 over the Find drawer
228    /// view). Operators, modifiers, and a few worked examples — mirrors the
229    /// canonical table in `docs/.../search.md`, condensed to the
230    /// keys|label shape the help widget already renders. The full prose guide
231    /// lives on the docs site, not here.
232    pub fn query_syntax() -> Self {
233        // (short / long, meaning) — sourced from search.md's operator table.
234        const OPERATORS: &[(&str, &str)] = &[
235            ("(type text)", "full-text body search"),
236            ("= / name:", "by note name"),
237            ("@ / in:", "by section heading"),
238            ("/ / pt:", "by path / folder"),
239            ("# / lb:", "by label (tag)"),
240            ("< / lk:", "links TO it (backlinks)"),
241            ("> / fwd:", "it links to (forward)"),
242            ("% / prop:", "by property (%due, %k=v)"),
243            ("^ / or:", "sort results (-^ = desc)"),
244        ];
245        const MODIFIERS: &[(&str, &str)] = &[
246            ("- prefix", "exclude (e.g. -#draft)"),
247            ("*", "wildcard prefix (screen*)"),
248            ("\" \"", "quote values with spaces"),
249        ];
250        const EXAMPLES: &[(&str, &str)] = &[
251            ("#finance report", "labelled finance + text"),
252            ("@work -cancelled", "Work section, not cancelled"),
253            ("<kimun #project", "kimun backlinks + label"),
254            ("%status=done ^%due", "done, sorted by due"),
255        ];
256
257        fn section(rows: &mut Vec<HelpRow>, header: &str, entries: &[(&str, &str)]) {
258            rows.push(HelpRow::Header(header.to_string()));
259            rows.push(HelpRow::Separator);
260            for (keys, label) in entries {
261                rows.push(HelpRow::Binding {
262                    keys: (*keys).to_string(),
263                    label: (*label).to_string(),
264                });
265            }
266            rows.push(HelpRow::Blank);
267        }
268
269        let mut rows: Vec<HelpRow> = Vec::new();
270        section(&mut rows, "Operators", OPERATORS);
271        section(&mut rows, "Modifiers", MODIFIERS);
272        section(&mut rows, "Examples", EXAMPLES);
273
274        Self {
275            rows,
276            title: " Search Query Syntax ",
277            scroll: 0,
278            last_body_height: 20,
279            shell: ModalShell::default(),
280            footer: footer_hints(),
281        }
282    }
283
284    fn scroll_up(&mut self) {
285        self.scroll = self.scroll.saturating_sub(1);
286    }
287
288    fn scroll_down(&mut self) {
289        // Clamped to rows.len() so render's slice is always valid even if called
290        // between renders.
291        self.scroll = self
292            .scroll
293            .saturating_add(1)
294            .min(self.rows.len().saturating_sub(1));
295    }
296
297    fn page_up(&mut self) {
298        let page = (self.last_body_height as usize).max(1);
299        self.scroll = self.scroll.saturating_sub(page);
300    }
301
302    fn page_down(&mut self) {
303        let page = (self.last_body_height as usize).max(1);
304        self.scroll = self
305            .scroll
306            .saturating_add(page)
307            .min(self.rows.len().saturating_sub(1));
308    }
309
310    #[cfg(test)]
311    pub(crate) fn scroll(&self) -> usize {
312        self.scroll
313    }
314
315    /// The wheel scrolls, `[Esc] Close` and a press outside the popup close
316    /// it. Modal: every mouse event is consumed.
317    pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
318        if let Some(key) = self.shell.pointer_key(m, &[&self.footer]) {
319            return self.handle_key(key, tx);
320        }
321        match m.kind {
322            MouseEventKind::ScrollUp => (0..WHEEL_ROWS).for_each(|_| self.scroll_up()),
323            MouseEventKind::ScrollDown => (0..WHEEL_ROWS).for_each(|_| self.scroll_down()),
324            _ => {}
325        }
326        EventState::Consumed
327    }
328
329    /// Key handler — mirrors the `handle_key` pattern used by all other dialog types.
330    pub fn handle_key(
331        &mut self,
332        key: ratatui::crossterm::event::KeyEvent,
333        tx: &AppTx,
334    ) -> EventState {
335        match key.code {
336            KeyCode::Esc => {
337                tx.send(AppEvent::CloseOverlay).ok();
338            }
339            KeyCode::Up => self.scroll_up(),
340            KeyCode::Down => self.scroll_down(),
341            KeyCode::PageUp => self.page_up(),
342            KeyCode::PageDown => self.page_down(),
343            _ => {}
344        }
345        EventState::Consumed
346    }
347}
348
349const OUTER_WIDTH: u16 = 50;
350/// Rows one wheel notch scrolls.
351const WHEEL_ROWS: usize = 3;
352
353/// The footer: an informational scroll chip, then `[Esc] Close`.
354fn footer_hints() -> HintRow {
355    HintRow::new(&[
356        (KeyCode::Null, "↑↓ PgUp/PgDn", "Scroll"),
357        (KeyCode::Esc, "Esc", "Close"),
358    ])
359    .with_gap(3)
360}
361const KEYS_COL_WIDTH: u16 = 18;
362
363impl Component for HelpDialog {
364    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
365        let InputEvent::Key(key) = event else {
366            return EventState::NotConsumed;
367        };
368        self.handle_key(*key, tx)
369    }
370
371    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
372        let content_rows = self.rows.len() as u16;
373        let desired_height = content_rows + 4; // borders(2) + footer(1) + bottom blank(1)
374        let max_height = (rect.height * 60 / 100).max(10);
375        let outer_height = desired_height.min(max_height);
376
377        let popup_area = super::fixed_centered_rect(OUTER_WIDTH, outer_height, rect);
378        self.shell.set(popup_area);
379        let inner = modal_chrome(
380            f,
381            popup_area,
382            theme,
383            ModalSpec {
384                title: Some(self.title),
385                border: Some(Style::default().fg(theme.fg.to_ratatui())),
386                ..Default::default()
387            },
388        );
389
390        if inner.height < 2 {
391            return;
392        }
393
394        let chunks = Layout::default()
395            .direction(Direction::Vertical)
396            .constraints([Constraint::Min(1), Constraint::Length(1)])
397            .split(inner);
398
399        let body_area = chunks[0];
400        let footer_area = chunks[1];
401
402        let bg = theme.bg_panel.to_ratatui();
403        let fg = theme.fg.to_ratatui();
404        let gray = theme.gray.to_ratatui();
405        let fg_accent = theme.selection_fg.to_ratatui();
406
407        // Cache for PageUp/PageDown.
408        self.last_body_height = body_area.height;
409
410        // Clamp scroll.
411        let body_height = body_area.height as usize;
412        let max_scroll = self.rows.len().saturating_sub(body_height);
413        self.scroll = self.scroll.min(max_scroll);
414
415        // Render visible rows.
416        let visible = &self.rows[self.scroll..];
417        for (y, row) in (body_area.y..).zip(visible.iter()) {
418            if y >= body_area.y + body_area.height {
419                break;
420            }
421            let row_rect = Rect {
422                x: body_area.x,
423                y,
424                width: body_area.width,
425                height: 1,
426            };
427            match row {
428                HelpRow::Blank => {}
429                HelpRow::Header(title) => {
430                    f.render_widget(
431                        Paragraph::new(format!("  {title}")).style(
432                            Style::default()
433                                .fg(fg_accent)
434                                .bg(bg)
435                                .add_modifier(Modifier::BOLD),
436                        ),
437                        row_rect,
438                    );
439                }
440                HelpRow::Separator => {
441                    super::render_separator(f, row_rect, gray, bg);
442                }
443                HelpRow::Binding { keys, label } => {
444                    let cols = Layout::default()
445                        .direction(Direction::Horizontal)
446                        .constraints([
447                            Constraint::Length(2),
448                            Constraint::Length(KEYS_COL_WIDTH),
449                            Constraint::Min(1),
450                        ])
451                        .split(row_rect);
452                    f.render_widget(
453                        Paragraph::new(keys.as_str()).style(Style::default().fg(fg_accent).bg(bg)),
454                        cols[1],
455                    );
456                    f.render_widget(
457                        Paragraph::new(label.as_str()).style(Style::default().fg(fg).bg(bg)),
458                        cols[2],
459                    );
460                }
461            }
462        }
463
464        let hint_style = Style::default().fg(gray).bg(bg);
465        self.footer.render(f, footer_area, hint_style, theme);
466    }
467}
468
469impl_dialog!(HelpDialog);
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::keys::KeyBindings;
475    use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
476    use crate::keys::key_strike::KeyStrike;
477
478    fn bindings_with_bold_and_quit() -> KeyBindings {
479        let mut kb = KeyBindings::empty();
480        kb.batch_add()
481            .with_ctrl()
482            .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
483            .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
484        kb
485    }
486
487    /// Formatting moved to the leader, so F1 has no chord to list for it.
488    /// It must still say where formatting went — a missing section reads as a
489    /// removed feature.
490    #[test]
491    fn f1_names_the_leader_route_when_no_formatting_chord_is_bound() {
492        let dialog = HelpDialog::new(
493            &crate::settings::AppSettings::default().key_bindings,
494            &crate::keys::leader::leader_tree(),
495        );
496        let row = dialog.rows.iter().find_map(|r| match r {
497            HelpRow::Binding { keys, label } if label.contains("bold") => {
498                Some((keys.clone(), label.clone()))
499            }
500            _ => None,
501        });
502        let (keys, label) = row.expect("F1 must point at the formatting route");
503        assert!(keys.ends_with(" t"), "should name the +text group: {keys}");
504        assert!(
505            keys.starts_with("ctrl&B"),
506            "prefixed by the gateway: {keys}"
507        );
508        assert_eq!(label, "bold / italic / strikethrough (+text)");
509    }
510
511    /// The leader route is the primary way to format, so it is listed even
512    /// beside a chord the user bound — a lone `TextEditor-Underline` chord is
513    /// a no-op, and hiding the three leaves that work behind it was the bug.
514    #[test]
515    fn f1_lists_the_leader_route_beside_a_real_formatting_chord() {
516        let mut kb = bindings_with_bold_and_quit();
517        kb.batch_add()
518            .with_ctrl()
519            .add(KeyStrike::KeyG, ActionShortcuts::Leader);
520        let dialog = HelpDialog::new(&kb, &crate::keys::leader::leader_tree());
521        let labels: Vec<&str> = dialog
522            .rows
523            .iter()
524            .filter_map(|r| match r {
525                HelpRow::Binding { label, .. } => Some(label.as_str()),
526                _ => None,
527            })
528            .collect();
529        assert!(labels.contains(&"Bold"), "{labels:?}");
530        assert!(
531            labels.iter().any(|l| l.contains("+text")),
532            "the leader route must stand beside the chord: {labels:?}"
533        );
534    }
535
536    /// The row is read off the tree, so a rebound or relabelled `+text`
537    /// group shows up as it really is rather than as the shipped default.
538    #[test]
539    fn f1_formatting_hint_follows_the_tree() {
540        use crate::keys::leader::{LeaderAction, LeaderNode};
541        let tree = LeaderNode::Group {
542            label: "leader".into(),
543            children: vec![(
544                'x',
545                LeaderNode::Group {
546                    label: "+fmt".into(),
547                    children: vec![
548                        (
549                            'b',
550                            LeaderNode::Leaf {
551                                label: "bold",
552                                action: LeaderAction::TextBold,
553                            },
554                        ),
555                        (
556                            'i',
557                            LeaderNode::Leaf {
558                                label: "italic",
559                                action: LeaderAction::TextItalic,
560                            },
561                        ),
562                    ],
563                },
564            )],
565        };
566        let dialog = HelpDialog::new(&crate::settings::AppSettings::default().key_bindings, &tree);
567        let row = dialog.rows.iter().find_map(|r| match r {
568            HelpRow::Binding { keys, label } if label.contains("+fmt") => {
569                Some((keys.clone(), label.clone()))
570            }
571            _ => None,
572        });
573        let (keys, label) = row.expect("the hint must be built from the tree");
574        assert_eq!(keys, "ctrl&B x");
575        assert_eq!(label, "bold / italic (+fmt)");
576    }
577
578    #[test]
579    fn rows_contain_both_categories() {
580        let dialog = HelpDialog::new(
581            &bindings_with_bold_and_quit(),
582            &crate::keys::leader::leader_tree(),
583        );
584        let headers: Vec<String> = dialog
585            .rows
586            .iter()
587            .filter_map(|r| {
588                if let HelpRow::Header(s) = r {
589                    Some(s.clone())
590                } else {
591                    None
592                }
593            })
594            .collect();
595        assert!(headers.contains(&"Text Editing".to_string()));
596        assert!(headers.contains(&"Other".to_string()));
597        assert!(!headers.contains(&"Navigation".to_string()));
598        assert!(!headers.contains(&"Notes".to_string()));
599    }
600
601    #[test]
602    fn binding_row_has_correct_keys_and_label() {
603        let dialog = HelpDialog::new(
604            &bindings_with_bold_and_quit(),
605            &crate::keys::leader::leader_tree(),
606        );
607        let binding = dialog.rows.iter().find_map(|r| {
608            if let HelpRow::Binding { keys, label } = r
609                && label == "Bold"
610            {
611                return Some(keys.clone());
612            }
613            None
614        });
615        assert!(binding.is_some(), "expected a Bold binding row");
616        assert_eq!(binding.unwrap(), "ctrl&B");
617    }
618
619    #[test]
620    fn empty_keybindings_produces_no_rows() {
621        let dialog = HelpDialog::new(&KeyBindings::empty(), &crate::keys::leader::leader_tree());
622        assert!(
623            !dialog
624                .rows
625                .iter()
626                .any(|r| matches!(r, HelpRow::Binding { .. }))
627        );
628        assert!(!dialog.rows.iter().any(|r| matches!(r, HelpRow::Header(_))));
629    }
630}