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::pango::{self, escape, severity_color, severity_for, visible_width};
15use crate::theme::Theme;
16use crate::usage::UsageWindow;
17
18/// One row of the bordered tooltip box.
19pub enum Line {
20    /// Centered text. The renderer pads both sides equally.
21    Center(String),
22    /// Body text. Left-justified, right-padded to fill the box.
23    Body(String),
24    /// A horizontal separator drawn with `─` characters.
25    Sep,
26}
27
28/// Append the standard three-line block every vendor uses for a usage window:
29/// icon + label, progress bar + bold percentage, then the dim reset countdown.
30///
31/// `elapsed` draws the pace marker inside the bar; pass `None` for a plain bar.
32pub fn push_window(
33    lines: &mut Vec<Line>,
34    label: &str,
35    w: &UsageWindow,
36    theme: &Theme,
37    now: DateTime<Utc>,
38    elapsed: Option<i32>,
39) {
40    let color = severity_color(severity_for(w.utilization_pct), theme);
41    let bar = pango::progress_bar(w.utilization_pct, color, theme, elapsed);
42    let fg = &theme.fg;
43    let dim = &theme.dim;
44    lines.push(Line::Body(format!(
45        " <span foreground='{fg}'>{label}</span>"
46    )));
47    lines.push(Line::Body(format!(
48        "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}%</span>",
49        pct = w.utilization_pct
50    )));
51    lines.push(Line::Body(format!(
52        " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
53        cd = escape(&countdown::format(w.resets_at, now))
54    )));
55}
56
57/// Render the bordered tooltip. Width is computed from the widest body/center
58/// line so different vendors auto-size correctly.
59pub fn render_bordered(lines: &[Line], theme: &Theme) -> String {
60    let blue = &theme.blue;
61    let dim = &theme.dim;
62
63    let mut max_w: usize = 0;
64    for line in lines {
65        let s = match line {
66            Line::Center(s) | Line::Body(s) => s.as_str(),
67            Line::Sep => continue,
68        };
69        let w = visible_width(s);
70        if w > max_w {
71            max_w = w;
72        }
73    }
74    let inner_w = max_w + 1;
75    let border_h: String = "─".repeat(inner_w);
76    let sep_inner: String = "─".repeat(inner_w.saturating_sub(2));
77    let sep_line = format!(" <span foreground='{dim}'>{sep_inner}</span>");
78
79    let mut out = String::with_capacity(256 * lines.len());
80    out.push_str(&format!("<span foreground='{blue}'>╭{border_h}╮</span>\n"));
81    for line in lines {
82        let body = match line {
83            Line::Body(s) => pad_right(s, inner_w),
84            Line::Center(s) => pad_center(s, inner_w),
85            Line::Sep => pad_right(&sep_line, inner_w),
86        };
87        out.push_str(&format!(
88            "<span foreground='{blue}'>│</span>{body}<span foreground='{blue}'>│</span>\n"
89        ));
90    }
91    out.push_str(&format!("<span foreground='{blue}'>╰{border_h}╯</span>"));
92    out
93}
94
95/// Pad `s` on the right with spaces so its visible width reaches `inner_w`.
96pub fn pad_right(s: &str, inner_w: usize) -> String {
97    let v = visible_width(s);
98    let need = inner_w.saturating_sub(v);
99    format!("{s}{}", " ".repeat(need))
100}
101
102/// Pad `s` symmetrically; when the difference is odd, the extra space goes
103/// on the right (claudebar `center_pad` precedent).
104pub fn pad_center(s: &str, inner_w: usize) -> String {
105    let v = visible_width(s);
106    let total = inner_w.saturating_sub(v);
107    let lp = total / 2;
108    let rp = total - lp;
109    format!("{}{s}{}", " ".repeat(lp), " ".repeat(rp))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn theme() -> Theme {
117        Theme::default()
118    }
119
120    #[test]
121    fn renders_top_and_bottom_borders() {
122        let lines = vec![Line::Center("Hi".into())];
123        let out = render_bordered(&lines, &theme());
124        assert!(out.contains("╭"));
125        assert!(out.contains("╮"));
126        assert!(out.contains("╰"));
127        assert!(out.contains("╯"));
128        assert!(out.contains("Hi"));
129    }
130
131    /// Escaped characters are one glyph wide; if the box measured them by
132    /// source length, rows containing one would stop short of the right border.
133    #[test]
134    fn rows_with_escaped_characters_keep_the_border_flush() {
135        let lines = vec![
136            Line::Body(crate::pango::escape("Claude & GPT (weekly)")),
137            Line::Body("Gemini (weekly)".into()),
138        ];
139        let out = render_bordered(&lines, &theme());
140        let right_edges: Vec<usize> = out.lines().map(crate::pango::visible_width).collect();
141        assert!(
142            right_edges.windows(2).all(|w| w[0] == w[1]),
143            "ragged box: {right_edges:?}\n{out}"
144        );
145    }
146
147    #[test]
148    fn body_line_is_right_padded_to_inner_width() {
149        // Box width = visible_width(widest) + 1 = "longest" (7) + 1 = 8.
150        let lines = vec![Line::Center("a".into()), Line::Body("longest".into())];
151        let out = render_bordered(&lines, &theme());
152        // The body line should be padded so the right `│` lands at inner_w + 2.
153        // We don't assert exact character offsets (Pango spans intervene), just
154        // that the resulting markup is well-formed (open/close balanced).
155        let opens = out.matches("<span").count();
156        let closes = out.matches("</span>").count();
157        assert_eq!(opens, closes);
158    }
159
160    #[test]
161    fn pad_right_strips_pango_tags_before_measuring() {
162        let s = "<span foreground='#fff'>abc</span>"; // visible width 3
163        let p = pad_right(s, 6);
164        // 3 padding spaces appended.
165        assert!(p.ends_with("   "));
166    }
167
168    #[test]
169    fn pad_center_distributes_extra_space_right_for_odd_diff() {
170        let p = pad_center("X", 4); // visible 1, total padding 3 → lp=1, rp=2
171        assert_eq!(p, " X  ");
172    }
173
174    #[test]
175    fn separator_line_width_grows_with_content() {
176        let lines = vec![
177            Line::Center("a".into()),
178            Line::Sep,
179            Line::Body("longer body line".into()),
180        ];
181        let out = render_bordered(&lines, &theme());
182        // The separator should reach the inner width of the box (just check
183        // that it contains the unicode dash glyph repeated).
184        assert!(out.contains("─"));
185    }
186}