Skip to main content

ai_usagebar/
pango.rs

1//! Pango-markup rendering helpers shared by the widget bar text and tooltip.
2//!
3//! Two primary primitives:
4//! - [`progress_bar`] — fixed-width filled+empty bar, optional elapsed marker.
5//!   Mirrors claudebar's `make_bar()` (claudebar:225-250).
6//! - [`color_span`] — wraps text in `<span foreground='…'>` (the only Pango
7//!   tag claudebar uses for color).
8//!
9//! Helpers also include the bordered tooltip frame builder
10//! ([`render_bordered_box`]), used by the default tooltip layout.
11
12use crate::pacing::PaceSeverity;
13use crate::theme::Theme;
14
15/// Width of the progress bar in characters. Matches `BAR_LEN=20` (claudebar:169).
16pub const BAR_LEN: u32 = 20;
17
18const FILLED: char = '█';
19const EMPTY: char = '░';
20
21/// Wrap `text` in a Pango `<span foreground='COLOR'>…</span>`.
22///
23/// `text` must already be Pango-safe (no raw `<` / `>` / `&`). Callers passing
24/// user-controlled strings should escape first via [`escape`].
25pub fn color_span(color: &str, text: &str) -> String {
26    format!("<span foreground='{color}'>{text}</span>")
27}
28
29/// Escape `&`, `<`, `>` for Pango markup (which is XML-ish).
30pub fn escape(s: &str) -> String {
31    s.replace('&', "&amp;")
32        .replace('<', "&lt;")
33        .replace('>', "&gt;")
34}
35
36/// Map a usage percentage to a severity tier, matching `color_for`
37/// (claudebar:198-205):
38///   >= 90 → critical (red); >= 75 → high (orange);
39///   >= 50 → mid (yellow); else low (green).
40pub fn severity_for(pct: i32) -> PaceSeverity {
41    if pct >= 90 {
42        PaceSeverity::Critical
43    } else if pct >= 75 {
44        PaceSeverity::High
45    } else if pct >= 50 {
46        PaceSeverity::Mid
47    } else {
48        PaceSeverity::Low
49    }
50}
51
52/// Resolve a severity tier to a concrete hex color from the theme.
53pub fn severity_color(sev: PaceSeverity, theme: &Theme) -> &str {
54    match sev {
55        PaceSeverity::Low => &theme.green,
56        PaceSeverity::Mid => &theme.yellow,
57        PaceSeverity::High => &theme.orange,
58        PaceSeverity::Critical => &theme.red,
59    }
60}
61
62/// Build a fixed-width progress bar in Pango markup.
63///
64/// - `pct` is clamped to `0..=100`.
65/// - `fill_color` colors the filled (`█`) cells; `theme.bar_empty` colors the
66///   empty (`░`) cells.
67/// - If `marker_pct` is `Some`, a single `█` in `theme.marker` color is placed
68///   at the corresponding cell, displacing one empty cell and (when usage
69///   exceeds the marker position) splitting the filled run around it.
70///
71/// Implementation note: this mirrors claudebar:225-250's two-branch logic but
72/// in a single expression. The resulting markup is byte-identical for the same
73/// inputs.
74pub fn progress_bar(pct: i32, fill_color: &str, theme: &Theme, marker_pct: Option<i32>) -> String {
75    let pct = pct.clamp(0, 100) as u32;
76    let bar_len = BAR_LEN;
77    let filled = (pct * bar_len) / 100;
78
79    let Some(marker) = marker_pct.map(|p| p.clamp(0, 100) as u32) else {
80        // Simple two-segment bar.
81        let empty = bar_len - filled;
82        return format!(
83            "<span foreground='{fill_color}'>{f}</span><span foreground='{empty_color}'>{e}</span>",
84            f = repeat_char(FILLED, filled),
85            e = repeat_char(EMPTY, empty),
86            empty_color = theme.bar_empty,
87        );
88    };
89
90    // Marker placement (claudebar:238-249).
91    let mut m = (marker * bar_len) / 100;
92    if m > bar_len - 1 {
93        m = bar_len - 1;
94    }
95    let pre_f = filled.min(m);
96    let post_f = if filled > m + 1 { filled - m - 1 } else { 0 };
97    let pre_e = m - pre_f;
98    let post_e = bar_len - m - 1 - post_f;
99
100    let mut out = String::with_capacity(256);
101    // Pre-marker segment: filled run, then empties up to the marker.
102    out.push_str(&format!(
103        "<span foreground='{fill_color}'>{}</span>",
104        repeat_char(FILLED, pre_f)
105    ));
106    out.push_str(&format!(
107        "<span foreground='{}'>{}</span>",
108        theme.bar_empty,
109        repeat_char(EMPTY, pre_e)
110    ));
111    // Marker (single filled cell in marker color).
112    out.push_str(&format!(
113        "<span foreground='{}'>{}</span>",
114        theme.marker, FILLED
115    ));
116    // Post-marker segment: filled run, then empties to fill the bar.
117    out.push_str(&format!(
118        "<span foreground='{fill_color}'>{}</span>",
119        repeat_char(FILLED, post_f)
120    ));
121    out.push_str(&format!(
122        "<span foreground='{}'>{}</span>",
123        theme.bar_empty,
124        repeat_char(EMPTY, post_e)
125    ));
126    out
127}
128
129fn repeat_char(c: char, n: u32) -> String {
130    std::iter::repeat_n(c, n as usize).collect()
131}
132
133/// Count the visible width of a Pango-marked string (its character count with
134/// all `<span …>…</span>` tags stripped). Used by the bordered-box renderer
135/// for padding alignment — claudebar implements this with `sed 's/<[^>]*>//g'`.
136pub fn visible_width(s: &str) -> usize {
137    let mut depth = 0usize;
138    let mut count = 0usize;
139    for ch in s.chars() {
140        match ch {
141            '<' => depth += 1,
142            '>' if depth > 0 => depth = depth.saturating_sub(1),
143            _ if depth == 0 => count += 1,
144            _ => {}
145        }
146    }
147    count
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn theme() -> Theme {
155        Theme::default()
156    }
157
158    #[test]
159    fn severity_thresholds_match_claudebar() {
160        assert_eq!(severity_for(0), PaceSeverity::Low);
161        assert_eq!(severity_for(49), PaceSeverity::Low);
162        assert_eq!(severity_for(50), PaceSeverity::Mid);
163        assert_eq!(severity_for(74), PaceSeverity::Mid);
164        assert_eq!(severity_for(75), PaceSeverity::High);
165        assert_eq!(severity_for(89), PaceSeverity::High);
166        assert_eq!(severity_for(90), PaceSeverity::Critical);
167        assert_eq!(severity_for(100), PaceSeverity::Critical);
168    }
169
170    #[test]
171    fn color_span_wraps_pango() {
172        assert_eq!(
173            color_span("#ff0000", "hi"),
174            "<span foreground='#ff0000'>hi</span>"
175        );
176    }
177
178    #[test]
179    fn escape_handles_markup_chars() {
180        // `&` must come first so we don't double-escape produced `&` chars.
181        assert_eq!(escape("a < b & c > d"), "a &lt; b &amp; c &gt; d");
182    }
183
184    #[test]
185    fn bar_zero_pct_is_all_empty() {
186        let b = progress_bar(0, "#000000", &theme(), None);
187        // Should contain 20 ░ chars and no █ chars.
188        assert_eq!(b.matches('░').count(), BAR_LEN as usize);
189        assert_eq!(b.matches('█').count(), 0);
190    }
191
192    #[test]
193    fn bar_hundred_pct_is_all_filled() {
194        let b = progress_bar(100, "#ff0000", &theme(), None);
195        assert_eq!(b.matches('█').count(), BAR_LEN as usize);
196        assert_eq!(b.matches('░').count(), 0);
197    }
198
199    #[test]
200    fn bar_clamps_overflow() {
201        let b = progress_bar(150, "#ff0000", &theme(), None);
202        assert_eq!(b.matches('█').count(), BAR_LEN as usize);
203    }
204
205    #[test]
206    fn bar_fifty_pct_splits_evenly() {
207        let b = progress_bar(50, "#ff0000", &theme(), None);
208        assert_eq!(b.matches('█').count(), 10);
209        assert_eq!(b.matches('░').count(), 10);
210    }
211
212    #[test]
213    fn bar_with_marker_keeps_total_width() {
214        // 50% usage, 50% marker → marker occupies cell 10, displacing one
215        // empty cell. Total visible width stays at BAR_LEN (claudebar
216        // semantics — marker replaces, doesn't append).
217        let b = progress_bar(50, "#ff0000", &theme(), Some(50));
218        assert!(b.contains("#ff0000"));
219        assert!(b.contains(&theme().marker));
220        assert_eq!(visible_width(&b), BAR_LEN as usize);
221    }
222
223    #[test]
224    fn bar_marker_at_zero_is_renderable() {
225        // Marker at 0 with 0% usage → no panic on underflow; width preserved.
226        let b = progress_bar(0, "#ff0000", &theme(), Some(0));
227        assert_eq!(visible_width(&b), BAR_LEN as usize);
228    }
229
230    #[test]
231    fn bar_marker_at_hundred_is_renderable() {
232        // Marker clamped to BAR_LEN - 1 (claudebar:240). 100% usage fills
233        // everything to the left of the marker; the marker is the last cell.
234        let b = progress_bar(100, "#ff0000", &theme(), Some(100));
235        assert_eq!(visible_width(&b), BAR_LEN as usize);
236        // Filled cells before marker = 19, marker = 1, nothing after.
237        assert_eq!(b.matches('█').count(), BAR_LEN as usize);
238        assert_eq!(b.matches('░').count(), 0);
239    }
240
241    #[test]
242    fn visible_width_strips_tags() {
243        assert_eq!(visible_width("<span foreground='#fff'>hello</span>"), 5);
244        assert_eq!(visible_width("a<x>b</x>c"), 3);
245        assert_eq!(visible_width("plain text"), 10);
246    }
247
248    #[test]
249    fn visible_width_handles_nested_tags() {
250        assert_eq!(visible_width("<a><b>xy</b></a>"), 2);
251    }
252}