//! Pretty terminal renderer. Unicode blitter-block bars, colored by usage. //! Degrades to ASCII with `--ascii`, and to no color with `--no-color` or when //! stdout is not a terminal. use anstyle::{AnsiColor, Style}; use crate::model::{AccountReport, ReportStatus, UsageFrame}; use crate::timefmt; const EIGHTHS: [&str; 7] = ["▏", "▎", "▍", "▌", "▋", "▊", "▉"]; /// One report block, then the next. Reports are separated by a blank line. pub fn render_pretty(reports: &[AccountReport], ascii: bool, no_color: bool) -> String { let width = bar_width(); let mut out = String::new(); for (i, r) in reports.iter().enumerate() { if i > 0 { out.push('\n'); } out.push_str(&render_one(r, width, ascii, no_color)); } out } fn render_one(r: &AccountReport, width: usize, ascii: bool, no_color: bool) -> String { let mut out = String::new(); // Header line. let status = paint(&status_text(&r.status), status_style(&r.status), no_color); let fetched = r .fetched_at .as_deref() .map(timefmt::relative) .unwrap_or_else(|| "unknown".to_string()); out.push_str(&format!("{} / {} {} fetched {}\n", r.account, r.workspace, status, fetched)); if let Some(l) = &r.limits { out.push_str(&frame_row("5h", &l.usage_5h, width, ascii, no_color, "resets")); out.push_str(&frame_row("week", &l.usage_week, width, ascii, no_color, "resets")); out.push_str(&frame_row("month", &l.usage_month, width, ascii, no_color, "resets")); if let Some(nu) = &l.next_update { out.push_str(&format!( " next {}\n", timefmt::absolute(nu), )); out.push_str(&format!( " ({})\n", timefmt::relative(nu), )); } } else { out.push_str(" limits unavailable\n"); } if let Some(b) = &r.balance { out.push_str(&format!( " balance {}{:.2} remaining\n", b.unit, b.remaining )); } else { out.push_str(" balance unavailable\n"); } // Print the failure detail on an error status. if let ReportStatus::Error(msg) = &r.status { out.push_str(&format!(" error {msg}\n")); } out } fn frame_row(label: &str, frame: &UsageFrame, width: usize, ascii: bool, no_color: bool, _reset_tag: &str) -> String { let pct = frame.pct(); let bar = colored_bar(pct, width, ascii, no_color); let usage = if frame.unit == "%" { format!("{:.0}%", pct) } else if frame.total > 0.0 { format!("{} / {} {}", fmt_num(frame.used), fmt_num(frame.total), frame.unit) } else { format!("{} {}", fmt_num(frame.used), frame.unit) }; let resets = frame .resets_at .as_deref() .map(|t| format!("resets {}", timefmt::relative(t))) .unwrap_or_default(); format!( " {:<7} {} {:<22} {:>5.1}% {}\n", label, bar, usage, pct, resets ) } fn colored_bar(pct: f64, width: usize, ascii: bool, no_color: bool) -> String { let total_eighths = ((pct / 100.0) * (width as f64 * 8.0)).round() as i64; let total_eighths = total_eighths.clamp(0, (width as i64) * 8); let whole = (total_eighths / 8) as usize; let rem = (total_eighths % 8) as usize; let mid = if rem > 0 { 1 } else { 0 }; let total_filled = whole + mid; let empty = width.saturating_sub(total_filled); let filled_str = if ascii { "#".repeat(whole) + if rem > 0 { "#" } else { "" } } else { "█".repeat(whole) + if rem > 0 { EIGHTHS[rem - 1] } else { "" } }; let empty_str = if ascii { "-".repeat(empty) } else { "░".repeat(empty) }; let style = zone_style(pct); format!("{}{}{}", paint(&filled_str, style, no_color), empty_str, "" ) } fn zone_style(pct: f64) -> Style { if pct >= 90.0 { Style::new().fg_color(Some(AnsiColor::BrightRed.into())) } else if pct >= 70.0 { Style::new().fg_color(Some(AnsiColor::Yellow.into())) } else { Style::new().fg_color(Some(AnsiColor::Green.into())) } } fn status_text(s: &ReportStatus) -> String { match s { ReportStatus::Ok => "[ok]".to_string(), ReportStatus::Cached => "[cached]".to_string(), ReportStatus::RateLimited => "[rate limited]".to_string(), ReportStatus::Timeout => "[timeout]".to_string(), ReportStatus::Error(_) => "[error]".to_string(), } } fn status_style(s: &ReportStatus) -> Style { match s { ReportStatus::Ok => Style::new().fg_color(Some(AnsiColor::Green.into())), ReportStatus::Cached => Style::new().fg_color(Some(AnsiColor::Cyan.into())), ReportStatus::RateLimited => Style::new().fg_color(Some(AnsiColor::Yellow.into())), ReportStatus::Timeout => Style::new().fg_color(Some(AnsiColor::Magenta.into())), ReportStatus::Error(_) => Style::new().fg_color(Some(AnsiColor::Red.into())), } } fn paint(text: &str, style: Style, no_color: bool) -> String { if no_color { text.to_string() } else { format!("{}{}{}", style.render(), text, style.render_reset()) } } fn bar_width() -> usize { let fallback = 20usize; if let Some(terminal_size::Width(w)) = terminal_size::terminal_size().map(|(w, _)| w) { (w as usize / 3).clamp(10, 44) } else { fallback } } fn fmt_num(n: f64) -> String { let n = n.abs(); if n >= 1000.0 { format!("{:.1}k", n / 1000.0) } else if n.fract() == 0.0 { format!("{}", n as i64) } else { format!("{:.2}", n) } }