use chrono::{DateTime, Utc};
use crate::countdown;
use crate::pacing;
use crate::pango::{self, escape, severity_color, severity_for, visible_width};
use crate::theme::Theme;
use crate::usage::UsageWindow;
pub enum Line {
Center(String),
Body(String),
Sep,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct WindowRow {
pub marker_pct: Option<i32>,
pub glyph: Option<&'static str>,
}
impl WindowRow {
pub fn paced(w: &UsageWindow, now: DateTime<Utc>, tolerance: u32, point_mode: bool) -> Self {
let p = pacing::calc(
w.utilization_pct,
w.resets_at,
now,
w.window_duration,
tolerance,
);
Self {
marker_pct: point_mode.then_some(p.elapsed_pct),
glyph: Some(if point_mode {
p.point_pace.glyph()
} else {
p.ratio_pace.glyph()
}),
}
}
}
pub fn push_window(
lines: &mut Vec<Line>,
label: &str,
w: &UsageWindow,
theme: &Theme,
now: DateTime<Utc>,
elapsed: Option<i32>,
) {
push_window_with_row(
lines,
label,
w,
theme,
now,
WindowRow {
marker_pct: elapsed,
..WindowRow::default()
},
);
}
pub fn push_window_with_row(
lines: &mut Vec<Line>,
label: &str,
w: &UsageWindow,
theme: &Theme,
now: DateTime<Utc>,
row: WindowRow,
) {
let color = severity_color(severity_for(w.utilization_pct), theme);
let bar = pango::progress_bar(w.utilization_pct, color, theme, row.marker_pct);
let fg = &theme.fg;
let dim = &theme.dim;
let glyph = row.glyph.map(|g| format!(" {g}")).unwrap_or_default();
lines.push(Line::Body(format!(
" <span foreground='{fg}'>{label}</span>"
)));
lines.push(Line::Body(format!(
" {bar} <span font_weight='bold' foreground='{color}'>{pct}%{glyph}</span>",
pct = w.utilization_pct
)));
lines.push(Line::Body(format!(
" <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
cd = escape(&countdown::format(w.resets_at, now))
)));
}
pub fn render_bordered(lines: &[Line], theme: &Theme) -> String {
let blue = &theme.blue;
let dim = &theme.dim;
let mut max_w: usize = 0;
for line in lines {
let s = match line {
Line::Center(s) | Line::Body(s) => s.as_str(),
Line::Sep => continue,
};
let w = visible_width(s);
if w > max_w {
max_w = w;
}
}
let inner_w = max_w + 1;
let border_h: String = "─".repeat(inner_w);
let sep_inner: String = "─".repeat(inner_w.saturating_sub(2));
let sep_line = format!(" <span foreground='{dim}'>{sep_inner}</span>");
let mut out = String::with_capacity(256 * lines.len());
out.push_str(&format!("<span foreground='{blue}'>╭{border_h}╮</span>\n"));
for line in lines {
let body = match line {
Line::Body(s) => pad_right(s, inner_w),
Line::Center(s) => pad_center(s, inner_w),
Line::Sep => pad_right(&sep_line, inner_w),
};
out.push_str(&format!(
"<span foreground='{blue}'>│</span>{body}<span foreground='{blue}'>│</span>\n"
));
}
out.push_str(&format!("<span foreground='{blue}'>╰{border_h}╯</span>"));
out
}
pub fn pad_right(s: &str, inner_w: usize) -> String {
let v = visible_width(s);
let need = inner_w.saturating_sub(v);
format!("{s}{}", " ".repeat(need))
}
pub fn pad_center(s: &str, inner_w: usize) -> String {
let v = visible_width(s);
let total = inner_w.saturating_sub(v);
let lp = total / 2;
let rp = total - lp;
format!("{}{s}{}", " ".repeat(lp), " ".repeat(rp))
}
#[cfg(test)]
mod tests {
use super::*;
fn theme() -> Theme {
Theme::default()
}
#[test]
fn renders_top_and_bottom_borders() {
let lines = vec![Line::Center("Hi".into())];
let out = render_bordered(&lines, &theme());
assert!(out.contains("╭"));
assert!(out.contains("╮"));
assert!(out.contains("╰"));
assert!(out.contains("╯"));
assert!(out.contains("Hi"));
}
#[test]
fn rows_with_escaped_characters_keep_the_border_flush() {
let lines = vec![
Line::Body(crate::pango::escape("Claude & GPT (weekly)")),
Line::Body("Gemini (weekly)".into()),
];
let out = render_bordered(&lines, &theme());
let right_edges: Vec<usize> = out.lines().map(crate::pango::visible_width).collect();
assert!(
right_edges.windows(2).all(|w| w[0] == w[1]),
"ragged box: {right_edges:?}\n{out}"
);
}
#[test]
fn body_line_is_right_padded_to_inner_width() {
let lines = vec![Line::Center("a".into()), Line::Body("longest".into())];
let out = render_bordered(&lines, &theme());
let opens = out.matches("<span").count();
let closes = out.matches("</span>").count();
assert_eq!(opens, closes);
}
#[test]
fn pad_right_strips_pango_tags_before_measuring() {
let s = "<span foreground='#fff'>abc</span>"; let p = pad_right(s, 6);
assert!(p.ends_with(" "));
}
#[test]
fn pad_center_distributes_extra_space_right_for_odd_diff() {
let p = pad_center("X", 4); assert_eq!(p, " X ");
}
#[test]
fn separator_line_width_grows_with_content() {
let lines = vec![
Line::Center("a".into()),
Line::Sep,
Line::Body("longer body line".into()),
];
let out = render_bordered(&lines, &theme());
assert!(out.contains("─"));
}
fn at(h: u32) -> DateTime<Utc> {
use chrono::TimeZone;
Utc.with_ymd_and_hms(2026, 8, 25, h, 0, 0).unwrap()
}
fn window(pct: i32, resets_in: i64) -> UsageWindow {
UsageWindow {
utilization_pct: pct,
resets_at: Some(at(12) + chrono::Duration::hours(resets_in)),
window_duration: chrono::Duration::hours(5),
}
}
fn row_markup(w: &UsageWindow, row: WindowRow) -> String {
let mut lines = Vec::new();
push_window_with_row(&mut lines, " L", w, &theme(), at(12), row);
render_bordered(&lines, &theme())
}
#[test]
fn a_default_row_stays_the_plain_bar_percent_and_reset() {
let out = row_markup(&window(40, 2), WindowRow::default());
assert!(out.contains("40%"), "{out}");
assert!(out.contains("Resets in 2h 00m"), "{out}");
assert!(
!out.contains('↑') && !out.contains('→') && !out.contains('↓'),
"an unpaced row must not grow a glyph: {out}"
);
assert!(!out.contains(" · "), "an unpaced row has no detail: {out}");
}
#[test]
fn paced_rows_keep_the_marker_behind_point_mode() {
let w = window(40, 3);
let ratio = WindowRow::paced(&w, at(12), pacing::DEFAULT_TOLERANCE, false);
assert_eq!(ratio.glyph, Some("→"));
assert_eq!(ratio.marker_pct, None);
let points = WindowRow::paced(&w, at(12), pacing::DEFAULT_TOLERANCE, true);
assert_eq!(points.glyph, Some("→"));
assert_eq!(points.marker_pct, Some(40));
}
#[test]
fn the_pace_modes_can_disagree_on_the_glyph() {
let w = window(42, 3); assert_eq!(WindowRow::paced(&w, at(12), 5, false).glyph, Some("→"));
assert_eq!(WindowRow::paced(&w, at(12), 5, true).glyph, Some("↑"));
}
#[test]
fn a_window_without_a_reset_still_gets_the_neutral_glyph() {
let w = UsageWindow {
utilization_pct: 0,
resets_at: None,
window_duration: chrono::Duration::hours(5),
};
let row = WindowRow::paced(&w, at(12), 5, false);
assert_eq!(row.glyph, Some("→"));
assert_eq!(row.marker_pct, None);
}
#[test]
fn the_glyph_reaches_the_rendered_row() {
let w = window(40, 3);
let out = row_markup(&w, WindowRow::paced(&w, at(12), 5, false));
assert!(out.contains("40% →"), "{out}");
assert!(out.contains("Resets in 3h 00m"), "{out}");
assert!(!out.contains("Resets in 3h 00m ·"), "{out}");
}
}