Skip to main content

binocular/ui/
help.rs

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(&sections);
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 &section.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(&sections)
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}