Skip to main content

kimun_notes/components/dialogs/
help_dialog.rs

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