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