1use std::collections::BTreeMap;
2
3use ratatui::Frame;
4use ratatui::crossterm::event::{KeyCode, MouseEvent, MouseEventKind};
5use ratatui::layout::{Constraint, Direction, Layout, Rect};
6use ratatui::style::{Modifier, Style};
7use ratatui::widgets::Paragraph;
8
9use super::ModalShell;
10use crate::components::Component;
11use crate::components::event_state::EventState;
12use crate::components::events::{AppEvent, AppTx, InputEvent};
13use crate::components::hint_row::HintRow;
14use crate::components::panel::{ModalSpec, modal_chrome};
15use crate::keys::KeyBindings;
16use crate::keys::action_shortcuts::ShortcutCategory;
17use crate::settings::themes::Theme;
18
19pub enum HelpRow {
24 Header(String),
25 Separator,
26 Binding { keys: String, label: String },
27 Blank,
28}
29
30pub struct HelpDialog {
35 pub rows: Vec<HelpRow>,
36 title: &'static str,
39 scroll: usize,
40 last_body_height: u16,
42 shell: ModalShell,
44 footer: HintRow,
45}
46
47impl HelpDialog {
48 pub fn new(key_bindings: &KeyBindings, tree: &crate::keys::leader::LeaderNode) -> Self {
49 use crate::keys::action_shortcuts::ActionShortcuts;
50 use crate::keys::leader::{LeaderAction, LeaderNode};
51
52 let mut by_category: BTreeMap<ShortcutCategory, Vec<(String, String)>> = BTreeMap::new();
53
54 let map = key_bindings.to_hashmap();
55 let mut entries: Vec<_> = map.into_iter().collect();
56 entries.sort_by_key(|(action, _)| action.to_string());
57
58 for (action, mut combos) in entries {
59 combos.sort();
60 let keys = combos
61 .iter()
62 .map(|c| c.to_string())
63 .collect::<Vec<_>>()
64 .join(" / ");
65 let label = action.label();
66 by_category
67 .entry(action.category())
68 .or_default()
69 .push((keys, label));
70 }
71
72 let text_group = tree.children().iter().find(|(_, node)| {
87 node.children().iter().any(|(_, leaf)| {
88 matches!(
89 leaf,
90 LeaderNode::Leaf {
91 action: LeaderAction::TextBold,
92 ..
93 }
94 )
95 })
96 });
97 if let Some(leader) = key_bindings.first_combo_for(&ActionShortcuts::Leader)
98 && let Some((key, group)) = text_group
99 {
100 let leaves = group
101 .children()
102 .iter()
103 .map(|(_, leaf)| leaf.label())
104 .collect::<Vec<_>>()
105 .join(" / ");
106 by_category
107 .entry(ShortcutCategory::TextEditing)
108 .or_default()
109 .push((
110 format!("{leader} {key}"),
111 format!("{leaves} ({})", group.label()),
112 ));
113 }
114
115 let mut rows: Vec<HelpRow> = Vec::new();
116 for (category, bindings) in by_category {
117 if bindings.is_empty() {
118 continue;
119 }
120 rows.push(HelpRow::Blank);
121 rows.push(HelpRow::Header(category.to_string()));
122 rows.push(HelpRow::Separator);
123 for (keys, label) in bindings {
124 rows.push(HelpRow::Binding { keys, label });
125 }
126 }
127 rows.push(HelpRow::Blank);
128
129 Self {
130 rows,
131 title: " Keyboard Shortcuts ",
132 scroll: 0,
133 last_body_height: 20,
134 shell: ModalShell::default(),
135 footer: footer_hints(),
136 }
137 }
138
139 pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
145 use crate::keys::action_shortcuts::ActionShortcuts;
146 use crate::keys::leader::LeaderNode;
147
148 let key_bindings = &settings.key_bindings;
149 let gateway = key_bindings
150 .first_combo_for(&ActionShortcuts::Leader)
151 .unwrap_or_else(|| "leader".to_string());
152
153 fn walk(node: &LeaderNode, prefix: &str, rows: &mut Vec<HelpRow>) {
154 for (key, child) in node.children() {
155 let keys = format!("{prefix} {key}");
156 match child {
157 LeaderNode::Leaf { label, .. } => rows.push(HelpRow::Binding {
158 keys,
159 label: (*label).to_string(),
160 }),
161 LeaderNode::Group { .. } => walk(child, &keys, rows),
162 }
163 }
164 }
165
166 let tree = settings.leader_tree();
167 let mut rows: Vec<HelpRow> = Vec::new();
168 rows.push(HelpRow::Header("Configuration".to_string()));
170 rows.push(HelpRow::Separator);
171 rows.push(HelpRow::Binding {
172 keys: settings.get_theme().name,
173 label: "active theme (leader v c to switch)".to_string(),
174 });
175 rows.push(HelpRow::Binding {
176 keys: gateway.clone(),
177 label: "leader gateway".to_string(),
178 });
179 rows.push(HelpRow::Binding {
180 keys: format!("{} ms", settings.leader_timeout_ms),
181 label: "which-key reveal timeout".to_string(),
182 });
183 rows.push(HelpRow::Binding {
184 keys: "F1 in Find".to_string(),
185 label: "search query syntax".to_string(),
186 });
187 for row in tree.display_children() {
188 if row.is_group {
189 let key = row.keys.chars().next().expect("group keys are one char");
191 let child = tree.child(key).expect("display row names a real child");
192 rows.push(HelpRow::Blank);
193 rows.push(HelpRow::Header(format!("{gateway} {key} {}", row.label)));
194 rows.push(HelpRow::Separator);
195 walk(child, &format!("{gateway} {key}"), &mut rows);
196 } else {
197 rows.push(HelpRow::Blank);
198 rows.push(HelpRow::Binding {
199 keys: format!("{gateway} {}", row.keys),
200 label: row.label,
201 });
202 }
203 }
204
205 let flat = Self::new(key_bindings, &tree);
207 rows.push(HelpRow::Blank);
208 rows.push(HelpRow::Header("Always-on shortcuts".to_string()));
209 rows.push(HelpRow::Separator);
210 rows.extend(
211 flat.rows
212 .into_iter()
213 .filter(|r| matches!(r, HelpRow::Binding { .. })),
214 );
215 rows.push(HelpRow::Blank);
216
217 Self {
218 rows,
219 title: " Cheatsheet — leader keys ",
220 scroll: 0,
221 last_body_height: 20,
222 shell: ModalShell::default(),
223 footer: footer_hints(),
224 }
225 }
226
227 pub fn query_syntax() -> Self {
233 const OPERATORS: &[(&str, &str)] = &[
235 ("(type text)", "full-text body search"),
236 ("= / name:", "by note name"),
237 ("@ / in:", "by section heading"),
238 ("/ / pt:", "by path / folder"),
239 ("# / lb:", "by label (tag)"),
240 ("< / lk:", "links TO it (backlinks)"),
241 ("> / fwd:", "it links to (forward)"),
242 ("% / prop:", "by property (%due, %k=v)"),
243 ("^ / or:", "sort results (-^ = desc)"),
244 ];
245 const MODIFIERS: &[(&str, &str)] = &[
246 ("- prefix", "exclude (e.g. -#draft)"),
247 ("*", "wildcard prefix (screen*)"),
248 ("\" \"", "quote values with spaces"),
249 ];
250 const EXAMPLES: &[(&str, &str)] = &[
251 ("#finance report", "labelled finance + text"),
252 ("@work -cancelled", "Work section, not cancelled"),
253 ("<kimun #project", "kimun backlinks + label"),
254 ("%status=done ^%due", "done, sorted by due"),
255 ];
256
257 fn section(rows: &mut Vec<HelpRow>, header: &str, entries: &[(&str, &str)]) {
258 rows.push(HelpRow::Header(header.to_string()));
259 rows.push(HelpRow::Separator);
260 for (keys, label) in entries {
261 rows.push(HelpRow::Binding {
262 keys: (*keys).to_string(),
263 label: (*label).to_string(),
264 });
265 }
266 rows.push(HelpRow::Blank);
267 }
268
269 let mut rows: Vec<HelpRow> = Vec::new();
270 section(&mut rows, "Operators", OPERATORS);
271 section(&mut rows, "Modifiers", MODIFIERS);
272 section(&mut rows, "Examples", EXAMPLES);
273
274 Self {
275 rows,
276 title: " Search Query Syntax ",
277 scroll: 0,
278 last_body_height: 20,
279 shell: ModalShell::default(),
280 footer: footer_hints(),
281 }
282 }
283
284 fn scroll_up(&mut self) {
285 self.scroll = self.scroll.saturating_sub(1);
286 }
287
288 fn scroll_down(&mut self) {
289 self.scroll = self
292 .scroll
293 .saturating_add(1)
294 .min(self.rows.len().saturating_sub(1));
295 }
296
297 fn page_up(&mut self) {
298 let page = (self.last_body_height as usize).max(1);
299 self.scroll = self.scroll.saturating_sub(page);
300 }
301
302 fn page_down(&mut self) {
303 let page = (self.last_body_height as usize).max(1);
304 self.scroll = self
305 .scroll
306 .saturating_add(page)
307 .min(self.rows.len().saturating_sub(1));
308 }
309
310 #[cfg(test)]
311 pub(crate) fn scroll(&self) -> usize {
312 self.scroll
313 }
314
315 pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
318 if let Some(key) = self.shell.pointer_key(m, &[&self.footer]) {
319 return self.handle_key(key, tx);
320 }
321 match m.kind {
322 MouseEventKind::ScrollUp => (0..WHEEL_ROWS).for_each(|_| self.scroll_up()),
323 MouseEventKind::ScrollDown => (0..WHEEL_ROWS).for_each(|_| self.scroll_down()),
324 _ => {}
325 }
326 EventState::Consumed
327 }
328
329 pub fn handle_key(
331 &mut self,
332 key: ratatui::crossterm::event::KeyEvent,
333 tx: &AppTx,
334 ) -> EventState {
335 match key.code {
336 KeyCode::Esc => {
337 tx.send(AppEvent::CloseOverlay).ok();
338 }
339 KeyCode::Up => self.scroll_up(),
340 KeyCode::Down => self.scroll_down(),
341 KeyCode::PageUp => self.page_up(),
342 KeyCode::PageDown => self.page_down(),
343 _ => {}
344 }
345 EventState::Consumed
346 }
347}
348
349const OUTER_WIDTH: u16 = 50;
350const WHEEL_ROWS: usize = 3;
352
353fn footer_hints() -> HintRow {
355 HintRow::new(&[
356 (KeyCode::Null, "↑↓ PgUp/PgDn", "Scroll"),
357 (KeyCode::Esc, "Esc", "Close"),
358 ])
359 .with_gap(3)
360}
361const KEYS_COL_WIDTH: u16 = 18;
362
363impl Component for HelpDialog {
364 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
365 let InputEvent::Key(key) = event else {
366 return EventState::NotConsumed;
367 };
368 self.handle_key(*key, tx)
369 }
370
371 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
372 let content_rows = self.rows.len() as u16;
373 let desired_height = content_rows + 4; let max_height = (rect.height * 60 / 100).max(10);
375 let outer_height = desired_height.min(max_height);
376
377 let popup_area = super::fixed_centered_rect(OUTER_WIDTH, outer_height, rect);
378 self.shell.set(popup_area);
379 let inner = modal_chrome(
380 f,
381 popup_area,
382 theme,
383 ModalSpec {
384 title: Some(self.title),
385 border: Some(Style::default().fg(theme.fg.to_ratatui())),
386 ..Default::default()
387 },
388 );
389
390 if inner.height < 2 {
391 return;
392 }
393
394 let chunks = Layout::default()
395 .direction(Direction::Vertical)
396 .constraints([Constraint::Min(1), Constraint::Length(1)])
397 .split(inner);
398
399 let body_area = chunks[0];
400 let footer_area = chunks[1];
401
402 let bg = theme.bg_panel.to_ratatui();
403 let fg = theme.fg.to_ratatui();
404 let gray = theme.gray.to_ratatui();
405 let fg_accent = theme.selection_fg.to_ratatui();
406
407 self.last_body_height = body_area.height;
409
410 let body_height = body_area.height as usize;
412 let max_scroll = self.rows.len().saturating_sub(body_height);
413 self.scroll = self.scroll.min(max_scroll);
414
415 let visible = &self.rows[self.scroll..];
417 for (y, row) in (body_area.y..).zip(visible.iter()) {
418 if y >= body_area.y + body_area.height {
419 break;
420 }
421 let row_rect = Rect {
422 x: body_area.x,
423 y,
424 width: body_area.width,
425 height: 1,
426 };
427 match row {
428 HelpRow::Blank => {}
429 HelpRow::Header(title) => {
430 f.render_widget(
431 Paragraph::new(format!(" {title}")).style(
432 Style::default()
433 .fg(fg_accent)
434 .bg(bg)
435 .add_modifier(Modifier::BOLD),
436 ),
437 row_rect,
438 );
439 }
440 HelpRow::Separator => {
441 super::render_separator(f, row_rect, gray, bg);
442 }
443 HelpRow::Binding { keys, label } => {
444 let cols = Layout::default()
445 .direction(Direction::Horizontal)
446 .constraints([
447 Constraint::Length(2),
448 Constraint::Length(KEYS_COL_WIDTH),
449 Constraint::Min(1),
450 ])
451 .split(row_rect);
452 f.render_widget(
453 Paragraph::new(keys.as_str()).style(Style::default().fg(fg_accent).bg(bg)),
454 cols[1],
455 );
456 f.render_widget(
457 Paragraph::new(label.as_str()).style(Style::default().fg(fg).bg(bg)),
458 cols[2],
459 );
460 }
461 }
462 }
463
464 let hint_style = Style::default().fg(gray).bg(bg);
465 self.footer.render(f, footer_area, hint_style, theme);
466 }
467}
468
469impl_dialog!(HelpDialog);
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use crate::keys::KeyBindings;
475 use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
476 use crate::keys::key_strike::KeyStrike;
477
478 fn bindings_with_bold_and_quit() -> KeyBindings {
479 let mut kb = KeyBindings::empty();
480 kb.batch_add()
481 .with_ctrl()
482 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
483 .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
484 kb
485 }
486
487 #[test]
491 fn f1_names_the_leader_route_when_no_formatting_chord_is_bound() {
492 let dialog = HelpDialog::new(
493 &crate::settings::AppSettings::default().key_bindings,
494 &crate::keys::leader::leader_tree(),
495 );
496 let row = dialog.rows.iter().find_map(|r| match r {
497 HelpRow::Binding { keys, label } if label.contains("bold") => {
498 Some((keys.clone(), label.clone()))
499 }
500 _ => None,
501 });
502 let (keys, label) = row.expect("F1 must point at the formatting route");
503 assert!(keys.ends_with(" t"), "should name the +text group: {keys}");
504 assert!(
505 keys.starts_with("ctrl&B"),
506 "prefixed by the gateway: {keys}"
507 );
508 assert_eq!(label, "bold / italic / strikethrough (+text)");
509 }
510
511 #[test]
515 fn f1_lists_the_leader_route_beside_a_real_formatting_chord() {
516 let mut kb = bindings_with_bold_and_quit();
517 kb.batch_add()
518 .with_ctrl()
519 .add(KeyStrike::KeyG, ActionShortcuts::Leader);
520 let dialog = HelpDialog::new(&kb, &crate::keys::leader::leader_tree());
521 let labels: Vec<&str> = dialog
522 .rows
523 .iter()
524 .filter_map(|r| match r {
525 HelpRow::Binding { label, .. } => Some(label.as_str()),
526 _ => None,
527 })
528 .collect();
529 assert!(labels.contains(&"Bold"), "{labels:?}");
530 assert!(
531 labels.iter().any(|l| l.contains("+text")),
532 "the leader route must stand beside the chord: {labels:?}"
533 );
534 }
535
536 #[test]
539 fn f1_formatting_hint_follows_the_tree() {
540 use crate::keys::leader::{LeaderAction, LeaderNode};
541 let tree = LeaderNode::Group {
542 label: "leader".into(),
543 children: vec![(
544 'x',
545 LeaderNode::Group {
546 label: "+fmt".into(),
547 children: vec![
548 (
549 'b',
550 LeaderNode::Leaf {
551 label: "bold",
552 action: LeaderAction::TextBold,
553 },
554 ),
555 (
556 'i',
557 LeaderNode::Leaf {
558 label: "italic",
559 action: LeaderAction::TextItalic,
560 },
561 ),
562 ],
563 },
564 )],
565 };
566 let dialog = HelpDialog::new(&crate::settings::AppSettings::default().key_bindings, &tree);
567 let row = dialog.rows.iter().find_map(|r| match r {
568 HelpRow::Binding { keys, label } if label.contains("+fmt") => {
569 Some((keys.clone(), label.clone()))
570 }
571 _ => None,
572 });
573 let (keys, label) = row.expect("the hint must be built from the tree");
574 assert_eq!(keys, "ctrl&B x");
575 assert_eq!(label, "bold / italic (+fmt)");
576 }
577
578 #[test]
579 fn rows_contain_both_categories() {
580 let dialog = HelpDialog::new(
581 &bindings_with_bold_and_quit(),
582 &crate::keys::leader::leader_tree(),
583 );
584 let headers: Vec<String> = dialog
585 .rows
586 .iter()
587 .filter_map(|r| {
588 if let HelpRow::Header(s) = r {
589 Some(s.clone())
590 } else {
591 None
592 }
593 })
594 .collect();
595 assert!(headers.contains(&"Text Editing".to_string()));
596 assert!(headers.contains(&"Other".to_string()));
597 assert!(!headers.contains(&"Navigation".to_string()));
598 assert!(!headers.contains(&"Notes".to_string()));
599 }
600
601 #[test]
602 fn binding_row_has_correct_keys_and_label() {
603 let dialog = HelpDialog::new(
604 &bindings_with_bold_and_quit(),
605 &crate::keys::leader::leader_tree(),
606 );
607 let binding = dialog.rows.iter().find_map(|r| {
608 if let HelpRow::Binding { keys, label } = r
609 && label == "Bold"
610 {
611 return Some(keys.clone());
612 }
613 None
614 });
615 assert!(binding.is_some(), "expected a Bold binding row");
616 assert_eq!(binding.unwrap(), "ctrl&B");
617 }
618
619 #[test]
620 fn empty_keybindings_produces_no_rows() {
621 let dialog = HelpDialog::new(&KeyBindings::empty(), &crate::keys::leader::leader_tree());
622 assert!(
623 !dialog
624 .rows
625 .iter()
626 .any(|r| matches!(r, HelpRow::Binding { .. }))
627 );
628 assert!(!dialog.rows.iter().any(|r| matches!(r, HelpRow::Header(_))));
629 }
630}