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::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
17pub enum HelpRow {
22 Header(String),
23 Separator,
24 Binding { keys: String, label: String },
25 Blank,
26}
27
28pub struct HelpDialog {
33 pub rows: Vec<HelpRow>,
34 title: &'static str,
37 scroll: usize,
38 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 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 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 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 pub fn query_syntax() -> Self {
178 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 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 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; 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 self.last_body_height = body_area.height;
319
320 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 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}