use super::bar;
use super::detail::{detail_row_keys, DetailMode, DetailView};
use crate::config::QuotaPreferences;
use crate::providers::{ProviderKind, ProviderResult, ProviderStatus, QuotaWindow};
use crate::update::UpdateInfo;
use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use std::cell::{Cell, RefCell};
use std::collections::BTreeSet;
#[derive(Clone, Copy)]
pub enum Direction {
Left,
Right,
Up,
Down,
}
pub enum HitResult {
Card(usize),
Refresh,
AutoRefreshToggle,
Quit,
OpenUpdate,
}
#[derive(Clone)]
pub enum ProviderEntry {
Loading,
Refreshing(ProviderResult),
Done(ProviderResult),
}
const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const MIN_CARD_W: u16 = 28;
const MIN_CARD_H: u16 = 7;
#[derive(Clone, Copy, Default)]
struct GridLayout {
cols: usize,
per_page: usize,
}
pub struct Dashboard {
pub kinds: Vec<ProviderKind>,
pub entries: Vec<ProviderEntry>,
pub selected_index: usize,
pub show_detail: bool,
pub detail_mode: DetailMode,
pub detail_focus: usize,
pub detail_scroll: u16,
pub spinner_frame: usize,
pub show_all_windows: bool,
pub auto_refresh_enabled: bool,
pub update_info: Option<UpdateInfo>,
favorite_providers: BTreeSet<String>,
quota_preferences: std::collections::BTreeMap<String, QuotaPreferences>,
last_layout: Cell<GridLayout>,
stable_order: Vec<usize>,
mouse_pos: Cell<Option<(u16, u16)>>,
card_hit_areas: RefCell<Vec<(Rect, usize)>>,
refresh_btn: Cell<Option<Rect>>,
auto_refresh_btn: Cell<Option<Rect>>,
quit_btn: Cell<Option<Rect>>,
update_btn: Cell<Option<Rect>>,
}
impl Dashboard {
pub fn new_loading(kinds: Vec<ProviderKind>) -> Self {
let n = kinds.len();
let entries = kinds.iter().map(|_| ProviderEntry::Loading).collect();
Self {
kinds,
entries,
selected_index: 0,
show_detail: false,
detail_mode: DetailMode::Auto,
detail_focus: 0,
detail_scroll: 0,
spinner_frame: 0,
show_all_windows: false,
auto_refresh_enabled: true,
update_info: None,
favorite_providers: BTreeSet::new(),
quota_preferences: std::collections::BTreeMap::new(),
last_layout: Cell::new(GridLayout::default()),
stable_order: (0..n).collect(),
mouse_pos: Cell::new(None),
card_hit_areas: RefCell::new(Vec::new()),
refresh_btn: Cell::new(None),
auto_refresh_btn: Cell::new(None),
quit_btn: Cell::new(None),
update_btn: Cell::new(None),
}
}
pub fn new_with_entries(kinds: Vec<ProviderKind>, entries: Vec<ProviderEntry>) -> Self {
let n = kinds.len();
let all_done = entries.iter().all(|e| matches!(e, ProviderEntry::Done(_)));
let mut dashboard = Self {
kinds,
entries,
selected_index: 0,
show_detail: false,
detail_mode: DetailMode::Auto,
detail_focus: 0,
detail_scroll: 0,
spinner_frame: 0,
show_all_windows: false,
auto_refresh_enabled: true,
update_info: None,
favorite_providers: BTreeSet::new(),
quota_preferences: std::collections::BTreeMap::new(),
last_layout: Cell::new(GridLayout::default()),
stable_order: (0..n).collect(),
mouse_pos: Cell::new(None),
card_hit_areas: RefCell::new(Vec::new()),
refresh_btn: Cell::new(None),
auto_refresh_btn: Cell::new(None),
quit_btn: Cell::new(None),
update_btn: Cell::new(None),
};
if all_done {
dashboard.stable_order = dashboard.compute_visual_order();
}
dashboard
}
pub fn set_update_info(&mut self, update_info: Option<UpdateInfo>) {
self.update_info = update_info.filter(|info| info.is_update_available());
}
pub fn set_mouse_pos(&self, col: u16, row: u16) {
self.mouse_pos.set(Some((col, row)));
}
pub fn hit_test(&self, col: u16, row: u16) -> Option<HitResult> {
let in_rect = |r: Rect| -> bool {
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
};
if self.refresh_btn.get().is_some_and(in_rect) {
return Some(HitResult::Refresh);
}
if self.auto_refresh_btn.get().is_some_and(in_rect) {
return Some(HitResult::AutoRefreshToggle);
}
if self.quit_btn.get().is_some_and(in_rect) {
return Some(HitResult::Quit);
}
if self.update_btn.get().is_some_and(in_rect) {
return Some(HitResult::OpenUpdate);
}
for &(area, vpos) in self.card_hit_areas.borrow().iter() {
if in_rect(area) {
return Some(HitResult::Card(vpos));
}
}
None
}
pub fn update_release_url(&self) -> Option<String> {
self.update_info
.as_ref()
.filter(|info| info.is_update_available())
.map(|info| info.release_page_url())
}
pub fn is_entry_done(&self, idx: usize) -> bool {
self.entries
.get(idx)
.is_some_and(|e| matches!(e, ProviderEntry::Done(_)))
}
pub fn reset_one(&mut self, idx: usize) {
if let Some(e) = self.entries.get_mut(idx) {
let old = std::mem::replace(e, ProviderEntry::Loading);
*e = match old {
ProviderEntry::Done(r) | ProviderEntry::Refreshing(r) => {
ProviderEntry::Refreshing(r)
}
ProviderEntry::Loading => ProviderEntry::Loading,
};
}
}
pub fn scroll_detail(&mut self, delta: i16) {
self.detail_scroll = (self.detail_scroll as i32 + delta as i32).max(0) as u16;
}
fn window_visible(&self, w: &crate::providers::QuotaWindow) -> bool {
if self.show_all_windows {
return true;
}
if bar::autohide_window(&w.window_type) {
return false;
}
w.limit > 0 || bar::currency_window(&w.window_type).is_some()
}
pub fn detail_prev(&mut self) {
let order = self.detail_navigable_order();
if let Some(pos) = order.iter().position(|&i| i == self.selected_index) {
if pos > 0 {
self.selected_index = order[pos - 1];
} else {
self.selected_index = order[order.len() - 1];
}
}
self.reset_detail_position();
}
pub fn detail_next(&mut self) {
let order = self.detail_navigable_order();
if let Some(pos) = order.iter().position(|&i| i == self.selected_index) {
if pos + 1 < order.len() {
self.selected_index = order[pos + 1];
} else {
self.selected_index = order[0];
}
}
self.reset_detail_position();
}
pub fn cycle_detail_mode(&mut self) {
self.detail_mode = match self.detail_mode {
DetailMode::Auto => DetailMode::Normal,
DetailMode::Normal => DetailMode::Compact,
DetailMode::Compact => DetailMode::Auto,
};
}
pub fn reset_detail_position(&mut self) {
self.detail_focus = 0;
self.detail_scroll = 0;
}
pub fn set_quota_preferences(&mut self, provider: &str, preferences: QuotaPreferences) {
let key = provider.to_ascii_lowercase();
if preferences.favorites.is_empty() && preferences.hidden.is_empty() {
self.quota_preferences.remove(&key);
} else {
self.quota_preferences.insert(key, preferences);
}
}
pub fn selected_provider_slug(&self) -> Option<String> {
Some(self.selected_provider()?.kind.slug().to_string())
}
pub fn selected_detail_row(&self) -> Option<super::detail::DetailRowKey> {
let selected = self.selected_provider()?;
let prefs = self
.quota_preferences
.get(selected.kind.slug())
.cloned()
.unwrap_or_default();
detail_row_keys(selected, self.show_all_windows, &prefs)
.get(self.detail_focus)
.cloned()
}
pub fn move_detail_focus(&mut self, delta: i32) {
let Some(selected) = self.selected_provider() else {
return;
};
let prefs = self
.quota_preferences
.get(selected.kind.slug())
.cloned()
.unwrap_or_default();
let row_count = detail_row_keys(selected, self.show_all_windows, &prefs).len();
if row_count == 0 {
self.detail_focus = 0;
return;
}
let next = (self.detail_focus as i32 + delta).clamp(0, row_count as i32 - 1);
self.detail_focus = next as usize;
}
fn detail_navigable_order(&self) -> Vec<usize> {
self.visual_order()
.into_iter()
.filter(|&i| self.is_entry_done(i))
.collect()
}
pub fn update(&mut self, idx: usize, result: ProviderResult) {
if idx < self.entries.len() {
self.entries[idx] = ProviderEntry::Done(result);
}
if self.all_loaded() {
self.stable_order = self.compute_visual_order();
if self.selected_index >= self.stable_order.len() {
self.selected_index = self.stable_order.len().saturating_sub(1);
}
}
}
pub fn reset_loading(&mut self) {
for e in &mut self.entries {
let old = std::mem::replace(e, ProviderEntry::Loading);
*e = match old {
ProviderEntry::Done(r) | ProviderEntry::Refreshing(r) => {
ProviderEntry::Refreshing(r)
}
ProviderEntry::Loading => ProviderEntry::Loading,
};
}
}
pub fn tick_spinner(&mut self) {
self.spinner_frame = (self.spinner_frame + 1) % SPINNER.len();
}
pub fn all_loaded(&self) -> bool {
self.entries
.iter()
.all(|e| matches!(e, ProviderEntry::Done(_)))
}
pub fn selected_provider(&self) -> Option<&ProviderResult> {
let entry_idx = self.selected_entry_index()?;
match self.entries.get(entry_idx)? {
ProviderEntry::Done(r) | ProviderEntry::Refreshing(r) => Some(r),
ProviderEntry::Loading => None,
}
}
pub fn selected_entry_index(&self) -> Option<usize> {
let order = self.visual_order();
order.get(self.selected_index).copied()
}
fn visual_order(&self) -> Vec<usize> {
self.stable_order.clone()
}
fn visible_count(&self) -> usize {
self.stable_order.len()
}
fn is_auth_required_entry(entry: &ProviderEntry) -> bool {
matches!(
entry,
ProviderEntry::Done(r) | ProviderEntry::Refreshing(r)
if matches!(r.status, ProviderStatus::AuthRequired)
)
}
fn compute_visual_order(&self) -> Vec<usize> {
let mut indices: Vec<usize> = (0..self.entries.len())
.filter(|&i| !Self::is_auth_required_entry(&self.entries[i]))
.collect();
indices.sort_by_key(|&i| {
(
!self.is_provider_favorite(self.kinds[i].slug()),
self.card_weight(&self.entries[i]),
i,
)
});
indices
}
pub fn set_provider_favorite(&mut self, provider: &str, favorite: bool) {
if favorite {
self.favorite_providers
.insert(provider.to_ascii_lowercase());
} else {
self.favorite_providers
.remove(&provider.to_ascii_lowercase());
}
}
pub fn refresh_visual_order(&mut self) {
self.stable_order = self.compute_visual_order();
}
pub fn toggle_selected_provider_favorite(&mut self) -> Option<String> {
let provider = self.selected_provider()?.kind.slug().to_string();
if self.is_provider_favorite(&provider) {
self.favorite_providers.remove(&provider);
} else {
self.favorite_providers.insert(provider.clone());
}
self.stable_order = self.compute_visual_order();
Some(provider)
}
pub fn is_provider_favorite(&self, provider: &str) -> bool {
self.favorite_providers
.contains(&provider.to_ascii_lowercase())
}
fn current_page(&self) -> usize {
let layout = self.last_layout.get();
self.selected_index
.checked_div(layout.per_page)
.unwrap_or(0)
}
pub fn page_count(&self) -> usize {
let layout = self.last_layout.get();
if layout.per_page == 0 {
1
} else {
self.visible_count().max(1).div_ceil(layout.per_page)
}
}
pub fn select_page(&mut self, page: usize) {
let layout = self.last_layout.get();
if layout.per_page == 0 {
return;
}
let max_index = self.visible_count().saturating_sub(1);
self.selected_index = (page * layout.per_page).min(max_index);
}
pub fn navigate(&mut self, dir: Direction) {
if self.entries.is_empty() {
return;
}
let layout = self.last_layout.get();
if layout.cols == 0 || layout.per_page == 0 {
return;
}
let total = self.visible_count();
if total == 0 {
return;
}
let page = self.current_page();
let page_start = page * layout.per_page;
let page_end = (page_start + layout.per_page).min(total);
let page_count = page_end - page_start;
let in_page_idx = self.selected_index - page_start;
let cols = layout.cols.min(page_count.max(1));
let rows_in_page = page_count.div_ceil(cols);
let col = in_page_idx % cols;
let row = in_page_idx / cols;
match dir {
Direction::Left => {
if col > 0 {
self.selected_index -= 1;
} else if page > 0 {
let prev_end = page * layout.per_page;
self.selected_index = prev_end - 1;
}
}
Direction::Right => {
if in_page_idx + 1 < page_count && col + 1 < cols {
self.selected_index += 1;
} else if page_end < total {
self.selected_index = page_end;
}
}
Direction::Up => {
if row > 0 {
self.selected_index -= cols;
}
}
Direction::Down => {
if row + 1 < rows_in_page {
let candidate = self.selected_index + cols;
if candidate < page_end {
self.selected_index = candidate;
} else {
self.selected_index = page_end - 1;
}
}
}
}
}
pub fn render(&self, f: &mut Frame) {
if self.show_detail {
self.render_detail(f);
} else {
self.render_grid(f);
}
}
fn flow_placements(
page_order: &[usize],
cols: usize,
) -> Vec<(usize, usize, usize)> {
if cols == 0 || page_order.is_empty() {
return Vec::new();
}
let max_rows = page_order.len() * 2 + 1; let mut occupied: Vec<Vec<bool>> = vec![vec![false; cols]; max_rows];
let mut out = Vec::with_capacity(page_order.len());
let mut scan_row = 0usize;
for &_entry_idx in page_order {
let span = 1usize.min(cols);
let row_span = 1usize;
let mut placed = false;
let mut r = scan_row;
while r < max_rows && !placed {
for c in 0..=cols.saturating_sub(span) {
let fits = (r..r + row_span)
.all(|rr| rr < max_rows && (c..c + span).all(|cc| !occupied[rr][cc]));
if fits {
for row in occupied.iter_mut().skip(r).take(row_span) {
for cell in row.iter_mut().skip(c).take(span) {
*cell = true;
}
}
out.push((r, c, span));
scan_row = r;
placed = true;
break;
}
}
r += 1;
}
if !placed {
let row = out.last().map(|(r, _, _)| *r).unwrap_or(0);
let final_span = span.min(cols);
for row_slice in occupied
.iter_mut()
.take((row + row_span).min(max_rows))
.skip(row)
{
for cell in row_slice.iter_mut().take(final_span.min(cols)) {
*cell = true;
}
}
out.push((row, 0, final_span));
}
}
out
}
fn compute_layout(&self, grid_area: Rect) -> GridLayout {
let n = self.visible_count().max(1);
let max_cols = (grid_area.width / MIN_CARD_W).max(1) as usize;
let max_rows = (grid_area.height / MIN_CARD_H).max(1) as usize;
let flow_rows_for = |cols: usize, k: usize| -> usize {
let total = k.min(self.stable_order.len());
if total == 0 || cols == 0 {
return 1;
}
Self::flow_placements(
&self.stable_order[..total],
cols,
)
.iter()
.map(|(r, _, _)| r + 1)
.max()
.unwrap_or(1)
};
let flow_rows = |cols: usize| flow_rows_for(cols, n);
let find_fitting_per_page = |cols: usize| -> usize {
let mut best = 1usize;
for k in 1..=n.min(self.stable_order.len()) {
let slice = &self.stable_order[..k];
let placements =
Self::flow_placements(slice, cols);
let nr = placements.iter().map(|(r, _, _)| r + 1).max().unwrap_or(0);
if nr > max_rows {
break;
}
let mut row_h: Vec<u16> = vec![MIN_CARD_H; nr];
for (i, &entry_idx) in slice.iter().enumerate() {
let (row_i, _, _) = placements[i];
let h = self.natural_card_height(&self.entries[entry_idx]);
if h > row_h[row_i] {
row_h[row_i] = h;
}
}
let total_h: u32 = row_h.iter().map(|h| *h as u32).sum();
if total_h * 5 <= grid_area.height as u32 * 6 {
best = k;
} else {
break;
}
}
best.max(1)
};
let per_page = (max_cols * max_rows).max(1);
if n <= per_page {
let aspect = (grid_area.width as f64) / (grid_area.height as f64 * 2.0).max(1.0);
let mut best_cols = 1usize.max(n.min(max_cols));
let mut best_score = f64::MAX;
for c in 1..=n.min(max_cols) {
let r = n.div_ceil(c);
if r > max_rows {
continue;
}
let ratio = (c as f64) / (r as f64);
let score = (ratio - aspect).abs() * (1.0 + (c as f64 - 1.0) * 0.05);
if score < best_score {
best_score = score;
best_cols = c;
}
}
let actual_rows = flow_rows(best_cols);
let cols = if actual_rows <= max_rows {
best_cols
} else {
let wider = (best_cols..=max_cols).find(|&c| flow_rows(c) <= max_rows);
match wider {
Some(c) => c,
None => {
let pp = find_fitting_per_page(max_cols);
return GridLayout {
cols: max_cols,
per_page: pp,
};
}
}
};
GridLayout { cols, per_page: n }
} else {
let pp = find_fitting_per_page(max_cols);
GridLayout {
cols: max_cols,
per_page: pp,
}
}
}
fn render_grid(&self, f: &mut Frame) {
let area = f.area();
if self.entries.is_empty() {
let paragraph = Paragraph::new(
"No providers configured. Set API keys via environment variables or config files.",
)
.block(Block::new().borders(Borders::ALL).title("quotas"));
f.render_widget(paragraph, area);
return;
}
let title_h = 2;
let footer_h = 1;
let title_area = Rect::new(area.x, area.y, area.width, title_h);
let grid_area = Rect::new(
area.x,
area.y + title_h,
area.width,
area.height.saturating_sub(title_h + footer_h),
);
let footer_area = Rect::new(
area.x,
area.y + area.height.saturating_sub(footer_h),
area.width,
footer_h,
);
let layout = self.compute_layout(grid_area);
self.last_layout.set(layout);
let total = self.visible_count();
let page = self.current_page();
let pages = total.div_ceil(layout.per_page.max(1));
let page_start = page * layout.per_page;
let page_end = (page_start + layout.per_page).min(total);
let loading_count = self
.entries
.iter()
.filter(|e| matches!(e, ProviderEntry::Loading | ProviderEntry::Refreshing(_)))
.count();
let unconfigured: Vec<&str> = self
.entries
.iter()
.filter_map(|e| match e {
ProviderEntry::Done(r) | ProviderEntry::Refreshing(r)
if matches!(r.status, ProviderStatus::AuthRequired) =>
{
Some(r.kind.display_name())
}
_ => None,
})
.collect();
let mut header_line = vec![Span::raw(" quotas ").bold().white()];
if loading_count > 0 {
header_line.push(Span::raw(" "));
header_line.push(
Span::raw(format!(
"{} fetching {} provider{}…",
SPINNER[self.spinner_frame],
loading_count,
if loading_count == 1 { "" } else { "s" }
))
.cyan(),
);
}
if pages > 1 {
header_line.push(Span::raw(" "));
header_line.push(Span::raw(format!("page {}/{}", page + 1, pages)).dim());
}
let hint_y = title_area.y + 1;
let mouse = self.mouse_pos.get();
let refresh_rect = Rect::new(title_area.x + 25, hint_y, 9, 1);
let auto_refresh_rect = Rect::new(title_area.x + 60, hint_y, 8, 1);
let quit_rect = Rect::new(title_area.x + 70, hint_y, 6, 1);
self.refresh_btn.set(Some(refresh_rect));
self.auto_refresh_btn.set(Some(auto_refresh_rect));
self.quit_btn.set(Some(quit_rect));
let in_rect = |r: Rect| -> bool {
mouse.is_some_and(|(c, row)| c >= r.x && c < r.x + r.width && row == r.y)
};
let btn_style = |r: Rect| -> Style {
if in_rect(r) {
Style::new().white().bold().underlined()
} else {
Style::new().dim()
}
};
let hint_line = Line::from(vec![
Span::raw(" ←↑↓→ Nav Enter Detail ").dim(),
Span::styled("R Refresh", btn_style(refresh_rect)),
Span::raw(" C Copy PgUp/PgDn Page ").dim(),
Span::styled(
if self.auto_refresh_enabled {
"[x] Auto"
} else {
"[ ] Auto"
},
btn_style(auto_refresh_rect),
),
Span::raw(" ").dim(),
Span::styled("Q Quit", btn_style(quit_rect)),
Span::raw(" · ").dim(),
Span::raw("▒").fg(Color::DarkGray),
Span::raw(" ahead ").dim(),
Span::raw("█").fg(Color::Rgb(255, 140, 0)),
Span::raw(" overspend ").dim(),
]);
let title = Paragraph::new(vec![Line::from(header_line), hint_line])
.block(Block::new().borders(Borders::NONE));
f.render_widget(title, title_area);
let loaded = total.saturating_sub(loading_count);
let update_hovered = self.update_btn.get().is_some_and(|r| {
mouse.is_some_and(|(c, row)| {
c >= r.x && c < r.x + r.width && row >= r.y && row < r.y + r.height
})
});
let footer_layout = footer_line(
loaded,
total,
&unconfigured,
env!("CARGO_PKG_VERSION"),
self.update_info.as_ref(),
update_hovered,
);
if let Some((offset, width)) = footer_layout.update_hit {
self.update_btn.set(Some(Rect::new(
footer_area.x.saturating_add(offset),
footer_area.y,
width,
1,
)));
} else {
self.update_btn.set(None);
}
let footer = Paragraph::new(footer_layout.line);
f.render_widget(footer, footer_area);
let order = self.visual_order();
let page_count = page_end - page_start;
let cols = layout.cols.min(page_count.max(1));
let placements = Self::flow_placements(
&order[page_start..page_end],
cols,
);
let num_rows = placements
.iter()
.map(|(r, _, _)| *r + 1)
.max()
.unwrap_or(0)
.max(1);
let mut row_heights: Vec<u16> = vec![MIN_CARD_H; num_rows];
for (i, visual_pos) in (page_start..page_end).enumerate() {
let (row_i, _, _) = placements[i];
let entry_idx = order[visual_pos];
let h = self.natural_card_height(&self.entries[entry_idx]);
if h > row_heights[row_i] {
row_heights[row_i] = h;
}
}
let total_fixed: u16 = row_heights.iter().sum();
if total_fixed > grid_area.height {
let mut remaining = total_fixed.saturating_sub(grid_area.height);
while remaining > 0 {
let mut shrinkable: Vec<usize> = (0..row_heights.len())
.filter(|&i| row_heights[i] > MIN_CARD_H)
.collect();
if shrinkable.is_empty() {
break;
}
shrinkable.sort_by(|&a, &b| row_heights[b].cmp(&row_heights[a]));
let per_row = (remaining / shrinkable.len() as u16).max(1);
let mut did_shrink = false;
for &idx in &shrinkable {
if remaining == 0 {
break;
}
let can_shrink = row_heights[idx].saturating_sub(MIN_CARD_H);
let take = per_row.min(can_shrink).min(remaining);
if take > 0 {
row_heights[idx] -= take;
remaining -= take;
did_shrink = true;
}
}
if !did_shrink {
break;
}
}
}
let mut row_constraints: Vec<Constraint> =
row_heights.iter().map(|h| Constraint::Length(*h)).collect();
row_constraints.push(Constraint::Fill(1));
let all_rects = Layout::vertical(row_constraints).split(grid_area);
let row_rects = &all_rects[..num_rows];
let unit_col_w = (grid_area.width / cols as u16).max(1);
let mut hit_areas: Vec<(Rect, usize)> = Vec::with_capacity(page_end - page_start);
for (i, visual_pos) in (page_start..page_end).enumerate() {
let (row_i, col_i, span) = placements[i];
let row_area = row_rects[row_i];
let x = row_area.x + col_i as u16 * unit_col_w;
let w = (span as u16 * unit_col_w).saturating_sub(1);
let card_area = Rect::new(x, row_area.y, w, row_area.height);
let selected = visual_pos == self.selected_index;
let entry_idx = order[visual_pos];
hit_areas.push((card_area, visual_pos));
self.render_entry(f, entry_idx, selected, card_area);
}
*self.card_hit_areas.borrow_mut() = hit_areas;
}
fn natural_card_height(&self, entry: &ProviderEntry) -> u16 {
match entry {
ProviderEntry::Loading => MIN_CARD_H,
ProviderEntry::Done(r) | ProviderEntry::Refreshing(r) => match &r.status {
ProviderStatus::Available { quota } => {
let visible = quota
.windows
.iter()
.filter(|w| self.window_visible(w))
.count();
let fixed: u16 = 4;
let content: u16 = (visible as u16) * 2 + 1;
(fixed + content).max(MIN_CARD_H)
}
ProviderStatus::AuthRequired => 4,
_ => MIN_CARD_H,
},
}
}
fn card_weight(&self, entry: &ProviderEntry) -> u32 {
match entry {
ProviderEntry::Loading => 4,
ProviderEntry::Done(r) | ProviderEntry::Refreshing(r) => match &r.status {
ProviderStatus::Available { quota } => {
let visible = quota
.windows
.iter()
.filter(|w| self.window_visible(w))
.count()
.max(1) as u32;
let effective = visible;
let raw = 4 + effective;
raw.clamp(5, 10)
}
ProviderStatus::AuthRequired => 20,
_ => 15,
},
}
}
fn render_entry(&self, f: &mut Frame, idx: usize, selected: bool, area: Rect) {
let border_style = if selected {
Style::new().green()
} else {
Style::new().dim()
};
let block = Block::new()
.borders(Borders::ALL)
.border_style(border_style);
let raw_inner = block.inner(area);
f.render_widget(block, area);
let inner = Rect::new(
raw_inner.x + 1,
raw_inner.y,
raw_inner.width.saturating_sub(1),
raw_inner.height,
);
match &self.entries[idx] {
ProviderEntry::Loading => {
let name = self.kinds[idx].display_name();
let favorite_marker = if self.is_provider_favorite(self.kinds[idx].slug()) {
"★ "
} else {
""
};
let spinner_char = SPINNER[self.spinner_frame];
let text = Text::from(vec![
Line::from(vec![Span::raw(format!(
"{} {}{}",
if selected { "▶" } else { " " },
favorite_marker,
name
))
.bold()]),
Line::from(""),
Line::from(vec![
Span::raw(spinner_char).cyan(),
Span::raw(" loading…").dim(),
]),
]);
let p = Paragraph::new(text);
f.render_widget(p, inner);
}
ProviderEntry::Refreshing(result) => {
self.render_done_card(f, result, selected, inner);
let spin = format!("{} ", SPINNER[self.spinner_frame]);
let spin_w = spin.chars().count() as u16;
if inner.width >= spin_w + 2 {
let x = inner.x + inner.width - spin_w;
f.render_widget(
Paragraph::new(Line::from(Span::raw(spin).cyan().dim())),
Rect::new(x, inner.y, spin_w, 1),
);
}
}
ProviderEntry::Done(result) => {
self.render_done_card(f, result, selected, inner);
}
}
}
fn render_done_card(
&self,
f: &mut Frame,
result: &ProviderResult,
selected: bool,
inner: Rect,
) {
use crate::tui::freshness::{FreshnessLabel, Staleness};
let is_auth = matches!(result.status, ProviderStatus::AuthRequired);
let favorite_marker = if self.is_provider_favorite(result.kind.slug()) {
"★ "
} else {
""
};
let mut lines: Vec<Line> = Vec::new();
let name_part = format!(
"{} {}{}",
if selected { "▶" } else { " " },
favorite_marker,
result.kind.display_name()
);
let name_len = name_part.chars().count();
let avail_for_fresh = (inner.width as usize).saturating_sub(name_len + 2);
let mut header_spans: Vec<Span> = if is_auth || avail_for_fresh == 0 {
let style = if is_auth {
Style::new().bold().dim()
} else {
Style::new().bold()
};
vec![Span::styled(
bar::truncate_suffix(&name_part, inner.width as usize),
style,
)]
} else {
let freshness = if let Some(cached_at) = result.cached_at {
let elapsed = (chrono::Utc::now() - cached_at).num_seconds().max(0);
FreshnessLabel::cached(elapsed)
} else {
let elapsed = (chrono::Utc::now() - result.fetched_at)
.num_seconds()
.max(0);
FreshnessLabel::with_interval(elapsed, result.kind.auto_refresh_secs())
};
let fresh_str = &freshness.label;
let field_w = fresh_str.chars().count().min(avail_for_fresh);
let text: String = fresh_str.chars().take(field_w).collect();
let text = format!("{:<width$}", text, width = field_w);
let text: String = text.chars().take(field_w).collect();
let fill_n = (freshness.fraction * field_w as f64).round() as usize;
let fill_n = fill_n.min(field_w);
let (fg, bg_fill) = match freshness.staleness {
Staleness::Fresh => (Color::Cyan, Color::Rgb(0, 42, 28)),
Staleness::Warning => (Color::Yellow, Color::Rgb(65, 38, 0)),
Staleness::Stale => (Color::Red, Color::Rgb(65, 0, 0)),
Staleness::Cached => (Color::Green, Color::Rgb(0, 65, 0)),
};
let filled_str: String = text.chars().take(fill_n).collect();
let unfilled_str: String = text.chars().skip(fill_n).collect();
let mut spans: Vec<Span> = vec![Span::raw(name_part).bold(), Span::raw(" ")];
if !filled_str.is_empty() {
spans.push(Span::styled(filled_str, Style::new().fg(fg).bg(bg_fill)));
}
if !unfilled_str.is_empty() {
spans.push(Span::styled(unfilled_str, Style::new().fg(fg).dim()));
}
spans
};
if let ProviderStatus::Available { quota } = &result.status {
if let Some(diff) = worst_pace_diff("a.windows) {
if let Some(icon) = pace_icon(diff) {
header_spans.push(Span::raw(" "));
header_spans.push(icon);
}
}
}
lines.push(Line::from(header_spans));
match &result.status {
ProviderStatus::Available { quota } => {
let mut plan_spans = vec![Span::raw(quota.plan_name.clone()).italic().dim()];
if let Some(banked) = "a.banked_resets {
if banked.available_count > 0 {
let n = banked.available_count;
let label = if n == 1 {
" · 1 banked".to_string()
} else {
format!(" · {n} banked")
};
plan_spans.push(Span::raw(label).cyan().dim());
}
}
lines.push(Line::from(plan_spans));
let mut visible: Vec<&QuotaWindow> = quota
.windows
.iter()
.filter(|w| self.window_visible(w))
.collect();
visible.sort_by_key(|w| bar::window_sort_key(w));
let total = visible.len();
let mut buckets_seen = std::collections::BTreeSet::new();
for w in &visible {
buckets_seen.insert(bar::window_sort_key(w).0);
}
let show_headers = total >= 6 && buckets_seen.len() >= 2;
let header_lines = lines.len() as u16;
let body_height = inner.height.saturating_sub(header_lines);
let footer_reserve: u16 = if total > 0 { 1 } else { 0 };
let usable = body_height.saturating_sub(footer_reserve) as usize;
let header_budget = if show_headers { buckets_seen.len() } else { 0 };
let usable_for_rows = usable.saturating_sub(header_budget);
let (mode, shown_count) = pick_layout(total, usable_for_rows);
let label_w: usize = visible
.iter()
.take(shown_count)
.map(|w| {
bar::display_label(&w.window_type, show_headers)
.chars()
.count()
})
.max()
.unwrap_or(6)
.clamp(6, 10);
let bar_width = inner.width.saturating_sub(label_w as u16 + 2).clamp(10, 64);
let mut last_bucket: Option<u8> = None;
for w in visible.iter().take(shown_count) {
let bucket = bar::window_sort_key(w).0;
if show_headers && Some(bucket) != last_bucket {
if let Some(label) = bar::bucket_label(bucket) {
lines.push(Line::from(Span::raw(label.to_string()).dim()));
}
last_bucket = Some(bucket);
}
let label_src = bar::display_label(&w.window_type, show_headers);
if let Some((sym, scale)) = bar::currency_window(&w.window_type) {
lines.push(Line::from(vec![Span::raw(format!(
"{:<width$} {}{:.2}",
label_src,
sym,
w.remaining as f64 / scale,
width = label_w
))]));
if matches!(mode, RenderMode::TwoLine) {
lines.push(Line::from(""));
}
continue;
}
let used_pct = (w.used as f64 / w.limit.max(1) as f64).clamp(0.0, 1.0);
let time_elapsed = bar::time_elapsed_fraction(w);
let color = bar::bar_color(used_pct);
let overlay =
bar_overlay_text(used_pct, w.used, w.limit, bar_width as usize, &w.window_type);
let bar_spans =
bar::build_labeled(used_pct, time_elapsed, bar_width, color, &overlay);
let mut l1 = vec![Span::raw(format!(
"{:<width$} ",
bar::truncate_suffix(&label_src, label_w),
width = label_w
))];
l1.extend(bar_spans);
lines.push(Line::from(l1));
if matches!(mode, RenderMode::TwoLine) {
if let Some(reset_at) = w.reset_at {
let rel = humanize_reset(reset_at - chrono::Utc::now());
lines.push(Line::from(
Span::raw(format!(
"{:width$}resets in {}",
"",
rel,
width = label_w + 1
))
.dim(),
));
}
}
}
if total > shown_count {
lines.push(Line::from(
Span::raw(format!("+ {} more · Enter for detail", total - shown_count))
.dim()
.italic(),
));
} else if matches!(mode, RenderMode::TwoLine) {
let spare = inner.height.saturating_sub(lines.len() as u16);
if spare >= 2 {
if let Some((badge_text, badge_style)) = pace_badge(&visible) {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(badge_text, badge_style),
]));
}
}
}
}
ProviderStatus::Unavailable { info } => {
lines.push(Line::from(
Span::raw(format!("Unavailable: {}", info.reason)).yellow(),
));
if let Some(url) = &info.console_url {
lines.push(Line::from(Span::raw(url.clone()).dim()));
}
}
ProviderStatus::AuthRequired => {
lines.push(Line::from(Span::raw("Set API key in env or config").dim()));
}
ProviderStatus::NetworkError { message } => {
lines.push(Line::from(Span::raw("Network error").red()));
lines.push(Line::from(Span::raw(message.clone()).dim()));
}
}
let paragraph =
Paragraph::new(Text::from(lines)).alignment(ratatui::layout::Alignment::Left);
f.render_widget(paragraph, inner);
}
pub fn page_up(&mut self) {
let layout = self.last_layout.get();
if layout.per_page == 0 {
return;
}
let page = self.current_page();
if page > 0 {
let new_page = page - 1;
self.selected_index = new_page * layout.per_page;
}
}
pub fn page_down(&mut self) {
let layout = self.last_layout.get();
if layout.per_page == 0 {
return;
}
let total = self.visible_count();
let page = self.current_page();
let pages = total.div_ceil(layout.per_page);
if page + 1 < pages {
self.selected_index = (page + 1) * layout.per_page;
}
}
fn render_detail(&self, f: &mut Frame) {
let area = f.area();
let title = Paragraph::new(vec![
Line::from(vec![Span::raw(" QUOTA DETAIL ").bold().white()]),
Line::from(vec![Span::raw(
" ← → providers ↑ ↓ focus PgUp/PgDn scroll Tab mode F favorite X hide Enter/Esc back C copy Q quit ",
)
.dim()]),
])
.block(Block::new().borders(Borders::BOTTOM));
f.render_widget(title, Rect::new(area.x, area.y, area.width, 3));
if let Some(selected) = self.selected_provider() {
let view = DetailView::new(selected.clone());
let quota_preferences = self
.quota_preferences
.get(selected.kind.slug())
.cloned()
.unwrap_or_default();
let detail_area = Rect::new(
area.x,
area.y + 3,
area.width,
area.height.saturating_sub(4),
);
let text = view.render(super::detail::DetailRenderOptions {
width: detail_area.width,
height: detail_area.height,
mode: self.detail_mode,
auto_refresh: self.auto_refresh_enabled,
provider_favorite: self.is_provider_favorite(selected.kind.slug()),
preferences: "a_preferences,
focused_row: Some(self.detail_focus),
});
let paragraph = Paragraph::new(text)
.block(Block::new().borders(Borders::NONE))
.wrap(Wrap { trim: false })
.scroll((self.detail_scroll, 0));
f.render_widget(paragraph, detail_area);
} else {
let p = Paragraph::new("Provider still loading…")
.block(Block::new().borders(Borders::NONE));
f.render_widget(
p,
Rect::new(
area.x,
area.y + 3,
area.width,
area.height.saturating_sub(4),
),
);
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum RenderMode {
TwoLine,
OneLine,
}
struct FooterLayout {
line: Line<'static>,
update_hit: Option<(u16, u16)>,
}
fn footer_line(
loaded: usize,
total: usize,
unconfigured: &[&str],
current_version: &str,
update_info: Option<&UpdateInfo>,
update_hovered: bool,
) -> FooterLayout {
let mut spans = if unconfigured.is_empty() {
vec![Span::styled(
format!("{} of {} providers loaded", loaded, total),
Style::new().dim(),
)]
} else {
vec![Span::styled(
format!(
"{} of {} loaded · No key: {}",
loaded,
total,
unconfigured.join(", ")
),
Style::new().dim(),
)]
};
let version_label = format!("v{}", current_version.trim_start_matches('v'));
spans.push(Span::styled(" · ", Style::new().dim()));
spans.push(Span::styled(version_label, Style::new().dim()));
let mut update_hit = None;
if let Some(update) = update_info.filter(|info| info.is_update_available()) {
spans.push(Span::styled(" · ", Style::new().dim()));
let notice = format!("update {}", update.latest_version);
let offset: u16 = spans
.iter()
.map(|s| s.content.chars().count() as u16)
.sum();
let width = notice.chars().count() as u16;
let style = if update_hovered {
Style::new().fg(Color::White).bold().underlined()
} else {
Style::new().fg(Color::Cyan).dim().underlined()
};
spans.push(Span::styled(notice, style));
update_hit = Some((offset, width));
}
FooterLayout {
line: Line::from(spans),
update_hit,
}
}
fn pick_layout(total: usize, usable_lines: usize) -> (RenderMode, usize) {
if total == 0 || usable_lines == 0 {
return (RenderMode::TwoLine, 0);
}
let two_fits = usable_lines / 2;
if two_fits >= total {
return (RenderMode::TwoLine, total);
}
let one_fits = usable_lines;
if one_fits >= total {
return (RenderMode::OneLine, total);
}
(RenderMode::OneLine, one_fits.min(total))
}
fn worst_pace_diff(windows: &[QuotaWindow]) -> Option<f64> {
let mut worst: f64 = f64::NEG_INFINITY;
for w in windows {
if bar::currency_window(&w.window_type).is_some() {
continue;
}
let used_pct = (w.used as f64 / w.limit.max(1) as f64).clamp(0.0, 1.0);
let Some(elapsed) = bar::time_elapsed_fraction(w) else {
continue;
};
let diff = used_pct - elapsed;
if diff > worst {
worst = diff;
}
}
if worst == f64::NEG_INFINITY {
None
} else {
Some(worst)
}
}
fn pace_icon(diff: f64) -> Option<Span<'static>> {
if diff >= 0.08 {
Some(Span::styled(
"\u{26a1}",
Style::new().fg(Color::Rgb(255, 140, 0)),
))
} else if diff <= -0.08 {
Some(Span::styled("\u{2713}", Style::new().green().dim()))
} else {
None
}
}
fn pace_badge(visible: &[&QuotaWindow]) -> Option<(String, Style)> {
let mut worst_diff: f64 = f64::NEG_INFINITY;
let mut worst_label = String::new();
let mut worst_pct: f64 = 0.0;
for w in visible {
if bar::currency_window(&w.window_type).is_some() {
continue;
}
let used_pct = (w.used as f64 / w.limit.max(1) as f64).clamp(0.0, 1.0);
let Some(elapsed) = bar::time_elapsed_fraction(w) else {
continue;
};
let diff = used_pct - elapsed;
if diff > worst_diff {
worst_diff = diff;
worst_label = bar::display_label(&w.window_type, false);
worst_pct = used_pct;
}
}
if worst_diff == f64::NEG_INFINITY {
return None;
}
if worst_diff >= 0.08 {
let text = format!(
"\u{26a1} {}: {:.0}% \u{2014} burning fast",
bar::truncate_suffix(&worst_label, 10),
worst_pct * 100.0
);
Some((text, Style::new().fg(Color::Rgb(255, 140, 0))))
} else if worst_diff <= -0.08 {
Some((
"\u{2713} all pacing ahead".to_string(),
Style::new().green().dim(),
))
} else {
None }
}
fn bar_overlay_text(
used_pct: f64,
used: i64,
limit: i64,
bar_width: usize,
window_type: &str,
) -> String {
let pct = format!("{:.0}%", used_pct * 100.0);
if window_type == "total_quota" || bar_width < 10 {
return pct;
}
let nums = if let Some((sym, scale)) = bar::currency_bar_scale(window_type) {
format!(
"{sym}{}/{sym}{}",
(used as f64 / scale).round() as i64,
(limit as f64 / scale).round() as i64,
)
} else {
format!("{}/{}", format_num(used), format_num(limit))
};
let compact = format!("{} {}", pct, nums);
if compact.chars().count() + 2 <= bar_width {
format!("{} ({})", pct, nums)
} else if compact.chars().count() <= bar_width {
compact
} else {
pct
}
}
fn humanize_reset(d: chrono::Duration) -> String {
let secs = d.num_seconds();
if secs <= 0 {
return "now".to_string();
}
let days = secs / 86400;
let hours = (secs % 86400) / 3600;
let mins = (secs % 3600) / 60;
if days > 0 {
format!("{}d {}h", days, hours)
} else if hours > 0 {
format!("{}h {}m", hours, mins)
} else {
format!("{}m", mins.max(1))
}
}
fn format_num(n: i64) -> String {
fn trim_trailing_zero(s: String) -> String {
if let Some(stripped) = s.strip_suffix(".0") {
stripped.to_string()
} else {
s
}
}
if n.abs() >= 1_000_000 {
trim_trailing_zero(format!("{:.1}", n as f64 / 1_000_000.0)) + "M"
} else if n.abs() >= 1_000 {
trim_trailing_zero(format!("{:.1}", n as f64 / 1_000.0)) + "k"
} else {
n.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::BankedResets;
use crate::providers::{ProviderKind, ProviderQuota, ProviderStatus};
fn dummy_available_result(kind: ProviderKind) -> ProviderResult {
ProviderResult {
kind,
status: ProviderStatus::Available {
quota: ProviderQuota {
plan_name: "test".into(),
windows: vec![],
unlimited: false,
banked_resets: None,
},
},
fetched_at: chrono::Utc::now(),
raw_response: None,
auth_source: None,
cached_at: None,
}
}
fn dummy_auth_required_result(kind: ProviderKind) -> ProviderResult {
ProviderResult {
kind,
status: ProviderStatus::AuthRequired,
fetched_at: chrono::Utc::now(),
raw_response: None,
auth_source: None,
cached_at: None,
}
}
fn antigravity_summary_quota_result() -> ProviderResult {
ProviderResult {
kind: ProviderKind::Antigravity,
status: ProviderStatus::Available {
quota: ProviderQuota {
plan_name: "Antigravity".into(),
windows: vec![
QuotaWindow {
window_type: "7d/gemini".into(),
used: 18,
limit: 100,
remaining: 82,
reset_at: Some(
chrono::DateTime::parse_from_rfc3339("2026-07-19T17:07:49Z")
.unwrap()
.with_timezone(&chrono::Utc),
),
period_seconds: Some(7 * 86400),
},
QuotaWindow {
window_type: "5h/gemini".into(),
used: 2,
limit: 100,
remaining: 98,
reset_at: Some(
chrono::DateTime::parse_from_rfc3339("2026-07-13T18:32:16Z")
.unwrap()
.with_timezone(&chrono::Utc),
),
period_seconds: Some(5 * 3600),
},
QuotaWindow {
window_type: "7d/3p".into(),
used: 0,
limit: 100,
remaining: 100,
reset_at: Some(
chrono::DateTime::parse_from_rfc3339("2026-07-20T14:17:18Z")
.unwrap()
.with_timezone(&chrono::Utc),
),
period_seconds: Some(7 * 86400),
},
QuotaWindow {
window_type: "5h/3p".into(),
used: 0,
limit: 100,
remaining: 100,
reset_at: Some(
chrono::DateTime::parse_from_rfc3339("2026-07-13T19:17:18Z")
.unwrap()
.with_timezone(&chrono::Utc),
),
period_seconds: Some(5 * 3600),
},
],
unlimited: false,
banked_resets: None,
},
},
fetched_at: chrono::Utc::now(),
raw_response: None,
auth_source: Some(
"oauth:/home/xertrov/.gemini/antigravity-cli/antigravity-oauth-token".into(),
),
cached_at: None,
}
}
fn render_text(dashboard: &Dashboard, width: u16, height: u16) -> String {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| dashboard.render(f)).unwrap();
let buffer = terminal.backend().buffer();
let mut lines = Vec::new();
for y in 0..height {
let mut line = String::new();
for x in 0..width {
line.push_str(buffer.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "));
}
lines.push(line.trim_end().to_string());
}
lines.join("\n")
}
fn render_buffer(dashboard: &Dashboard, width: u16, height: u16) -> ratatui::buffer::Buffer {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| dashboard.render(f)).unwrap();
terminal.backend().buffer().clone()
}
#[test]
fn format_num_scales() {
assert_eq!(format_num(500), "500");
assert_eq!(format_num(1_500), "1.5k");
assert_eq!(format_num(2_000), "2k");
assert_eq!(format_num(150_000), "150k");
assert_eq!(format_num(2_500_000), "2.5M");
}
#[test]
fn footer_line_always_shows_current_version() {
let layout = footer_line(6, 6, &[], "0.9.0", None, false);
let rendered: String = layout
.line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(rendered, "6 of 6 providers loaded · v0.9.0");
assert_eq!(layout.update_hit, None);
let version = layout
.line
.spans
.iter()
.find(|span| span.content.as_ref() == "v0.9.0")
.expect("version label span");
assert!(
version.style.add_modifier.contains(Modifier::DIM),
"version label is permanent chrome, kept subtle"
);
assert!(
!version.style.add_modifier.contains(Modifier::UNDERLINED),
"version itself is not a link"
);
}
#[test]
fn footer_line_shows_available_update_to_the_right_of_version() {
let update = UpdateInfo {
current_version: "0.8.1".into(),
latest_version: "0.8.2".into(),
checked_at: chrono::Utc::now(),
};
let layout = footer_line(6, 6, &[], "0.8.1", Some(&update), false);
let rendered: String = layout
.line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(
rendered,
"6 of 6 providers loaded · v0.8.1 · update 0.8.2"
);
let notice = layout
.line
.spans
.iter()
.find(|span| span.content.as_ref() == "update 0.8.2")
.expect("update notice span should be present");
assert_eq!(notice.style.fg, Some(Color::Cyan));
assert!(notice.style.add_modifier.contains(Modifier::DIM));
assert!(
notice.style.add_modifier.contains(Modifier::UNDERLINED),
"update notice should look like a link"
);
assert_eq!(
layout.update_hit,
Some((
"6 of 6 providers loaded · v0.8.1 · ".chars().count() as u16,
"update 0.8.2".chars().count() as u16
))
);
}
#[test]
fn footer_line_omits_update_notice_when_current_is_latest() {
let update = UpdateInfo {
current_version: "0.8.2".into(),
latest_version: "0.8.2".into(),
checked_at: chrono::Utc::now(),
};
let layout = footer_line(6, 6, &[], "0.8.2", Some(&update), false);
let rendered: String = layout
.line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(rendered, "6 of 6 providers loaded · v0.8.2");
assert_eq!(layout.update_hit, None);
}
#[test]
fn footer_line_strips_leading_v_from_current_version() {
let layout = footer_line(1, 1, &[], "v0.9.0", None, false);
let rendered: String = layout
.line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
rendered.ends_with(" · v0.9.0"),
"got {rendered:?}"
);
assert!(!rendered.contains("vv0.9.0"));
}
#[test]
fn footer_update_link_hovers_bold_white() {
let update = UpdateInfo {
current_version: "0.8.1".into(),
latest_version: "0.8.2".into(),
checked_at: chrono::Utc::now(),
};
let layout = footer_line(6, 6, &[], "0.8.1", Some(&update), true);
let notice = layout
.line
.spans
.iter()
.find(|span| span.content.as_ref() == "update 0.8.2")
.expect("update notice");
assert_eq!(notice.style.fg, Some(Color::White));
assert!(notice.style.add_modifier.contains(Modifier::BOLD));
assert!(notice.style.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn dashboard_footer_renders_version_and_update_notice() {
let kinds = vec![ProviderKind::Claude];
let entries = vec![ProviderEntry::Done(dummy_available_result(
ProviderKind::Claude,
))];
let mut dashboard = Dashboard::new_with_entries(kinds, entries);
dashboard.set_update_info(Some(UpdateInfo {
current_version: "0.8.1".into(),
latest_version: "0.8.2".into(),
checked_at: chrono::Utc::now(),
}));
let buffer = render_buffer(&dashboard, 80, 20);
let footer_y = 19;
let footer_text: String = (0..80)
.map(|x| {
buffer
.cell((x, footer_y))
.map(|c| c.symbol())
.unwrap_or(" ")
})
.collect();
let version_label = format!("v{}", env!("CARGO_PKG_VERSION"));
assert!(
footer_text.contains(&version_label),
"footer should always show current package version, got {footer_text:?}"
);
let notice_start = footer_text
.find("update 0.8.2")
.expect("update notice should render in footer");
let version_start = footer_text
.find(&version_label)
.expect("version label in footer");
assert!(
notice_start > version_start,
"update notice should appear after version ({notice_start} > {version_start})"
);
let notice_cell = buffer
.cell((notice_start as u16, footer_y))
.expect("notice cell should exist");
assert_eq!(notice_cell.fg, Color::Cyan);
assert!(notice_cell.modifier.contains(Modifier::DIM));
assert!(notice_cell.modifier.contains(Modifier::UNDERLINED));
let version_hit = dashboard.hit_test(version_start as u16, footer_y);
assert!(
!matches!(version_hit, Some(HitResult::OpenUpdate)),
"version label must not open the release page"
);
let hit = dashboard
.hit_test(notice_start as u16, footer_y)
.expect("update notice should be clickable");
assert!(matches!(hit, HitResult::OpenUpdate));
assert_eq!(
dashboard.update_release_url().as_deref(),
Some("https://github.com/clankercode/quotas/releases/tag/v0.8.2")
);
}
#[test]
fn dashboard_footer_shows_version_without_update_info() {
let kinds = vec![ProviderKind::Claude];
let entries = vec![ProviderEntry::Done(dummy_available_result(
ProviderKind::Claude,
))];
let dashboard = Dashboard::new_with_entries(kinds, entries);
let buffer = render_buffer(&dashboard, 80, 20);
let footer_y = 19;
let footer_text: String = (0..80)
.map(|x| {
buffer
.cell((x, footer_y))
.map(|c| c.symbol())
.unwrap_or(" ")
})
.collect();
let version_label = format!("v{}", env!("CARGO_PKG_VERSION"));
assert!(
footer_text.contains(&version_label),
"expected {version_label:?} in footer, got {footer_text:?}"
);
assert!(
!footer_text.contains("update "),
"no update notice without update_info"
);
}
#[test]
fn new_with_entries_all_done_freezes_order() {
let kinds = vec![ProviderKind::Claude, ProviderKind::Codex];
let entries = vec![
ProviderEntry::Done(dummy_available_result(ProviderKind::Claude)),
ProviderEntry::Done(dummy_available_result(ProviderKind::Codex)),
];
let dashboard = Dashboard::new_with_entries(kinds, entries);
assert!(dashboard.all_loaded());
assert_eq!(dashboard.stable_order, vec![0, 1]);
}
#[test]
fn new_with_entries_mixed_loading_not_frozen() {
let kinds = vec![
ProviderKind::Claude,
ProviderKind::Codex,
ProviderKind::DeepSeek,
];
let entries = vec![
ProviderEntry::Done(dummy_available_result(ProviderKind::Claude)),
ProviderEntry::Loading,
ProviderEntry::Done(dummy_available_result(ProviderKind::DeepSeek)),
];
let dashboard = Dashboard::new_with_entries(kinds, entries);
assert!(!dashboard.all_loaded());
assert_eq!(dashboard.stable_order, vec![0, 1, 2]);
}
#[test]
fn new_with_entries_auth_required_hidden_from_order() {
let kinds = vec![
ProviderKind::Claude,
ProviderKind::Codex,
ProviderKind::DeepSeek,
];
let entries = vec![
ProviderEntry::Done(dummy_available_result(ProviderKind::Claude)),
ProviderEntry::Done(dummy_auth_required_result(ProviderKind::Codex)),
ProviderEntry::Done(dummy_available_result(ProviderKind::DeepSeek)),
];
let dashboard = Dashboard::new_with_entries(kinds, entries);
assert!(dashboard.all_loaded());
assert_eq!(dashboard.stable_order, vec![0, 2]);
}
#[test]
fn new_with_entries_refreshing_counts_as_loaded_for_spinner() {
let kinds = vec![ProviderKind::Claude];
let entries = vec![ProviderEntry::Refreshing(dummy_available_result(
ProviderKind::Claude,
))];
let dashboard = Dashboard::new_with_entries(kinds, entries);
assert!(!dashboard.all_loaded());
}
#[test]
fn renders_auto_refresh_checkbox_state() {
let kinds = vec![ProviderKind::Claude];
let entries = vec![ProviderEntry::Done(dummy_available_result(
ProviderKind::Claude,
))];
let mut dashboard = Dashboard::new_with_entries(kinds, entries);
let enabled = render_text(&dashboard, 100, 24);
assert!(enabled.contains("[x] Auto"));
dashboard.auto_refresh_enabled = false;
let disabled = render_text(&dashboard, 100, 24);
assert!(disabled.contains("[ ] Auto"));
}
#[test]
fn auto_refresh_checkbox_has_mouse_hit_result() {
let kinds = vec![ProviderKind::Claude];
let entries = vec![ProviderEntry::Done(dummy_available_result(
ProviderKind::Claude,
))];
let dashboard = Dashboard::new_with_entries(kinds, entries);
let _ = render_text(&dashboard, 100, 24);
assert!(matches!(
dashboard.hit_test(60, 1),
Some(HitResult::AutoRefreshToggle)
));
}
#[test]
fn renders_antigravity_group_quotas_on_card() {
let kinds = vec![ProviderKind::Antigravity];
let entries = vec![ProviderEntry::Done(antigravity_summary_quota_result())];
let dashboard = Dashboard::new_with_entries(kinds, entries);
let out = render_text(&dashboard, 80, 24);
assert!(out.contains("Antigravity"), "plan name: {out}");
assert!(
out.contains("gemini") || out.contains("5h/gemini") || out.contains("7d/gemini"),
"expected gemini group window, got: {out}"
);
assert!(
out.contains("3p") || out.contains("5h/3p") || out.contains("7d/3p"),
"expected 3p group window, got: {out}"
);
assert!(out.contains("18%") || out.contains("82%"), "got: {out}");
}
#[test]
fn renders_banked_reset_count_on_codex_card() {
let mut result = dummy_available_result(ProviderKind::Codex);
if let ProviderStatus::Available { quota } = &mut result.status {
quota.plan_name = "Codex / ChatGPT pro".into();
quota.banked_resets = Some(BankedResets {
available_count: 2,
credits: vec![],
});
}
let kinds = vec![ProviderKind::Codex];
let entries = vec![ProviderEntry::Done(result)];
let dashboard = Dashboard::new_with_entries(kinds, entries);
let out = render_text(&dashboard, 80, 20);
assert!(
out.contains("2 banked"),
"expected banked count on plan line, got: {out}"
);
assert!(out.contains("Codex"), "got: {out}");
}
#[test]
fn favorite_providers_sort_first_in_visual_order() {
let kinds = vec![
ProviderKind::Claude,
ProviderKind::Codex,
ProviderKind::Antigravity,
];
let entries = vec![
ProviderEntry::Done(dummy_available_result(ProviderKind::Claude)),
ProviderEntry::Done(dummy_available_result(ProviderKind::Codex)),
ProviderEntry::Done(dummy_available_result(ProviderKind::Antigravity)),
];
let mut dashboard = Dashboard::new_with_entries(kinds, entries);
dashboard.set_provider_favorite("antigravity", true);
dashboard.stable_order = dashboard.compute_visual_order();
assert_eq!(dashboard.stable_order[0], 2);
}
#[test]
fn overlay_formats_grok_monthly_as_dollars() {
let overlay = bar_overlay_text(0.1954, 293_100, 1_500_000, 24, "monthly_allowance");
assert!(overlay.contains("$29"), "overlay: {overlay}");
assert!(overlay.contains("$150"), "overlay: {overlay}");
assert!(!overlay.contains("293"), "overlay: {overlay}");
assert!(!overlay.contains('M'), "overlay: {overlay}");
}
#[test]
fn overlay_keeps_bare_monthly_as_plain_counts() {
let overlay = bar_overlay_text(0.1667, 50, 300, 24, "monthly");
assert!(overlay.contains("50/300"), "overlay: {overlay}");
assert!(!overlay.contains('$'), "overlay: {overlay}");
}
#[test]
fn overlay_suppresses_numbers_for_total_quota() {
assert_eq!(bar_overlay_text(1.0, 1, 1, 24, "total_quota"), "100%");
assert_eq!(bar_overlay_text(0.0, 0, 1, 24, "total_quota"), "0%");
}
}