1use chrono::{DateTime, Utc};
12
13use crate::countdown;
14use crate::pacing;
15use crate::pango::{self, escape, severity_color, severity_for, visible_width};
16use crate::theme::Theme;
17use crate::usage::UsageWindow;
18
19pub enum Line {
21 Center(String),
23 Body(String),
25 Sep,
27}
28
29#[derive(Debug, Clone, Copy, Default)]
36pub struct WindowRow<'a> {
37 pub marker_pct: Option<i32>,
39 pub glyph: Option<&'static str>,
41 pub detail: Option<&'a str>,
44}
45
46impl<'a> WindowRow<'a> {
47 pub fn paced(w: &UsageWindow, now: DateTime<Utc>, tolerance: u32, point_mode: bool) -> Self {
56 let p = pacing::calc(
57 w.utilization_pct,
58 w.resets_at,
59 now,
60 w.window_duration,
61 tolerance,
62 );
63 Self {
64 marker_pct: point_mode.then_some(p.elapsed_pct),
65 glyph: Some(if point_mode {
66 p.point_pace.glyph()
67 } else {
68 p.ratio_pace.glyph()
69 }),
70 detail: None,
71 }
72 }
73
74 pub fn with_detail(self, detail: &'a str) -> Self {
76 Self {
77 detail: Some(detail),
78 ..self
79 }
80 }
81}
82
83pub fn push_window(
90 lines: &mut Vec<Line>,
91 label: &str,
92 w: &UsageWindow,
93 theme: &Theme,
94 now: DateTime<Utc>,
95 elapsed: Option<i32>,
96) {
97 push_window_with_row(
98 lines,
99 label,
100 w,
101 theme,
102 now,
103 WindowRow {
104 marker_pct: elapsed,
105 ..WindowRow::default()
106 },
107 );
108}
109
110pub fn push_window_with_row(
112 lines: &mut Vec<Line>,
113 label: &str,
114 w: &UsageWindow,
115 theme: &Theme,
116 now: DateTime<Utc>,
117 row: WindowRow<'_>,
118) {
119 let color = severity_color(severity_for(w.utilization_pct), theme);
120 let bar = pango::progress_bar(w.utilization_pct, color, theme, row.marker_pct);
121 let fg = &theme.fg;
122 let dim = &theme.dim;
123 let glyph = row.glyph.map(|g| format!(" {g}")).unwrap_or_default();
124 let detail = row.detail.map(|d| format!(" · {d}")).unwrap_or_default();
125 lines.push(Line::Body(format!(
126 " <span foreground='{fg}'>{label}</span>"
127 )));
128 lines.push(Line::Body(format!(
129 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%{glyph}</span>",
130 pct = w.utilization_pct
131 )));
132 lines.push(Line::Body(format!(
133 " <span foreground='{dim}'> ⏱ Resets in {cd}{detail}</span>",
134 cd = escape(&countdown::format(w.resets_at, now))
135 )));
136}
137
138pub fn render_bordered(lines: &[Line], theme: &Theme) -> String {
141 let blue = &theme.blue;
142 let dim = &theme.dim;
143
144 let mut max_w: usize = 0;
145 for line in lines {
146 let s = match line {
147 Line::Center(s) | Line::Body(s) => s.as_str(),
148 Line::Sep => continue,
149 };
150 let w = visible_width(s);
151 if w > max_w {
152 max_w = w;
153 }
154 }
155 let inner_w = max_w + 1;
156 let border_h: String = "─".repeat(inner_w);
157 let sep_inner: String = "─".repeat(inner_w.saturating_sub(2));
158 let sep_line = format!(" <span foreground='{dim}'>{sep_inner}</span>");
159
160 let mut out = String::with_capacity(256 * lines.len());
161 out.push_str(&format!("<span foreground='{blue}'>╭{border_h}╮</span>\n"));
162 for line in lines {
163 let body = match line {
164 Line::Body(s) => pad_right(s, inner_w),
165 Line::Center(s) => pad_center(s, inner_w),
166 Line::Sep => pad_right(&sep_line, inner_w),
167 };
168 out.push_str(&format!(
169 "<span foreground='{blue}'>│</span>{body}<span foreground='{blue}'>│</span>\n"
170 ));
171 }
172 out.push_str(&format!("<span foreground='{blue}'>╰{border_h}╯</span>"));
173 out
174}
175
176pub fn pad_right(s: &str, inner_w: usize) -> String {
178 let v = visible_width(s);
179 let need = inner_w.saturating_sub(v);
180 format!("{s}{}", " ".repeat(need))
181}
182
183pub fn pad_center(s: &str, inner_w: usize) -> String {
186 let v = visible_width(s);
187 let total = inner_w.saturating_sub(v);
188 let lp = total / 2;
189 let rp = total - lp;
190 format!("{}{s}{}", " ".repeat(lp), " ".repeat(rp))
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn theme() -> Theme {
198 Theme::default()
199 }
200
201 #[test]
202 fn renders_top_and_bottom_borders() {
203 let lines = vec![Line::Center("Hi".into())];
204 let out = render_bordered(&lines, &theme());
205 assert!(out.contains("╭"));
206 assert!(out.contains("╮"));
207 assert!(out.contains("╰"));
208 assert!(out.contains("╯"));
209 assert!(out.contains("Hi"));
210 }
211
212 #[test]
215 fn rows_with_escaped_characters_keep_the_border_flush() {
216 let lines = vec![
217 Line::Body(crate::pango::escape("Claude & GPT (weekly)")),
218 Line::Body("Gemini (weekly)".into()),
219 ];
220 let out = render_bordered(&lines, &theme());
221 let right_edges: Vec<usize> = out.lines().map(crate::pango::visible_width).collect();
222 assert!(
223 right_edges.windows(2).all(|w| w[0] == w[1]),
224 "ragged box: {right_edges:?}\n{out}"
225 );
226 }
227
228 #[test]
229 fn body_line_is_right_padded_to_inner_width() {
230 let lines = vec![Line::Center("a".into()), Line::Body("longest".into())];
232 let out = render_bordered(&lines, &theme());
233 let opens = out.matches("<span").count();
237 let closes = out.matches("</span>").count();
238 assert_eq!(opens, closes);
239 }
240
241 #[test]
242 fn pad_right_strips_pango_tags_before_measuring() {
243 let s = "<span foreground='#fff'>abc</span>"; let p = pad_right(s, 6);
245 assert!(p.ends_with(" "));
247 }
248
249 #[test]
250 fn pad_center_distributes_extra_space_right_for_odd_diff() {
251 let p = pad_center("X", 4); assert_eq!(p, " X ");
253 }
254
255 #[test]
256 fn separator_line_width_grows_with_content() {
257 let lines = vec![
258 Line::Center("a".into()),
259 Line::Sep,
260 Line::Body("longer body line".into()),
261 ];
262 let out = render_bordered(&lines, &theme());
263 assert!(out.contains("─"));
266 }
267
268 fn at(h: u32) -> DateTime<Utc> {
269 use chrono::TimeZone;
270 Utc.with_ymd_and_hms(2026, 8, 25, h, 0, 0).unwrap()
271 }
272
273 fn window(pct: i32, resets_in: i64) -> UsageWindow {
275 UsageWindow {
276 utilization_pct: pct,
277 resets_at: Some(at(12) + chrono::Duration::hours(resets_in)),
278 window_duration: chrono::Duration::hours(5),
279 }
280 }
281
282 fn row_markup(w: &UsageWindow, row: WindowRow<'_>) -> String {
283 let mut lines = Vec::new();
284 push_window_with_row(&mut lines, " L", w, &theme(), at(12), row);
285 render_bordered(&lines, &theme())
286 }
287
288 #[test]
291 fn a_default_row_stays_the_plain_bar_percent_and_reset() {
292 let out = row_markup(&window(40, 2), WindowRow::default());
293 assert!(out.contains("40%"), "{out}");
294 assert!(out.contains("Resets in 2h 00m"), "{out}");
295 assert!(
296 !out.contains('↑') && !out.contains('→') && !out.contains('↓'),
297 "an unpaced row must not grow a glyph: {out}"
298 );
299 assert!(!out.contains(" · "), "an unpaced row has no detail: {out}");
300 }
301
302 #[test]
305 fn paced_rows_keep_the_marker_behind_point_mode() {
306 let w = window(40, 3);
308 let ratio = WindowRow::paced(&w, at(12), pacing::DEFAULT_TOLERANCE, false);
309 assert_eq!(ratio.glyph, Some("→"));
310 assert_eq!(ratio.marker_pct, None);
311
312 let points = WindowRow::paced(&w, at(12), pacing::DEFAULT_TOLERANCE, true);
313 assert_eq!(points.glyph, Some("→"));
314 assert_eq!(points.marker_pct, Some(40));
315 }
316
317 #[test]
320 fn the_pace_modes_can_disagree_on_the_glyph() {
321 let w = window(42, 3); assert_eq!(WindowRow::paced(&w, at(12), 5, false).glyph, Some("→"));
323 assert_eq!(WindowRow::paced(&w, at(12), 5, true).glyph, Some("↑"));
324 }
325
326 #[test]
329 fn a_window_without_a_reset_still_gets_the_neutral_glyph() {
330 let w = UsageWindow {
331 utilization_pct: 0,
332 resets_at: None,
333 window_duration: chrono::Duration::hours(5),
334 };
335 let row = WindowRow::paced(&w, at(12), 5, false);
336 assert_eq!(row.glyph, Some("→"));
337 assert_eq!(row.marker_pct, None);
338 }
339
340 #[test]
341 fn the_glyph_and_detail_reach_the_rendered_row() {
342 let w = window(40, 3);
343 let out = row_markup(
344 &w,
345 WindowRow::paced(&w, at(12), 5, false).with_detail("2 / 100"),
346 );
347 assert!(out.contains("40% →"), "{out}");
348 assert!(out.contains("Resets in 3h 00m · 2 / 100"), "{out}");
349 }
350}