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::{ExtraUsage, 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    // Primary model-scoped weekly window (the common case is exactly one, e.g.
149    // "Fable"). The tooltip renders every entry of `snap.scoped`, but the
150    // desktop surfaces (macOS menu bar, GNOME, Windows tray) redraw from
151    // `--format` and have a single per-model row — so expose the first scoped
152    // window through flat `{scoped_*}` placeholders they can read. Empty /
153    // neutral when the account has no scoped window.
154    let scoped0 = snap.scoped.first();
155    let scoped0_pacing = scoped0.map(|s| {
156        pacing::calc(
157            s.window.utilization_pct,
158            s.window.resets_at,
159            input.now,
160            s.window.window_duration,
161            input.pace_tolerance,
162        )
163    });
164    let scoped0_bar = if let Some(s) = scoped0 {
165        let c = pango::severity_color(severity_for(s.window.utilization_pct), theme);
166        pango::progress_bar(s.window.utilization_pct, c, theme, None)
167    } else {
168        String::new()
169    };
170
171    let mut v = placeholders(vec![
172        ("icon", "󰚩".to_string()),
173        ("vendor_short", "cld".to_string()),
174        ("plan", snap.plan.clone()),
175        ("session_pct", snap.session.utilization_pct.to_string()),
176        (
177            "session_reset",
178            countdown::format(snap.session.resets_at, input.now),
179        ),
180        ("session_elapsed", session.elapsed_pct.to_string()),
181        ("session_bar", session_bar.clone()),
182        ("weekly_pct", snap.weekly.utilization_pct.to_string()),
183        (
184            "weekly_reset",
185            countdown::format(snap.weekly.resets_at, input.now),
186        ),
187        ("weekly_elapsed", weekly.elapsed_pct.to_string()),
188        ("weekly_bar", weekly_bar.clone()),
189        (
190            "sonnet_pct",
191            sonnet_window
192                .map(|w| w.utilization_pct.to_string())
193                .unwrap_or_else(|| "0".into()),
194        ),
195        (
196            "sonnet_reset",
197            sonnet_window
198                .map(|w| countdown::format(w.resets_at, input.now))
199                .unwrap_or_else(|| "—".into()),
200        ),
201        (
202            "sonnet_elapsed",
203            sonnet
204                .as_ref()
205                .map(|s| s.elapsed_pct.to_string())
206                .unwrap_or_else(|| "0".into()),
207        ),
208        ("sonnet_bar", sonnet_bar.clone()),
209        // Model-scoped weekly window (first entry of `snap.scoped`, e.g. Fable).
210        (
211            "scoped_model",
212            scoped0.map(|s| s.label.clone()).unwrap_or_default(),
213        ),
214        (
215            "scoped_pct",
216            scoped0
217                .map(|s| s.window.utilization_pct.to_string())
218                .unwrap_or_else(|| "0".into()),
219        ),
220        (
221            "scoped_reset",
222            scoped0
223                .map(|s| countdown::format(s.window.resets_at, input.now))
224                .unwrap_or_else(|| "—".into()),
225        ),
226        (
227            "scoped_elapsed",
228            scoped0_pacing
229                .as_ref()
230                .map(|p| p.elapsed_pct.to_string())
231                .unwrap_or_else(|| "0".into()),
232        ),
233        ("scoped_bar", scoped0_bar.clone()),
234        (
235            "extra_spent",
236            snap.extra
237                .as_ref()
238                .map(ExtraUsage::fmt_spent)
239                .unwrap_or_default(),
240        ),
241        (
242            "extra_limit",
243            // "—" (not empty) for an uncapped plan: GNOME and the macOS menu
244            // bar hide the whole extra row when this field is empty, which
245            // would hide real spend — the exact symptom of #30.
246            snap.extra
247                .as_ref()
248                .map(|e| e.fmt_limit().unwrap_or_else(|| "—".into()))
249                .unwrap_or_default(),
250        ),
251        (
252            "extra_pct",
253            snap.extra
254                .as_ref()
255                .map(|e| e.percent().to_string())
256                .unwrap_or_else(|| "0".into()),
257        ),
258        ("extra_bar", extra_bar),
259    ]);
260
261    insert_pace(&mut v, "session", &session, input.format_pace_color, theme);
262    insert_pace(&mut v, "weekly", &weekly, input.format_pace_color, theme);
263    if let Some(sp) = sonnet.as_ref() {
264        insert_pace(&mut v, "sonnet", sp, input.format_pace_color, theme);
265    } else {
266        // Empty placeholders so `{sonnet_pace}` etc. don't render the literal
267        // brace text when sonnet is absent.
268        insert_pace(
269            &mut v,
270            "sonnet",
271            &pacing::Pacing::neutral(),
272            input.format_pace_color,
273            theme,
274        );
275    }
276    v
277}
278
279fn insert_pace(
280    map: &mut HashMap<&'static str, String>,
281    prefix: &'static str,
282    p: &pacing::Pacing,
283    pace_color: bool,
284    theme: &Theme,
285) {
286    let pace_glyph = p.ratio_pace.glyph();
287    let indicator_glyph = p.point_pace.glyph();
288    let delta = p.delta.to_string();
289    let abs_delta = p.delta.unsigned_abs().to_string();
290    let pct = &p.ratio_label;
291    let pts = &p.point_label;
292
293    let wrap = |s: &str| -> String {
294        if pace_color {
295            let sev = pacing::pace_severity(p.delta);
296            let color = pango::severity_color(sev, theme);
297            color_span(color, s)
298        } else {
299            s.to_string()
300        }
301    };
302
303    let keys: [(&'static str, String); 6] = match prefix {
304        "session" => [
305            ("session_pace", wrap(pace_glyph)),
306            ("session_pace_indicator", wrap(indicator_glyph)),
307            ("session_pace_pct", wrap(pct)),
308            ("session_pace_pts", wrap(pts)),
309            ("session_pace_delta", wrap(&delta)),
310            ("session_pace_abs_delta", wrap(&abs_delta)),
311        ],
312        "weekly" => [
313            ("weekly_pace", wrap(pace_glyph)),
314            ("weekly_pace_indicator", wrap(indicator_glyph)),
315            ("weekly_pace_pct", wrap(pct)),
316            ("weekly_pace_pts", wrap(pts)),
317            ("weekly_pace_delta", wrap(&delta)),
318            ("weekly_pace_abs_delta", wrap(&abs_delta)),
319        ],
320        "sonnet" => [
321            ("sonnet_pace", wrap(pace_glyph)),
322            ("sonnet_pace_indicator", wrap(indicator_glyph)),
323            ("sonnet_pace_pct", wrap(pct)),
324            ("sonnet_pace_pts", wrap(pts)),
325            ("sonnet_pace_delta", wrap(&delta)),
326            ("sonnet_pace_abs_delta", wrap(&abs_delta)),
327        ],
328        _ => return,
329    };
330    for (k, v) in keys {
331        map.insert(k, v);
332    }
333}
334
335/// The bordered Pango tooltip (claudebar:707-860).
336fn render_default_tooltip(input: &RenderInput) -> String {
337    let snap = &input.outcome.snapshot;
338    let theme = input.theme;
339    let blue = &theme.blue;
340    let dim = &theme.dim;
341    let fg = &theme.fg;
342
343    let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
344    let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
345
346    let session_pacing = pacing::calc(
347        snap.session.utilization_pct,
348        snap.session.resets_at,
349        input.now,
350        snap.session.window_duration,
351        input.pace_tolerance,
352    );
353    let weekly_pacing = pacing::calc(
354        snap.weekly.utilization_pct,
355        snap.weekly.resets_at,
356        input.now,
357        snap.weekly.window_duration,
358        input.pace_tolerance,
359    );
360
361    let session_bar = if input.tooltip_pace_pts {
362        pango::progress_bar(
363            snap.session.utilization_pct,
364            session_color,
365            theme,
366            Some(session_pacing.elapsed_pct),
367        )
368    } else {
369        pango::progress_bar(snap.session.utilization_pct, session_color, theme, None)
370    };
371    let weekly_bar = if input.tooltip_pace_pts {
372        pango::progress_bar(
373            snap.weekly.utilization_pct,
374            weekly_color,
375            theme,
376            Some(weekly_pacing.elapsed_pct),
377        )
378    } else {
379        pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None)
380    };
381
382    let session_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &session_pacing);
383    let weekly_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &weekly_pacing);
384
385    let mut lines: Vec<Line> = Vec::new();
386    let _ = pango::severity_color; // silence unused-import warning if any
387    lines.push(Line::Center(format!(
388        "<span font_weight='bold' foreground='{blue}'>Claude {plan}</span>",
389        plan = escape(&snap.plan)
390    )));
391    lines.push(Line::Sep);
392    lines.push(Line::Body("".into()));
393
394    lines.push(Line::Body(format!(
395        " <span foreground='{fg}'>  󰔟  Session</span>"
396    )));
397    lines.push(Line::Body(format!(
398        "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
399        bar = session_bar,
400        color = session_color,
401        pct = snap.session.utilization_pct,
402        glyph = session_pace_glyph
403    )));
404    lines.push(Line::Body(format!(
405        " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
406        cd = escape(&countdown::format(snap.session.resets_at, input.now))
407    )));
408    lines.push(Line::Body("".into()));
409
410    lines.push(Line::Body(format!(
411        " <span foreground='{fg}'>  󰃰  Weekly</span>"
412    )));
413    lines.push(Line::Body(format!(
414        "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
415        bar = weekly_bar,
416        color = weekly_color,
417        pct = snap.weekly.utilization_pct,
418        glyph = weekly_pace_glyph
419    )));
420    lines.push(Line::Body(format!(
421        " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
422        cd = escape(&countdown::format(snap.weekly.resets_at, input.now))
423    )));
424
425    if let Some(sw) = snap.sonnet.as_ref() {
426        let sonnet_color = pango::severity_color(severity_for(sw.utilization_pct), theme);
427        let sonnet_pacing = pacing::calc(
428            sw.utilization_pct,
429            sw.resets_at,
430            input.now,
431            sw.window_duration,
432            input.pace_tolerance,
433        );
434        let sonnet_bar = if input.tooltip_pace_pts {
435            pango::progress_bar(
436                sw.utilization_pct,
437                sonnet_color,
438                theme,
439                Some(sonnet_pacing.elapsed_pct),
440            )
441        } else {
442            pango::progress_bar(sw.utilization_pct, sonnet_color, theme, None)
443        };
444        lines.push(Line::Body("".into()));
445        lines.push(Line::Body(format!(
446            " <span foreground='{fg}'>  󱤔  Sonnet only</span>"
447        )));
448        lines.push(Line::Body(format!(
449            "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}%</span>",
450            bar = sonnet_bar,
451            color = sonnet_color,
452            pct = sw.utilization_pct
453        )));
454        lines.push(Line::Body(format!(
455            " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
456            cd = escape(&countdown::format(sw.resets_at, input.now))
457        )));
458    }
459
460    for sw in &snap.scoped {
461        let scoped_color = pango::severity_color(severity_for(sw.window.utilization_pct), theme);
462        let scoped_pacing = pacing::calc(
463            sw.window.utilization_pct,
464            sw.window.resets_at,
465            input.now,
466            sw.window.window_duration,
467            input.pace_tolerance,
468        );
469        let scoped_bar = if input.tooltip_pace_pts {
470            pango::progress_bar(
471                sw.window.utilization_pct,
472                scoped_color,
473                theme,
474                Some(scoped_pacing.elapsed_pct),
475            )
476        } else {
477            pango::progress_bar(sw.window.utilization_pct, scoped_color, theme, None)
478        };
479        lines.push(Line::Body("".into()));
480        lines.push(Line::Body(format!(
481            " <span foreground='{fg}'>  󰆧  {label} weekly</span>",
482            label = escape(&sw.label)
483        )));
484        lines.push(Line::Body(format!(
485            "   {bar}  <span font_weight='bold' foreground='{color}'>{pct}%</span>",
486            bar = scoped_bar,
487            color = scoped_color,
488            pct = sw.window.utilization_pct
489        )));
490        lines.push(Line::Body(format!(
491            " <span foreground='{dim}'>  ⏱  Resets in {cd}</span>",
492            cd = escape(&countdown::format(sw.window.resets_at, input.now))
493        )));
494    }
495
496    if let Some(extra) = snap.extra.as_ref() {
497        let extra_color = pango::severity_color(severity_for(extra.percent()), theme);
498        let extra_bar = pango::progress_bar(extra.percent(), extra_color, theme, None);
499        lines.push(Line::Body("".into()));
500        lines.push(Line::Sep);
501        lines.push(Line::Body(format!(
502            " <span foreground='{fg}'>  󰄑  Extra usage</span>"
503        )));
504        lines.push(Line::Body(format!(
505            "   {bar}  <span font_weight='bold' foreground='{color}'>{spent}</span>",
506            bar = extra_bar,
507            color = extra_color,
508            spent = escape(&extra.fmt_spent())
509        )));
510        let lim = match extra.fmt_limit() {
511            Some(l) => l,
512            // No usable `monthly_limit` in the payload (null — observed for
513            // uncapped plans — or absent). "none reported" states exactly
514            // that; inferring a plan tier from it would overclaim, and a
515            // $0.00 ceiling would be invented.
516            None => "none reported".into(),
517        };
518        lines.push(Line::Body(format!(
519            " <span foreground='{dim}'>  󰀓  Limit: {lim}</span>",
520            lim = escape(&lim)
521        )));
522    }
523
524    if let Some((code, msg)) = input.outcome.last_error.as_ref()
525        && *code != 0
526    {
527        let (icon, color) = if *code >= 500 {
528            ("󰅚", theme.red.as_str())
529        } else {
530            ("󰀪", theme.orange.as_str())
531        };
532        lines.push(Line::Body("".into()));
533        lines.push(Line::Sep);
534        lines.push(Line::Body(format!(
535            " <span foreground='{color}'>  {icon}  HTTP {code}</span>"
536        )));
537        for wrapped in wrap_words(&escape(msg), 35) {
538            lines.push(Line::Body(format!(
539                "     <span foreground='{dim}'>{wrapped}</span>"
540            )));
541        }
542    }
543
544    let updated = updated_at_hm(input.now, input.outcome.cache_age);
545    lines.push(Line::Body("".into()));
546    lines.push(Line::Sep);
547    lines.push(Line::Body(format!(
548        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
549    )));
550
551    tooltip::render_bordered(&lines, theme)
552}
553
554fn pick_pace_glyph(point_mode: bool, p: &pacing::Pacing) -> &'static str {
555    if point_mode {
556        p.point_pace.glyph()
557    } else {
558        p.ratio_pace.glyph()
559    }
560}
561
562/// Greedy word-wrap to a target column. Used for the API-error message
563/// in the tooltip (claudebar:779-790).
564fn wrap_words(s: &str, width: usize) -> Vec<String> {
565    let mut out = Vec::new();
566    let mut buf = String::new();
567    for word in s.split_whitespace() {
568        if buf.is_empty() {
569            buf = word.into();
570        } else if buf.len() + 1 + word.len() <= width {
571            buf.push(' ');
572            buf.push_str(word);
573        } else {
574            out.push(std::mem::take(&mut buf));
575            buf = word.into();
576        }
577    }
578    if !buf.is_empty() {
579        out.push(buf);
580    }
581    out
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use crate::anthropic::fetch::FetchOutcome;
588    use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, UsageWindow};
589    use chrono::TimeZone;
590
591    fn now() -> DateTime<Utc> {
592        Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
593    }
594
595    fn sample_outcome() -> FetchOutcome {
596        let session = UsageWindow {
597            utilization_pct: 62,
598            resets_at: Some(now() + chrono::Duration::minutes(90)),
599            window_duration: chrono::Duration::hours(5),
600        };
601        let weekly = UsageWindow {
602            utilization_pct: 27,
603            resets_at: Some(now() + chrono::Duration::days(4) + chrono::Duration::hours(1)),
604            window_duration: chrono::Duration::days(7),
605        };
606        let sonnet = UsageWindow {
607            utilization_pct: 4,
608            resets_at: Some(now() + chrono::Duration::hours(2) + chrono::Duration::minutes(24)),
609            window_duration: chrono::Duration::days(7),
610        };
611        let snap = AnthropicSnapshot {
612            plan: "Max 5x".into(),
613            session,
614            weekly,
615            sonnet: Some(sonnet),
616            scoped: vec![],
617            extra: Some(ExtraUsage {
618                limit: Some(Cents(5000)),
619                spent: Cents(250),
620                currency: None,
621                decimal_places: Some(2),
622            }),
623        };
624        FetchOutcome {
625            snapshot: snap,
626            stale: false,
627            last_error: None,
628            cache_age: Some(std::time::Duration::from_secs(30)),
629        }
630    }
631
632    fn input<'a>(outcome: &'a FetchOutcome, theme: &'a Theme) -> RenderInput<'a> {
633        RenderInput {
634            outcome,
635            theme,
636            format: DEFAULT_FORMAT,
637            tooltip_format: None,
638            icon: None,
639            pace_tolerance: 5,
640            format_pace_color: false,
641            tooltip_pace_pts: false,
642            now: now(),
643        }
644    }
645
646    #[test]
647    fn uncapped_extra_usage_renders_spend_with_dash_limit() {
648        // The #30 shape: real spend, `monthly_limit: null`. The spend must
649        // stay visible and `{extra_limit}` must be "—", NOT empty — GNOME and
650        // the macOS menu bar hide the whole extra row on an empty limit,
651        // which would re-hide the spend the fix is recovering.
652        let mut oc = sample_outcome();
653        if let Some(e) = oc.snapshot.extra.as_mut() {
654            e.limit = None;
655            e.spent = Cents(14157);
656        }
657        let theme = Theme::default();
658        let mut inp = input(&oc, &theme);
659        inp.format = "{extra_spent}|{extra_limit}|{extra_pct}";
660        let out = render_anthropic(&inp);
661        assert!(out.text.contains("$141.57|—|0"), "got: {}", out.text);
662
663        // Default tooltip: spend shown, the missing limit stated as exactly
664        // that, and no fabricated "$0.00" anywhere near the extra block.
665        let inp2 = input(&oc, &theme);
666        let out2 = render_anthropic(&inp2);
667        assert!(out2.tooltip.contains("$141.57"));
668        assert!(out2.tooltip.contains("none reported"));
669        assert!(!out2.tooltip.contains("Limit: $0.00"));
670    }
671
672    #[test]
673    fn extra_usage_placeholders_and_tooltip_use_the_blocks_currency() {
674        // Non-vacuous currency pin: with BRL in the snapshot, formatting
675        // through fmt_dollars again ("$141.57") must fail this test — that is
676        // the wrong-currency claim the wiring exists to prevent.
677        let mut oc = sample_outcome();
678        if let Some(e) = oc.snapshot.extra.as_mut() {
679            e.limit = None;
680            e.spent = Cents(14157);
681            e.currency = Some("BRL".into());
682        }
683        let theme = Theme::default();
684        let mut inp = input(&oc, &theme);
685        inp.format = "{extra_spent}";
686        let out = render_anthropic(&inp);
687        assert!(out.text.contains("R$141.57"), "got: {}", out.text);
688        assert!(!out.text.contains("$141.57|"), "got: {}", out.text);
689
690        let inp2 = input(&oc, &theme);
691        let out2 = render_anthropic(&inp2);
692        assert!(
693            out2.tooltip.contains("R$141.57"),
694            "tooltip must carry the block's currency"
695        );
696    }
697
698    #[test]
699    fn default_format_renders_pct_and_reset() {
700        let oc = sample_outcome();
701        let theme = Theme::default();
702        let out = render_anthropic(&input(&oc, &theme));
703        // Bar text wraps in a span; content should include "62%" and the
704        // session countdown "1h 30m".
705        assert!(out.text.contains("62%"));
706        assert!(out.text.contains("1h 30m"));
707        assert_eq!(out.class, Class::Mid); // session=62 → mid
708    }
709
710    #[test]
711    fn stale_appends_pause_indicator() {
712        let mut oc = sample_outcome();
713        oc.stale = true;
714        let theme = Theme::default();
715        let out = render_anthropic(&input(&oc, &theme));
716        assert!(out.text.contains("⏸"));
717    }
718
719    #[test]
720    fn icon_prepends() {
721        let oc = sample_outcome();
722        let theme = Theme::default();
723        let mut inp = input(&oc, &theme);
724        inp.icon = Some("󰚩");
725        let out = render_anthropic(&inp);
726        assert!(out.text.contains("󰚩 "));
727    }
728
729    #[test]
730    fn custom_tooltip_format_uses_placeholders() {
731        let oc = sample_outcome();
732        let theme = Theme::default();
733        let mut inp = input(&oc, &theme);
734        inp.tooltip_format = Some("S:{session_pct} W:{weekly_pct}");
735        let out = render_anthropic(&inp);
736        assert_eq!(out.tooltip, "S:62 W:27");
737    }
738
739    #[test]
740    fn scoped_placeholders_expose_model_scoped_window() {
741        // The desktop surfaces read these `{scoped_*}` fields to show the
742        // model-scoped weekly bar (e.g. Fable) that only lives in `limits[]`.
743        let mut oc = sample_outcome();
744        oc.snapshot.scoped = vec![crate::usage::ScopedWindow {
745            label: "Fable".into(),
746            window: UsageWindow {
747                utilization_pct: 84,
748                resets_at: Some(now() + chrono::Duration::days(5)),
749                window_duration: chrono::Duration::days(7),
750            },
751        }];
752        let theme = Theme::default();
753        let mut inp = input(&oc, &theme);
754        inp.tooltip_format = Some(
755            "M:{scoped_model} P:{scoped_pct} R:{scoped_reset} E:{scoped_elapsed} B:{scoped_bar}",
756        );
757        let out = render_anthropic(&inp);
758        assert!(out.tooltip.starts_with("M:Fable P:84 R:"));
759        for placeholder in [
760            "{scoped_model}",
761            "{scoped_pct}",
762            "{scoped_reset}",
763            "{scoped_elapsed}",
764            "{scoped_bar}",
765        ] {
766            assert!(!out.tooltip.contains(placeholder));
767        }
768    }
769
770    #[test]
771    fn scoped_placeholders_are_neutral_when_absent() {
772        let oc = sample_outcome(); // scoped: vec![]
773        let theme = Theme::default();
774        let mut inp = input(&oc, &theme);
775        inp.tooltip_format = Some("[{scoped_model}] {scoped_pct} {scoped_reset}");
776        let out = render_anthropic(&inp);
777        assert_eq!(out.tooltip, "[] 0 —");
778    }
779
780    #[test]
781    fn default_tooltip_contains_all_sections() {
782        let oc = sample_outcome();
783        let theme = Theme::default();
784        let out = render_anthropic(&input(&oc, &theme));
785        assert!(out.tooltip.contains("Claude Max 5x"));
786        assert!(out.tooltip.contains("Session"));
787        assert!(out.tooltip.contains("Weekly"));
788        assert!(out.tooltip.contains("Sonnet only"));
789        assert!(out.tooltip.contains("Extra usage"));
790        assert!(out.tooltip.contains("Updated"));
791        assert!(out.tooltip.contains("62%"));
792        assert!(out.tooltip.contains("27%"));
793        assert!(out.tooltip.contains("$2.50"));
794        assert!(out.tooltip.contains("$50.00"));
795    }
796
797    #[test]
798    fn tooltip_omits_sonnet_and_extra_when_absent() {
799        let mut oc = sample_outcome();
800        oc.snapshot.sonnet = None;
801        oc.snapshot.extra = None;
802        let theme = Theme::default();
803        let out = render_anthropic(&input(&oc, &theme));
804        assert!(!out.tooltip.contains("Sonnet only"));
805        assert!(!out.tooltip.contains("Extra usage"));
806        // Still contains the basics.
807        assert!(out.tooltip.contains("Session"));
808        assert!(out.tooltip.contains("Weekly"));
809    }
810
811    #[test]
812    fn tooltip_includes_http_error_when_last_error_present() {
813        let mut oc = sample_outcome();
814        oc.last_error = Some((429, "rate limited".into()));
815        let theme = Theme::default();
816        let out = render_anthropic(&input(&oc, &theme));
817        assert!(out.tooltip.contains("HTTP 429"));
818        assert!(out.tooltip.contains("rate limited"));
819    }
820
821    #[test]
822    fn tooltip_omits_http_zero() {
823        // claudebar treats code 0 (no HTTP response) as "don't render"
824        // because it would be misleading.
825        let mut oc = sample_outcome();
826        oc.last_error = Some((0, "n/a".into()));
827        let theme = Theme::default();
828        let out = render_anthropic(&input(&oc, &theme));
829        assert!(!out.tooltip.contains("HTTP 0"));
830    }
831
832    #[test]
833    fn worst_window_promotes_class_to_critical() {
834        let mut oc = sample_outcome();
835        oc.snapshot.weekly.utilization_pct = 95;
836        let theme = Theme::default();
837        let out = render_anthropic(&input(&oc, &theme));
838        assert_eq!(out.class, Class::Critical);
839    }
840
841    #[test]
842    fn pace_color_mode_uses_neutral_wrapper() {
843        let oc = sample_outcome();
844        let theme = Theme::default();
845        let mut inp = input(&oc, &theme);
846        inp.format = "{session_pct}% {session_pace}";
847        inp.format_pace_color = true;
848        let out = render_anthropic(&inp);
849        // Wrapper color should be the foreground (neutral), not severity.
850        assert!(out.text.contains(&theme.fg));
851    }
852
853    #[test]
854    fn wrap_words_breaks_on_width_boundary() {
855        let lines = wrap_words("aaa bbb ccc ddd eee fff", 8);
856        // "aaa bbb" (7) fits; "ccc ddd" (7) fits next; "eee fff" (7) next.
857        assert_eq!(lines, vec!["aaa bbb", "ccc ddd", "eee fff"]);
858    }
859}