use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
use ratatui::Frame;
use super::app::{App, Level, Mode, Tab};
pub fn layout(area: Rect) -> [Rect; 4] {
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
[rows[0], rows[1], rows[2], rows[3]]
}
pub fn render(app: &App, frame: &mut Frame) {
let [tabs, header, body, footer] = layout(frame.area());
render_tabs(app, frame, tabs);
render_header(app, frame, header);
render_list(app, frame, body);
render_footer(app, frame, footer);
if app.mode() == &Mode::Help {
render_help(frame, frame.area());
}
}
fn render_tabs(app: &App, frame: &mut Frame, area: Rect) {
let mut spans = vec![Span::raw(" ")];
for tab in Tab::ALL {
let label = if tab == app.tab() {
Span::styled(
format!("[{}]", tab.title()),
Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED),
)
} else {
Span::raw(format!(" {} ", tab.title()))
};
spans.push(label);
spans.push(Span::raw(" "));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_header(app: &App, frame: &mut Frame, area: Rect) {
let text = match app.mode() {
Mode::Search => format!("/{}", app.filter()),
Mode::Rename(buf) => format!("rename \u{203a} {buf}"),
Mode::Add(buf) => format!("add \u{203a} {buf}"),
Mode::ConfirmDelete => {
let name = app.current().unwrap_or_default();
format!("delete '{name}'? (y/n)")
}
_ => {
let mut line = app.breadcrumb();
if !app.filter().is_empty() {
line.push_str(&format!(" (filter: {})", app.filter()));
}
line
}
};
let mut line = text;
if let Some(status) = app.status() {
line.push_str(&format!(" — {status}"));
}
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
line,
Style::default().add_modifier(Modifier::BOLD),
))),
area,
);
}
fn render_list(app: &App, frame: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(list_title(app));
let items = app.items();
if items.is_empty() {
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(app.empty_hint()).style(Style::default().add_modifier(Modifier::DIM)),
inner,
);
return;
}
let rows: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, name)| {
let gutter = if i == app.selected() { "> " } else { " " };
let style = if i == app.selected() {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
ListItem::new(Line::from(format!("{gutter}{name}"))).style(style)
})
.collect();
let mut state = ListState::default();
state.select(Some(app.selected()));
frame.render_stateful_widget(List::new(rows).block(block), area, &mut state);
}
fn list_title(app: &App) -> String {
match app.level() {
Level::Providers => " providers ".to_string(),
Level::Models(p) => format!(" models · {p} "),
Level::Specialists => " specialists ".to_string(),
Level::Tools => " tools ".to_string(),
}
}
fn render_footer(app: &App, frame: &mut Frame, area: Rect) {
let hints: &[(&str, &str)] = match app.mode() {
Mode::Search => &[("type", "filter"), ("⏎", "apply"), ("esc", "cancel")],
Mode::Rename(_) | Mode::Add(_) => &[("type", "name"), ("⏎", "ok"), ("esc", "cancel")],
Mode::ConfirmDelete => &[("y", "delete"), ("n", "keep")],
Mode::Help => &[("any", "close")],
Mode::Normal => &[
("p/s/t", "tabs"),
("hjkl", "nav"),
("a", "add"),
("o", "open"),
("e", "edit"),
("r", "rename"),
("d", "del"),
("/", "search"),
("^r", "refresh"),
("?", "help"),
("q", "quit"),
],
};
frame.render_widget(Paragraph::new(footer_line(hints)), area);
}
fn footer_line(hints: &[(&str, &str)]) -> Line<'static> {
let mut spans = Vec::new();
for (k, label) in hints {
spans.push(Span::styled(
format!(" {k} "),
Style::default().add_modifier(Modifier::REVERSED),
));
spans.push(Span::raw(format!(" {label} ")));
}
Line::from(spans)
}
fn render_help(frame: &mut Frame, area: Rect) {
let lines = [
"Keys",
"",
" p / s / t providers · specialists · tools",
" h j k l ←↓↑→ navigate (left: back · right: into / open)",
" enter into folder / open file",
" a add",
" o open (chat specialist · run tool · drill into provider)",
" e edit in $EDITOR",
" d delete (confirm y/n)",
" r rename",
" / search current view (enter applies)",
" ctrl+r refresh from disk",
" ? this help",
" q ctrl+c ^d quit",
"",
" press any key to close",
];
let height = (lines.len() as u16 + 2).min(area.height);
let width = 64.min(area.width);
let popup = centered_rect(width, height, area);
frame.render_widget(Clear, popup);
let block = Block::default().borders(Borders::ALL).title(" help ");
let text: Vec<Line> = lines.iter().map(|l| Line::from(*l)).collect();
frame.render_widget(Paragraph::new(text).block(block), popup);
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect {
x,
y,
width,
height,
}
}
#[cfg(test)]
#[path = "render_tests.rs"]
mod tests;