kimun_notes/components/dialogs/
help_dialog.rs1use 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
16pub enum HelpRow {
21 Header(String),
22 Separator,
23 Binding { keys: String, label: String },
24 Blank,
25}
26
27pub struct HelpDialog {
32 pub rows: Vec<HelpRow>,
33 scroll: usize,
34 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 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 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::CloseOverlay).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; 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 self.last_body_height = body_area.height;
174
175 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 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}