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
12/// Slice a string by a byte range, returning `""` on any invalid or
13/// char-boundary-crossing range. The `regex` crate always returns char-aligned
14/// offsets in practice, so this only matters for defensively-handled spans
15/// constructed by callers — we never want to panic the TUI on an odd input.
16fn safe_slice(s: &str, start: usize, end: usize) -> &str {
17    s.get(start..end).unwrap_or("")
18}
19
20pub fn render(frame: &mut Frame, app: &FilterApp) {
21    let area = frame.area();
22    let chunks = Layout::default()
23        .direction(Direction::Vertical)
24        .constraints([
25            Constraint::Length(3),
26            Constraint::Min(1),
27            Constraint::Length(1),
28        ])
29        .split(area);
30
31    render_pattern_pane(frame, chunks[0], app);
32    render_match_list(frame, chunks[1], app);
33    render_status(frame, chunks[2], app);
34}
35
36fn render_pattern_pane(frame: &mut Frame, area: Rect, app: &FilterApp) {
37    let content = app.pattern();
38    let style = if app.error.is_some() {
39        Style::default().fg(theme::RED)
40    } else {
41        Style::default().fg(theme::TEXT)
42    };
43    let title = if app.error.is_some() {
44        " Pattern (invalid) "
45    } else {
46        " Pattern "
47    };
48    let block = Block::default()
49        .title(Span::styled(title, Style::default().fg(theme::BLUE)))
50        .borders(Borders::ALL);
51    let paragraph =
52        Paragraph::new(Line::from(Span::styled(content.to_string(), style))).block(block);
53    frame.render_widget(paragraph, area);
54}
55
56fn render_match_list(frame: &mut Frame, area: Rect, app: &FilterApp) {
57    if let Some(err) = app.error.as_deref() {
58        let block = Block::default().borders(Borders::ALL).title(" Matches ");
59        let paragraph = Paragraph::new(Line::from(Span::styled(
60            format!("error: {err}"),
61            Style::default().fg(theme::RED),
62        )))
63        .block(block);
64        frame.render_widget(paragraph, area);
65        return;
66    }
67
68    let inner_height = area.height.saturating_sub(2) as usize;
69    // --json mode renders two lines per row (raw JSON + extracted value).
70    // Narrow terminals (< 60 cols) fall back to single-line rendering to keep
71    // the display legible.
72    let two_line = app.json_extracted.is_some() && area.width >= 60;
73    let rows_per_entry = if two_line { 2 } else { 1 };
74    let max_rows = inner_height / rows_per_entry;
75
76    // Derive the scroll offset so the selected row is always visible. `app.scroll`
77    // is retained as a hint for future page-up/down, but the effective start is
78    // whatever keeps `selected` in view.
79    let start = if max_rows == 0 || app.matched.is_empty() {
80        0
81    } else {
82        (app.selected + 1).saturating_sub(max_rows)
83    };
84    let end = (start + max_rows).min(app.matched.len());
85    let visible = if start < end {
86        &app.matched[start..end]
87    } else {
88        &[][..]
89    };
90    let items: Vec<ListItem> = visible
91        .iter()
92        .enumerate()
93        .flat_map(|(visible_idx, &line_idx)| {
94            let absolute = start + visible_idx;
95            let is_selected = absolute == app.selected;
96            // Empty Vec if invert mode or empty pattern.
97            let spans_for_line: &[std::ops::Range<usize>] = app
98                .match_spans
99                .get(absolute)
100                .map(Vec::as_slice)
101                .unwrap_or(&[]);
102            build_row(app, line_idx, spans_for_line, is_selected, two_line)
103        })
104        .collect();
105    let block = Block::default().borders(Borders::ALL).title(Span::styled(
106        format!(" Matches ({}/{}) ", app.matched.len(), app.lines.len()),
107        Style::default().fg(theme::BLUE),
108    ));
109    frame.render_widget(List::new(items).block(block), area);
110}
111
112/// Build one or two `ListItem`s for a single matched line.
113///
114/// Returns two items when `two_line` is `true` (--json mode on a wide enough
115/// terminal): first the raw JSON line dimmed, then `↳ <extracted>` with the
116/// match spans highlighted. Otherwise returns a single item whose content
117/// depends on whether --json is active: either the raw line with spans, or
118/// the extracted value with spans (narrow --json fallback).
119fn build_row<'a>(
120    app: &'a FilterApp,
121    line_idx: usize,
122    spans: &[std::ops::Range<usize>],
123    is_selected: bool,
124    two_line: bool,
125) -> Vec<ListItem<'a>> {
126    let raw = &app.lines[line_idx];
127    let extracted = app
128        .json_extracted
129        .as_ref()
130        .and_then(|v| v.get(line_idx).and_then(|o| o.as_deref()));
131
132    match (extracted, two_line) {
133        (Some(ext), true) => {
134            // Two-line row: raw JSON (dim, no span highlights) + extracted with spans.
135            let raw_item = build_raw_context(raw, line_idx, is_selected);
136            let ext_item = build_extracted(ext, spans, is_selected);
137            vec![raw_item, ext_item]
138        }
139        (Some(ext), false) => {
140            // Narrow fallback: render only the extracted value with spans.
141            // We still prefix with the line number so selection/orientation is clear.
142            vec![build_line_spans(ext, line_idx, spans, is_selected)]
143        }
144        (None, _) => {
145            // No --json: render the raw line with match spans (existing behavior).
146            vec![build_line_spans(raw, line_idx, spans, is_selected)]
147        }
148    }
149}
150
151fn build_raw_context(line: &str, line_idx: usize, is_selected: bool) -> ListItem<'_> {
152    let modifier = if is_selected {
153        Modifier::REVERSED | Modifier::DIM
154    } else {
155        Modifier::DIM
156    };
157    let style = Style::default().fg(theme::SUBTEXT).add_modifier(modifier);
158    let prefix = Span::styled(format!("{:>5}  ", line_idx + 1), style);
159    let body = Span::styled(line, style);
160    ListItem::new(Line::from(vec![prefix, body]))
161}
162
163fn build_extracted<'a>(
164    extracted: &'a str,
165    spans: &[std::ops::Range<usize>],
166    is_selected: bool,
167) -> ListItem<'a> {
168    let base_style = Style::default().fg(theme::TEXT);
169    let modifier = if is_selected {
170        Modifier::REVERSED
171    } else {
172        Modifier::empty()
173    };
174
175    let mut out: Vec<Span<'a>> = Vec::new();
176    // 7-char indent to align with the line-number prefix width above (5) + 2 spaces.
177    out.push(Span::styled(
178        "     \u{21b3} ",
179        Style::default().fg(theme::BLUE).add_modifier(modifier),
180    ));
181
182    if spans.is_empty() {
183        out.push(Span::styled(extracted, base_style.add_modifier(modifier)));
184        return ListItem::new(Line::from(out));
185    }
186
187    let mut pos = 0;
188    for (i, range) in spans.iter().enumerate() {
189        let start = range.start.min(extracted.len());
190        let end = range.end.min(extracted.len());
191        if start < end {
192            if start > pos {
193                let chunk = safe_slice(extracted, pos, start);
194                if !chunk.is_empty() {
195                    out.push(Span::styled(chunk, base_style.add_modifier(modifier)));
196                }
197            }
198            let bg = theme::match_bg(i);
199            let chunk = safe_slice(extracted, start, end);
200            if !chunk.is_empty() {
201                out.push(Span::styled(
202                    chunk,
203                    base_style
204                        .bg(bg)
205                        .add_modifier(Modifier::BOLD)
206                        .add_modifier(modifier),
207                ));
208            }
209            pos = end;
210        }
211    }
212    if pos < extracted.len() {
213        let chunk = safe_slice(extracted, pos, extracted.len());
214        if !chunk.is_empty() {
215            out.push(Span::styled(chunk, base_style.add_modifier(modifier)));
216        }
217    }
218
219    ListItem::new(Line::from(out))
220}
221
222/// Build a styled `ListItem` for a single line, alternating match-span backgrounds
223/// to match the main rgx match-display panel. When `is_selected` is true the
224/// entire row is reversed to preserve the existing selection indicator.
225fn build_line_spans<'a>(
226    line: &'a str,
227    line_idx: usize,
228    spans: &[std::ops::Range<usize>],
229    is_selected: bool,
230) -> ListItem<'a> {
231    let base_style = Style::default().fg(theme::TEXT);
232    let modifier = if is_selected {
233        Modifier::REVERSED
234    } else {
235        Modifier::empty()
236    };
237
238    let mut out: Vec<Span<'a>> = Vec::new();
239    // Prefix stays unstyled (no match background) but still reverses on selection.
240    out.push(Span::styled(
241        format!("{:>5}  ", line_idx + 1),
242        base_style.add_modifier(modifier),
243    ));
244
245    if spans.is_empty() {
246        out.push(Span::styled(line, base_style.add_modifier(modifier)));
247        return ListItem::new(Line::from(out));
248    }
249
250    let mut pos = 0;
251    for (i, range) in spans.iter().enumerate() {
252        // Clamp to line length so `pos = end` below stays inside the string —
253        // otherwise the trailing "emit remainder" branch would skip content
254        // when `end > line.len()`. `safe_slice` already handles the slice
255        // itself.
256        let start = range.start.min(line.len());
257        let end = range.end.min(line.len());
258        if start < end {
259            if start > pos {
260                let chunk = safe_slice(line, pos, start);
261                if !chunk.is_empty() {
262                    out.push(Span::styled(chunk, base_style.add_modifier(modifier)));
263                }
264            }
265            let bg = theme::match_bg(i);
266            let chunk = safe_slice(line, start, end);
267            if !chunk.is_empty() {
268                out.push(Span::styled(
269                    chunk,
270                    base_style
271                        .bg(bg)
272                        .add_modifier(Modifier::BOLD)
273                        .add_modifier(modifier),
274                ));
275            }
276            pos = end;
277        }
278    }
279    if pos < line.len() {
280        let chunk = safe_slice(line, pos, line.len());
281        if !chunk.is_empty() {
282            out.push(Span::styled(chunk, base_style.add_modifier(modifier)));
283        }
284    }
285
286    ListItem::new(Line::from(out))
287}
288
289fn render_status(frame: &mut Frame, area: Rect, app: &FilterApp) {
290    let flags = if app.options.case_insensitive {
291        "i"
292    } else {
293        "-"
294    };
295    let invert = if app.options.invert { "v" } else { "-" };
296    let text = format!(
297        " flags: [{flags}{invert}]   Enter: emit  Esc: discard  Alt+i: case  Alt+v: invert  Up/Down: browse "
298    );
299    frame.render_widget(
300        Paragraph::new(Line::from(Span::styled(
301            text,
302            Style::default().fg(theme::SUBTEXT),
303        ))),
304        area,
305    );
306}