use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::super::{panel, wrap};
use crate::tui::app::App;
use crate::tui::data::catalogue::Entry;
use crate::tui::theme;
pub fn draw(f: &mut Frame, app: &App, area: Rect) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(46), Constraint::Percentage(54)])
.split(area);
draw_list(f, app, cols[0]);
super::list_detail::draw_detail(f, app, cols[1]);
}
fn badges(app: &App, entry: &Entry) -> Vec<Span<'static>> {
let mut out = Vec::new();
let (label, style) = if app.download.is_downloading(&entry.model) {
(
" downloading ",
theme::brand_cyan().add_modifier(Modifier::REVERSED | Modifier::BOLD),
)
} else if entry.has_weights() {
(
" on disk ",
theme::brand_green().add_modifier(Modifier::REVERSED | Modifier::BOLD),
)
} else if entry.local.is_some() {
(
" partial ",
theme::warn().add_modifier(Modifier::REVERSED | Modifier::BOLD),
)
} else {
(" not downloaded ", theme::dim())
};
out.push(Span::styled(label, style));
out.push(Span::raw(" "));
if let Some(r) = entry.primary() {
let (label, style) = if r.is_metrale() {
(" recipe ", theme::brand_purple())
} else {
(" vllm ", theme::dim())
};
out.push(Span::styled(
label,
style.add_modifier(Modifier::REVERSED | Modifier::BOLD),
));
out.push(Span::raw(" "));
}
if entry.optimized() {
out.push(Span::styled(
" optimized ",
theme::brand_cyan().add_modifier(Modifier::REVERSED | Modifier::BOLD),
));
out.push(Span::raw(" "));
}
match app.download.freshness.get(&entry.model) {
Some(f) if f.is_stale() => out.push(Span::styled(
" update ",
theme::warn().add_modifier(Modifier::REVERSED | Modifier::BOLD),
)),
_ if app.download.checking.as_deref() == Some(entry.model.as_str()) => {
out.push(Span::styled(" ░░░░░░ ", theme::dim()));
}
_ => {}
}
out
}
fn draw_search_field(f: &mut Frame, app: &App, row: Rect, total: usize, shown: usize) {
app.lib_search_click.set(Some(row));
let mut spans = vec![Span::styled(" ⌕ ", theme::brand_cyan())];
if app.lib.filter_editing {
spans.push(Span::styled(app.lib.filter.clone(), theme::text()));
spans.push(Span::styled("▏", theme::brand_cyan()));
} else if !app.lib.filter.is_empty() {
spans.push(Span::styled(app.lib.filter.clone(), theme::text()));
spans.push(Span::styled(
format!(" — {shown} of {total} · / edits"),
theme::dim(),
));
} else {
spans.push(Span::styled("search models — / or click", theme::dim()));
}
let mut line = Line::from(spans);
if app.lib.filter_editing {
line = line.style(theme::selected());
}
f.render_widget(Paragraph::new(line), row);
}
fn draw_list(f: &mut Frame, app: &App, area: Rect) {
let rows = app.lib.visible();
let status = if app.lib.fetching {
format!(
" {} fetching ─",
theme::SPINNER[(app.tick as usize / 2) % theme::SPINNER.len()]
)
} else {
format!(" recipes {} ─", app.lib.index.status_text())
};
let block = panel(format!("MODELS ─ {} ─{status} ─", rows.len()), true);
let mut inner = block.inner(area);
f.render_widget(block, area);
if inner.height >= 1 {
let field = Rect { height: 1, ..inner };
draw_search_field(f, app, field, app.lib.rows.len(), rows.len());
inner.y += 1;
inner.height -= 1;
}
let mut header: Vec<Line> = Vec::new();
if let Some(detail) = app.lib.index.offline_detail() {
header.extend(wrap(
&detail,
inner.width.saturating_sub(2) as usize,
theme::warn(),
));
header.push(Line::from(""));
}
if rows.is_empty() {
let hint = if app.lib.filter.is_empty() {
"no models or recipes yet — press r to fetch recipes"
} else {
"nothing matches this search"
};
header.push(Line::from(Span::styled(format!(" {hint}"), theme::dim())));
f.render_widget(Paragraph::new(header), inner);
return;
}
let per_row = 3usize;
let visible = (inner.height as usize / per_row).max(1);
let first = app.lib.selected.saturating_sub(visible.saturating_sub(1));
let mut lines: Vec<Line> = header;
for (i, entry) in rows.iter().enumerate().skip(first).take(visible) {
let selected = i == app.lib.selected;
let bar = if selected {
Span::styled("▌", theme::brand_purple())
} else {
Span::raw(" ")
};
let mark = if app.download.is_downloading(&entry.model) {
Span::styled("↓ ", theme::brand_cyan())
} else if entry.has_weights() {
Span::styled("✓ ", theme::brand_green())
} else if entry.local.is_some() {
Span::styled("◐ ", theme::warn())
} else {
Span::styled("· ", theme::dim())
};
let name_style = if selected {
theme::text().add_modifier(Modifier::BOLD)
} else {
theme::text()
};
let mut head_spans = vec![bar, mark, Span::styled(entry.model.clone(), name_style)];
if let Some(job) = app.download.job.as_ref().filter(|j| j.repo == entry.model) {
let glow = if job.cancelling {
theme::warn().add_modifier(Modifier::DIM)
} else if (app.tick / 4).is_multiple_of(2) {
theme::brand_cyan().add_modifier(Modifier::BOLD)
} else {
theme::brand_cyan().add_modifier(Modifier::DIM)
};
head_spans.push(Span::styled(" ●", glow));
}
let mut head = Line::from(head_spans);
if selected {
head = head.style(theme::selected());
}
lines.push(head);
let mut second = vec![Span::raw(" ")];
second.extend(badges(app, entry));
let subtitle = entry.subtitle();
if !subtitle.is_empty() {
second.push(Span::styled(format!(" {subtitle}"), theme::dim()));
}
lines.push(Line::from(second));
lines.push(match progress_line(app, &entry.model, inner.width) {
Some(l) => l,
None => Line::from(vec![
Span::raw(" "),
Span::styled(entry.size_text(), theme::text2()),
Span::styled(
match entry.recipes.len() {
0 => " · no recipe — ⏎ starting points".to_string(),
1 => format!(" · {}", entry.recipes[0].id),
n => format!(" · {n} recipes"),
},
theme::dim(),
),
]),
});
}
f.render_widget(Paragraph::new(lines), inner);
}
fn progress_line(app: &App, model: &str, width: u16) -> Option<Line<'static>> {
let job = app.download.job.as_ref().filter(|j| j.repo == model)?;
let mut spans = vec![Span::raw(" ")];
let phase = (app.tick as usize / 2) % theme::SPINNER.len();
spans.push(Span::styled(theme::SPINNER[phase], theme::brand_cyan()));
spans.push(Span::raw(" "));
match job.fraction() {
Some(f) => {
const CELLS: usize = 12;
let exact = (f * CELLS as f64).round() as usize;
let filled = if job.done > 0 { exact.max(1) } else { exact };
spans.push(Span::styled(
"▓".repeat(filled.min(CELLS)),
theme::brand_cyan(),
));
spans.push(Span::styled(
"░".repeat(CELLS.saturating_sub(filled)),
theme::dim(),
));
spans.push(Span::styled(
format!(" {:>5}", crate::tui::format::percent(f)),
theme::text(),
));
}
None => spans.push(Span::styled("fetching", theme::text())),
}
if job.cancelling {
spans.push(Span::styled(" stopping…", theme::warn()));
return Some(Line::from(spans));
}
if width >= 60 && job.total > 0 {
spans.push(Span::styled(
format!(" {} / {}", gb(job.done), gb(job.total)),
theme::text2(),
));
}
if width >= 70 && job.rate_bps > 0.0 {
spans.push(Span::styled(
format!(" {}", crate::tui::format::rate(job.rate_bps)),
theme::dim(),
));
}
if width >= 88
&& let Some((i, of, _)) = &job.file
{
spans.push(Span::styled(format!(" file {i}/{of}"), theme::dim()));
}
Some(Line::from(spans))
}
fn gb(bytes: u64) -> String {
crate::tui::format::bytes(bytes)
}