Skip to main content

ai_usagebar/
tooltip.rs

1//! Pango-bordered tooltip primitives shared by every vendor renderer.
2//!
3//! Extracted from the per-vendor implementations because every tooltip
4//! (Anthropic, OpenAI, Z.AI, OpenRouter) draws the same kind of box: blue
5//! corners + horizontals, dim separators, centered title, left-padded body
6//! lines. The only thing that varies is the line content.
7//!
8//! Mirrors the visual style of `claudebar`'s `${B}╭${border_h}╮${E}` block
9//! (claudebar:843-859).
10
11use 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
19/// One row of the bordered tooltip box.
20pub enum Line {
21    /// Centered text. The renderer pads both sides equally.
22    Center(String),
23    /// Body text. Left-justified, right-padded to fill the box.
24    Body(String),
25    /// A horizontal separator drawn with `─` characters.
26    Sep,
27}
28
29/// Optional decorations for a [`push_window`] row. `Default` reproduces the
30/// plain row every vendor drew before pacing reached the tooltip: bar +
31/// percentage, then `⏱  Resets in …`.
32///
33/// `glyph` is inserted into Pango markup as-is, so a caller passing anything
34/// vendor-reported must [`escape`] it first.
35#[derive(Debug, Clone, Copy, Default)]
36pub struct WindowRow {
37    /// Elapsed-time marker drawn inside the bar (`--tooltip-pace-pts`).
38    pub marker_pct: Option<i32>,
39    /// Pace glyph appended after the bold percentage (`↑` / `→` / `↓`).
40    pub glyph: Option<&'static str>,
41}
42
43impl WindowRow {
44    /// The Anthropic tooltip's pace convention in one place (mirrors
45    /// `widget::render::pick_pace_glyph`): the ratio glyph by default, and with
46    /// `point_mode` (`--tooltip-pace-pts`) the point-delta glyph plus the
47    /// elapsed marker inside the bar.
48    ///
49    /// A window with no reported reset degrades to `pacing::Pacing::neutral()`,
50    /// so it still gets a `→` rather than a blank where every sibling row has
51    /// a glyph.
52    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
71/// Append the standard three-line block every vendor uses for a usage window:
72/// icon + label, progress bar + bold percentage, then the dim reset countdown.
73///
74/// `elapsed` draws the pace marker inside the bar; pass `None` for a plain bar.
75/// Keep this signature stable for library callers — a row that also wants the
76/// pace glyph goes through [`push_window_with_row`].
77pub 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
98/// [`push_window`], plus the optional decorations a [`WindowRow`] carries.
99pub 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
110/// [`push_window_with_row`], with an optional vendor-specific detail appended
111/// to the percentage line. Spend-based vendors use this for values such as
112/// "$1.23 of $14.00" while keeping the same label / gauge / reset rhythm as
113/// percentage-based vendors such as OpenAI.
114pub 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
144/// Render the bordered tooltip. Width is computed from the widest body/center
145/// line so different vendors auto-size correctly.
146pub 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
182/// Pad `s` on the right with spaces so its visible width reaches `inner_w`.
183pub 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
189/// Pad `s` symmetrically; when the difference is odd, the extra space goes
190/// on the right (claudebar `center_pad` precedent).
191pub 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    /// Escaped characters are one glyph wide; if the box measured them by
219    /// source length, rows containing one would stop short of the right border.
220    #[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    /// The reason `visible_width` measures columns rather than characters: a
235    /// Japanese or Korean row is twice as wide as its character count, so a
236    /// char-counting box stopped short of the right border by one cell per
237    /// ideograph. This is the integration proof behind that change.
238    #[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        // "日本" is 2 chars but 4 columns; padding to 6 needs 2 spaces, not 4.
256        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        // Box width = visible_width(widest) + 1 = "longest" (7) + 1 = 8.
263        let lines = vec![Line::Center("a".into()), Line::Body("longest".into())];
264        let out = render_bordered(&lines, &theme());
265        // The body line should be padded so the right `│` lands at inner_w + 2.
266        // We don't assert exact character offsets (Pango spans intervene), just
267        // that the resulting markup is well-formed (open/close balanced).
268        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>"; // visible width 3
276        let p = pad_right(s, 6);
277        // 3 padding spaces appended.
278        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); // visible 1, total padding 3 → lp=1, rp=2
284        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        // The separator should reach the inner width of the box (just check
296        // that it contains the unicode dash glyph repeated).
297        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    /// One five-hour window `resets_in` hours from `at(12)`.
306    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    /// Every vendor that has not opted into pacing still renders the original
321    /// row; `WindowRow::default()` is what keeps that output untouched.
322    #[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    /// Mirrors the Anthropic tooltip: the ratio glyph always, the elapsed
335    /// marker only behind `--tooltip-pace-pts`.
336    #[test]
337    fn paced_rows_keep_the_marker_behind_point_mode() {
338        // 3h left of a 5h window → 40% elapsed, matched by 40% used.
339        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    /// The two modes disagree inside the tolerance band — the split `pacing`
350    /// documents, and the reason the glyph is picked from the mode.
351    #[test]
352    fn the_pace_modes_can_disagree_on_the_glyph() {
353        let w = window(42, 3); // 40% elapsed, 42% used
354        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    /// A window the vendor reports without a reset still gets a glyph, so a
359    /// row never sits blank beside siblings that have one.
360    #[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        // The reset line carries nothing after the countdown now that no
379        // vendor appends a fragment to it.
380        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}