Skip to main content

ai_usagebar/widget/
render.rs

1//! Pango-markup rendering for the Anthropic widget — both the bar text and
2//! the bordered tooltip.
3//!
4//! Closely mirrors claudebar:625-860. Pure functions over an immutable
5//! [`RenderInput`] so all of the visual logic is unit-testable without I/O.
6
7use std::collections::HashMap;
8
9use chrono::{DateTime, Utc};
10
11use crate::anthropic::fetch::FetchOutcome;
12use crate::countdown;
13use crate::format::{placeholders, substitute, updated_at_hm};
14use crate::pacing;
15use crate::pango::{self, color_span, escape, severity_for};
16use crate::theme::Theme;
17use crate::tooltip::{self, Line};
18use crate::usage::anthropic_severity;
19use crate::waybar::{Class, WaybarOutput};
20
21/// Default format string when `--format` is omitted (claudebar:55).
22pub const DEFAULT_FORMAT: &str = "{session_pct}% · {session_reset}";
23
24/// All inputs needed to render the widget — packaged so tests can construct
25/// it without any I/O.
26pub struct RenderInput<'a> {
27    pub outcome: &'a FetchOutcome,
28    pub theme: &'a Theme,
29    pub format: &'a str,
30    pub tooltip_format: Option<&'a str>,
31    pub icon: Option<&'a str>,
32    pub pace_tolerance: u32,
33    pub format_pace_color: bool,
34    pub tooltip_pace_pts: bool,
35    pub now: DateTime<Utc>,
36}
37
38/// Compose the full Waybar output for an Anthropic snapshot.
39pub fn render_anthropic(input: &RenderInput) -> WaybarOutput {
40    let snap = &input.outcome.snapshot;
41    let class = Class::from(anthropic_severity(snap));
42    let bar_text = render_bar_text(input, class);
43    let tooltip = if let Some(fmt) = input.tooltip_format {
44        // Custom tooltip uses the same placeholder set as the bar.
45        let values = build_placeholders(input);
46        substitute(fmt, &values)
47    } else {
48        render_default_tooltip(input)
49    };
50
51    WaybarOutput {
52        text: bar_text,
53        tooltip,
54        class,
55    }
56}
57
58/// Build the bar-text string with all placeholders substituted and the
59/// surrounding `<span foreground='…'>` wrapper applied.
60fn render_bar_text(input: &RenderInput, class: Class) -> String {
61    let values = build_placeholders(input);
62    let mut text = substitute(input.format, &values);
63
64    // Append stale indicator (claudebar:687-690).
65    if input.outcome.stale {
66        text.push_str(" ⏸");
67    }
68
69    // Wrap in the global color or the neutral foreground (when individual
70    // pace placeholders supply their own color via --format-pace-color).
71    let wrapper_color = if input.format_pace_color && input.format.contains("_pace") {
72        input.theme.fg.clone()
73    } else {
74        bar_color_for(class, input.theme).to_string()
75    };
76    let icon_prefix = match input.icon {
77        Some(ic) if !ic.is_empty() => format!("{ic} "),
78        _ => String::new(),
79    };
80    color_span(&wrapper_color, &format!("{icon_prefix}{text}"))
81}
82
83fn bar_color_for(class: Class, theme: &Theme) -> &str {
84    match class {
85        Class::Low => &theme.green,
86        Class::Mid => &theme.yellow,
87        Class::High => &theme.orange,
88        Class::Critical => &theme.red,
89    }
90}
91
92/// Build the full placeholder map for an Anthropic snapshot.
93///
94/// Mirrors claudebar's "{...}" surface (claudebar:625-667). Per-window pacing
95/// is pre-computed once; bars are rendered both raw (for `{*_bar}`) and with
96/// elapsed-position markers in the tooltip when `--tooltip-pace-pts` is set.
97fn build_placeholders(input: &RenderInput) -> HashMap<&'static str, String> {
98    let snap = &input.outcome.snapshot;
99    let theme = input.theme;
100
101    let session = pacing::calc(
102        snap.session.utilization_pct,
103        snap.session.resets_at,
104        input.now,
105        snap.session.window_duration,
106        input.pace_tolerance,
107    );
108    let weekly = pacing::calc(
109        snap.weekly.utilization_pct,
110        snap.weekly.resets_at,
111        input.now,
112        snap.weekly.window_duration,
113        input.pace_tolerance,
114    );
115    let sonnet_window = snap.sonnet.as_ref();
116    let sonnet = sonnet_window.map(|w| {
117        pacing::calc(
118            w.utilization_pct,
119            w.resets_at,
120            input.now,
121            w.window_duration,
122            input.pace_tolerance,
123        )
124    });
125
126    let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
127    let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
128    let sonnet_color =
129        sonnet_window.map(|w| pango::severity_color(severity_for(w.utilization_pct), theme));
130    let extra_color = snap
131        .extra
132        .as_ref()
133        .map(|e| pango::severity_color(severity_for(e.percent()), theme));
134
135    let session_bar = pango::progress_bar(snap.session.utilization_pct, session_color, theme, None);
136    let weekly_bar = pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None);
137    let sonnet_bar = if let (Some(w), Some(c)) = (sonnet_window, sonnet_color) {
138        pango::progress_bar(w.utilization_pct, c, theme, None)
139    } else {
140        String::new()
141    };
142    let extra_bar = if let (Some(e), Some(c)) = (snap.extra.as_ref(), extra_color) {
143        pango::progress_bar(e.percent(), c, theme, None)
144    } else {
145        String::new()
146    };
147
148    let mut v = placeholders(vec![
149        ("icon", "󰚩".to_string()),
150        ("vendor_short", "cld".to_string()),
151        ("plan", snap.plan.clone()),
152        ("session_pct", snap.session.utilization_pct.to_string()),
153        (
154            "session_reset",
155            countdown::format(snap.session.resets_at, input.now),
156        ),
157        ("session_elapsed", session.elapsed_pct.to_string()),
158        ("session_bar", session_bar.clone()),
159        ("weekly_pct", snap.weekly.utilization_pct.to_string()),
160        (
161            "weekly_reset",
162            countdown::format(snap.weekly.resets_at, input.now),
163        ),
164        ("weekly_elapsed", weekly.elapsed_pct.to_string()),
165        ("weekly_bar", weekly_bar.clone()),
166        (
167            "sonnet_pct",
168            sonnet_window
169                .map(|w| w.utilization_pct.to_string())
170                .unwrap_or_else(|| "0".into()),
171        ),
172        (
173            "sonnet_reset",
174            sonnet_window
175                .map(|w| countdown::format(w.resets_at, input.now))
176                .unwrap_or_else(|| "—".into()),
177        ),
178        (
179            "sonnet_elapsed",
180            sonnet
181                .as_ref()
182                .map(|s| s.elapsed_pct.to_string())
183                .unwrap_or_else(|| "0".into()),
184        ),
185        ("sonnet_bar", sonnet_bar.clone()),
186        (
187            "extra_spent",
188            snap.extra
189                .map(|e| e.spent.fmt_dollars())
190                .unwrap_or_default(),
191        ),
192        (
193            "extra_limit",
194            snap.extra
195                .map(|e| e.limit.fmt_dollars())
196                .unwrap_or_default(),
197        ),
198        (
199            "extra_pct",
200            snap.extra
201                .map(|e| e.percent().to_string())
202                .unwrap_or_else(|| "0".into()),
203        ),
204        ("extra_bar", extra_bar),
205    ]);
206
207    insert_pace(&mut v, "session", &session, input.format_pace_color, theme);
208    insert_pace(&mut v, "weekly", &weekly, input.format_pace_color, theme);
209    if let Some(sp) = sonnet.as_ref() {
210        insert_pace(&mut v, "sonnet", sp, input.format_pace_color, theme);
211    } else {
212        // Empty placeholders so `{sonnet_pace}` etc. don't render the literal
213        // brace text when sonnet is absent.
214        insert_pace(
215            &mut v,
216            "sonnet",
217            &pacing::Pacing::neutral(),
218            input.format_pace_color,
219            theme,
220        );
221    }
222    v
223}
224
225fn insert_pace(
226    map: &mut HashMap<&'static str, String>,
227    prefix: &'static str,
228    p: &pacing::Pacing,
229    pace_color: bool,
230    theme: &Theme,
231) {
232    let pace_glyph = p.ratio_pace.glyph();
233    let indicator_glyph = p.point_pace.glyph();
234    let delta = p.delta.to_string();
235    let abs_delta = p.delta.unsigned_abs().to_string();
236    let pct = &p.ratio_label;
237    let pts = &p.point_label;
238
239    let wrap = |s: &str| -> String {
240        if pace_color {
241            let sev = pacing::pace_severity(p.delta);
242            let color = pango::severity_color(sev, theme);
243            color_span(color, s)
244        } else {
245            s.to_string()
246        }
247    };
248
249    let keys: [(&'static str, String); 6] = match prefix {
250        "session" => [
251            ("session_pace", wrap(pace_glyph)),
252            ("session_pace_indicator", wrap(indicator_glyph)),
253            ("session_pace_pct", wrap(pct)),
254            ("session_pace_pts", wrap(pts)),
255            ("session_pace_delta", wrap(&delta)),
256            ("session_pace_abs_delta", wrap(&abs_delta)),
257        ],
258        "weekly" => [
259            ("weekly_pace", wrap(pace_glyph)),
260            ("weekly_pace_indicator", wrap(indicator_glyph)),
261            ("weekly_pace_pct", wrap(pct)),
262            ("weekly_pace_pts", wrap(pts)),
263            ("weekly_pace_delta", wrap(&delta)),
264            ("weekly_pace_abs_delta", wrap(&abs_delta)),
265        ],
266        "sonnet" => [
267            ("sonnet_pace", wrap(pace_glyph)),
268            ("sonnet_pace_indicator", wrap(indicator_glyph)),
269            ("sonnet_pace_pct", wrap(pct)),
270            ("sonnet_pace_pts", wrap(pts)),
271            ("sonnet_pace_delta", wrap(&delta)),
272            ("sonnet_pace_abs_delta", wrap(&abs_delta)),
273        ],
274        _ => return,
275    };
276    for (k, v) in keys {
277        map.insert(k, v);
278    }
279}
280
281/// The bordered Pango tooltip (claudebar:707-860).
282fn render_default_tooltip(input: &RenderInput) -> String {
283    let snap = &input.outcome.snapshot;
284    let theme = input.theme;
285    let blue = &theme.blue;
286    let dim = &theme.dim;
287    let fg = &theme.fg;
288
289    let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
290    let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
291
292    let session_pacing = pacing::calc(
293        snap.session.utilization_pct,
294        snap.session.resets_at,
295        input.now,
296        snap.session.window_duration,
297        input.pace_tolerance,
298    );
299    let weekly_pacing = pacing::calc(
300        snap.weekly.utilization_pct,
301        snap.weekly.resets_at,
302        input.now,
303        snap.weekly.window_duration,
304        input.pace_tolerance,
305    );
306
307    let session_bar = if input.tooltip_pace_pts {
308        pango::progress_bar(
309            snap.session.utilization_pct,
310            session_color,
311            theme,
312            Some(session_pacing.elapsed_pct),
313        )
314    } else {
315        pango::progress_bar(snap.session.utilization_pct, session_color, theme, None)
316    };
317    let weekly_bar = if input.tooltip_pace_pts {
318        pango::progress_bar(
319            snap.weekly.utilization_pct,
320            weekly_color,
321            theme,
322            Some(weekly_pacing.elapsed_pct),
323        )
324    } else {
325        pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None)
326    };
327
328    let session_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &session_pacing);
329    let weekly_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &weekly_pacing);
330
331    let mut lines: Vec<Line> = Vec::new();
332    let _ = pango::severity_color; // silence unused-import warning if any
333    lines.push(Line::Center(format!(
334        "<span font_weight='bold' foreground='{blue}'>Claude {plan}</span>",
335        plan = escape(&snap.plan)
336    )));
337    lines.push(Line::Sep);
338    lines.push(Line::Body("".into()));
339
340    lines.push(Line::Body(format!(
341        " <span foreground='{fg}'>  󰔟  Session</span>"
342    )));
343    lines.push(Line::Body(format!(
344        "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
345        bar = session_bar,
346        color = session_color,
347        pct = snap.session.utilization_pct,
348        glyph = session_pace_glyph
349    )));
350    lines.push(Line::Body(format!(
351        " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
352        cd = escape(&countdown::format(snap.session.resets_at, input.now))
353    )));
354    lines.push(Line::Body("".into()));
355
356    lines.push(Line::Body(format!(
357        " <span foreground='{fg}'>  󰃰  Weekly</span>"
358    )));
359    lines.push(Line::Body(format!(
360        "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
361        bar = weekly_bar,
362        color = weekly_color,
363        pct = snap.weekly.utilization_pct,
364        glyph = weekly_pace_glyph
365    )));
366    lines.push(Line::Body(format!(
367        " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
368        cd = escape(&countdown::format(snap.weekly.resets_at, input.now))
369    )));
370
371    if let Some(sw) = snap.sonnet.as_ref() {
372        let sonnet_color = pango::severity_color(severity_for(sw.utilization_pct), theme);
373        let sonnet_pacing = pacing::calc(
374            sw.utilization_pct,
375            sw.resets_at,
376            input.now,
377            sw.window_duration,
378            input.pace_tolerance,
379        );
380        let sonnet_bar = if input.tooltip_pace_pts {
381            pango::progress_bar(
382                sw.utilization_pct,
383                sonnet_color,
384                theme,
385                Some(sonnet_pacing.elapsed_pct),
386            )
387        } else {
388            pango::progress_bar(sw.utilization_pct, sonnet_color, theme, None)
389        };
390        lines.push(Line::Body("".into()));
391        lines.push(Line::Body(format!(
392            " <span foreground='{fg}'>  󱤔  Sonnet only</span>"
393        )));
394        lines.push(Line::Body(format!(
395            "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}%</span>",
396            bar = sonnet_bar,
397            color = sonnet_color,
398            pct = sw.utilization_pct
399        )));
400        lines.push(Line::Body(format!(
401            " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
402            cd = escape(&countdown::format(sw.resets_at, input.now))
403        )));
404    }
405
406    if let Some(extra) = snap.extra {
407        let extra_color = pango::severity_color(severity_for(extra.percent()), theme);
408        let extra_bar = pango::progress_bar(extra.percent(), extra_color, theme, None);
409        lines.push(Line::Body("".into()));
410        lines.push(Line::Sep);
411        lines.push(Line::Body(format!(
412            " <span foreground='{fg}'>  󰄑  Extra usage</span>"
413        )));
414        lines.push(Line::Body(format!(
415            "   {bar}  <span font_weight='bold' foreground='{color}'>{spent}</span>",
416            bar = extra_bar,
417            color = extra_color,
418            spent = escape(&extra.spent.fmt_dollars())
419        )));
420        lines.push(Line::Body(format!(
421            " <span foreground='{dim}'>  󰀓  Limit: {lim}</span>",
422            lim = escape(&extra.limit.fmt_dollars())
423        )));
424    }
425
426    if let Some((code, msg)) = input.outcome.last_error.as_ref() {
427        if *code != 0 {
428            let (icon, color) = if *code >= 500 {
429                ("󰅚", theme.red.as_str())
430            } else {
431                ("󰀪", theme.orange.as_str())
432            };
433            lines.push(Line::Body("".into()));
434            lines.push(Line::Sep);
435            lines.push(Line::Body(format!(
436                " <span foreground='{color}'>  {icon}  HTTP {code}</span>"
437            )));
438            for wrapped in wrap_words(&escape(msg), 35) {
439                lines.push(Line::Body(format!(
440                    "     <span foreground='{dim}'>{wrapped}</span>"
441                )));
442            }
443        }
444    }
445
446    let updated = updated_at_hm(input.now, input.outcome.cache_age);
447    lines.push(Line::Body("".into()));
448    lines.push(Line::Sep);
449    lines.push(Line::Body(format!(
450        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
451    )));
452
453    tooltip::render_bordered(&lines, theme)
454}
455
456fn pick_pace_glyph(point_mode: bool, p: &pacing::Pacing) -> &'static str {
457    if point_mode {
458        p.point_pace.glyph()
459    } else {
460        p.ratio_pace.glyph()
461    }
462}
463
464/// Greedy word-wrap to a target column. Used for the API-error message
465/// in the tooltip (claudebar:779-790).
466fn wrap_words(s: &str, width: usize) -> Vec<String> {
467    let mut out = Vec::new();
468    let mut buf = String::new();
469    for word in s.split_whitespace() {
470        if buf.is_empty() {
471            buf = word.into();
472        } else if buf.len() + 1 + word.len() <= width {
473            buf.push(' ');
474            buf.push_str(word);
475        } else {
476            out.push(std::mem::take(&mut buf));
477            buf = word.into();
478        }
479    }
480    if !buf.is_empty() {
481        out.push(buf);
482    }
483    out
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::anthropic::fetch::FetchOutcome;
490    use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, UsageWindow};
491    use chrono::TimeZone;
492
493    fn now() -> DateTime<Utc> {
494        Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
495    }
496
497    fn sample_outcome() -> FetchOutcome {
498        let session = UsageWindow {
499            utilization_pct: 62,
500            resets_at: Some(now() + chrono::Duration::minutes(90)),
501            window_duration: chrono::Duration::hours(5),
502        };
503        let weekly = UsageWindow {
504            utilization_pct: 27,
505            resets_at: Some(now() + chrono::Duration::days(4) + chrono::Duration::hours(1)),
506            window_duration: chrono::Duration::days(7),
507        };
508        let sonnet = UsageWindow {
509            utilization_pct: 4,
510            resets_at: Some(now() + chrono::Duration::hours(2) + chrono::Duration::minutes(24)),
511            window_duration: chrono::Duration::days(7),
512        };
513        let snap = AnthropicSnapshot {
514            plan: "Max 5x".into(),
515            session,
516            weekly,
517            sonnet: Some(sonnet),
518            extra: Some(ExtraUsage {
519                limit: Cents(5000),
520                spent: Cents(250),
521            }),
522        };
523        FetchOutcome {
524            snapshot: snap,
525            stale: false,
526            last_error: None,
527            cache_age: Some(std::time::Duration::from_secs(30)),
528        }
529    }
530
531    fn input<'a>(outcome: &'a FetchOutcome, theme: &'a Theme) -> RenderInput<'a> {
532        RenderInput {
533            outcome,
534            theme,
535            format: DEFAULT_FORMAT,
536            tooltip_format: None,
537            icon: None,
538            pace_tolerance: 5,
539            format_pace_color: false,
540            tooltip_pace_pts: false,
541            now: now(),
542        }
543    }
544
545    #[test]
546    fn default_format_renders_pct_and_reset() {
547        let oc = sample_outcome();
548        let theme = Theme::default();
549        let out = render_anthropic(&input(&oc, &theme));
550        // Bar text wraps in a span; content should include "62%" and the
551        // session countdown "1h 30m".
552        assert!(out.text.contains("62%"));
553        assert!(out.text.contains("1h 30m"));
554        assert_eq!(out.class, Class::Mid); // session=62 → mid
555    }
556
557    #[test]
558    fn stale_appends_pause_indicator() {
559        let mut oc = sample_outcome();
560        oc.stale = true;
561        let theme = Theme::default();
562        let out = render_anthropic(&input(&oc, &theme));
563        assert!(out.text.contains("⏸"));
564    }
565
566    #[test]
567    fn icon_prepends() {
568        let oc = sample_outcome();
569        let theme = Theme::default();
570        let mut inp = input(&oc, &theme);
571        inp.icon = Some("󰚩");
572        let out = render_anthropic(&inp);
573        assert!(out.text.contains("󰚩 "));
574    }
575
576    #[test]
577    fn custom_tooltip_format_uses_placeholders() {
578        let oc = sample_outcome();
579        let theme = Theme::default();
580        let mut inp = input(&oc, &theme);
581        inp.tooltip_format = Some("S:{session_pct} W:{weekly_pct}");
582        let out = render_anthropic(&inp);
583        assert_eq!(out.tooltip, "S:62 W:27");
584    }
585
586    #[test]
587    fn default_tooltip_contains_all_sections() {
588        let oc = sample_outcome();
589        let theme = Theme::default();
590        let out = render_anthropic(&input(&oc, &theme));
591        assert!(out.tooltip.contains("Claude Max 5x"));
592        assert!(out.tooltip.contains("Session"));
593        assert!(out.tooltip.contains("Weekly"));
594        assert!(out.tooltip.contains("Sonnet only"));
595        assert!(out.tooltip.contains("Extra usage"));
596        assert!(out.tooltip.contains("Updated"));
597        assert!(out.tooltip.contains("62%"));
598        assert!(out.tooltip.contains("27%"));
599        assert!(out.tooltip.contains("$2.50"));
600        assert!(out.tooltip.contains("$50.00"));
601    }
602
603    #[test]
604    fn tooltip_omits_sonnet_and_extra_when_absent() {
605        let mut oc = sample_outcome();
606        oc.snapshot.sonnet = None;
607        oc.snapshot.extra = None;
608        let theme = Theme::default();
609        let out = render_anthropic(&input(&oc, &theme));
610        assert!(!out.tooltip.contains("Sonnet only"));
611        assert!(!out.tooltip.contains("Extra usage"));
612        // Still contains the basics.
613        assert!(out.tooltip.contains("Session"));
614        assert!(out.tooltip.contains("Weekly"));
615    }
616
617    #[test]
618    fn tooltip_includes_http_error_when_last_error_present() {
619        let mut oc = sample_outcome();
620        oc.last_error = Some((429, "rate limited".into()));
621        let theme = Theme::default();
622        let out = render_anthropic(&input(&oc, &theme));
623        assert!(out.tooltip.contains("HTTP 429"));
624        assert!(out.tooltip.contains("rate limited"));
625    }
626
627    #[test]
628    fn tooltip_omits_http_zero() {
629        // claudebar treats code 0 (no HTTP response) as "don't render"
630        // because it would be misleading.
631        let mut oc = sample_outcome();
632        oc.last_error = Some((0, "n/a".into()));
633        let theme = Theme::default();
634        let out = render_anthropic(&input(&oc, &theme));
635        assert!(!out.tooltip.contains("HTTP 0"));
636    }
637
638    #[test]
639    fn worst_window_promotes_class_to_critical() {
640        let mut oc = sample_outcome();
641        oc.snapshot.weekly.utilization_pct = 95;
642        let theme = Theme::default();
643        let out = render_anthropic(&input(&oc, &theme));
644        assert_eq!(out.class, Class::Critical);
645    }
646
647    #[test]
648    fn pace_color_mode_uses_neutral_wrapper() {
649        let oc = sample_outcome();
650        let theme = Theme::default();
651        let mut inp = input(&oc, &theme);
652        inp.format = "{session_pct}% {session_pace}";
653        inp.format_pace_color = true;
654        let out = render_anthropic(&inp);
655        // Wrapper color should be the foreground (neutral), not severity.
656        assert!(out.text.contains(&theme.fg));
657    }
658
659    #[test]
660    fn wrap_words_breaks_on_width_boundary() {
661        let lines = wrap_words("aaa bbb ccc ddd eee fff", 8);
662        // "aaa bbb" (7) fits; "ccc ddd" (7) fits next; "eee fff" (7) next.
663        assert_eq!(lines, vec!["aaa bbb", "ccc ddd", "eee fff"]);
664    }
665}