use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui_bubbletea_components::{Help, KeyBinding, ListItem, SelectList};
use crate::format::local_time_hms;
use crate::tui::app::App;
use crate::tui::app::TabState;
use crate::tui::panels;
use crate::tui::style::bubble_theme;
use crate::vendor::VendorId;
const WIDE_LAYOUT_MIN_WIDTH: u16 = 86;
const SIDEBAR_WIDTH: u16 = 28;
pub fn draw(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(f.area());
draw_header(f, app, chunks[0]);
draw_main(f, app, chunks[1]);
draw_footer(f, app, chunks[2]);
if let Some(s) = &app.settings {
crate::tui::settings::render(f, f.area(), s, &app.theme);
}
}
fn vendor_label(id: VendorId) -> &'static str {
match id {
VendorId::Anthropic => "Claude",
VendorId::Openai => "OpenAI",
VendorId::Zai => "GLM (Z.AI)",
VendorId::Openrouter => "OpenRouter",
VendorId::Deepseek => "DeepSeek",
}
}
fn draw_header(f: &mut Frame, app: &App, area: Rect) {
let theme = bubble_theme(&app.theme);
let block = theme.titled_block(" ai-usagebar ");
let inner = block.inner(area);
f.render_widget(block, area);
let active = app.active_vendor().map(vendor_label).unwrap_or("no vendor");
let line = Line::from(vec![
theme.accent(" Usage dashboard"),
theme.muted(" · "),
theme.span(format!("{} vendors", app.vendors.len())),
theme.muted(" · "),
theme.span(format!("active {active}")),
theme.muted(" · "),
theme.muted(format!("last refresh {}", local_time_hms(app.last_refresh))),
]);
f.render_widget(Paragraph::new(line), inner);
}
fn draw_main(f: &mut Frame, app: &App, area: Rect) {
if area.width >= WIDE_LAYOUT_MIN_WIDTH {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(1)])
.split(area);
draw_sidebar(f, app, chunks[0]);
draw_detail(f, app, chunks[1]);
} else {
let sidebar_height = (app.vendors.len() as u16 + 2).min(8);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(sidebar_height), Constraint::Min(1)])
.split(area);
draw_sidebar(f, app, chunks[0]);
draw_detail(f, app, chunks[1]);
}
}
fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
let theme = bubble_theme(&app.theme);
let block = theme
.titled_block(" vendors ")
.border_style(theme.focused_border);
let inner = block.inner(area);
f.render_widget(block, area);
let items = app
.vendors
.iter()
.enumerate()
.map(|(index, vendor)| {
ListItem::new(vendor_label(*vendor)).description(tab_status(app.tabs.get(index)))
})
.collect::<Vec<_>>();
let mut list = SelectList::new(items).theme(theme);
list.select(Some(app.active));
f.render_widget(&list, inner);
}
fn draw_detail(f: &mut Frame, app: &App, area: Rect) {
let theme = bubble_theme(&app.theme);
let title = app
.active_vendor()
.map(|vendor| format!(" {} ", vendor_label(vendor)))
.unwrap_or_else(|| " details ".to_string());
let block = theme.titled_block(title);
let inner = block.inner(area);
f.render_widget(block, area);
let Some(tab) = app.tabs.get(app.active) else {
return;
};
let sections = panels::sections_for(tab, chrono::Utc::now(), 5);
panels::render(f, inner, &app.theme, §ions);
}
fn tab_status(tab: Option<&TabState>) -> &'static str {
match tab {
Some(TabState::Loading) => "fetching",
Some(TabState::Error(_)) => "error",
Some(TabState::Ready(ready)) if ready.stale => "stale cache",
Some(TabState::Ready(ready))
if ready
.last_error
.as_ref()
.is_some_and(|(code, _)| *code != 0) =>
{
"cached"
}
Some(TabState::Ready(_)) => "ready",
None => "waiting",
}
}
fn draw_footer(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let theme = bubble_theme(&app.theme);
let help = Help::new([
KeyBinding::with_keys(["tab", "h/l"], "switch"),
KeyBinding::new("r", "refresh"),
KeyBinding::new("R", "refresh all"),
KeyBinding::new("s", "settings"),
KeyBinding::with_keys(["q", "esc"], "quit"),
])
.theme(theme);
f.render_widget(&help, area);
}