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 let color = severity_color(severity_for(w.utilization_pct), theme);
108 let bar = pango::progress_bar(w.utilization_pct, color, theme, row.marker_pct);
109 let fg = &theme.fg;
110 let dim = &theme.dim;
111 let glyph = row.glyph.map(|g| format!(" {g}")).unwrap_or_default();
112 lines.push(Line::Body(format!(
113 " <span foreground='{fg}'>{label}</span>"
114 )));
115 lines.push(Line::Body(format!(
116 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%{glyph}</span>",
117 pct = w.utilization_pct
118 )));
119 lines.push(Line::Body(format!(
120 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
121 cd = escape(&countdown::format(w.resets_at, now))
122 )));
123}
124
125pub fn render_bordered(lines: &[Line], theme: &Theme) -> String {
128 let blue = &theme.blue;
129 let dim = &theme.dim;
130
131 let mut max_w: usize = 0;
132 for line in lines {
133 let s = match line {
134 Line::Center(s) | Line::Body(s) => s.as_str(),
135 Line::Sep => continue,
136 };
137 let w = visible_width(s);
138 if w > max_w {
139 max_w = w;
140 }
141 }
142 let inner_w = max_w + 1;
143 let border_h: String = "─".repeat(inner_w);
144 let sep_inner: String = "─".repeat(inner_w.saturating_sub(2));
145 let sep_line = format!(" <span foreground='{dim}'>{sep_inner}</span>");
146
147 let mut out = String::with_capacity(256 * lines.len());
148 out.push_str(&format!("<span foreground='{blue}'>╭{border_h}╮</span>\n"));
149 for line in lines {
150 let body = match line {
151 Line::Body(s) => pad_right(s, inner_w),
152 Line::Center(s) => pad_center(s, inner_w),
153 Line::Sep => pad_right(&sep_line, inner_w),
154 };
155 out.push_str(&format!(
156 "<span foreground='{blue}'>│</span>{body}<span foreground='{blue}'>│</span>\n"
157 ));
158 }
159 out.push_str(&format!("<span foreground='{blue}'>╰{border_h}╯</span>"));
160 out
161}
162
163pub fn pad_right(s: &str, inner_w: usize) -> String {
165 let v = visible_width(s);
166 let need = inner_w.saturating_sub(v);
167 format!("{s}{}", " ".repeat(need))
168}
169
170pub fn pad_center(s: &str, inner_w: usize) -> String {
173 let v = visible_width(s);
174 let total = inner_w.saturating_sub(v);
175 let lp = total / 2;
176 let rp = total - lp;
177 format!("{}{s}{}", " ".repeat(lp), " ".repeat(rp))
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn theme() -> Theme {
185 Theme::default()
186 }
187
188 #[test]
189 fn renders_top_and_bottom_borders() {
190 let lines = vec![Line::Center("Hi".into())];
191 let out = render_bordered(&lines, &theme());
192 assert!(out.contains("╭"));
193 assert!(out.contains("╮"));
194 assert!(out.contains("╰"));
195 assert!(out.contains("╯"));
196 assert!(out.contains("Hi"));
197 }
198
199 #[test]
202 fn rows_with_escaped_characters_keep_the_border_flush() {
203 let lines = vec![
204 Line::Body(crate::pango::escape("Claude & GPT (weekly)")),
205 Line::Body("Gemini (weekly)".into()),
206 ];
207 let out = render_bordered(&lines, &theme());
208 let right_edges: Vec<usize> = out.lines().map(crate::pango::visible_width).collect();
209 assert!(
210 right_edges.windows(2).all(|w| w[0] == w[1]),
211 "ragged box: {right_edges:?}\n{out}"
212 );
213 }
214
215 #[test]
220 fn rows_with_double_width_glyphs_keep_the_border_flush() {
221 let lines = vec![
222 Line::Body("セッション (5h)".into()),
223 Line::Body("사용량".into()),
224 Line::Body("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 with CJK rows: {right_edges:?}\n{out}"
231 );
232 }
233
234 #[test]
235 fn pad_right_pads_a_double_width_string_by_columns() {
236 assert!(pad_right("日本", 6).ends_with(" "));
238 assert_eq!(crate::pango::visible_width(&pad_right("日本", 6)), 6);
239 }
240
241 #[test]
242 fn body_line_is_right_padded_to_inner_width() {
243 let lines = vec![Line::Center("a".into()), Line::Body("longest".into())];
245 let out = render_bordered(&lines, &theme());
246 let opens = out.matches("<span").count();
250 let closes = out.matches("</span>").count();
251 assert_eq!(opens, closes);
252 }
253
254 #[test]
255 fn pad_right_strips_pango_tags_before_measuring() {
256 let s = "<span foreground='#fff'>abc</span>"; let p = pad_right(s, 6);
258 assert!(p.ends_with(" "));
260 }
261
262 #[test]
263 fn pad_center_distributes_extra_space_right_for_odd_diff() {
264 let p = pad_center("X", 4); assert_eq!(p, " X ");
266 }
267
268 #[test]
269 fn separator_line_width_grows_with_content() {
270 let lines = vec![
271 Line::Center("a".into()),
272 Line::Sep,
273 Line::Body("longer body line".into()),
274 ];
275 let out = render_bordered(&lines, &theme());
276 assert!(out.contains("─"));
279 }
280
281 fn at(h: u32) -> DateTime<Utc> {
282 use chrono::TimeZone;
283 Utc.with_ymd_and_hms(2026, 8, 25, h, 0, 0).unwrap()
284 }
285
286 fn window(pct: i32, resets_in: i64) -> UsageWindow {
288 UsageWindow {
289 utilization_pct: pct,
290 resets_at: Some(at(12) + chrono::Duration::hours(resets_in)),
291 window_duration: chrono::Duration::hours(5),
292 }
293 }
294
295 fn row_markup(w: &UsageWindow, row: WindowRow) -> String {
296 let mut lines = Vec::new();
297 push_window_with_row(&mut lines, " L", w, &theme(), at(12), row);
298 render_bordered(&lines, &theme())
299 }
300
301 #[test]
304 fn a_default_row_stays_the_plain_bar_percent_and_reset() {
305 let out = row_markup(&window(40, 2), WindowRow::default());
306 assert!(out.contains("40%"), "{out}");
307 assert!(out.contains("Resets in 2h 00m"), "{out}");
308 assert!(
309 !out.contains('↑') && !out.contains('→') && !out.contains('↓'),
310 "an unpaced row must not grow a glyph: {out}"
311 );
312 assert!(!out.contains(" · "), "an unpaced row has no detail: {out}");
313 }
314
315 #[test]
318 fn paced_rows_keep_the_marker_behind_point_mode() {
319 let w = window(40, 3);
321 let ratio = WindowRow::paced(&w, at(12), pacing::DEFAULT_TOLERANCE, false);
322 assert_eq!(ratio.glyph, Some("→"));
323 assert_eq!(ratio.marker_pct, None);
324
325 let points = WindowRow::paced(&w, at(12), pacing::DEFAULT_TOLERANCE, true);
326 assert_eq!(points.glyph, Some("→"));
327 assert_eq!(points.marker_pct, Some(40));
328 }
329
330 #[test]
333 fn the_pace_modes_can_disagree_on_the_glyph() {
334 let w = window(42, 3); assert_eq!(WindowRow::paced(&w, at(12), 5, false).glyph, Some("→"));
336 assert_eq!(WindowRow::paced(&w, at(12), 5, true).glyph, Some("↑"));
337 }
338
339 #[test]
342 fn a_window_without_a_reset_still_gets_the_neutral_glyph() {
343 let w = UsageWindow {
344 utilization_pct: 0,
345 resets_at: None,
346 window_duration: chrono::Duration::hours(5),
347 };
348 let row = WindowRow::paced(&w, at(12), 5, false);
349 assert_eq!(row.glyph, Some("→"));
350 assert_eq!(row.marker_pct, None);
351 }
352
353 #[test]
354 fn the_glyph_reaches_the_rendered_row() {
355 let w = window(40, 3);
356 let out = row_markup(&w, WindowRow::paced(&w, at(12), 5, false));
357 assert!(out.contains("40% →"), "{out}");
358 assert!(out.contains("Resets in 3h 00m"), "{out}");
359 assert!(!out.contains("Resets in 3h 00m ·"), "{out}");
362 }
363}