use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::filter::FilterApp;
use crate::ui::theme;
fn safe_slice(s: &str, start: usize, end: usize) -> &str {
s.get(start..end).unwrap_or("")
}
pub fn render(frame: &mut Frame, app: &FilterApp) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(1),
])
.split(area);
render_pattern_pane(frame, chunks[0], app);
render_match_list(frame, chunks[1], app);
render_status(frame, chunks[2], app);
}
fn render_pattern_pane(frame: &mut Frame, area: Rect, app: &FilterApp) {
let content = app.pattern();
let style = if app.error.is_some() {
Style::default().fg(theme::RED)
} else {
Style::default().fg(theme::TEXT)
};
let title = if app.error.is_some() {
" Pattern (invalid) "
} else {
" Pattern "
};
let block = Block::default()
.title(Span::styled(title, Style::default().fg(theme::BLUE)))
.borders(Borders::ALL);
let paragraph =
Paragraph::new(Line::from(Span::styled(content.to_string(), style))).block(block);
frame.render_widget(paragraph, area);
}
fn render_match_list(frame: &mut Frame, area: Rect, app: &FilterApp) {
if let Some(err) = app.error.as_deref() {
let block = Block::default().borders(Borders::ALL).title(" Matches ");
let paragraph = Paragraph::new(Line::from(Span::styled(
format!("error: {err}"),
Style::default().fg(theme::RED),
)))
.block(block);
frame.render_widget(paragraph, area);
return;
}
let inner_height = area.height.saturating_sub(2) as usize;
let two_line = app.json_extracted.is_some() && area.width >= 60;
let rows_per_entry = if two_line { 2 } else { 1 };
let max_rows = inner_height / rows_per_entry;
let start = if max_rows == 0 || app.matched.is_empty() {
0
} else {
(app.selected + 1).saturating_sub(max_rows)
};
let end = (start + max_rows).min(app.matched.len());
let visible = if start < end {
&app.matched[start..end]
} else {
&[][..]
};
let items: Vec<ListItem> = visible
.iter()
.enumerate()
.flat_map(|(visible_idx, &line_idx)| {
let absolute = start + visible_idx;
let is_selected = absolute == app.selected;
let spans_for_line: &[std::ops::Range<usize>] = app
.match_spans
.get(absolute)
.map(Vec::as_slice)
.unwrap_or(&[]);
build_row(app, line_idx, spans_for_line, is_selected, two_line)
})
.collect();
let block = Block::default().borders(Borders::ALL).title(Span::styled(
format!(" Matches ({}/{}) ", app.matched.len(), app.lines.len()),
Style::default().fg(theme::BLUE),
));
frame.render_widget(List::new(items).block(block), area);
}
fn build_row<'a>(
app: &'a FilterApp,
line_idx: usize,
spans: &[std::ops::Range<usize>],
is_selected: bool,
two_line: bool,
) -> Vec<ListItem<'a>> {
let raw = &app.lines[line_idx];
let extracted = app
.json_extracted
.as_ref()
.and_then(|v| v.get(line_idx).and_then(|o| o.as_deref()));
match (extracted, two_line) {
(Some(ext), true) => {
let raw_item = build_raw_context(raw, line_idx, is_selected);
let ext_item = build_extracted(ext, spans, is_selected);
vec![raw_item, ext_item]
}
(Some(ext), false) => {
vec![build_line_spans(ext, line_idx, spans, is_selected)]
}
(None, _) => {
vec![build_line_spans(raw, line_idx, spans, is_selected)]
}
}
}
fn build_raw_context(line: &str, line_idx: usize, is_selected: bool) -> ListItem<'_> {
let modifier = if is_selected {
Modifier::REVERSED | Modifier::DIM
} else {
Modifier::DIM
};
let style = Style::default().fg(theme::SUBTEXT).add_modifier(modifier);
let prefix = Span::styled(format!("{:>5} ", line_idx + 1), style);
let body = Span::styled(line, style);
ListItem::new(Line::from(vec![prefix, body]))
}
fn build_extracted<'a>(
extracted: &'a str,
spans: &[std::ops::Range<usize>],
is_selected: bool,
) -> ListItem<'a> {
let base_style = Style::default().fg(theme::TEXT);
let modifier = if is_selected {
Modifier::REVERSED
} else {
Modifier::empty()
};
let mut out: Vec<Span<'a>> = Vec::new();
out.push(Span::styled(
" \u{21b3} ",
Style::default().fg(theme::BLUE).add_modifier(modifier),
));
if spans.is_empty() {
out.push(Span::styled(extracted, base_style.add_modifier(modifier)));
return ListItem::new(Line::from(out));
}
let mut pos = 0;
for (i, range) in spans.iter().enumerate() {
let start = range.start.min(extracted.len());
let end = range.end.min(extracted.len());
if start < end {
if start > pos {
let chunk = safe_slice(extracted, pos, start);
if !chunk.is_empty() {
out.push(Span::styled(chunk, base_style.add_modifier(modifier)));
}
}
let bg = theme::match_bg(i);
let chunk = safe_slice(extracted, start, end);
if !chunk.is_empty() {
out.push(Span::styled(
chunk,
base_style
.bg(bg)
.add_modifier(Modifier::BOLD)
.add_modifier(modifier),
));
}
pos = end;
}
}
if pos < extracted.len() {
let chunk = safe_slice(extracted, pos, extracted.len());
if !chunk.is_empty() {
out.push(Span::styled(chunk, base_style.add_modifier(modifier)));
}
}
ListItem::new(Line::from(out))
}
fn build_line_spans<'a>(
line: &'a str,
line_idx: usize,
spans: &[std::ops::Range<usize>],
is_selected: bool,
) -> ListItem<'a> {
let base_style = Style::default().fg(theme::TEXT);
let modifier = if is_selected {
Modifier::REVERSED
} else {
Modifier::empty()
};
let mut out: Vec<Span<'a>> = Vec::new();
out.push(Span::styled(
format!("{:>5} ", line_idx + 1),
base_style.add_modifier(modifier),
));
if spans.is_empty() {
out.push(Span::styled(line, base_style.add_modifier(modifier)));
return ListItem::new(Line::from(out));
}
let mut pos = 0;
for (i, range) in spans.iter().enumerate() {
let start = range.start.min(line.len());
let end = range.end.min(line.len());
if start < end {
if start > pos {
let chunk = safe_slice(line, pos, start);
if !chunk.is_empty() {
out.push(Span::styled(chunk, base_style.add_modifier(modifier)));
}
}
let bg = theme::match_bg(i);
let chunk = safe_slice(line, start, end);
if !chunk.is_empty() {
out.push(Span::styled(
chunk,
base_style
.bg(bg)
.add_modifier(Modifier::BOLD)
.add_modifier(modifier),
));
}
pos = end;
}
}
if pos < line.len() {
let chunk = safe_slice(line, pos, line.len());
if !chunk.is_empty() {
out.push(Span::styled(chunk, base_style.add_modifier(modifier)));
}
}
ListItem::new(Line::from(out))
}
fn render_status(frame: &mut Frame, area: Rect, app: &FilterApp) {
let flags = if app.options.case_insensitive {
"i"
} else {
"-"
};
let invert = if app.options.invert { "v" } else { "-" };
let text = format!(
" flags: [{flags}{invert}] Enter: emit Esc: discard Alt+i: case Alt+v: invert Up/Down: browse "
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
text,
Style::default().fg(theme::SUBTEXT),
))),
area,
);
}