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) -> Self {
44        let mut by_category: BTreeMap<ShortcutCategory, Vec<(String, String)>> = BTreeMap::new();
45
46        let map = key_bindings.to_hashmap();
47        let mut entries: Vec<_> = map.into_iter().collect();
48        entries.sort_by_key(|(action, _)| action.to_string());
49
50        for (action, mut combos) in entries {
51            combos.sort();
52            let keys = combos
53                .iter()
54                .map(|c| c.to_string())
55                .collect::<Vec<_>>()
56                .join(" / ");
57            let label = action.label();
58            by_category
59                .entry(action.category())
60                .or_default()
61                .push((keys, label));
62        }
63
64        let mut rows: Vec<HelpRow> = Vec::new();
65        for (category, bindings) in by_category {
66            if bindings.is_empty() {
67                continue;
68            }
69            rows.push(HelpRow::Blank);
70            rows.push(HelpRow::Header(category.to_string()));
71            rows.push(HelpRow::Separator);
72            for (keys, label) in bindings {
73                rows.push(HelpRow::Binding { keys, label });
74            }
75        }
76        rows.push(HelpRow::Blank);
77
78        Self {
79            rows,
80            title: " Keyboard Shortcuts ",
81            scroll: 0,
82            last_body_height: 20,
83        }
84    }
85
86    /// The full leader-tree cheatsheet (leader `?`): every sequence in the
87    /// tree as `gateway keys → description`, grouped per top-level group,
88    /// followed by the flat Tier-0 bindings. Built from the same
89    /// `leader_tree()` the engine and the which-key overlay walk — one
90    /// source, three surfaces.
91    pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
92        use crate::keys::action_shortcuts::ActionShortcuts;
93        use crate::keys::leader::LeaderNode;
94
95        let key_bindings = &settings.key_bindings;
96        let gateway = key_bindings
97            .first_combo_for(&ActionShortcuts::Leader)
98            .unwrap_or_else(|| "leader".to_string());
99
100        fn walk(node: &LeaderNode, prefix: &str, rows: &mut Vec<HelpRow>) {
101            for (key, child) in node.children() {
102                let keys = format!("{prefix} {key}");
103                match child {
104                    LeaderNode::Leaf { label, .. } => rows.push(HelpRow::Binding {
105                        keys,
106                        label: (*label).to_string(),
107                    }),
108                    LeaderNode::Group { .. } => walk(child, &keys, rows),
109                }
110            }
111        }
112
113        let tree = settings.leader_tree();
114        let mut rows: Vec<HelpRow> = Vec::new();
115        // Current configuration up top (spec phase-10: surface theme + keys).
116        rows.push(HelpRow::Header("Configuration".to_string()));
117        rows.push(HelpRow::Separator);
118        rows.push(HelpRow::Binding {
119            keys: settings.get_theme().name,
120            label: "active theme (leader v c to switch)".to_string(),
121        });
122        rows.push(HelpRow::Binding {
123            keys: gateway.clone(),
124            label: "leader gateway".to_string(),
125        });
126        rows.push(HelpRow::Binding {
127            keys: format!("{} ms", settings.leader_timeout_ms),
128            label: "which-key reveal timeout".to_string(),
129        });
130        rows.push(HelpRow::Binding {
131            keys: "F1 in Find".to_string(),
132            label: "search query syntax".to_string(),
133        });
134        for (key, child) in tree.children() {
135            match child {
136                LeaderNode::Group { label, .. } => {
137                    rows.push(HelpRow::Blank);
138                    rows.push(HelpRow::Header(format!("{gateway} {key}  {label}")));
139                    rows.push(HelpRow::Separator);
140                    walk(child, &format!("{gateway} {key}"), &mut rows);
141                }
142                LeaderNode::Leaf { label, .. } => {
143                    rows.push(HelpRow::Blank);
144                    rows.push(HelpRow::Binding {
145                        keys: format!("{gateway} {key}"),
146                        label: (*label).to_string(),
147                    });
148                }
149            }
150        }
151
152        // Tier-0: the flat always-on bindings, from the same help builder.
153        let flat = Self::new(key_bindings);
154        rows.push(HelpRow::Blank);
155        rows.push(HelpRow::Header("Always-on shortcuts".to_string()));
156        rows.push(HelpRow::Separator);
157        rows.extend(
158            flat.rows
159                .into_iter()
160                .filter(|r| matches!(r, HelpRow::Binding { .. })),
161        );
162        rows.push(HelpRow::Blank);
163
164        Self {
165            rows,
166            title: " Cheatsheet — leader keys ",
167            scroll: 0,
168            last_body_height: 20,
169        }
170    }
171
172    /// Reference card for the search query language (F1 over the Find drawer
173    /// view). Operators, modifiers, and a few worked examples — mirrors the
174    /// canonical table in `docs/.../search.md` and ADR-0005, condensed to the
175    /// keys|label shape the help widget already renders. The full prose guide
176    /// lives on the docs site, not here.
177    pub fn query_syntax() -> Self {
178        // (short / long, meaning) — sourced from search.md's operator table.
179        const OPERATORS: &[(&str, &str)] = &[
180            ("(type text)", "full-text body search"),
181            ("= / name:", "by note name"),
182            ("@ / in:", "by section heading"),
183            ("/ / pt:", "by path / folder"),
184            ("# / lb:", "by label (tag)"),
185            ("< / lk:", "links TO it (backlinks)"),
186            ("> / fwd:", "it links to (forward)"),
187            ("^ / or:", "sort results (-^ = desc)"),
188        ];
189        const MODIFIERS: &[(&str, &str)] = &[
190            ("- prefix", "exclude (e.g. -#draft)"),
191            ("*", "wildcard prefix (screen*)"),
192            ("\" \"", "quote values with spaces"),
193        ];
194        const EXAMPLES: &[(&str, &str)] = &[
195            ("#finance report", "labelled finance + text"),
196            ("@work -cancelled", "Work section, not cancelled"),
197            ("<kimun #project", "kimun backlinks + label"),
198        ];
199
200        fn section(rows: &mut Vec<HelpRow>, header: &str, entries: &[(&str, &str)]) {
201            rows.push(HelpRow::Header(header.to_string()));
202            rows.push(HelpRow::Separator);
203            for (keys, label) in entries {
204                rows.push(HelpRow::Binding {
205                    keys: (*keys).to_string(),
206                    label: (*label).to_string(),
207                });
208            }
209            rows.push(HelpRow::Blank);
210        }
211
212        let mut rows: Vec<HelpRow> = Vec::new();
213        section(&mut rows, "Operators", OPERATORS);
214        section(&mut rows, "Modifiers", MODIFIERS);
215        section(&mut rows, "Examples", EXAMPLES);
216
217        Self {
218            rows,
219            title: " Search Query Syntax ",
220            scroll: 0,
221            last_body_height: 20,
222        }
223    }
224
225    fn scroll_up(&mut self) {
226        self.scroll = self.scroll.saturating_sub(1);
227    }
228
229    fn scroll_down(&mut self) {
230        // Clamped to rows.len() so render's slice is always valid even if called
231        // between renders.
232        self.scroll = self
233            .scroll
234            .saturating_add(1)
235            .min(self.rows.len().saturating_sub(1));
236    }
237
238    fn page_up(&mut self) {
239        let page = (self.last_body_height as usize).max(1);
240        self.scroll = self.scroll.saturating_sub(page);
241    }
242
243    fn page_down(&mut self) {
244        let page = (self.last_body_height as usize).max(1);
245        self.scroll = self
246            .scroll
247            .saturating_add(page)
248            .min(self.rows.len().saturating_sub(1));
249    }
250
251    /// Key handler — mirrors the `handle_key` pattern used by all other dialog types.
252    pub fn handle_key(
253        &mut self,
254        key: ratatui::crossterm::event::KeyEvent,
255        tx: &AppTx,
256    ) -> EventState {
257        match key.code {
258            KeyCode::Esc => {
259                tx.send(AppEvent::CloseOverlay).ok();
260            }
261            KeyCode::Up => self.scroll_up(),
262            KeyCode::Down => self.scroll_down(),
263            KeyCode::PageUp => self.page_up(),
264            KeyCode::PageDown => self.page_down(),
265            _ => {}
266        }
267        EventState::Consumed
268    }
269}
270
271const OUTER_WIDTH: u16 = 50;
272const KEYS_COL_WIDTH: u16 = 18;
273
274impl Component for HelpDialog {
275    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
276        let InputEvent::Key(key) = event else {
277            return EventState::NotConsumed;
278        };
279        self.handle_key(*key, tx)
280    }
281
282    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
283        let content_rows = self.rows.len() as u16;
284        let desired_height = content_rows + 4; // borders(2) + footer(1) + bottom blank(1)
285        let max_height = (rect.height * 60 / 100).max(10);
286        let outer_height = desired_height.min(max_height);
287
288        let popup_area = super::fixed_centered_rect(OUTER_WIDTH, outer_height, rect);
289        let inner = modal_chrome(
290            f,
291            popup_area,
292            theme,
293            ModalSpec {
294                title: Some(self.title),
295                border: Some(Style::default().fg(theme.fg.to_ratatui())),
296                ..Default::default()
297            },
298        );
299
300        if inner.height < 2 {
301            return;
302        }
303
304        let chunks = Layout::default()
305            .direction(Direction::Vertical)
306            .constraints([Constraint::Min(1), Constraint::Length(1)])
307            .split(inner);
308
309        let body_area = chunks[0];
310        let footer_area = chunks[1];
311
312        let bg = theme.bg_panel.to_ratatui();
313        let fg = theme.fg.to_ratatui();
314        let gray = theme.gray.to_ratatui();
315        let fg_accent = theme.selection_fg.to_ratatui();
316
317        // Cache for PageUp/PageDown.
318        self.last_body_height = body_area.height;
319
320        // Clamp scroll.
321        let body_height = body_area.height as usize;
322        let max_scroll = self.rows.len().saturating_sub(body_height);
323        self.scroll = self.scroll.min(max_scroll);
324
325        // Render visible rows.
326        let visible = &self.rows[self.scroll..];
327        for (y, row) in (body_area.y..).zip(visible.iter()) {
328            if y >= body_area.y + body_area.height {
329                break;
330            }
331            let row_rect = Rect {
332                x: body_area.x,
333                y,
334                width: body_area.width,
335                height: 1,
336            };
337            match row {
338                HelpRow::Blank => {}
339                HelpRow::Header(title) => {
340                    f.render_widget(
341                        Paragraph::new(format!("  {title}")).style(
342                            Style::default()
343                                .fg(fg_accent)
344                                .bg(bg)
345                                .add_modifier(Modifier::BOLD),
346                        ),
347                        row_rect,
348                    );
349                }
350                HelpRow::Separator => {
351                    super::render_separator(f, row_rect, gray, bg);
352                }
353                HelpRow::Binding { keys, label } => {
354                    let cols = Layout::default()
355                        .direction(Direction::Horizontal)
356                        .constraints([
357                            Constraint::Length(2),
358                            Constraint::Length(KEYS_COL_WIDTH),
359                            Constraint::Min(1),
360                        ])
361                        .split(row_rect);
362                    f.render_widget(
363                        Paragraph::new(keys.as_str()).style(Style::default().fg(fg_accent).bg(bg)),
364                        cols[1],
365                    );
366                    f.render_widget(
367                        Paragraph::new(label.as_str()).style(Style::default().fg(fg).bg(bg)),
368                        cols[2],
369                    );
370                }
371            }
372        }
373
374        f.render_widget(
375            Paragraph::new("  [↑↓ PgUp/PgDn] Scroll   [Esc] Close")
376                .style(Style::default().fg(gray).bg(bg)),
377            footer_area,
378        );
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::keys::KeyBindings;
386    use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
387    use crate::keys::key_strike::KeyStrike;
388
389    fn bindings_with_bold_and_quit() -> KeyBindings {
390        let mut kb = KeyBindings::empty();
391        kb.batch_add()
392            .with_ctrl()
393            .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
394            .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
395        kb
396    }
397
398    #[test]
399    fn rows_contain_both_categories() {
400        let dialog = HelpDialog::new(&bindings_with_bold_and_quit());
401        let headers: Vec<String> = dialog
402            .rows
403            .iter()
404            .filter_map(|r| {
405                if let HelpRow::Header(s) = r {
406                    Some(s.clone())
407                } else {
408                    None
409                }
410            })
411            .collect();
412        assert!(headers.contains(&"Text Editing".to_string()));
413        assert!(headers.contains(&"Other".to_string()));
414        assert!(!headers.contains(&"Navigation".to_string()));
415        assert!(!headers.contains(&"Notes".to_string()));
416    }
417
418    #[test]
419    fn binding_row_has_correct_keys_and_label() {
420        let dialog = HelpDialog::new(&bindings_with_bold_and_quit());
421        let binding = dialog.rows.iter().find_map(|r| {
422            if let HelpRow::Binding { keys, label } = r
423                && label == "Bold"
424            {
425                return Some(keys.clone());
426            }
427            None
428        });
429        assert!(binding.is_some(), "expected a Bold binding row");
430        assert_eq!(binding.unwrap(), "ctrl&B");
431    }
432
433    #[test]
434    fn empty_keybindings_produces_no_rows() {
435        let dialog = HelpDialog::new(&KeyBindings::empty());
436        assert!(
437            !dialog
438                .rows
439                .iter()
440                .any(|r| matches!(r, HelpRow::Binding { .. }))
441        );
442        assert!(!dialog.rows.iter().any(|r| matches!(r, HelpRow::Header(_))));
443    }
444}