1use crate::app::{App, HelpTab};
2use crate::config::{format_keybindings, KeyBinding};
3use ratatui::{
4 layout::{Constraint, Direction, Layout, Rect},
5 style::{Color, Modifier, Style},
6 text::{Line, Span, Text},
7 widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
8 Frame,
9};
10
11struct HelpSection<'a> {
12 title: &'a str,
13 rows: Vec<HelpRow>,
14}
15
16enum HelpRow {
17 Shortcut { keys: String, description: String },
18 Text(String),
19}
20
21pub fn render_help_modal(f: &mut Frame, app: &App) {
22 if !app.ui.help.visible {
23 return;
24 }
25
26 let area = centered_rect(88, 86, f.area());
27 let outer = Block::default()
28 .borders(Borders::ALL)
29 .border_type(BorderType::Rounded)
30 .title(
31 Line::from(vec![
32 Span::raw(" "),
33 Span::styled("Help", Style::default().add_modifier(Modifier::BOLD)),
34 Span::raw(" "),
35 ])
36 .centered(),
37 )
38 .border_style(Style::default().fg(Color::LightCyan));
39
40 f.render_widget(Clear, area);
41 f.render_widget(outer.clone(), area);
42
43 let inner = outer.inner(area);
44 let [header_area, body_area, footer_area] = Layout::default()
45 .direction(Direction::Vertical)
46 .constraints([
47 Constraint::Length(4),
48 Constraint::Min(10),
49 Constraint::Length(2),
50 ])
51 .areas(inner);
52 let [tabs_area, content_area] = Layout::default()
53 .direction(Direction::Horizontal)
54 .constraints([Constraint::Length(22), Constraint::Min(20)])
55 .areas(body_area);
56
57 render_header(f, app, header_area);
58 render_tabs(f, app, tabs_area);
59 render_content(f, app, content_area);
60 render_footer(f, app, footer_area);
61}
62
63fn render_header(f: &mut Frame, app: &App, area: Rect) {
64 let [title_area, subtitle_area] = Layout::default()
65 .direction(Direction::Vertical)
66 .constraints([Constraint::Length(1), Constraint::Length(2)])
67 .areas(area);
68
69 let title = app.ui.help.tab.title().to_string();
70 let subtitle = match app.ui.help.tab {
71 HelpTab::Overview => "Configured app shortcuts and how the help modal works",
72 HelpTab::Search => "Search results, search bar editing, and result actions",
73 HelpTab::Preview => "Preview focus, text editing, and read-only behavior",
74 HelpTab::Logs => "Structured-log filtering, columns, and live navigation",
75 HelpTab::Layout => "Preview visibility, pane arrangement, and window controls",
76 };
77
78 f.render_widget(
79 Paragraph::new(Line::from(vec![Span::styled(
80 title,
81 Style::default()
82 .fg(Color::Yellow)
83 .add_modifier(Modifier::BOLD),
84 )])),
85 title_area,
86 );
87 f.render_widget(
88 Paragraph::new(Line::from(vec![Span::styled(
89 subtitle,
90 Style::default().fg(Color::Gray),
91 )]))
92 .block(
93 Block::default()
94 .borders(Borders::BOTTOM)
95 .border_style(Style::default().fg(Color::DarkGray)),
96 ),
97 subtitle_area,
98 );
99}
100
101fn render_tabs(f: &mut Frame, app: &App, area: Rect) {
102 let tabs = [
103 HelpTab::Overview,
104 HelpTab::Search,
105 HelpTab::Preview,
106 HelpTab::Logs,
107 HelpTab::Layout,
108 ];
109
110 let lines = tabs
111 .iter()
112 .enumerate()
113 .map(|(idx, tab)| {
114 let active = *tab == app.ui.help.tab;
115 let style = if active {
116 Style::default()
117 .fg(Color::Black)
118 .bg(Color::LightCyan)
119 .add_modifier(Modifier::BOLD)
120 } else {
121 Style::default().fg(Color::Gray)
122 };
123 Line::from(vec![Span::styled(
124 format!(" {}. {} ", idx + 1, tab.title()),
125 style,
126 )])
127 })
128 .collect::<Vec<_>>();
129
130 let block = Block::default()
131 .title(" Sections ")
132 .borders(Borders::ALL)
133 .border_type(BorderType::Rounded)
134 .border_style(Style::default().fg(Color::DarkGray));
135 f.render_widget(Paragraph::new(Text::from(lines)).block(block), area);
136}
137
138fn render_content(f: &mut Frame, app: &App, area: Rect) {
139 let sections = match app.ui.help.tab {
140 HelpTab::Overview => overview_sections(app),
141 HelpTab::Search => search_sections(app),
142 HelpTab::Preview => preview_sections(app),
143 HelpTab::Logs => logs_sections(app),
144 HelpTab::Layout => layout_sections(app),
145 };
146 let lines = render_sections(§ions);
147 let block = Block::default()
148 .title(format!(" {} ", app.ui.help.tab.title()))
149 .borders(Borders::ALL)
150 .border_type(BorderType::Rounded)
151 .border_style(Style::default().fg(Color::DarkGray));
152 let inner = block.inner(area);
153 f.render_widget(block, area);
154 f.render_widget(
155 Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
156 inner,
157 );
158}
159
160fn render_footer(f: &mut Frame, app: &App, area: Rect) {
161 let close = format_keybindings(&app.keybindings().toggle_help);
162 let footer = Paragraph::new(Line::from(vec![
163 Span::styled(close, Style::default().fg(Color::LightCyan)),
164 Span::styled(" or Esc close", Style::default().fg(Color::Gray)),
165 Span::styled(" • ", Style::default().fg(Color::DarkGray)),
166 Span::styled("1-5", Style::default().fg(Color::LightCyan)),
167 Span::styled(" jump tabs", Style::default().fg(Color::Gray)),
168 Span::styled(" • ", Style::default().fg(Color::DarkGray)),
169 Span::styled("Tab / Shift+Tab", Style::default().fg(Color::LightCyan)),
170 Span::styled(" cycle", Style::default().fg(Color::Gray)),
171 ]));
172 f.render_widget(footer, area);
173}
174
175fn overview_sections(app: &App) -> Vec<HelpSection<'static>> {
176 vec![HelpSection {
177 title: "Configured App Shortcuts",
178 rows: vec![
179 shortcut(&app.keybindings().toggle_help, "toggle help"),
180 shortcut(&app.keybindings().quit, "quit binocular"),
181 shortcut(
182 &app.keybindings().toggle_exact,
183 "toggle fuzzy/exact matcher",
184 ),
185 shortcut(&app.keybindings().mode_path, "switch to path mode"),
186 shortcut(&app.keybindings().mode_files, "switch to file-name mode"),
187 shortcut(&app.keybindings().mode_grep, "switch to content mode"),
188 shortcut(&app.keybindings().mode_dirs, "switch to directory mode"),
189 ],
190 }]
191}
192
193fn search_sections(app: &App) -> Vec<HelpSection<'static>> {
194 vec![
195 HelpSection {
196 title: "Search Results",
197 rows: vec![
198 shortcut(
199 &app.keybindings().mark_result,
200 "mark or unmark selected result",
201 ),
202 shortcut(
203 &app.keybindings().mark_diff_result,
204 "mark or unmark result for diff",
205 ),
206 HelpRow::Shortcut {
207 keys: "Enter".to_string(),
208 description: "select current result and quit".to_string(),
209 },
210 HelpRow::Shortcut {
211 keys: "j / k".to_string(),
212 description: "move selection in normal mode".to_string(),
213 },
214 HelpRow::Shortcut {
215 keys: "Up / Down".to_string(),
216 description: "move selection in insert mode".to_string(),
217 },
218 ],
219 },
220 HelpSection {
221 title: "Search Bar",
222 rows: vec![
223 HelpRow::Shortcut {
224 keys: "Type".to_string(),
225 description: "edit the query in insert mode".to_string(),
226 },
227 HelpRow::Shortcut {
228 keys: "Esc".to_string(),
229 description: "leave insert mode".to_string(),
230 },
231 HelpRow::Shortcut {
232 keys: "h / l / w / e / b / 0 / ^ / $".to_string(),
233 description: "vim cursor and word motions in normal mode".to_string(),
234 },
235 HelpRow::Shortcut {
236 keys: "i / a / I / A".to_string(),
237 description: "enter insert mode variants".to_string(),
238 },
239 HelpRow::Shortcut {
240 keys: "d/c + motion, diw, ciw".to_string(),
241 description: "use vim-style edit operators".to_string(),
242 },
243 ],
244 },
245 ]
246}
247
248fn preview_sections(app: &App) -> Vec<HelpSection<'static>> {
249 vec![
250 HelpSection {
251 title: "Preview Actions",
252 rows: vec![
253 shortcut(&app.keybindings().toggle_preview_focus, "switch between search and preview"),
254 shortcut(&app.keybindings().scroll_preview_up, "page preview upward"),
255 shortcut(&app.keybindings().scroll_preview_down, "page preview downward"),
256 shortcut(&app.keybindings().select_from_preview, "select highlighted location from preview"),
257 ],
258 },
259 HelpSection {
260 title: "Preview Vim Controls",
261 rows: vec![
262 HelpRow::Shortcut {
263 keys: "h / j / k / l".to_string(),
264 description: "move cursor".to_string(),
265 },
266 HelpRow::Shortcut {
267 keys: "w / e / b / gg / G / % / f / F / ;".to_string(),
268 description: "navigate text quickly".to_string(),
269 },
270 HelpRow::Shortcut {
271 keys: "v / V / y / d / c / u / Ctrl+R".to_string(),
272 description: "visual mode, yank, edit, undo, redo".to_string(),
273 },
274 HelpRow::Shortcut {
275 keys: "/ / n / N / :w / :q / :wq".to_string(),
276 description: "search and command-line actions".to_string(),
277 },
278 HelpRow::Text(
279 "Read-only previews keep navigation but block focus/editing; the status line will say so when needed.".to_string(),
280 ),
281 ],
282 },
283 ]
284}
285
286fn logs_sections(_app: &App) -> Vec<HelpSection<'static>> {
287 vec![
288 HelpSection {
289 title: "Structured Log Navigation",
290 rows: vec![
291 HelpRow::Shortcut {
292 keys: "j / k / Up / Down".to_string(),
293 description: "move between visible log rows".to_string(),
294 },
295 HelpRow::Shortcut {
296 keys: "g / G / u / d".to_string(),
297 description: "jump newest, oldest, page up, page down".to_string(),
298 },
299 HelpRow::Shortcut {
300 keys: "Tab".to_string(),
301 description: "mark current row".to_string(),
302 },
303 HelpRow::Shortcut {
304 keys: "y / Y".to_string(),
305 description: "copy visible row or raw row".to_string(),
306 },
307 HelpRow::Shortcut {
308 keys: "p".to_string(),
309 description: "pause or resume live updates".to_string(),
310 },
311 HelpRow::Shortcut {
312 keys: "Esc / q".to_string(),
313 description: "leave the log viewer".to_string(),
314 },
315 ],
316 },
317 HelpSection {
318 title: "Filtering and Columns",
319 rows: vec![
320 HelpRow::Shortcut {
321 keys: "/".to_string(),
322 description: "start editing the filter query".to_string(),
323 },
324 HelpRow::Shortcut {
325 keys: "Esc / Enter".to_string(),
326 description: "leave filter editing".to_string(),
327 },
328 HelpRow::Shortcut {
329 keys: "h / l / Left / Right".to_string(),
330 description: "move between visible columns".to_string(),
331 },
332 HelpRow::Shortcut {
333 keys: "a".to_string(),
334 description: "open the column picker modal".to_string(),
335 },
336 HelpRow::Shortcut {
337 keys: "H / o / < / >".to_string(),
338 description: "hide, isolate, or resize the selected column".to_string(),
339 },
340 HelpRow::Shortcut {
341 keys: "r".to_string(),
342 description: "reset filters, marks, and column layout".to_string(),
343 },
344 HelpRow::Text(
345 "Inside the column picker: j/k moves, Space toggles, Tab toggles and advances, Enter applies, Esc cancels.".to_string(),
346 ),
347 ],
348 },
349 ]
350}
351
352fn layout_sections(app: &App) -> Vec<HelpSection<'static>> {
353 vec![
354 HelpSection {
355 title: "Window Layout",
356 rows: vec![
357 shortcut(&app.keybindings().toggle_preview_visibility, "show or hide the preview pane"),
358 shortcut(&app.keybindings().toggle_preview_fullscreen, "toggle fullscreen preview"),
359 shortcut(&app.keybindings().swap_panes, "swap results and preview columns"),
360 shortcut(&app.keybindings().preview_wider, "make preview wider"),
361 shortcut(&app.keybindings().preview_narrower, "make preview narrower"),
362 shortcut(&app.keybindings().toggle_search_bar_position, "move search bar top or bottom"),
363 ],
364 },
365 HelpSection {
366 title: "Mode Notes",
367 rows: vec![
368 HelpRow::Text(
369 "Preview focus only works for editable text and structured-log previews.".to_string(),
370 ),
371 HelpRow::Text(
372 "Direct diff, git-backed previews, and many binary/plain previews stay read-only.".to_string(),
373 ),
374 ],
375 },
376 ]
377}
378
379fn render_sections(sections: &[HelpSection<'_>]) -> Vec<Line<'static>> {
380 let mut lines = Vec::new();
381 for (idx, section) in sections.iter().enumerate() {
382 if idx > 0 {
383 lines.push(Line::from(""));
384 }
385 lines.push(Line::from(vec![Span::styled(
386 section.title.to_string(),
387 Style::default()
388 .fg(Color::LightBlue)
389 .add_modifier(Modifier::BOLD),
390 )]));
391 lines.push(Line::from(vec![Span::styled(
392 "─".repeat(section.title.len().min(32)),
393 Style::default().fg(Color::DarkGray),
394 )]));
395 for row in §ion.rows {
396 match row {
397 HelpRow::Shortcut { keys, description } => {
398 let key_width = 26;
399 let key_len = keys.chars().count();
400 if key_len <= key_width {
401 let mut spans = Vec::new();
402 spans.push(Span::raw(" "));
403 spans.extend(render_key_spans(keys));
404 spans.push(Span::raw(" ".repeat(key_width.saturating_sub(key_len) + 1)));
405 spans.push(Span::styled(
406 description.clone(),
407 Style::default().fg(Color::Gray),
408 ));
409 lines.push(Line::from(spans));
410 } else {
411 let mut key_line = vec![Span::raw(" ")];
412 key_line.extend(render_key_spans(keys));
413 lines.push(Line::from(key_line));
414 lines.push(Line::from(vec![
415 Span::raw(" ".repeat(key_width + 3)),
416 Span::styled(description.clone(), Style::default().fg(Color::Gray)),
417 ]));
418 }
419 }
420 HelpRow::Text(text) => lines.push(Line::from(vec![Span::styled(
421 format!(" {text}"),
422 Style::default().fg(Color::DarkGray),
423 )])),
424 }
425 }
426 }
427 lines
428}
429
430fn render_key_spans(keys: &str) -> Vec<Span<'static>> {
431 let parts = keys.split(" / ").collect::<Vec<_>>();
432 let mut spans = Vec::new();
433 for (idx, part) in parts.iter().enumerate() {
434 if idx > 0 {
435 spans.push(Span::styled(" / ", Style::default().fg(Color::DarkGray)));
436 }
437 spans.push(Span::styled(
438 (*part).to_string(),
439 Style::default().fg(Color::LightCyan),
440 ));
441 }
442 spans
443}
444
445fn shortcut(bindings: &[KeyBinding], description: &str) -> HelpRow {
446 HelpRow::Shortcut {
447 keys: format_keybindings(bindings),
448 description: description.to_string(),
449 }
450}
451
452fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
453 let popup_layout = Layout::default()
454 .direction(Direction::Vertical)
455 .constraints([
456 Constraint::Percentage((100 - percent_y) / 2),
457 Constraint::Percentage(percent_y),
458 Constraint::Percentage((100 - percent_y) / 2),
459 ])
460 .split(r);
461
462 Layout::default()
463 .direction(Direction::Horizontal)
464 .constraints([
465 Constraint::Percentage((100 - percent_x) / 2),
466 Constraint::Percentage(percent_x),
467 Constraint::Percentage((100 - percent_x) / 2),
468 ])
469 .split(popup_layout[1])[1]
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::config::{KeyBinding, Keybindings};
476 use crate::search::types::{MatcherMode, SearchConfig, SearchMode, SearchSettings};
477 use crossterm::event::{KeyCode, KeyModifiers};
478
479 #[test]
480 fn shortcut_rows_use_formatted_custom_bindings() {
481 let row = shortcut(
482 &[
483 KeyBinding {
484 code: KeyCode::F(9),
485 modifiers: KeyModifiers::NONE,
486 },
487 KeyBinding {
488 code: KeyCode::Char('g'),
489 modifiers: KeyModifiers::CONTROL,
490 },
491 ],
492 "toggle",
493 );
494 let HelpRow::Shortcut { keys, description } = row else {
495 panic!("expected shortcut row");
496 };
497 assert_eq!(keys, "F9 / Ctrl+G");
498 assert_eq!(description, "toggle");
499 }
500
501 #[test]
502 fn overview_section_uses_runtime_help_binding() {
503 let mut keybindings = Keybindings::default();
504 keybindings.toggle_help = vec![KeyBinding {
505 code: KeyCode::F(12),
506 modifiers: KeyModifiers::NONE,
507 }];
508 let app = App::from_configs(
509 crate::runtime::config::RunConfig {
510 headless: false,
511 output_format: crate::cli::args::OutputFormat::Plain,
512 output_file: None,
513 stdin: false,
514 log: false,
515 diff: None,
516 preview_command: None,
517 preview_delimiter: ":".to_string(),
518 split: None,
519 log_files: Vec::new(),
520 },
521 SearchConfig {
522 query: None,
523 locations: vec![],
524 search_pdf: false,
525 no_hidden: false,
526 no_git_ignore: false,
527 no_ignore: false,
528 no_default_ignore_dirs: false,
529 git_search_scope: None,
530 settings: SearchSettings {
531 mode: SearchMode::Path,
532 matcher: MatcherMode::Fuzzy,
533 },
534 },
535 crate::config::LoadedAppConfig {
536 keybindings,
537 ..Default::default()
538 },
539 );
540 let sections = overview_sections(&app);
541 let rendered = render_sections(§ions)
542 .into_iter()
543 .flat_map(|line| line.spans.into_iter().map(|span| span.content.into_owned()))
544 .collect::<Vec<_>>()
545 .join("");
546 assert!(rendered.contains("F12"));
547 }
548
549 #[test]
550 fn logs_section_documents_log_viewer_controls() {
551 let rendered = render_sections(&logs_sections(&App::from_configs(
552 crate::runtime::config::RunConfig {
553 headless: false,
554 output_format: crate::cli::args::OutputFormat::Plain,
555 output_file: None,
556 stdin: false,
557 log: false,
558 diff: None,
559 preview_command: None,
560 preview_delimiter: ":".to_string(),
561 split: None,
562 log_files: Vec::new(),
563 },
564 SearchConfig {
565 query: None,
566 locations: vec![],
567 search_pdf: false,
568 no_hidden: false,
569 no_git_ignore: false,
570 no_ignore: false,
571 no_default_ignore_dirs: false,
572 git_search_scope: None,
573 settings: SearchSettings {
574 mode: SearchMode::Path,
575 matcher: MatcherMode::Fuzzy,
576 },
577 },
578 crate::config::LoadedAppConfig::default(),
579 )))
580 .into_iter()
581 .flat_map(|line| line.spans.into_iter().map(|span| span.content.into_owned()))
582 .collect::<Vec<_>>()
583 .join("");
584 assert!(rendered.contains("column picker"));
585 assert!(rendered.contains("pause or resume live updates"));
586 }
587}