Skip to main content

kimun_notes/components/dialogs/
help_dialog.rs

1use std::collections::BTreeMap;
2
3use ratatui::Frame;
4use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
5use ratatui::layout::{Constraint, Direction, Layout, Rect};
6use ratatui::style::{Modifier, Style};
7use ratatui::widgets::Paragraph;
8
9use crate::components::Component;
10use crate::components::clickable::is_press_outside;
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    /// Outer popup rect from the last render; a press outside closes.
43    popup_rect: Rect,
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            popup_rect: Rect::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            popup_rect: Rect::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            popup_rect: Rect::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 is_press_outside(m, self.popup_rect) {
319            return self.handle_key(KeyEvent::from(KeyCode::Esc), tx);
320        }
321        if let Some(key) = self.footer.hit(m) {
322            return self.handle_key(key, tx);
323        }
324        match m.kind {
325            MouseEventKind::ScrollUp => (0..WHEEL_ROWS).for_each(|_| self.scroll_up()),
326            MouseEventKind::ScrollDown => (0..WHEEL_ROWS).for_each(|_| self.scroll_down()),
327            _ => {}
328        }
329        EventState::Consumed
330    }
331
332    /// Key handler — mirrors the `handle_key` pattern used by all other dialog types.
333    pub fn handle_key(
334        &mut self,
335        key: ratatui::crossterm::event::KeyEvent,
336        tx: &AppTx,
337    ) -> EventState {
338        match key.code {
339            KeyCode::Esc => {
340                tx.send(AppEvent::CloseOverlay).ok();
341            }
342            KeyCode::Up => self.scroll_up(),
343            KeyCode::Down => self.scroll_down(),
344            KeyCode::PageUp => self.page_up(),
345            KeyCode::PageDown => self.page_down(),
346            _ => {}
347        }
348        EventState::Consumed
349    }
350}
351
352const OUTER_WIDTH: u16 = 50;
353/// Rows one wheel notch scrolls.
354const WHEEL_ROWS: usize = 3;
355
356/// The footer: an informational scroll chip, then `[Esc] Close`.
357fn footer_hints() -> HintRow {
358    HintRow::new(&[
359        (KeyCode::Null, "↑↓ PgUp/PgDn", "Scroll"),
360        (KeyCode::Esc, "Esc", "Close"),
361    ])
362    .with_gap(3)
363}
364const KEYS_COL_WIDTH: u16 = 18;
365
366impl Component for HelpDialog {
367    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
368        let InputEvent::Key(key) = event else {
369            return EventState::NotConsumed;
370        };
371        self.handle_key(*key, tx)
372    }
373
374    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
375        let content_rows = self.rows.len() as u16;
376        let desired_height = content_rows + 4; // borders(2) + footer(1) + bottom blank(1)
377        let max_height = (rect.height * 60 / 100).max(10);
378        let outer_height = desired_height.min(max_height);
379
380        let popup_area = super::fixed_centered_rect(OUTER_WIDTH, outer_height, rect);
381        self.popup_rect = popup_area;
382        let inner = modal_chrome(
383            f,
384            popup_area,
385            theme,
386            ModalSpec {
387                title: Some(self.title),
388                border: Some(Style::default().fg(theme.fg.to_ratatui())),
389                ..Default::default()
390            },
391        );
392
393        if inner.height < 2 {
394            return;
395        }
396
397        let chunks = Layout::default()
398            .direction(Direction::Vertical)
399            .constraints([Constraint::Min(1), Constraint::Length(1)])
400            .split(inner);
401
402        let body_area = chunks[0];
403        let footer_area = chunks[1];
404
405        let bg = theme.bg_panel.to_ratatui();
406        let fg = theme.fg.to_ratatui();
407        let gray = theme.gray.to_ratatui();
408        let fg_accent = theme.selection_fg.to_ratatui();
409
410        // Cache for PageUp/PageDown.
411        self.last_body_height = body_area.height;
412
413        // Clamp scroll.
414        let body_height = body_area.height as usize;
415        let max_scroll = self.rows.len().saturating_sub(body_height);
416        self.scroll = self.scroll.min(max_scroll);
417
418        // Render visible rows.
419        let visible = &self.rows[self.scroll..];
420        for (y, row) in (body_area.y..).zip(visible.iter()) {
421            if y >= body_area.y + body_area.height {
422                break;
423            }
424            let row_rect = Rect {
425                x: body_area.x,
426                y,
427                width: body_area.width,
428                height: 1,
429            };
430            match row {
431                HelpRow::Blank => {}
432                HelpRow::Header(title) => {
433                    f.render_widget(
434                        Paragraph::new(format!("  {title}")).style(
435                            Style::default()
436                                .fg(fg_accent)
437                                .bg(bg)
438                                .add_modifier(Modifier::BOLD),
439                        ),
440                        row_rect,
441                    );
442                }
443                HelpRow::Separator => {
444                    super::render_separator(f, row_rect, gray, bg);
445                }
446                HelpRow::Binding { keys, label } => {
447                    let cols = Layout::default()
448                        .direction(Direction::Horizontal)
449                        .constraints([
450                            Constraint::Length(2),
451                            Constraint::Length(KEYS_COL_WIDTH),
452                            Constraint::Min(1),
453                        ])
454                        .split(row_rect);
455                    f.render_widget(
456                        Paragraph::new(keys.as_str()).style(Style::default().fg(fg_accent).bg(bg)),
457                        cols[1],
458                    );
459                    f.render_widget(
460                        Paragraph::new(label.as_str()).style(Style::default().fg(fg).bg(bg)),
461                        cols[2],
462                    );
463                }
464            }
465        }
466
467        let hint_style = Style::default().fg(gray).bg(bg);
468        self.footer.render(f, footer_area, hint_style, theme);
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::keys::KeyBindings;
476    use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
477    use crate::keys::key_strike::KeyStrike;
478
479    fn bindings_with_bold_and_quit() -> KeyBindings {
480        let mut kb = KeyBindings::empty();
481        kb.batch_add()
482            .with_ctrl()
483            .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
484            .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
485        kb
486    }
487
488    /// Formatting moved to the leader, so F1 has no chord to list for it.
489    /// It must still say where formatting went — a missing section reads as a
490    /// removed feature.
491    #[test]
492    fn f1_names_the_leader_route_when_no_formatting_chord_is_bound() {
493        let dialog = HelpDialog::new(
494            &crate::settings::AppSettings::default().key_bindings,
495            &crate::keys::leader::leader_tree(),
496        );
497        let row = dialog.rows.iter().find_map(|r| match r {
498            HelpRow::Binding { keys, label } if label.contains("bold") => {
499                Some((keys.clone(), label.clone()))
500            }
501            _ => None,
502        });
503        let (keys, label) = row.expect("F1 must point at the formatting route");
504        assert!(keys.ends_with(" t"), "should name the +text group: {keys}");
505        assert!(
506            keys.starts_with("ctrl&B"),
507            "prefixed by the gateway: {keys}"
508        );
509        assert_eq!(label, "bold / italic / strikethrough (+text)");
510    }
511
512    /// The leader route is the primary way to format, so it is listed even
513    /// beside a chord the user bound — a lone `TextEditor-Underline` chord is
514    /// a no-op, and hiding the three leaves that work behind it was the bug.
515    #[test]
516    fn f1_lists_the_leader_route_beside_a_real_formatting_chord() {
517        let mut kb = bindings_with_bold_and_quit();
518        kb.batch_add()
519            .with_ctrl()
520            .add(KeyStrike::KeyG, ActionShortcuts::Leader);
521        let dialog = HelpDialog::new(&kb, &crate::keys::leader::leader_tree());
522        let labels: Vec<&str> = dialog
523            .rows
524            .iter()
525            .filter_map(|r| match r {
526                HelpRow::Binding { label, .. } => Some(label.as_str()),
527                _ => None,
528            })
529            .collect();
530        assert!(labels.contains(&"Bold"), "{labels:?}");
531        assert!(
532            labels.iter().any(|l| l.contains("+text")),
533            "the leader route must stand beside the chord: {labels:?}"
534        );
535    }
536
537    /// The row is read off the tree, so a rebound or relabelled `+text`
538    /// group shows up as it really is rather than as the shipped default.
539    #[test]
540    fn f1_formatting_hint_follows_the_tree() {
541        use crate::keys::leader::{LeaderAction, LeaderNode};
542        let tree = LeaderNode::Group {
543            label: "leader".into(),
544            children: vec![(
545                'x',
546                LeaderNode::Group {
547                    label: "+fmt".into(),
548                    children: vec![
549                        (
550                            'b',
551                            LeaderNode::Leaf {
552                                label: "bold",
553                                action: LeaderAction::TextBold,
554                            },
555                        ),
556                        (
557                            'i',
558                            LeaderNode::Leaf {
559                                label: "italic",
560                                action: LeaderAction::TextItalic,
561                            },
562                        ),
563                    ],
564                },
565            )],
566        };
567        let dialog = HelpDialog::new(&crate::settings::AppSettings::default().key_bindings, &tree);
568        let row = dialog.rows.iter().find_map(|r| match r {
569            HelpRow::Binding { keys, label } if label.contains("+fmt") => {
570                Some((keys.clone(), label.clone()))
571            }
572            _ => None,
573        });
574        let (keys, label) = row.expect("the hint must be built from the tree");
575        assert_eq!(keys, "ctrl&B x");
576        assert_eq!(label, "bold / italic (+fmt)");
577    }
578
579    #[test]
580    fn rows_contain_both_categories() {
581        let dialog = HelpDialog::new(
582            &bindings_with_bold_and_quit(),
583            &crate::keys::leader::leader_tree(),
584        );
585        let headers: Vec<String> = dialog
586            .rows
587            .iter()
588            .filter_map(|r| {
589                if let HelpRow::Header(s) = r {
590                    Some(s.clone())
591                } else {
592                    None
593                }
594            })
595            .collect();
596        assert!(headers.contains(&"Text Editing".to_string()));
597        assert!(headers.contains(&"Other".to_string()));
598        assert!(!headers.contains(&"Navigation".to_string()));
599        assert!(!headers.contains(&"Notes".to_string()));
600    }
601
602    #[test]
603    fn binding_row_has_correct_keys_and_label() {
604        let dialog = HelpDialog::new(
605            &bindings_with_bold_and_quit(),
606            &crate::keys::leader::leader_tree(),
607        );
608        let binding = dialog.rows.iter().find_map(|r| {
609            if let HelpRow::Binding { keys, label } = r
610                && label == "Bold"
611            {
612                return Some(keys.clone());
613            }
614            None
615        });
616        assert!(binding.is_some(), "expected a Bold binding row");
617        assert_eq!(binding.unwrap(), "ctrl&B");
618    }
619
620    #[test]
621    fn empty_keybindings_produces_no_rows() {
622        let dialog = HelpDialog::new(&KeyBindings::empty(), &crate::keys::leader::leader_tree());
623        assert!(
624            !dialog
625                .rows
626                .iter()
627                .any(|r| matches!(r, HelpRow::Binding { .. }))
628        );
629        assert!(!dialog.rows.iter().any(|r| matches!(r, HelpRow::Header(_))));
630    }
631}