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 {
37 pub marker_pct: Option<i32>,
39 pub glyph: Option<&'static str>,
41}
42
43impl WindowRow {
44 pub fn paced(w: &UsageWindow, now: DateTime<Utc>, tolerance: u32, point_mode: bool) -> Self {
53 let p = pacing::calc(
54 w.utilization_pct,
55 w.resets_at,
56 now,
57 w.window_duration,
58 tolerance,
59 );
60 Self {
61 marker_pct: point_mode.then_some(p.elapsed_pct),
62 glyph: Some(if point_mode {
63 p.point_pace.glyph()
64 } else {
65 p.ratio_pace.glyph()
66 }),
67 }
68 }
69}
70
71pub fn push_window(
78 lines: &mut Vec<Line>,
79 label: &str,
80 w: &UsageWindow,
81 theme: &Theme,
82 now: DateTime<Utc>,
83 elapsed: Option<i32>,
84) {
85 push_window_with_row(
86 lines,
87 label,
88 w,
89 theme,
90 now,
91 WindowRow {
92 marker_pct: elapsed,
93 ..WindowRow::default()
94 },
95 );
96}
97
98pub fn push_window_with_row(
100 lines: &mut Vec<Line>,
101 label: &str,
102 w: &UsageWindow,
103 theme: &Theme,
104 now: DateTime<Utc>,
105 row: WindowRow,
106) {
107 push_window_with_detail(lines, label, w, theme, now, row, None);
108}
109
110pub fn push_window_with_detail(
115 lines: &mut Vec<Line>,
116 label: &str,
117 w: &UsageWindow,
118 theme: &Theme,
119 now: DateTime<Utc>,
120 row: WindowRow,
121 detail: Option<&str>,
122) {
123 let color = severity_color(severity_for(w.utilization_pct), theme);
124 let bar = pango::progress_bar(w.utilization_pct, color, theme, row.marker_pct);
125 let fg = &theme.fg;
126 let dim = &theme.dim;
127 let glyph = row.glyph.map(|g| format!(" {g}")).unwrap_or_default();
128 let detail = detail
129 .map(|detail| format!(" <span foreground='{dim}'>· {}</span>", escape(detail)))
130 .unwrap_or_default();
131 lines.push(Line::Body(format!(
132 " <span foreground='{fg}'>{label}</span>"
133 )));
134 lines.push(Line::Body(format!(
135 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%{glyph}</span>{detail}",
136 pct = w.utilization_pct
137 )));
138 lines.push(Line::Body(format!(
139 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
140 cd = escape(&countdown::format(w.resets_at, now))
141 )));
142}
143
144pub fn render_bordered(lines: &[Line], theme: &Theme) -> String {
147 let blue = &theme.blue;
148 let dim = &theme.dim;
149
150 let mut max_w: usize = 0;
151 for line in lines {
152 let s = match line {
153 Line::Center(s) | Line::Body(s) => s.as_str(),
154 Line::Sep => continue,
155 };
156 let w = visible_width(s);
157 if w > max_w {
158 max_w = w;
159 }
160 }
161 let inner_w = max_w + 1;
162 let border_h: String = "─".repeat(inner_w);
163 let sep_inner: String = "─".repeat(inner_w.saturating_sub(2));
164 let sep_line = format!(" <span foreground='{dim}'>{sep_inner}</span>");
165
166 let mut out = String::with_capacity(256 * lines.len());
167 out.push_str(&format!("<span foreground='{blue}'>╭{border_h}╮</span>\n"));
168 for line in lines {
169 let body = match line {
170 Line::Body(s) => pad_right(s, inner_w),
171 Line::Center(s) => pad_center(s, inner_w),
172 Line::Sep => pad_right(&sep_line, inner_w),
173 };
174 out.push_str(&format!(
175 "<span foreground='{blue}'>│</span>{body}<span foreground='{blue}'>│</span>\n"
176 ));
177 }
178 out.push_str(&format!("<span foreground='{blue}'>╰{border_h}╯</span>"));
179 out
180}
181
182pub fn pad_right(s: &str, inner_w: usize) -> String {
184 let v = visible_width(s);
185 let need = inner_w.saturating_sub(v);
186 format!("{s}{}", " ".repeat(need))
187}
188
189pub fn pad_center(s: &str, inner_w: usize) -> String {
192 let v = visible_width(s);
193 let total = inner_w.saturating_sub(v);
194 let lp = total / 2;
195 let rp = total - lp;
196 format!("{}{s}{}", " ".repeat(lp), " ".repeat(rp))
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn theme() -> Theme {
204 Theme::default()
205 }
206
207 #[test]
208 fn renders_top_and_bottom_borders() {
209 let lines = vec![Line::Center("Hi".into())];
210 let out = render_bordered(&lines, &theme());
211 assert!(out.contains("╭"));
212 assert!(out.contains("╮"));
213 assert!(out.contains("╰"));
214 assert!(out.contains("╯"));
215 assert!(out.contains("Hi"));
216 }
217
218 #[test]
221 fn rows_with_escaped_characters_keep_the_border_flush() {
222 let lines = vec![
223 Line::Body(crate::pango::escape("Claude & GPT (weekly)")),
224 Line::Body("Gemini (weekly)".into()),
225 ];
226 let out = render_bordered(&lines, &theme());
227 let right_edges: Vec<usize> = out.lines().map(crate::pango::visible_width).collect();
228 assert!(
229 right_edges.windows(2).all(|w| w[0] == w[1]),
230 "ragged box: {right_edges:?}\n{out}"
231 );
232 }
233
234 #[test]
239 fn rows_with_double_width_glyphs_keep_the_border_flush() {
240 let lines = vec![
241 Line::Body("セッション (5h)".into()),
242 Line::Body("사용량".into()),
243 Line::Body("Weekly".into()),
244 ];
245 let out = render_bordered(&lines, &theme());
246 let right_edges: Vec<usize> = out.lines().map(crate::pango::visible_width).collect();
247 assert!(
248 right_edges.windows(2).all(|w| w[0] == w[1]),
249 "ragged box with CJK rows: {right_edges:?}\n{out}"
250 );
251 }
252
253 #[test]
254 fn pad_right_pads_a_double_width_string_by_columns() {
255 assert!(pad_right("日本", 6).ends_with(" "));
257 assert_eq!(crate::pango::visible_width(&pad_right("日本", 6)), 6);
258 }
259
260 #[test]
261 fn body_line_is_right_padded_to_inner_width() {
262 let lines = vec![Line::Center("a".into()), Line::Body("longest".into())];
264 let out = render_bordered(&lines, &theme());
265 let opens = out.matches("<span").count();
269 let closes = out.matches("</span>").count();
270 assert_eq!(opens, closes);
271 }
272
273 #[test]
274 fn pad_right_strips_pango_tags_before_measuring() {
275 let s = "<span foreground='#fff'>abc</span>"; let p = pad_right(s, 6);
277 assert!(p.ends_with(" "));
279 }
280
281 #[test]
282 fn pad_center_distributes_extra_space_right_for_odd_diff() {
283 let p = pad_center("X", 4); assert_eq!(p, " X ");
285 }
286
287 #[test]
288 fn separator_line_width_grows_with_content() {
289 let lines = vec![
290 Line::Center("a".into()),
291 Line::Sep,
292 Line::Body("longer body line".into()),
293 ];
294 let out = render_bordered(&lines, &theme());
295 assert!(out.contains("─"));
298 }
299
300 fn at(h: u32) -> DateTime<Utc> {
301 use chrono::TimeZone;
302 Utc.with_ymd_and_hms(2026, 8, 25, h, 0, 0).unwrap()
303 }
304
305 fn window(pct: i32, resets_in: i64) -> UsageWindow {
307 UsageWindow {
308 utilization_pct: pct,
309 resets_at: Some(at(12) + chrono::Duration::hours(resets_in)),
310 window_duration: chrono::Duration::hours(5),
311 }
312 }
313
314 fn row_markup(w: &UsageWindow, row: WindowRow) -> String {
315 let mut lines = Vec::new();
316 push_window_with_row(&mut lines, " L", w, &theme(), at(12), row);
317 render_bordered(&lines, &theme())
318 }
319
320 #[test]
323 fn a_default_row_stays_the_plain_bar_percent_and_reset() {
324 let out = row_markup(&window(40, 2), WindowRow::default());
325 assert!(out.contains("40%"), "{out}");
326 assert!(out.contains("Resets in 2h 00m"), "{out}");
327 assert!(
328 !out.contains('↑') && !out.contains('→') && !out.contains('↓'),
329 "an unpaced row must not grow a glyph: {out}"
330 );
331 assert!(!out.contains(" · "), "an unpaced row has no detail: {out}");
332 }
333
334 #[test]
337 fn paced_rows_keep_the_marker_behind_point_mode() {
338 let w = window(40, 3);
340 let ratio = WindowRow::paced(&w, at(12), pacing::DEFAULT_TOLERANCE, false);
341 assert_eq!(ratio.glyph, Some("→"));
342 assert_eq!(ratio.marker_pct, None);
343
344 let points = WindowRow::paced(&w, at(12), pacing::DEFAULT_TOLERANCE, true);
345 assert_eq!(points.glyph, Some("→"));
346 assert_eq!(points.marker_pct, Some(40));
347 }
348
349 #[test]
352 fn the_pace_modes_can_disagree_on_the_glyph() {
353 let w = window(42, 3); assert_eq!(WindowRow::paced(&w, at(12), 5, false).glyph, Some("→"));
355 assert_eq!(WindowRow::paced(&w, at(12), 5, true).glyph, Some("↑"));
356 }
357
358 #[test]
361 fn a_window_without_a_reset_still_gets_the_neutral_glyph() {
362 let w = UsageWindow {
363 utilization_pct: 0,
364 resets_at: None,
365 window_duration: chrono::Duration::hours(5),
366 };
367 let row = WindowRow::paced(&w, at(12), 5, false);
368 assert_eq!(row.glyph, Some("→"));
369 assert_eq!(row.marker_pct, None);
370 }
371
372 #[test]
373 fn the_glyph_reaches_the_rendered_row() {
374 let w = window(40, 3);
375 let out = row_markup(&w, WindowRow::paced(&w, at(12), 5, false));
376 assert!(out.contains("40% →"), "{out}");
377 assert!(out.contains("Resets in 3h 00m"), "{out}");
378 assert!(!out.contains("Resets in 3h 00m ·"), "{out}");
381 }
382
383 #[test]
384 fn a_detail_is_kept_on_the_meter_line() {
385 let w = window(40, 2);
386 let mut lines = Vec::new();
387 push_window_with_detail(
388 &mut lines,
389 " Spend",
390 &w,
391 &theme(),
392 at(12),
393 WindowRow::default(),
394 Some("$4.00 of $10.00"),
395 );
396 let out = render_bordered(&lines, &theme());
397 assert!(out.contains("40%"), "{out}");
398 assert!(out.contains("· $4.00 of $10.00"), "{out}");
399 assert!(out.contains("Resets in 2h 00m"), "{out}");
400 }
401}