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    // --json mode renders two lines per row (raw JSON + extracted value).
62    // Narrow terminals (< 60 cols) fall back to single-line rendering to keep
63    // the display legible.
64    let two_line = app.json_extracted.is_some() && area.width >= 60;
65    let rows_per_entry = if two_line { 2 } else { 1 };
66    let max_rows = inner_height / rows_per_entry.max(1);
67
68    // Derive the scroll offset so the selected row is always visible. `app.scroll`
69    // is retained as a hint for future page-up/down, but the effective start is
70    // whatever keeps `selected` in view.
71    let start = if max_rows == 0 || app.matched.is_empty() {
72        0
73    } else {
74        (app.selected + 1).saturating_sub(max_rows)
75    };
76    let end = (start + max_rows).min(app.matched.len());
77    let visible = if start < end {
78        &app.matched[start..end]
79    } else {
80        &[][..]
81    };
82    let items: Vec<ListItem> = visible
83        .iter()
84        .enumerate()
85        .flat_map(|(visible_idx, &line_idx)| {
86            let absolute = start + visible_idx;
87            let is_selected = absolute == app.selected;
88            // Empty Vec if invert mode or empty pattern.
89            let spans_for_line: &[std::ops::Range<usize>] = app
90                .match_spans
91                .get(absolute)
92                .map(|v| v.as_slice())
93                .unwrap_or(&[]);
94            build_row(app, line_idx, spans_for_line, is_selected, two_line)
95        })
96        .collect();
97    let block = Block::default().borders(Borders::ALL).title(Span::styled(
98        format!(" Matches ({}/{}) ", app.matched.len(), app.lines.len()),
99        Style::default().fg(theme::BLUE),
100    ));
101    frame.render_widget(List::new(items).block(block), area);
102}
103
104/// Build one or two `ListItem`s for a single matched line.
105///
106/// Returns two items when `two_line` is `true` (--json mode on a wide enough
107/// terminal): first the raw JSON line dimmed, then `↳ <extracted>` with the
108/// match spans highlighted. Otherwise returns a single item whose content
109/// depends on whether --json is active: either the raw line with spans, or
110/// the extracted value with spans (narrow --json fallback).
111fn build_row<'a>(
112    app: &'a FilterApp,
113    line_idx: usize,
114    spans: &[std::ops::Range<usize>],
115    is_selected: bool,
116    two_line: bool,
117) -> Vec<ListItem<'a>> {
118    let raw = &app.lines[line_idx];
119    let extracted = app
120        .json_extracted
121        .as_ref()
122        .and_then(|v| v.get(line_idx).and_then(|o| o.as_deref()));
123
124    match (extracted, two_line) {
125        (Some(ext), true) => {
126            // Two-line row: raw JSON (dim, no span highlights) + extracted with spans.
127            let raw_item = build_raw_context(raw, line_idx, is_selected);
128            let ext_item = build_extracted(ext, spans, is_selected);
129            vec![raw_item, ext_item]
130        }
131        (Some(ext), false) => {
132            // Narrow fallback: render only the extracted value with spans.
133            // We still prefix with the line number so selection/orientation is clear.
134            vec![build_line_spans(ext, line_idx, spans, is_selected)]
135        }
136        (None, _) => {
137            // No --json: render the raw line with match spans (existing behavior).
138            vec![build_line_spans(raw, line_idx, spans, is_selected)]
139        }
140    }
141}
142
143fn build_raw_context(line: &str, line_idx: usize, is_selected: bool) -> ListItem<'_> {
144    let modifier = if is_selected {
145        Modifier::REVERSED | Modifier::DIM
146    } else {
147        Modifier::DIM
148    };
149    let style = Style::default().fg(theme::SUBTEXT).add_modifier(modifier);
150    let prefix = Span::styled(format!("{:>5}  ", line_idx + 1), style);
151    let body = Span::styled(line, style);
152    ListItem::new(Line::from(vec![prefix, body]))
153}
154
155fn build_extracted<'a>(
156    extracted: &'a str,
157    spans: &[std::ops::Range<usize>],
158    is_selected: bool,
159) -> ListItem<'a> {
160    let base_style = Style::default().fg(theme::TEXT);
161    let modifier = if is_selected {
162        Modifier::REVERSED
163    } else {
164        Modifier::empty()
165    };
166
167    let mut out: Vec<Span<'a>> = Vec::new();
168    // 7-char indent to align with the line-number prefix width above (5) + 2 spaces.
169    out.push(Span::styled(
170        "     \u{21b3} ",
171        Style::default().fg(theme::BLUE).add_modifier(modifier),
172    ));
173
174    if spans.is_empty() {
175        out.push(Span::styled(extracted, base_style.add_modifier(modifier)));
176        return ListItem::new(Line::from(out));
177    }
178
179    let mut pos = 0;
180    for (i, range) in spans.iter().enumerate() {
181        let start = range.start.min(extracted.len());
182        let end = range.end.min(extracted.len());
183        if start < end {
184            if start > pos {
185                out.push(Span::styled(
186                    &extracted[pos..start],
187                    base_style.add_modifier(modifier),
188                ));
189            }
190            let bg = theme::match_bg(i);
191            out.push(Span::styled(
192                &extracted[start..end],
193                base_style
194                    .bg(bg)
195                    .add_modifier(Modifier::BOLD)
196                    .add_modifier(modifier),
197            ));
198            pos = end;
199        }
200    }
201    if pos < extracted.len() {
202        out.push(Span::styled(
203            &extracted[pos..],
204            base_style.add_modifier(modifier),
205        ));
206    }
207
208    ListItem::new(Line::from(out))
209}
210
211/// Build a styled `ListItem` for a single line, alternating match-span backgrounds
212/// to match the main rgx match-display panel. When `is_selected` is true the
213/// entire row is reversed to preserve the existing selection indicator.
214fn build_line_spans<'a>(
215    line: &'a str,
216    line_idx: usize,
217    spans: &[std::ops::Range<usize>],
218    is_selected: bool,
219) -> ListItem<'a> {
220    let base_style = Style::default().fg(theme::TEXT);
221    let modifier = if is_selected {
222        Modifier::REVERSED
223    } else {
224        Modifier::empty()
225    };
226
227    let mut out: Vec<Span<'a>> = Vec::new();
228    // Prefix stays unstyled (no match background) but still reverses on selection.
229    out.push(Span::styled(
230        format!("{:>5}  ", line_idx + 1),
231        base_style.add_modifier(modifier),
232    ));
233
234    if spans.is_empty() {
235        out.push(Span::styled(line, base_style.add_modifier(modifier)));
236        return ListItem::new(Line::from(out));
237    }
238
239    let mut pos = 0;
240    for (i, range) in spans.iter().enumerate() {
241        // Clamp to line length defensively; a malformed range would panic the slice.
242        let start = range.start.min(line.len());
243        let end = range.end.min(line.len());
244        if start < end {
245            if start > pos {
246                out.push(Span::styled(
247                    &line[pos..start],
248                    base_style.add_modifier(modifier),
249                ));
250            }
251            let bg = theme::match_bg(i);
252            out.push(Span::styled(
253                &line[start..end],
254                base_style
255                    .bg(bg)
256                    .add_modifier(Modifier::BOLD)
257                    .add_modifier(modifier),
258            ));
259            pos = end;
260        }
261    }
262    if pos < line.len() {
263        out.push(Span::styled(
264            &line[pos..],
265            base_style.add_modifier(modifier),
266        ));
267    }
268
269    ListItem::new(Line::from(out))
270}
271
272fn render_status(frame: &mut Frame, area: Rect, app: &FilterApp) {
273    let flags = if app.options.case_insensitive {
274        "i"
275    } else {
276        "-"
277    };
278    let invert = if app.options.invert { "v" } else { "-" };
279    let text = format!(
280        " flags: [{flags}{invert}]   Enter: emit  Esc: discard  Alt+i: case  Alt+v: invert  Up/Down: browse "
281    );
282    frame.render_widget(
283        Paragraph::new(Line::from(Span::styled(
284            text,
285            Style::default().fg(theme::SUBTEXT),
286        ))),
287        area,
288    );
289}