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` and `detail` are inserted into Pango markup as-is, so a caller
34/// passing anything vendor-reported must [`escape`] it first.
35#[derive(Debug, Clone, Copy, Default)]
36pub struct WindowRow<'a> {
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    /// Extra dim text appended to the reset line — Kimi's `2 / 100` counters,
42    /// which the bar's percentage summarises but does not spell out.
43    pub detail: Option<&'a str>,
44}
45
46impl<'a> WindowRow<'a> {
47    /// The Anthropic tooltip's pace convention in one place (mirrors
48    /// `widget::render::pick_pace_glyph`): the ratio glyph by default, and with
49    /// `point_mode` (`--tooltip-pace-pts`) the point-delta glyph plus the
50    /// elapsed marker inside the bar.
51    ///
52    /// A window with no reported reset degrades to `pacing::Pacing::neutral()`,
53    /// so it still gets a `→` rather than a blank where every sibling row has
54    /// a glyph.
55    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    /// Same row, carrying an extra dim fragment on the reset line.
75    pub fn with_detail(self, detail: &'a str) -> Self {
76        Self {
77            detail: Some(detail),
78            ..self
79        }
80    }
81}
82
83/// Append the standard three-line block every vendor uses for a usage window:
84/// icon + label, progress bar + bold percentage, then the dim reset countdown.
85///
86/// `elapsed` draws the pace marker inside the bar; pass `None` for a plain bar.
87/// Keep this signature stable for library callers — a row that also wants the
88/// pace glyph or a detail fragment goes through [`push_window_with_row`].
89pub 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
110/// [`push_window`], plus the optional decorations a [`WindowRow`] carries.
111pub 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
138/// Render the bordered tooltip. Width is computed from the widest body/center
139/// line so different vendors auto-size correctly.
140pub 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
176/// Pad `s` on the right with spaces so its visible width reaches `inner_w`.
177pub 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
183/// Pad `s` symmetrically; when the difference is odd, the extra space goes
184/// on the right (claudebar `center_pad` precedent).
185pub 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    /// Escaped characters are one glyph wide; if the box measured them by
213    /// source length, rows containing one would stop short of the right border.
214    #[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        // Box width = visible_width(widest) + 1 = "longest" (7) + 1 = 8.
231        let lines = vec![Line::Center("a".into()), Line::Body("longest".into())];
232        let out = render_bordered(&lines, &theme());
233        // The body line should be padded so the right `│` lands at inner_w + 2.
234        // We don't assert exact character offsets (Pango spans intervene), just
235        // that the resulting markup is well-formed (open/close balanced).
236        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>"; // visible width 3
244        let p = pad_right(s, 6);
245        // 3 padding spaces appended.
246        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); // visible 1, total padding 3 → lp=1, rp=2
252        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        // The separator should reach the inner width of the box (just check
264        // that it contains the unicode dash glyph repeated).
265        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    /// One five-hour window `resets_in` hours from `at(12)`.
274    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    /// Every vendor that has not opted into pacing still renders the original
289    /// row; `WindowRow::default()` is what keeps that output untouched.
290    #[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    /// Mirrors the Anthropic tooltip: the ratio glyph always, the elapsed
303    /// marker only behind `--tooltip-pace-pts`.
304    #[test]
305    fn paced_rows_keep_the_marker_behind_point_mode() {
306        // 3h left of a 5h window → 40% elapsed, matched by 40% used.
307        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    /// The two modes disagree inside the tolerance band — the split `pacing`
318    /// documents, and the reason the glyph is picked from the mode.
319    #[test]
320    fn the_pace_modes_can_disagree_on_the_glyph() {
321        let w = window(42, 3); // 40% elapsed, 42% used
322        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    /// A window the vendor reports without a reset still gets a glyph, so a
327    /// row never sits blank beside siblings that have one.
328    #[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}