1use chrono::{DateTime, Utc};
12
13use crate::countdown;
14use crate::pango::{self, escape, severity_color, severity_for, visible_width};
15use crate::theme::Theme;
16use crate::usage::UsageWindow;
17
18pub enum Line {
20 Center(String),
22 Body(String),
24 Sep,
26}
27
28pub fn push_window(
33 lines: &mut Vec<Line>,
34 label: &str,
35 w: &UsageWindow,
36 theme: &Theme,
37 now: DateTime<Utc>,
38 elapsed: Option<i32>,
39) {
40 let color = severity_color(severity_for(w.utilization_pct), theme);
41 let bar = pango::progress_bar(w.utilization_pct, color, theme, elapsed);
42 let fg = &theme.fg;
43 let dim = &theme.dim;
44 lines.push(Line::Body(format!(
45 " <span foreground='{fg}'>{label}</span>"
46 )));
47 lines.push(Line::Body(format!(
48 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>",
49 pct = w.utilization_pct
50 )));
51 lines.push(Line::Body(format!(
52 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
53 cd = escape(&countdown::format(w.resets_at, now))
54 )));
55}
56
57pub fn render_bordered(lines: &[Line], theme: &Theme) -> String {
60 let blue = &theme.blue;
61 let dim = &theme.dim;
62
63 let mut max_w: usize = 0;
64 for line in lines {
65 let s = match line {
66 Line::Center(s) | Line::Body(s) => s.as_str(),
67 Line::Sep => continue,
68 };
69 let w = visible_width(s);
70 if w > max_w {
71 max_w = w;
72 }
73 }
74 let inner_w = max_w + 1;
75 let border_h: String = "─".repeat(inner_w);
76 let sep_inner: String = "─".repeat(inner_w.saturating_sub(2));
77 let sep_line = format!(" <span foreground='{dim}'>{sep_inner}</span>");
78
79 let mut out = String::with_capacity(256 * lines.len());
80 out.push_str(&format!("<span foreground='{blue}'>╭{border_h}╮</span>\n"));
81 for line in lines {
82 let body = match line {
83 Line::Body(s) => pad_right(s, inner_w),
84 Line::Center(s) => pad_center(s, inner_w),
85 Line::Sep => pad_right(&sep_line, inner_w),
86 };
87 out.push_str(&format!(
88 "<span foreground='{blue}'>│</span>{body}<span foreground='{blue}'>│</span>\n"
89 ));
90 }
91 out.push_str(&format!("<span foreground='{blue}'>╰{border_h}╯</span>"));
92 out
93}
94
95pub fn pad_right(s: &str, inner_w: usize) -> String {
97 let v = visible_width(s);
98 let need = inner_w.saturating_sub(v);
99 format!("{s}{}", " ".repeat(need))
100}
101
102pub fn pad_center(s: &str, inner_w: usize) -> String {
105 let v = visible_width(s);
106 let total = inner_w.saturating_sub(v);
107 let lp = total / 2;
108 let rp = total - lp;
109 format!("{}{s}{}", " ".repeat(lp), " ".repeat(rp))
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 fn theme() -> Theme {
117 Theme::default()
118 }
119
120 #[test]
121 fn renders_top_and_bottom_borders() {
122 let lines = vec![Line::Center("Hi".into())];
123 let out = render_bordered(&lines, &theme());
124 assert!(out.contains("╭"));
125 assert!(out.contains("╮"));
126 assert!(out.contains("╰"));
127 assert!(out.contains("╯"));
128 assert!(out.contains("Hi"));
129 }
130
131 #[test]
134 fn rows_with_escaped_characters_keep_the_border_flush() {
135 let lines = vec![
136 Line::Body(crate::pango::escape("Claude & GPT (weekly)")),
137 Line::Body("Gemini (weekly)".into()),
138 ];
139 let out = render_bordered(&lines, &theme());
140 let right_edges: Vec<usize> = out.lines().map(crate::pango::visible_width).collect();
141 assert!(
142 right_edges.windows(2).all(|w| w[0] == w[1]),
143 "ragged box: {right_edges:?}\n{out}"
144 );
145 }
146
147 #[test]
148 fn body_line_is_right_padded_to_inner_width() {
149 let lines = vec![Line::Center("a".into()), Line::Body("longest".into())];
151 let out = render_bordered(&lines, &theme());
152 let opens = out.matches("<span").count();
156 let closes = out.matches("</span>").count();
157 assert_eq!(opens, closes);
158 }
159
160 #[test]
161 fn pad_right_strips_pango_tags_before_measuring() {
162 let s = "<span foreground='#fff'>abc</span>"; let p = pad_right(s, 6);
164 assert!(p.ends_with(" "));
166 }
167
168 #[test]
169 fn pad_center_distributes_extra_space_right_for_odd_diff() {
170 let p = pad_center("X", 4); assert_eq!(p, " X ");
172 }
173
174 #[test]
175 fn separator_line_width_grows_with_content() {
176 let lines = vec![
177 Line::Center("a".into()),
178 Line::Sep,
179 Line::Body("longer body line".into()),
180 ];
181 let out = render_bordered(&lines, &theme());
182 assert!(out.contains("─"));
185 }
186}