use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui_bubbletea_components::{Help, KeyBinding, ListItem, SelectList};
use crate::format::local_time_hms;
use crate::tui::app::TabId;
use crate::tui::app::TabSource;
use crate::tui::app::TabState;
use crate::tui::app::{App, FooterAction, NavTarget};
use crate::tui::panels;
use crate::tui::style::{bubble_theme, color, severity_color};
use crate::vendor::VendorId;
const WIDE_LAYOUT_MIN_WIDTH: u16 = 86;
const SIDEBAR_WIDTH: u16 = 28;
const FOOTER_SEPARATOR_WIDTH: u16 = 3;
#[derive(Clone, Copy)]
struct FooterBinding {
action: Option<FooterAction>,
key: &'static str,
description: &'static str,
}
impl FooterBinding {
fn width(self) -> u16 {
(crate::display::text_width(self.key) as u16)
.saturating_add(1)
.saturating_add(crate::display::text_width(self.description) as u16)
}
}
pub fn draw(f: &mut Frame, app: &mut App) {
{
let mut hit = app.hit.borrow_mut();
hit.nav_entries.clear();
hit.footer_actions.clear();
hit.settings_rows.clear();
}
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_body(f, app, chunks[1]);
draw_footer(f, app, chunks[2]);
let mut hit = app.hit.borrow_mut();
if app.settings.is_some() {
let theme = app.theme.clone();
if let Some(s) = app.settings.as_mut() {
crate::tui::settings::render(f, f.area(), s, &theme, &mut hit.settings_rows);
}
}
}
fn draw_body(f: &mut Frame, app: &App, area: Rect) {
use crate::config::ContextLayout;
use crate::tui::context;
let Some(state) = &app.context else {
draw_main(f, app, area);
return;
};
match state.layout {
ContextLayout::Full => context::render(f, area, state, &app.theme),
ContextLayout::Split => {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
draw_main(f, app, cols[0]);
context::render(f, cols[1], state, &app.theme);
}
ContextLayout::Bottom => {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
draw_main(f, app, rows[0]);
context::render(f, rows[1], state, &app.theme);
}
}
}
fn vendor_label(id: VendorId) -> &'static str {
if id == VendorId::Zai {
"GLM (Z.AI)"
} else {
id.display_name()
}
}
fn source_label(tab: &TabId) -> &str {
match &tab.source {
TabSource::Builtin(vendor) => vendor_label(*vendor),
TabSource::Custom { name, .. } => name,
}
}
fn compact_source_label(tab: &TabId) -> &str {
match &tab.source {
TabSource::Builtin(vendor) => vendor.display_name(),
TabSource::Custom { short_name, .. } => short_name,
}
}
fn tab_label(tab: &TabId) -> String {
let label = match &tab.account {
Some(acct) => format!("{} · {}", source_label(tab), acct),
None => source_label(tab).to_string(),
};
crate::display::sanitize_untrusted_field(&label)
}
fn compact_tab_label(tab: &TabId) -> String {
let label = match &tab.account {
Some(acct) => format!("{} · {}", compact_source_label(tab), acct),
None => compact_source_label(tab).to_string(),
};
crate::display::sanitize_untrusted_field(&label)
}
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 = if app.overview {
"Overview".to_string()
} else {
app.active_tab_id()
.map(tab_label)
.unwrap_or_else(|| "no vendor".to_string())
};
let line = Line::from(vec![
theme.accent(" Usage dashboard"),
theme.muted(" · "),
theme.span(format!("{} tabs", app.tabs_meta.len())),
theme.muted(" · "),
theme.span(format!("active {active}")),
theme.muted(" · "),
theme.muted(header_refresh_text(app)),
]);
f.render_widget(Paragraph::new(line), inner);
}
fn header_refresh_text(app: &App) -> String {
let fetched_at = match app.tabs.get(app.active) {
Some(TabState::Ready(ready)) => ready.fetched_at,
_ => None,
};
match fetched_at {
Some(at) => format!("last refresh {}", local_time_hms(at)),
None => "last refresh —".to_string(),
}
}
fn draw_main(f: &mut Frame, app: &App, area: Rect) {
use crate::config::VendorBoxStyle;
match app.vendor_box {
VendorBoxStyle::Sidebar => {
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 chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(area);
draw_top_nav(f, app, chunks[0]);
draw_detail(f, app, chunks[1]);
}
}
VendorBoxStyle::Navbar => {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(area);
draw_top_nav(f, app, chunks[0]);
draw_detail(f, app, chunks[1]);
}
VendorBoxStyle::None => {
draw_detail(f, app, area);
}
}
}
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 mut items = vec![ListItem::new("Overview").description("all vendors")];
items.extend(app.tabs_meta.iter().enumerate().map(|(index, tab)| {
ListItem::new(tab_label(tab)).description(tab_status(
app.tabs.get(index),
app.tab_is_refreshing(index),
))
}));
let mut list = SelectList::new(items).theme(theme);
list.select(Some(if app.overview { 0 } else { app.active + 1 }));
f.render_widget(&list, inner);
let bottom = inner.y.saturating_add(inner.height);
let mut hits: Vec<(NavTarget, ratatui::layout::Rect)> = Vec::new();
if inner.y < bottom {
hits.push((
NavTarget::Overview,
ratatui::layout::Rect::new(inner.x, inner.y, inner.width, 1),
));
}
for (index, _) in app.tabs_meta.iter().enumerate() {
let y = inner.y + 1 + index as u16;
if y >= bottom {
break;
}
hits.push((
NavTarget::Tab(index),
ratatui::layout::Rect::new(inner.x, y, inner.width, 1),
));
}
app.hit.borrow_mut().nav_entries = hits;
}
fn draw_top_nav(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 mut entries: Vec<(NavTarget, String, bool)> =
vec![(NavTarget::Overview, "Overview".to_string(), app.overview)];
for (index, tab) in app.tabs_meta.iter().enumerate() {
let selected = !app.overview && index == app.active;
entries.push((NavTarget::Tab(index), compact_tab_label(tab), selected));
}
let mut spans = vec![theme.muted(" ")];
let mut hits: Vec<(NavTarget, ratatui::layout::Rect)> = Vec::new();
let mut x = inner.x + 1; let mut first = true;
for (target, label, selected) in entries {
let label_w = crate::display::text_width(&label) as u16;
if !first {
spans.push(theme.muted(" "));
x += 2;
}
let marker = if selected {
theme.symbols.selected
} else {
theme.symbols.bullet
};
let marker_style = if selected { theme.accent } else { theme.muted };
let label_style = if selected { theme.selected } else { theme.text };
spans.push(Span::styled(marker, marker_style));
spans.push(theme.span(" "));
spans.push(Span::styled(label, label_style));
hits.push((
target,
ratatui::layout::Rect::new(x, inner.y, label_w + 2, inner.height),
));
x += label_w + 2;
first = false;
}
f.render_widget(Paragraph::new(Line::from(spans)), inner);
app.hit.borrow_mut().nav_entries = hits;
}
fn draw_detail(f: &mut Frame, app: &App, area: Rect) {
let theme = bubble_theme(&app.theme);
let title = if app.overview {
" Overview ".to_string()
} else {
app.active_tab_id()
.map(|tab| {
let refreshing = if app.is_refreshing(tab) { " ↻" } else { "" };
format!(" {}{refreshing} ", tab_label(tab))
})
.unwrap_or_else(|| " details ".to_string())
};
let block = theme.titled_block(title);
let inner = block.inner(area);
f.render_widget(block, area);
if app.overview {
draw_overview(f, app, inner);
return;
}
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);
}
const OVERVIEW_BAR_W: usize = 12;
fn draw_overview(f: &mut Frame, app: &App, area: Rect) {
let theme = bubble_theme(&app.theme);
let idxs = app.overview_tabs();
if idxs.is_empty() {
f.render_widget(
Paragraph::new(Line::from(theme.muted(" No vendors to summarize."))),
area,
);
return;
}
let name_w = idxs
.iter()
.map(|&i| crate::display::text_width(&tab_label(&app.tabs_meta[i])))
.max()
.unwrap_or(6)
.clamp(6, 22);
let mut lines: Vec<Line> = Vec::new();
for &i in &idxs {
let name = tab_label(&app.tabs_meta[i]);
let pad = name_w.saturating_sub(crate::display::text_width(&name));
let mut spans = vec![
Span::styled(name, theme.text),
theme.span(" ".repeat(pad + 2)),
];
match app.tabs.get(i) {
Some(TabState::Ready(r)) => {
if let Some(p) = panels::headline_pct(&r.snapshot) {
let filled = (p.clamp(0, 100) as usize * OVERVIEW_BAR_W).div_ceil(100);
let sev_color =
severity_color(&app.theme, &theme, crate::pango::severity_for(p));
spans.push(Span::styled(
"█".repeat(filled),
Style::default().fg(sev_color),
));
let empty =
color(&app.theme.bar_empty).unwrap_or(theme.palette.selected_background);
spans.push(Span::styled(
"░".repeat(OVERVIEW_BAR_W - filled),
Style::default().fg(empty),
));
spans.push(theme.span(" "));
}
let (plan, cells) = panels::compact_cells(&r.snapshot);
if !plan.is_empty() {
spans.push(theme.muted(format!("{plan} ")));
}
for (j, (text, sev)) in cells.iter().enumerate() {
if j > 0 {
spans.push(theme.span(" "));
}
let color = severity_color(&app.theme, &theme, *sev);
spans.push(Span::styled(text.clone(), Style::default().fg(color)));
}
if r.stale {
spans.push(theme.muted(" ⏸"));
}
if r.last_error.is_some() {
spans.push(theme.muted(" ⚠"));
}
if app.tab_is_refreshing(i) {
spans.push(theme.muted(" ↻"));
}
}
Some(TabState::Error { .. }) => spans.push(Span::styled(
"error",
Style::default().fg(theme.palette.error),
)),
Some(TabState::Loading) | None => spans.push(theme.muted("fetching…")),
}
lines.push(Line::from(spans));
lines.push(Line::from(""));
}
f.render_widget(Paragraph::new(lines), area);
}
fn tab_status(tab: Option<&TabState>, refreshing: bool) -> &'static str {
match tab {
Some(TabState::Ready(_)) if refreshing => "refreshing",
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 mut bindings = vec![
FooterBinding {
action: None,
key: "tab / shift+tab / ↑ / ↓ / ← / →",
description: "switch",
},
FooterBinding {
action: Some(FooterAction::Refresh),
key: "r",
description: "refresh",
},
FooterBinding {
action: Some(FooterAction::RefreshAll),
key: "R",
description: "refresh all",
},
FooterBinding {
action: Some(FooterAction::Settings),
key: "s",
description: "settings",
},
];
if app.context_enabled {
bindings.push(FooterBinding {
action: None,
key: "c",
description: "context",
});
}
bindings.push(FooterBinding {
action: Some(FooterAction::Quit),
key: "q/esc",
description: "quit",
});
let help = Help::new(
bindings
.iter()
.map(|binding| KeyBinding::new(binding.key, binding.description)),
)
.theme(theme);
f.render_widget(&help, area);
let right = area.x.saturating_add(area.width);
let mut x = area.x;
let mut actions = Vec::new();
for binding in bindings {
let width = binding.width();
if let Some(action) = binding.action
&& x < right
{
let visible_width = width.min(right.saturating_sub(x));
if visible_width > 0 {
actions.push((action, Rect::new(x, area.y, visible_width, area.height)));
}
}
x = x
.saturating_add(width)
.saturating_add(FOOTER_SEPARATOR_WIDTH);
}
app.hit.borrow_mut().footer_actions = actions;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
use crate::tui::app::ReadyTab;
use crate::usage::{OpenRouterSnapshot, VendorSnapshot};
use chrono::{DateTime, TimeZone, Utc};
fn ready_at(fetched_at: Option<DateTime<Utc>>) -> TabState {
TabState::Ready(Box::new(ReadyTab {
snapshot: VendorSnapshot::Openrouter(OpenRouterSnapshot {
label: "test".into(),
total_credits: 0.0,
total_usage: 0.0,
usage_daily: 0.0,
usage_weekly: 0.0,
usage_monthly: 0.0,
is_free_tier: false,
limit: None,
limit_remaining: None,
recent_models: Vec::new(),
}),
stale: false,
last_error: None,
fetched_at,
display: Default::default(),
}))
}
fn app_with(tabs: Vec<TabState>) -> App {
let mut app = App::with_theme(
vec![
TabId::vendor(VendorId::Anthropic),
TabId::vendor(VendorId::Openrouter),
],
Theme::default(),
);
app.tabs = tabs;
app
}
fn settings_state() -> crate::tui::settings::SettingsState {
use crate::tui::settings::{KeyInput, ProviderSwitch, SettingsState};
SettingsState {
focus: crate::tui::settings::Focus::Primary,
primary_choices: vec![VendorId::Anthropic],
primary: VendorId::Anthropic,
keys: crate::tui::settings::KEY_VENDORS
.iter()
.map(|_| KeyInput::default())
.collect(),
vendors: VendorId::all()
.iter()
.map(|_| ProviderSwitch {
enabled: false,
dirty: false,
})
.collect(),
notify_enabled: true,
notify_enabled_dirty: false,
notify_threshold: KeyInput::from_config(Some("97")),
status: String::new(),
scroll: 0,
picker: None,
}
}
#[test]
fn header_refresh_follows_the_active_tab() {
let anthropic_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
let openrouter_at = Utc.with_ymd_and_hms(2026, 5, 23, 9, 30, 0).unwrap();
let mut app = app_with(vec![
ready_at(Some(anthropic_at)),
ready_at(Some(openrouter_at)),
]);
let anthropic_header = format!("last refresh {}", local_time_hms(anthropic_at));
let openrouter_header = format!("last refresh {}", local_time_hms(openrouter_at));
assert_ne!(anthropic_header, openrouter_header);
assert_eq!(header_refresh_text(&app), anthropic_header);
app.next_tab();
assert_eq!(header_refresh_text(&app), openrouter_header);
}
#[test]
fn header_refresh_is_dash_when_active_tab_never_fetched() {
let sibling = ready_at(Some(Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()));
let mut app = app_with(vec![TabState::Loading, sibling]);
assert_eq!(header_refresh_text(&app), "last refresh —");
app.tabs[0] = TabState::error("401 Unauthorized");
assert_eq!(header_refresh_text(&app), "last refresh —");
}
#[test]
fn header_refresh_is_dash_when_ready_tab_has_no_fetched_at() {
let app = app_with(vec![ready_at(None), TabState::Loading]);
assert_eq!(header_refresh_text(&app), "last refresh —");
}
#[test]
fn overview_keeps_metrics_visible_while_refreshing() {
let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
let mut app = app_with(vec![ready_at(Some(fetched_at)), ready_at(Some(fetched_at))]);
app.overview = true;
let tab = app.tabs_meta[0].clone();
assert!(app.begin_refresh(&tab));
let out = body_text(&mut app);
assert!(out.contains("$0.00"), "ready metrics disappeared: {out}");
assert!(out.contains('↻'), "refresh indicator missing: {out}");
assert!(!out.contains("fetching…"), "ready row flickered: {out}");
assert_eq!(tab_status(app.tabs.first(), true), "refreshing");
}
fn app_with_context(layout: crate::config::ContextLayout) -> App {
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.context_enabled = true;
app.context = Some({
let mut state = crate::tui::context::ContextState::new(layout);
state.apply_scan(0, Err("scan error".into()));
state
});
app
}
fn body_text(app: &mut App) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut terminal = Terminal::new(TestBackend::new(160, 40)).unwrap();
terminal.draw(|frame| draw(frame, app)).unwrap();
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<Vec<_>>()
.concat()
}
#[test]
fn full_layout_takes_the_body_and_hides_the_vendor_sidebar() {
use crate::config::ContextLayout;
let out = body_text(&mut app_with_context(ContextLayout::Full));
assert!(out.contains("Claude context"), "{out}");
assert!(
!out.contains("vendors"),
"full layout must not leave the dashboard around it"
);
}
#[test]
fn split_and_bottom_layouts_keep_the_dashboard_visible() {
use crate::config::ContextLayout;
for layout in [ContextLayout::Split, ContextLayout::Bottom] {
let out = body_text(&mut app_with_context(layout));
assert!(out.contains("Claude context"), "{layout:?}: {out}");
assert!(out.contains("vendors"), "{layout:?}: {out}");
}
}
#[test]
fn context_footer_hint_is_visible_only_when_the_feature_is_enabled() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn rendered(mut app: App, enabled: bool) -> String {
app.context_enabled = enabled;
let mut terminal = Terminal::new(TestBackend::new(160, 24)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<Vec<_>>()
.concat()
}
let disabled = rendered(app_with(vec![TabState::Loading, TabState::Loading]), false);
assert!(!disabled.contains("context"));
let enabled = rendered(app_with(vec![TabState::Loading, TabState::Loading]), true);
assert!(enabled.contains("context"));
}
fn main_rows(app: &App, width: u16, height: u16) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw_main(frame, app, frame.area()))
.unwrap();
let buffer = terminal.backend().buffer();
(0..height)
.map(|row| {
(0..width)
.map(|col| buffer[(col, row)].symbol())
.collect::<Vec<_>>()
.concat()
})
.collect()
}
#[test]
fn vendor_box_sidebar_splits_horizontally_on_wide_terminals() {
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.overview = true;
let rows = main_rows(&app, 160, 24);
assert!(rows[0].contains("vendors"), "{:?}", rows[0]);
assert!(rows[0].contains("Overview"), "{:?}", rows[0]);
}
#[test]
fn vendor_box_sidebar_falls_back_to_top_nav_on_narrow_terminals() {
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.overview = true;
let rows = main_rows(&app, 60, 24);
assert!(rows[0].contains("vendors"), "{:?}", rows[0]);
assert!(!rows[0].contains(" Overview "), "{:?}", rows[0]);
assert!(
rows.iter().any(|r| r.contains(" Overview ")),
"detail title missing entirely: {rows:?}"
);
}
#[test]
fn vendor_box_navbar_forces_top_nav_even_on_wide_terminals() {
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.overview = true;
app.vendor_box = crate::config::VendorBoxStyle::Navbar;
let rows = main_rows(&app, 160, 24);
assert!(rows[0].contains("vendors"), "{:?}", rows[0]);
assert!(
!rows[0].contains(" Overview "),
"navbar must stack, not sit beside the detail panel: {:?}",
rows[0]
);
}
#[test]
fn vendor_box_none_hides_navigation_and_uses_full_width() {
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.overview = true;
app.vendor_box = crate::config::VendorBoxStyle::None;
let rows = main_rows(&app, 160, 24);
assert!(
!rows.iter().any(|r| r.contains("vendors")),
"vendor nav must be fully hidden: {rows:?}"
);
assert!(rows[0].contains(" Overview "), "{:?}", rows[0]);
}
#[test]
fn sidebar_records_nav_hit_rects_in_entry_order() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.overview = true;
let mut terminal = Terminal::new(TestBackend::new(160, 24)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let hit = app.hit.borrow();
assert_eq!(hit.nav_entries.len(), 3); assert_eq!(hit.nav_entries[0].0, NavTarget::Overview);
assert_eq!(hit.nav_entries[1].0, NavTarget::Tab(0));
assert_eq!(hit.nav_entries[2].0, NavTarget::Tab(1));
let (first, second, third) = (
hit.nav_entries[0].1,
hit.nav_entries[1].1,
hit.nav_entries[2].1,
);
assert_eq!(first.y + 1, second.y);
assert_eq!(second.y + 1, third.y);
assert_eq!(first.width, second.width);
assert_eq!(first.height, 1);
}
#[test]
fn sidebar_hit_rects_skip_rows_the_panel_does_not_render() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.overview = true;
let mut terminal = Terminal::new(TestBackend::new(160, 8)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let hit = app.hit.borrow();
assert_eq!(hit.nav_entries.len(), 2, "Overview and first tab only");
assert_eq!(hit.nav_entries[0].0, NavTarget::Overview);
assert_eq!(hit.nav_entries[1].0, NavTarget::Tab(0));
for (target, rect) in &hit.nav_entries {
assert!(
rect.y + rect.height <= 6,
"{target:?} escapes the sidebar's inner area: {rect:?}"
);
}
}
#[test]
fn top_nav_records_hit_rects_in_entry_order() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.overview = true;
app.vendor_box = crate::config::VendorBoxStyle::Navbar;
let mut terminal = Terminal::new(TestBackend::new(160, 24)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let hit = app.hit.borrow();
assert_eq!(hit.nav_entries.len(), 3);
assert_eq!(hit.nav_entries[0].0, NavTarget::Overview);
let (first, second) = (hit.nav_entries[0].1, hit.nav_entries[1].1);
assert_eq!(first.x + first.width + 2, second.x);
assert_eq!(first.y, second.y);
}
#[test]
fn top_nav_hit_rect_spans_cjk_label_columns() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = App::with_theme(
vec![TabId {
source: TabSource::Custom {
id: "cjk".into(),
name: "漢".into(),
short_name: "漢".into(),
},
account: None,
desktop: false,
}],
Theme::default(),
);
app.tabs = vec![TabState::Loading];
app.overview = true;
app.vendor_box = crate::config::VendorBoxStyle::Navbar;
let mut terminal = Terminal::new(TestBackend::new(160, 24)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let hit = app.hit.borrow();
let tab = &hit.nav_entries[1];
let label = compact_tab_label(&app.tabs_meta[0]);
assert_eq!(tab.0, NavTarget::Tab(0));
assert_eq!(tab.1.width, crate::display::text_width(&label) as u16 + 2);
assert_eq!(
tab.1.x + tab.1.width - 1,
tab.1.x + 1 + crate::display::text_width(&label) as u16
);
}
#[test]
fn top_nav_hit_rect_does_not_steal_separator_for_combining_mark() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = App::with_theme(
vec![TabId {
source: TabSource::Custom {
id: "comb".into(),
name: "e\u{0301}".into(),
short_name: "e\u{0301}".into(),
},
account: None,
desktop: false,
}],
Theme::default(),
);
app.tabs = vec![TabState::Loading];
app.overview = true;
app.vendor_box = crate::config::VendorBoxStyle::Navbar;
let mut terminal = Terminal::new(TestBackend::new(160, 24)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let hit = app.hit.borrow();
let tab = &hit.nav_entries[1];
let label = compact_tab_label(&app.tabs_meta[0]);
assert_eq!(tab.0, NavTarget::Tab(0));
assert_eq!(tab.1.width, crate::display::text_width(&label) as u16 + 2);
assert_eq!(tab.1.width, 3);
}
#[test]
fn footer_records_click_targets_for_mouse_actions() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
let mut terminal = Terminal::new(TestBackend::new(160, 24)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let hit = app.hit.borrow();
assert_eq!(
hit.footer_actions
.iter()
.map(|(action, _)| *action)
.collect::<Vec<_>>(),
vec![
FooterAction::Refresh,
FooterAction::RefreshAll,
FooterAction::Settings,
FooterAction::Quit,
]
);
assert!(hit.footer_actions.iter().all(|(_, rect)| rect.height == 1));
}
#[test]
fn settings_draw_renders_all_key_rows_and_records_hits() {
use crate::tui::settings::{Focus as SFocus, SettingsRow};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.settings = Some(settings_state());
let mut terminal = Terminal::new(TestBackend::new(160, 70)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let hit = app.hit.borrow();
assert!(
hit.settings_rows
.iter()
.any(|(row, _)| matches!(row, SettingsRow::Focus(SFocus::Save)))
);
for index in 0..crate::tui::settings::KEY_VENDORS.len() {
assert!(
hit.settings_rows.iter().any(
|(row, _)| matches!(row, SettingsRow::Focus(SFocus::Key(i)) if *i == index)
),
"missing hit target for key provider {index}"
);
}
}
#[test]
fn settings_draw_clamps_hit_rects_on_short_terminals() {
use crate::tui::settings::{Focus as SFocus, SettingsRow};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.settings = Some(settings_state());
let mut terminal = Terminal::new(TestBackend::new(160, 12)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let hit = app.hit.borrow();
assert!(
!hit.settings_rows
.iter()
.any(|(row, _)| matches!(row, SettingsRow::Focus(SFocus::Save)))
);
for (_, rect) in &hit.settings_rows {
assert!(rect.y + rect.height <= 12);
}
}
#[test]
fn settings_hit_targets_do_not_accumulate_across_redraws() {
use crate::tui::settings::{Focus as SFocus, KeyCode, KeyModifiers, SettingsRow};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = app_with(vec![TabState::Loading, TabState::Loading]);
app.settings = Some(settings_state()); let mut terminal = Terminal::new(TestBackend::new(160, 70)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let one_frame = app.hit.borrow().settings_rows.len();
assert!(one_frame > 0);
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
assert_eq!(
app.hit.borrow().settings_rows.len(),
one_frame,
"redraw must not accumulate hit targets"
);
app.settings.as_mut().unwrap().focus = SFocus::Vendor(0);
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let hit = app.hit.borrow();
assert!(
!hit.settings_rows
.iter()
.any(|(row, _)| matches!(row, SettingsRow::HintKey(KeyCode::Right, _))),
"stale Primary hint link survived the redraw"
);
let toggles = hit
.settings_rows
.iter()
.filter(|(row, _)| {
matches!(
row,
SettingsRow::HintKey(KeyCode::Char(' '), KeyModifiers::NONE)
)
})
.count();
assert_eq!(toggles, 1, "exactly one frame's toggle link recorded");
}
}