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>] =
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
109fn 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 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 vec![build_line_spans(ext, line_idx, spans, is_selected)]
140 }
141 (None, _) => {
142 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 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
219fn 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 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 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}