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