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 crate::pango::visible_width;
12use crate::theme::Theme;
13
14/// One row of the bordered tooltip box.
15pub enum Line {
16    /// Centered text. The renderer pads both sides equally.
17    Center(String),
18    /// Body text. Left-justified, right-padded to fill the box.
19    Body(String),
20    /// A horizontal separator drawn with `─` characters.
21    Sep,
22}
23
24/// Render the bordered tooltip. Width is computed from the widest body/center
25/// line so different vendors auto-size correctly.
26pub fn render_bordered(lines: &[Line], theme: &Theme) -> String {
27    let blue = &theme.blue;
28    let dim = &theme.dim;
29
30    let mut max_w: usize = 0;
31    for line in lines {
32        let s = match line {
33            Line::Center(s) | Line::Body(s) => s.as_str(),
34            Line::Sep => continue,
35        };
36        let w = visible_width(s);
37        if w > max_w {
38            max_w = w;
39        }
40    }
41    let inner_w = max_w + 1;
42    let border_h: String = "─".repeat(inner_w);
43    let sep_inner: String = "─".repeat(inner_w.saturating_sub(2));
44    let sep_line = format!(" <span foreground='{dim}'>{sep_inner}</span>");
45
46    let mut out = String::with_capacity(256 * lines.len());
47    out.push_str(&format!("<span foreground='{blue}'>╭{border_h}╮</span>\n"));
48    for line in lines {
49        let body = match line {
50            Line::Body(s) => pad_right(s, inner_w),
51            Line::Center(s) => pad_center(s, inner_w),
52            Line::Sep => pad_right(&sep_line, inner_w),
53        };
54        out.push_str(&format!(
55            "<span foreground='{blue}'>│</span>{body}<span foreground='{blue}'>│</span>\n"
56        ));
57    }
58    out.push_str(&format!("<span foreground='{blue}'>╰{border_h}╯</span>"));
59    out
60}
61
62/// Pad `s` on the right with spaces so its visible width reaches `inner_w`.
63pub fn pad_right(s: &str, inner_w: usize) -> String {
64    let v = visible_width(s);
65    let need = inner_w.saturating_sub(v);
66    format!("{s}{}", " ".repeat(need))
67}
68
69/// Pad `s` symmetrically; when the difference is odd, the extra space goes
70/// on the right (claudebar `center_pad` precedent).
71pub fn pad_center(s: &str, inner_w: usize) -> String {
72    let v = visible_width(s);
73    let total = inner_w.saturating_sub(v);
74    let lp = total / 2;
75    let rp = total - lp;
76    format!("{}{s}{}", " ".repeat(lp), " ".repeat(rp))
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn theme() -> Theme {
84        Theme::default()
85    }
86
87    #[test]
88    fn renders_top_and_bottom_borders() {
89        let lines = vec![Line::Center("Hi".into())];
90        let out = render_bordered(&lines, &theme());
91        assert!(out.contains("╭"));
92        assert!(out.contains("╮"));
93        assert!(out.contains("╰"));
94        assert!(out.contains("╯"));
95        assert!(out.contains("Hi"));
96    }
97
98    #[test]
99    fn body_line_is_right_padded_to_inner_width() {
100        // Box width = visible_width(widest) + 1 = "longest" (7) + 1 = 8.
101        let lines = vec![Line::Center("a".into()), Line::Body("longest".into())];
102        let out = render_bordered(&lines, &theme());
103        // The body line should be padded so the right `│` lands at inner_w + 2.
104        // We don't assert exact character offsets (Pango spans intervene), just
105        // that the resulting markup is well-formed (open/close balanced).
106        let opens = out.matches("<span").count();
107        let closes = out.matches("</span>").count();
108        assert_eq!(opens, closes);
109    }
110
111    #[test]
112    fn pad_right_strips_pango_tags_before_measuring() {
113        let s = "<span foreground='#fff'>abc</span>"; // visible width 3
114        let p = pad_right(s, 6);
115        // 3 padding spaces appended.
116        assert!(p.ends_with("   "));
117    }
118
119    #[test]
120    fn pad_center_distributes_extra_space_right_for_odd_diff() {
121        let p = pad_center("X", 4); // visible 1, total padding 3 → lp=1, rp=2
122        assert_eq!(p, " X  ");
123    }
124
125    #[test]
126    fn separator_line_width_grows_with_content() {
127        let lines = vec![
128            Line::Center("a".into()),
129            Line::Sep,
130            Line::Body("longer body line".into()),
131        ];
132        let out = render_bordered(&lines, &theme());
133        // The separator should reach the inner width of the box (just check
134        // that it contains the unicode dash glyph repeated).
135        assert!(out.contains("─"));
136    }
137}