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