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