use ratatui::{
layout::{Constraint, Layout, Margin, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{
Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Scrollbar,
ScrollbarOrientation, ScrollbarState, Table, TableState, Wrap,
},
Frame,
};
use crate::app::group_digits;
use crate::app::{App, Focus, Prompt, UiLayout, RENDER_CAP};
use crate::filter::value_to_string;
const FOCUS_COLOR: Color = Color::Cyan;
fn to_area(r: Rect) -> crate::app::Area {
crate::app::Area {
x: r.x,
y: r.y,
w: r.width,
h: r.height,
}
}
pub fn draw(f: &mut Frame, app: &mut App) {
app.ui = UiLayout::default();
let [inputs_area, main_area, status_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(2),
])
.areas(f.area());
draw_inputs(f, app, inputs_area);
let sidebar_width = 42.min(main_area.width / 2).max(20);
let [sidebar, table_area] =
Layout::horizontal([Constraint::Length(sidebar_width), Constraint::Min(20)])
.areas(main_area);
draw_sidebar(f, app, sidebar);
draw_table(f, app, table_area);
draw_status(f, app, status_area);
if app.detail.is_some() {
draw_detail(f, app);
}
if app.prompt.is_some() {
draw_prompt(f, app);
}
}
fn block(title: &str, focused: bool) -> Block<'_> {
let mut b = Block::default().borders(Borders::ALL).title(title);
if focused {
b = b.border_style(Style::new().fg(FOCUS_COLOR));
}
b
}
fn draw_scrollbar(f: &mut Frame, area: Rect, total: usize, viewport: usize, offset: usize) {
if total <= viewport || viewport == 0 {
return;
}
let mut state = ScrollbarState::new(total.saturating_sub(viewport)).position(offset);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None),
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut state,
);
}
fn draw_inputs(f: &mut Frame, app: &mut App, area: Rect) {
let [search_a, filter_a, group_a] = Layout::horizontal([
Constraint::Percentage(34),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.areas(area);
app.ui.search = to_area(search_a);
app.ui.filter = to_area(filter_a);
app.ui.group = to_area(group_a);
draw_input_box(
f,
search_a,
"Search [/] (re: = regex)",
&app.search_input,
app.focus == Focus::Search,
app.search_error.is_some(),
);
draw_input_box(
f,
filter_a,
"Filter [f] (field=v f>v f~re ...)",
&app.filter_input,
app.focus == Focus::Filter,
app.filter_error.is_some(),
);
let group_display = if app.focus == Focus::GroupBy {
app.group_input.clone()
} else {
app.group_field.clone().unwrap_or_default()
};
draw_input_box(
f,
group_a,
"Group by [g] (field name)",
&group_display,
app.focus == Focus::GroupBy,
false,
);
}
fn draw_input_box(f: &mut Frame, area: Rect, title: &str, text: &str, focused: bool, error: bool) {
let mut style = Style::new();
if error {
style = style.fg(Color::Red);
}
let p = Paragraph::new(text)
.style(style)
.block(block(title, focused));
f.render_widget(p, area);
if focused {
let x = area.x
+ 1
+ text
.chars()
.count()
.min((area.width as usize).saturating_sub(3)) as u16;
f.set_cursor_position((x, area.y + 1));
}
}
fn draw_sidebar(f: &mut Frame, app: &mut App, area: Rect) {
let columns_height = (app.active_columns.len() as u16 + 2).clamp(3, 10);
let mut constraints = vec![Constraint::Min(5), Constraint::Length(columns_height)];
if app.group_field.is_some() {
let facet_height = (app.facets.len() as u16 + 2).clamp(3, 10);
constraints.push(Constraint::Length(facet_height));
}
let chunks = Layout::vertical(constraints).split(area);
draw_field_tree(f, app, chunks[0]);
draw_active_columns(f, app, chunks[1]);
if app.group_field.is_some() {
draw_facets(f, app, chunks[2]);
}
}
fn draw_field_tree(f: &mut Frame, app: &mut App, area: Rect) {
let total = app.dataset.records.len().max(1);
let items: Vec<ListItem> = app
.field_paths
.iter()
.map(|path| {
let info = &app.dataset.schema[path];
let depth = path.split('.').count().saturating_sub(1);
let name = path.rsplit('.').next().unwrap_or(path);
let checked = app.active_columns.contains(path);
let checkbox = if checked { "[x] " } else { "[ ] " };
let pct = info.count * 100 / total;
let types: Vec<&str> = info.types.iter().copied().collect();
let ann = format!(" {pct}% {}", types.join("|"));
ListItem::new(Line::from(vec![
Span::styled(
checkbox,
if checked {
Style::new().fg(Color::Green)
} else {
Style::new()
},
),
Span::raw(" ".repeat(depth)),
Span::raw(name.to_string()),
Span::styled(ann, Style::new().add_modifier(Modifier::DIM)),
]))
})
.collect();
let title = format!("Fields ({}) [Space toggles]", app.field_paths.len());
let blk = block(&title, app.focus == Focus::FieldTree);
let inner = blk.inner(area);
let list = List::new(items)
.block(blk)
.highlight_style(
Style::new()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut state = ListState::default()
.with_offset(app.tree_view)
.with_selected(Some(app.tree_selected));
f.render_stateful_widget(list, area, &mut state);
app.tree_view = state.offset();
app.ui.tree = to_area(inner);
app.ui.tree_offset = state.offset();
draw_scrollbar(
f,
area,
app.field_paths.len(),
inner.height as usize,
state.offset(),
);
}
fn draw_active_columns(f: &mut Frame, app: &mut App, area: Rect) {
let items: Vec<ListItem> = app
.active_columns
.iter()
.enumerate()
.map(|(i, c)| {
let mut spans = vec![Span::raw(format!("{}. {}", i + 1, c))];
if app.sort_field.as_deref() == Some(c.as_str()) {
spans.push(Span::styled(
if app.sort_desc { " ▼" } else { " ▲" },
Style::new().fg(Color::Yellow),
));
}
ListItem::new(Line::from(spans))
})
.collect();
let blk = block(
"Active columns [ [ ] reorder, Space removes, s sorts ]",
app.focus == Focus::ActiveColumns,
);
let inner = blk.inner(area);
let list = List::new(items)
.block(blk)
.highlight_style(
Style::new()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut state = ListState::default()
.with_offset(app.columns_view)
.with_selected(Some(app.columns_selected));
f.render_stateful_widget(list, area, &mut state);
app.columns_view = state.offset();
app.ui.columns = to_area(inner);
app.ui.columns_offset = state.offset();
draw_scrollbar(
f,
area,
app.active_columns.len(),
inner.height as usize,
state.offset(),
);
}
fn draw_facets(f: &mut Frame, app: &mut App, area: Rect) {
let items: Vec<ListItem> = app
.facets
.iter()
.map(|(value, count)| {
let selected = app.group_facet.as_deref() == Some(value.as_str());
let marker = if selected { "● " } else { " " };
ListItem::new(Line::from(vec![
Span::styled(marker, Style::new().fg(Color::Yellow)),
Span::raw(truncate(value, 24)),
Span::styled(
format!(" ({})", group_digits(*count)),
Style::new().add_modifier(Modifier::DIM),
),
]))
})
.collect();
let title = format!(
"Group: {} [Enter filters]",
app.group_field.as_deref().unwrap_or("")
);
let blk = block(&title, app.focus == Focus::Facets);
let inner = blk.inner(area);
let list = List::new(items)
.block(blk)
.highlight_style(
Style::new()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut state = ListState::default()
.with_offset(app.facets_view)
.with_selected(Some(app.facet_selected));
f.render_stateful_widget(list, area, &mut state);
app.facets_view = state.offset();
app.ui.facets = to_area(inner);
app.ui.facets_offset = state.offset();
draw_scrollbar(
f,
area,
app.facets.len(),
inner.height as usize,
state.offset(),
);
}
fn cell_display(v: &serde_json::Value) -> String {
value_to_string(v).replace('\n', "").replace('\r', "")
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
fn draw_table(f: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focus == Focus::Table;
let title = format!(
"Records — {}",
app.file_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
);
if app.active_columns.is_empty() {
let blk = block(&title, focused);
app.ui.table = to_area(blk.inner(area));
let p = Paragraph::new(
"No columns selected.\n\nPress Tab to focus the field tree, then Space to add columns.",
)
.block(blk)
.wrap(Wrap { trim: false });
f.render_widget(p, area);
return;
}
let shown = app.shown_rows();
let rows_idx = &app.visible_indices[..shown];
let widths: Vec<Constraint> = app
.active_columns
.iter()
.map(|col| {
let mut w = col.chars().count() + 2; for &i in rows_idx.iter().take(100) {
if let Some(v) = app.dataset.records[i].flat.get(col) {
w = w.max(cell_display(v).chars().count());
}
}
Constraint::Length(w.clamp(6, 40) as u16)
})
.collect();
let header_cells: Vec<Cell> = app
.active_columns
.iter()
.enumerate()
.map(|(i, col)| {
let mut text = truncate(col, 38);
if app.sort_field.as_deref() == Some(col.as_str()) {
text.push_str(if app.sort_desc { " ▼" } else { " ▲" });
}
let mut style = Style::new().add_modifier(Modifier::BOLD);
if focused && i == app.table_col_selected {
style = style.fg(FOCUS_COLOR).add_modifier(Modifier::UNDERLINED);
} else if app.sort_field.as_deref() == Some(col.as_str()) {
style = style.fg(Color::Yellow);
}
Cell::from(text).style(style)
})
.collect();
let rows: Vec<Row> = rows_idx
.iter()
.map(|&i| {
let rec = &app.dataset.records[i];
let cells: Vec<Cell> = app
.active_columns
.iter()
.map(|col| {
Cell::from(
rec.flat
.get(col)
.map(|v| truncate(&cell_display(v), 40))
.unwrap_or_default(),
)
})
.collect();
Row::new(cells)
})
.collect();
let table = Table::new(rows, widths.clone())
.header(Row::new(header_cells).height(1))
.block(block(&title, focused))
.row_highlight_style(
Style::new()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut state = TableState::default()
.with_offset(app.table_view)
.with_selected(Some(app.table_selected));
f.render_stateful_widget(table, area, &mut state);
app.table_view = state.offset();
let inner = block(&title, focused).inner(area);
let sel_w: u16 = 2;
let data_rect = Rect {
x: inner.x + sel_w,
y: inner.y,
width: inner.width.saturating_sub(sel_w),
height: inner.height,
};
let col_rects = Layout::horizontal(widths).spacing(1).split(data_rect);
app.ui.table = to_area(inner);
app.ui.table_offset = state.offset();
app.ui.col_spans = col_rects.iter().map(|r| (r.x, r.x + r.width)).collect();
draw_scrollbar(
f,
area,
shown,
(inner.height as usize).saturating_sub(1), state.offset(),
);
}
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
let visible = app.visible_indices.len();
let total = app.dataset.records.len();
let mut spans = vec![Span::styled(
format!("{}/{} records", group_digits(visible), group_digits(total)),
Style::new().add_modifier(Modifier::BOLD),
)];
if visible > RENDER_CAP {
spans.push(Span::styled(
format!(" (showing first {})", group_digits(RENDER_CAP)),
Style::new().fg(Color::Yellow),
));
}
if app.dataset.parse_errors > 0 {
spans.push(Span::styled(
format!(
" | {}/{} lines malformed (skipped)",
group_digits(app.dataset.parse_errors),
group_digits(app.dataset.total_lines)
),
Style::new().fg(Color::Red),
));
}
if let Some(sf) = &app.sort_field {
spans.push(Span::raw(format!(
" | sort: {sf} {}",
if app.sort_desc { "▼" } else { "▲" }
)));
}
if let Some(gf) = &app.group_field {
let facet = app
.group_facet
.as_deref()
.map(|v| format!(" = {}", truncate(v, 20)))
.unwrap_or_default();
spans.push(Span::raw(format!(" | group: {gf}{facet}")));
}
if let Some(err) = &app.filter_error {
spans.push(Span::styled(
format!(" | filter error: {err}"),
Style::new().fg(Color::Red),
));
}
if let Some(err) = &app.search_error {
spans.push(Span::styled(
format!(" | search error: {err}"),
Style::new().fg(Color::Red),
));
}
let line2 = if let Some(msg) = &app.status {
Line::from(Span::styled(msg.clone(), Style::new().fg(Color::Yellow)))
} else {
Line::from(Span::raw(
"q quit / search f filter g group Tab focus Space toggle col [ ] reorder \
s sort Enter detail ^S save ^L load ^E export ^R reset",
))
.add_modifier(Modifier::DIM)
};
let p = Paragraph::new(vec![Line::from(spans), line2]);
f.render_widget(p, area);
}
fn centered_rect(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
let [_, mid, _] = Layout::vertical([
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
])
.areas(area);
let [_, rect, _] = Layout::horizontal([
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
])
.areas(mid);
rect
}
fn draw_detail(f: &mut Frame, app: &mut App) {
let Some(rec_idx) = app.detail else { return };
let area = centered_rect(f.area(), 84, 84);
app.ui.detail = to_area(area);
f.render_widget(Clear, area);
let pretty = serde_json::to_string_pretty(&app.dataset.records[rec_idx].original)
.unwrap_or_else(|_| "<unprintable>".to_string());
let line_count = pretty.lines().count() as u16;
let inner_h = area.height.saturating_sub(2);
app.detail_scroll = app.detail_scroll.min(line_count.saturating_sub(inner_h));
let title = format!(
"Record {} of {} — ↑/↓ scroll, Esc closes",
rec_idx + 1,
group_digits(app.dataset.records.len())
);
let p = Paragraph::new(pretty)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::new().fg(FOCUS_COLOR)),
)
.scroll((app.detail_scroll, 0))
.wrap(Wrap { trim: false });
f.render_widget(p, area);
}
fn draw_prompt(f: &mut Frame, app: &App) {
let Some(Prompt { label, input, .. }) = &app.prompt else {
return;
};
let width = f.area().width.saturating_sub(8).clamp(30, 72);
let area = Rect {
x: (f.area().width.saturating_sub(width)) / 2,
y: (f.area().height / 2).saturating_sub(1),
width,
height: 3,
}
.intersection(f.area());
f.render_widget(Clear, area);
let p = Paragraph::new(input.as_str()).block(
Block::default()
.borders(Borders::ALL)
.title(truncate(label, width as usize - 4))
.border_style(Style::new().fg(Color::Yellow)),
);
f.render_widget(p, area);
let x = area.x
+ 1
+ input
.chars()
.count()
.min((area.width as usize).saturating_sub(3)) as u16;
f.set_cursor_position((x, area.y + 1));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_from_reader;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{backend::TestBackend, Terminal};
use std::io::Cursor;
use std::path::PathBuf;
#[test]
fn rendered_layout_supports_header_click_sort() {
let input = "{\"type\":\"a\",\"score\":1}\n{\"type\":\"b\",\"score\":2}\n";
let ds = load_from_reader(Cursor::new(input), None).unwrap();
let mut app = App::new(ds, PathBuf::from("t.jsonl"));
let backend = TestBackend::new(160, 45);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draw(f, &mut app)).unwrap();
assert!(app.ui.table.w > 0, "table area recorded");
assert_eq!(app.ui.col_spans.len(), app.active_columns.len());
assert!(app.ui.tree.w > 0, "tree area recorded");
let (sx, _) = app.ui.col_spans[0];
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: sx,
row: app.ui.table.y, modifiers: KeyModifiers::NONE,
};
crate::event::handle_mouse(&mut app, click);
assert_eq!(
app.sort_field.as_deref(),
Some(app.active_columns[0].as_str())
);
assert!(!app.sort_desc);
crate::event::handle_mouse(&mut app, click);
assert!(app.sort_desc, "second click toggles direction");
}
}