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::{Block, Borders, Clear, Paragraph};
8
9use crate::components::Component;
10use crate::components::event_state::EventState;
11use crate::components::events::{AppEvent, AppTx, InputEvent};
12use crate::keys::KeyBindings;
13use crate::keys::action_shortcuts::ShortcutCategory;
14use crate::settings::themes::Theme;
15
16// ---------------------------------------------------------------------------
17// HelpRow
18// ---------------------------------------------------------------------------
19
20pub enum HelpRow {
21    Header(String),
22    Separator,
23    Binding { keys: String, label: String },
24    Blank,
25}
26
27// ---------------------------------------------------------------------------
28// HelpDialog
29// ---------------------------------------------------------------------------
30
31pub struct HelpDialog {
32    pub rows: Vec<HelpRow>,
33    scroll: usize,
34    /// Cached body height from last render, used for PageUp/PageDown page size.
35    last_body_height: u16,
36}
37
38impl HelpDialog {
39    pub fn new(key_bindings: &KeyBindings) -> Self {
40        let mut by_category: BTreeMap<ShortcutCategory, Vec<(String, String)>> = BTreeMap::new();
41
42        let map = key_bindings.to_hashmap();
43        let mut entries: Vec<_> = map.into_iter().collect();
44        entries.sort_by_key(|(action, _)| action.to_string());
45
46        for (action, mut combos) in entries {
47            combos.sort();
48            let keys = combos
49                .iter()
50                .map(|c| c.to_string())
51                .collect::<Vec<_>>()
52                .join(" / ");
53            let label = action.label();
54            by_category
55                .entry(action.category())
56                .or_default()
57                .push((keys, label));
58        }
59
60        let mut rows: Vec<HelpRow> = Vec::new();
61        for (category, bindings) in by_category {
62            if bindings.is_empty() {
63                continue;
64            }
65            rows.push(HelpRow::Blank);
66            rows.push(HelpRow::Header(category.to_string()));
67            rows.push(HelpRow::Separator);
68            for (keys, label) in bindings {
69                rows.push(HelpRow::Binding { keys, label });
70            }
71        }
72        rows.push(HelpRow::Blank);
73
74        Self {
75            rows,
76            scroll: 0,
77            last_body_height: 20,
78        }
79    }
80
81    fn scroll_up(&mut self) {
82        self.scroll = self.scroll.saturating_sub(1);
83    }
84
85    fn scroll_down(&mut self) {
86        // Clamped to rows.len() so render's slice is always valid even if called
87        // between renders.
88        self.scroll = self
89            .scroll
90            .saturating_add(1)
91            .min(self.rows.len().saturating_sub(1));
92    }
93
94    fn page_up(&mut self) {
95        let page = (self.last_body_height as usize).max(1);
96        self.scroll = self.scroll.saturating_sub(page);
97    }
98
99    fn page_down(&mut self) {
100        let page = (self.last_body_height as usize).max(1);
101        self.scroll = self
102            .scroll
103            .saturating_add(page)
104            .min(self.rows.len().saturating_sub(1));
105    }
106
107    /// Key handler — mirrors the `handle_key` pattern used by all other dialog types.
108    pub fn handle_key(
109        &mut self,
110        key: ratatui::crossterm::event::KeyEvent,
111        tx: &AppTx,
112    ) -> EventState {
113        match key.code {
114            KeyCode::Esc => {
115                tx.send(AppEvent::CloseDialog).ok();
116            }
117            KeyCode::Up => self.scroll_up(),
118            KeyCode::Down => self.scroll_down(),
119            KeyCode::PageUp => self.page_up(),
120            KeyCode::PageDown => self.page_down(),
121            _ => {}
122        }
123        EventState::Consumed
124    }
125}
126
127const OUTER_WIDTH: u16 = 50;
128const KEYS_COL_WIDTH: u16 = 18;
129
130impl Component for HelpDialog {
131    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
132        let InputEvent::Key(key) = event else {
133            return EventState::NotConsumed;
134        };
135        self.handle_key(*key, tx)
136    }
137
138    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
139        let content_rows = self.rows.len() as u16;
140        let desired_height = content_rows + 4; // borders(2) + footer(1) + bottom blank(1)
141        let max_height = (rect.height * 60 / 100).max(10);
142        let outer_height = desired_height.min(max_height);
143
144        let popup_area = super::fixed_centered_rect(OUTER_WIDTH, outer_height, rect);
145        f.render_widget(Clear, popup_area);
146
147        let outer_block = Block::default()
148            .title(" Keyboard Shortcuts ")
149            .borders(Borders::ALL)
150            .border_style(Style::default().fg(theme.fg.to_ratatui()))
151            .style(theme.panel_style());
152        let inner = outer_block.inner(popup_area);
153        f.render_widget(outer_block, popup_area);
154
155        if inner.height < 2 {
156            return;
157        }
158
159        let chunks = Layout::default()
160            .direction(Direction::Vertical)
161            .constraints([Constraint::Min(1), Constraint::Length(1)])
162            .split(inner);
163
164        let body_area = chunks[0];
165        let footer_area = chunks[1];
166
167        let bg = theme.bg_panel.to_ratatui();
168        let fg = theme.fg.to_ratatui();
169        let fg_muted = theme.fg_muted.to_ratatui();
170        let fg_accent = theme.fg_selected.to_ratatui();
171
172        // Cache for PageUp/PageDown.
173        self.last_body_height = body_area.height;
174
175        // Clamp scroll.
176        let body_height = body_area.height as usize;
177        let max_scroll = self.rows.len().saturating_sub(body_height);
178        self.scroll = self.scroll.min(max_scroll);
179
180        // Render visible rows.
181        let visible = &self.rows[self.scroll..];
182        for (y, row) in (body_area.y..).zip(visible.iter()) {
183            if y >= body_area.y + body_area.height {
184                break;
185            }
186            let row_rect = Rect {
187                x: body_area.x,
188                y,
189                width: body_area.width,
190                height: 1,
191            };
192            match row {
193                HelpRow::Blank => {}
194                HelpRow::Header(title) => {
195                    f.render_widget(
196                        Paragraph::new(format!("  {title}")).style(
197                            Style::default()
198                                .fg(fg_accent)
199                                .bg(bg)
200                                .add_modifier(Modifier::BOLD),
201                        ),
202                        row_rect,
203                    );
204                }
205                HelpRow::Separator => {
206                    super::render_separator(f, row_rect, fg_muted, bg);
207                }
208                HelpRow::Binding { keys, label } => {
209                    let cols = Layout::default()
210                        .direction(Direction::Horizontal)
211                        .constraints([
212                            Constraint::Length(2),
213                            Constraint::Length(KEYS_COL_WIDTH),
214                            Constraint::Min(1),
215                        ])
216                        .split(row_rect);
217                    f.render_widget(
218                        Paragraph::new(keys.as_str()).style(Style::default().fg(fg_accent).bg(bg)),
219                        cols[1],
220                    );
221                    f.render_widget(
222                        Paragraph::new(label.as_str()).style(Style::default().fg(fg).bg(bg)),
223                        cols[2],
224                    );
225                }
226            }
227        }
228
229        f.render_widget(
230            Paragraph::new("  [↑↓ PgUp/PgDn] Scroll   [Esc] Close")
231                .style(Style::default().fg(fg_muted).bg(bg)),
232            footer_area,
233        );
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::keys::KeyBindings;
241    use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
242    use crate::keys::key_strike::KeyStrike;
243
244    fn bindings_with_bold_and_quit() -> KeyBindings {
245        let mut kb = KeyBindings::empty();
246        kb.batch_add()
247            .with_ctrl()
248            .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
249            .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
250        kb
251    }
252
253    #[test]
254    fn rows_contain_both_categories() {
255        let dialog = HelpDialog::new(&bindings_with_bold_and_quit());
256        let headers: Vec<String> = dialog
257            .rows
258            .iter()
259            .filter_map(|r| {
260                if let HelpRow::Header(s) = r {
261                    Some(s.clone())
262                } else {
263                    None
264                }
265            })
266            .collect();
267        assert!(headers.contains(&"Text Editing".to_string()));
268        assert!(headers.contains(&"Other".to_string()));
269        assert!(!headers.contains(&"Navigation".to_string()));
270        assert!(!headers.contains(&"Notes".to_string()));
271    }
272
273    #[test]
274    fn binding_row_has_correct_keys_and_label() {
275        let dialog = HelpDialog::new(&bindings_with_bold_and_quit());
276        let binding = dialog.rows.iter().find_map(|r| {
277            if let HelpRow::Binding { keys, label } = r
278                && label == "Bold"
279            {
280                return Some(keys.clone());
281            }
282            None
283        });
284        assert!(binding.is_some(), "expected a Bold binding row");
285        assert_eq!(binding.unwrap(), "ctrl&B");
286    }
287
288    #[test]
289    fn empty_keybindings_produces_no_rows() {
290        let dialog = HelpDialog::new(&KeyBindings::empty());
291        assert!(
292            !dialog
293                .rows
294                .iter()
295                .any(|r| matches!(r, HelpRow::Binding { .. }))
296        );
297        assert!(!dialog.rows.iter().any(|r| matches!(r, HelpRow::Header(_))));
298    }
299}