1use std::collections::BTreeMap;
2
3use ratatui::Frame;
4use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
5use ratatui::layout::{Constraint, Direction, Layout, Rect};
6use ratatui::style::{Modifier, Style};
7use ratatui::widgets::Paragraph;
8
9use crate::components::Component;
10use crate::components::clickable::is_press_outside;
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 popup_rect: Rect,
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 popup_rect: Rect::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 popup_rect: Rect::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 popup_rect: Rect::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 is_press_outside(m, self.popup_rect) {
319 return self.handle_key(KeyEvent::from(KeyCode::Esc), tx);
320 }
321 if let Some(key) = self.footer.hit(m) {
322 return self.handle_key(key, tx);
323 }
324 match m.kind {
325 MouseEventKind::ScrollUp => (0..WHEEL_ROWS).for_each(|_| self.scroll_up()),
326 MouseEventKind::ScrollDown => (0..WHEEL_ROWS).for_each(|_| self.scroll_down()),
327 _ => {}
328 }
329 EventState::Consumed
330 }
331
332 pub fn handle_key(
334 &mut self,
335 key: ratatui::crossterm::event::KeyEvent,
336 tx: &AppTx,
337 ) -> EventState {
338 match key.code {
339 KeyCode::Esc => {
340 tx.send(AppEvent::CloseOverlay).ok();
341 }
342 KeyCode::Up => self.scroll_up(),
343 KeyCode::Down => self.scroll_down(),
344 KeyCode::PageUp => self.page_up(),
345 KeyCode::PageDown => self.page_down(),
346 _ => {}
347 }
348 EventState::Consumed
349 }
350}
351
352const OUTER_WIDTH: u16 = 50;
353const WHEEL_ROWS: usize = 3;
355
356fn footer_hints() -> HintRow {
358 HintRow::new(&[
359 (KeyCode::Null, "↑↓ PgUp/PgDn", "Scroll"),
360 (KeyCode::Esc, "Esc", "Close"),
361 ])
362 .with_gap(3)
363}
364const KEYS_COL_WIDTH: u16 = 18;
365
366impl Component for HelpDialog {
367 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
368 let InputEvent::Key(key) = event else {
369 return EventState::NotConsumed;
370 };
371 self.handle_key(*key, tx)
372 }
373
374 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
375 let content_rows = self.rows.len() as u16;
376 let desired_height = content_rows + 4; let max_height = (rect.height * 60 / 100).max(10);
378 let outer_height = desired_height.min(max_height);
379
380 let popup_area = super::fixed_centered_rect(OUTER_WIDTH, outer_height, rect);
381 self.popup_rect = popup_area;
382 let inner = modal_chrome(
383 f,
384 popup_area,
385 theme,
386 ModalSpec {
387 title: Some(self.title),
388 border: Some(Style::default().fg(theme.fg.to_ratatui())),
389 ..Default::default()
390 },
391 );
392
393 if inner.height < 2 {
394 return;
395 }
396
397 let chunks = Layout::default()
398 .direction(Direction::Vertical)
399 .constraints([Constraint::Min(1), Constraint::Length(1)])
400 .split(inner);
401
402 let body_area = chunks[0];
403 let footer_area = chunks[1];
404
405 let bg = theme.bg_panel.to_ratatui();
406 let fg = theme.fg.to_ratatui();
407 let gray = theme.gray.to_ratatui();
408 let fg_accent = theme.selection_fg.to_ratatui();
409
410 self.last_body_height = body_area.height;
412
413 let body_height = body_area.height as usize;
415 let max_scroll = self.rows.len().saturating_sub(body_height);
416 self.scroll = self.scroll.min(max_scroll);
417
418 let visible = &self.rows[self.scroll..];
420 for (y, row) in (body_area.y..).zip(visible.iter()) {
421 if y >= body_area.y + body_area.height {
422 break;
423 }
424 let row_rect = Rect {
425 x: body_area.x,
426 y,
427 width: body_area.width,
428 height: 1,
429 };
430 match row {
431 HelpRow::Blank => {}
432 HelpRow::Header(title) => {
433 f.render_widget(
434 Paragraph::new(format!(" {title}")).style(
435 Style::default()
436 .fg(fg_accent)
437 .bg(bg)
438 .add_modifier(Modifier::BOLD),
439 ),
440 row_rect,
441 );
442 }
443 HelpRow::Separator => {
444 super::render_separator(f, row_rect, gray, bg);
445 }
446 HelpRow::Binding { keys, label } => {
447 let cols = Layout::default()
448 .direction(Direction::Horizontal)
449 .constraints([
450 Constraint::Length(2),
451 Constraint::Length(KEYS_COL_WIDTH),
452 Constraint::Min(1),
453 ])
454 .split(row_rect);
455 f.render_widget(
456 Paragraph::new(keys.as_str()).style(Style::default().fg(fg_accent).bg(bg)),
457 cols[1],
458 );
459 f.render_widget(
460 Paragraph::new(label.as_str()).style(Style::default().fg(fg).bg(bg)),
461 cols[2],
462 );
463 }
464 }
465 }
466
467 let hint_style = Style::default().fg(gray).bg(bg);
468 self.footer.render(f, footer_area, hint_style, theme);
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::keys::KeyBindings;
476 use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
477 use crate::keys::key_strike::KeyStrike;
478
479 fn bindings_with_bold_and_quit() -> KeyBindings {
480 let mut kb = KeyBindings::empty();
481 kb.batch_add()
482 .with_ctrl()
483 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
484 .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
485 kb
486 }
487
488 #[test]
492 fn f1_names_the_leader_route_when_no_formatting_chord_is_bound() {
493 let dialog = HelpDialog::new(
494 &crate::settings::AppSettings::default().key_bindings,
495 &crate::keys::leader::leader_tree(),
496 );
497 let row = dialog.rows.iter().find_map(|r| match r {
498 HelpRow::Binding { keys, label } if label.contains("bold") => {
499 Some((keys.clone(), label.clone()))
500 }
501 _ => None,
502 });
503 let (keys, label) = row.expect("F1 must point at the formatting route");
504 assert!(keys.ends_with(" t"), "should name the +text group: {keys}");
505 assert!(
506 keys.starts_with("ctrl&B"),
507 "prefixed by the gateway: {keys}"
508 );
509 assert_eq!(label, "bold / italic / strikethrough (+text)");
510 }
511
512 #[test]
516 fn f1_lists_the_leader_route_beside_a_real_formatting_chord() {
517 let mut kb = bindings_with_bold_and_quit();
518 kb.batch_add()
519 .with_ctrl()
520 .add(KeyStrike::KeyG, ActionShortcuts::Leader);
521 let dialog = HelpDialog::new(&kb, &crate::keys::leader::leader_tree());
522 let labels: Vec<&str> = dialog
523 .rows
524 .iter()
525 .filter_map(|r| match r {
526 HelpRow::Binding { label, .. } => Some(label.as_str()),
527 _ => None,
528 })
529 .collect();
530 assert!(labels.contains(&"Bold"), "{labels:?}");
531 assert!(
532 labels.iter().any(|l| l.contains("+text")),
533 "the leader route must stand beside the chord: {labels:?}"
534 );
535 }
536
537 #[test]
540 fn f1_formatting_hint_follows_the_tree() {
541 use crate::keys::leader::{LeaderAction, LeaderNode};
542 let tree = LeaderNode::Group {
543 label: "leader".into(),
544 children: vec![(
545 'x',
546 LeaderNode::Group {
547 label: "+fmt".into(),
548 children: vec![
549 (
550 'b',
551 LeaderNode::Leaf {
552 label: "bold",
553 action: LeaderAction::TextBold,
554 },
555 ),
556 (
557 'i',
558 LeaderNode::Leaf {
559 label: "italic",
560 action: LeaderAction::TextItalic,
561 },
562 ),
563 ],
564 },
565 )],
566 };
567 let dialog = HelpDialog::new(&crate::settings::AppSettings::default().key_bindings, &tree);
568 let row = dialog.rows.iter().find_map(|r| match r {
569 HelpRow::Binding { keys, label } if label.contains("+fmt") => {
570 Some((keys.clone(), label.clone()))
571 }
572 _ => None,
573 });
574 let (keys, label) = row.expect("the hint must be built from the tree");
575 assert_eq!(keys, "ctrl&B x");
576 assert_eq!(label, "bold / italic (+fmt)");
577 }
578
579 #[test]
580 fn rows_contain_both_categories() {
581 let dialog = HelpDialog::new(
582 &bindings_with_bold_and_quit(),
583 &crate::keys::leader::leader_tree(),
584 );
585 let headers: Vec<String> = dialog
586 .rows
587 .iter()
588 .filter_map(|r| {
589 if let HelpRow::Header(s) = r {
590 Some(s.clone())
591 } else {
592 None
593 }
594 })
595 .collect();
596 assert!(headers.contains(&"Text Editing".to_string()));
597 assert!(headers.contains(&"Other".to_string()));
598 assert!(!headers.contains(&"Navigation".to_string()));
599 assert!(!headers.contains(&"Notes".to_string()));
600 }
601
602 #[test]
603 fn binding_row_has_correct_keys_and_label() {
604 let dialog = HelpDialog::new(
605 &bindings_with_bold_and_quit(),
606 &crate::keys::leader::leader_tree(),
607 );
608 let binding = dialog.rows.iter().find_map(|r| {
609 if let HelpRow::Binding { keys, label } = r
610 && label == "Bold"
611 {
612 return Some(keys.clone());
613 }
614 None
615 });
616 assert!(binding.is_some(), "expected a Bold binding row");
617 assert_eq!(binding.unwrap(), "ctrl&B");
618 }
619
620 #[test]
621 fn empty_keybindings_produces_no_rows() {
622 let dialog = HelpDialog::new(&KeyBindings::empty(), &crate::keys::leader::leader_tree());
623 assert!(
624 !dialog
625 .rows
626 .iter()
627 .any(|r| matches!(r, HelpRow::Binding { .. }))
628 );
629 assert!(!dialog.rows.iter().any(|r| matches!(r, HelpRow::Header(_))));
630 }
631}