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
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 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 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 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
104fn 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 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 vec![build_line_spans(ext, line_idx, spans, is_selected)]
135 }
136 (None, _) => {
137 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 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
211fn 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 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 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}