1use 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
12fn 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 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 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 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
112fn 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 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 vec![build_line_spans(ext, line_idx, spans, is_selected)]
143 }
144 (None, _) => {
145 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 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
222fn 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 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 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}