Skip to main content

rgx/filter/
ui.rs

1//! Rendering for `rgx filter` TUI mode.
2
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4use ratatui::style::{Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
7use ratatui::Frame;
8
9use crate::filter::FilterApp;
10use crate::ui::theme;
11
12pub fn render(frame: &mut Frame, app: &FilterApp) {
13    let area = frame.area();
14    let chunks = Layout::default()
15        .direction(Direction::Vertical)
16        .constraints([
17            Constraint::Length(3),
18            Constraint::Min(1),
19            Constraint::Length(1),
20        ])
21        .split(area);
22
23    render_pattern_pane(frame, chunks[0], app);
24    render_match_list(frame, chunks[1], app);
25    render_status(frame, chunks[2], app);
26}
27
28fn render_pattern_pane(frame: &mut Frame, area: Rect, app: &FilterApp) {
29    let content = app.pattern();
30    let style = if app.error.is_some() {
31        Style::default().fg(theme::RED)
32    } else {
33        Style::default().fg(theme::TEXT)
34    };
35    let title = if app.error.is_some() {
36        " Pattern (invalid) "
37    } else {
38        " Pattern "
39    };
40    let block = Block::default()
41        .title(Span::styled(title, Style::default().fg(theme::BLUE)))
42        .borders(Borders::ALL);
43    let paragraph =
44        Paragraph::new(Line::from(Span::styled(content.to_string(), style))).block(block);
45    frame.render_widget(paragraph, area);
46}
47
48fn render_match_list(frame: &mut Frame, area: Rect, app: &FilterApp) {
49    if let Some(err) = app.error.as_deref() {
50        let block = Block::default().borders(Borders::ALL).title(" Matches ");
51        let paragraph = Paragraph::new(Line::from(Span::styled(
52            format!("error: {err}"),
53            Style::default().fg(theme::RED),
54        )))
55        .block(block);
56        frame.render_widget(paragraph, area);
57        return;
58    }
59
60    let inner_height = area.height.saturating_sub(2) as usize;
61    // Derive the scroll offset so the selected row is always visible. `app.scroll`
62    // is retained as a hint for future page-up/down, but the effective start is
63    // whatever keeps `selected` in view.
64    let start = if inner_height == 0 || app.matched.is_empty() {
65        0
66    } else {
67        (app.selected + 1).saturating_sub(inner_height)
68    };
69    let end = (start + inner_height).min(app.matched.len());
70    let visible = if start < end {
71        &app.matched[start..end]
72    } else {
73        &[][..]
74    };
75    let items: Vec<ListItem> = visible
76        .iter()
77        .enumerate()
78        .map(|(visible_idx, &line_idx)| {
79            let absolute = start + visible_idx;
80            let mut style = Style::default().fg(theme::TEXT);
81            if absolute == app.selected {
82                style = style.add_modifier(Modifier::REVERSED);
83            }
84            let content = format!("{:>5}  {}", line_idx + 1, app.lines[line_idx]);
85            ListItem::new(Line::from(Span::styled(content, style)))
86        })
87        .collect();
88    let block = Block::default().borders(Borders::ALL).title(Span::styled(
89        format!(" Matches ({}/{}) ", app.matched.len(), app.lines.len()),
90        Style::default().fg(theme::BLUE),
91    ));
92    frame.render_widget(List::new(items).block(block), area);
93}
94
95fn render_status(frame: &mut Frame, area: Rect, app: &FilterApp) {
96    let flags = if app.options.case_insensitive {
97        "i"
98    } else {
99        "-"
100    };
101    let invert = if app.options.invert { "v" } else { "-" };
102    let text = format!(
103        " flags: [{flags}{invert}]   Enter: emit  Esc: discard  Alt+i: case  Alt+v: invert  Up/Down: browse "
104    );
105    frame.render_widget(
106        Paragraph::new(Line::from(Span::styled(
107            text,
108            Style::default().fg(theme::SUBTEXT),
109        ))),
110        area,
111    );
112}