1use std::collections::HashMap;
8
9use chrono::{DateTime, Utc};
10
11use crate::anthropic::fetch::FetchOutcome;
12use crate::countdown;
13use crate::format::{placeholders, substitute, updated_at_hm};
14use crate::pacing;
15use crate::pango::{self, color_span, escape, severity_for};
16use crate::theme::Theme;
17use crate::tooltip::{self, Line};
18use crate::usage::anthropic_severity;
19use crate::waybar::{Class, WaybarOutput};
20
21pub const DEFAULT_FORMAT: &str = "{session_pct}% · {session_reset}";
23
24pub 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
38pub 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 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
58fn render_bar_text(input: &RenderInput, class: Class) -> String {
61 let values = build_placeholders(input);
62 let mut text = substitute(input.format, &values);
63
64 if input.outcome.stale {
66 text.push_str(" ⏸");
67 }
68
69 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
92fn build_placeholders(input: &RenderInput) -> HashMap<&'static str, String> {
98 let snap = &input.outcome.snapshot;
99 let theme = input.theme;
100
101 let session = pacing::calc(
102 snap.session.utilization_pct,
103 snap.session.resets_at,
104 input.now,
105 snap.session.window_duration,
106 input.pace_tolerance,
107 );
108 let weekly = pacing::calc(
109 snap.weekly.utilization_pct,
110 snap.weekly.resets_at,
111 input.now,
112 snap.weekly.window_duration,
113 input.pace_tolerance,
114 );
115 let sonnet_window = snap.sonnet.as_ref();
116 let sonnet = sonnet_window.map(|w| {
117 pacing::calc(
118 w.utilization_pct,
119 w.resets_at,
120 input.now,
121 w.window_duration,
122 input.pace_tolerance,
123 )
124 });
125
126 let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
127 let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
128 let sonnet_color =
129 sonnet_window.map(|w| pango::severity_color(severity_for(w.utilization_pct), theme));
130 let extra_color = snap
131 .extra
132 .as_ref()
133 .map(|e| pango::severity_color(severity_for(e.percent()), theme));
134
135 let session_bar = pango::progress_bar(snap.session.utilization_pct, session_color, theme, None);
136 let weekly_bar = pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None);
137 let sonnet_bar = if let (Some(w), Some(c)) = (sonnet_window, sonnet_color) {
138 pango::progress_bar(w.utilization_pct, c, theme, None)
139 } else {
140 String::new()
141 };
142 let extra_bar = if let (Some(e), Some(c)) = (snap.extra.as_ref(), extra_color) {
143 pango::progress_bar(e.percent(), c, theme, None)
144 } else {
145 String::new()
146 };
147
148 let mut v = placeholders(vec![
149 ("icon", "".to_string()),
150 ("vendor_short", "cld".to_string()),
151 ("plan", snap.plan.clone()),
152 ("session_pct", snap.session.utilization_pct.to_string()),
153 (
154 "session_reset",
155 countdown::format(snap.session.resets_at, input.now),
156 ),
157 ("session_elapsed", session.elapsed_pct.to_string()),
158 ("session_bar", session_bar.clone()),
159 ("weekly_pct", snap.weekly.utilization_pct.to_string()),
160 (
161 "weekly_reset",
162 countdown::format(snap.weekly.resets_at, input.now),
163 ),
164 ("weekly_elapsed", weekly.elapsed_pct.to_string()),
165 ("weekly_bar", weekly_bar.clone()),
166 (
167 "sonnet_pct",
168 sonnet_window
169 .map(|w| w.utilization_pct.to_string())
170 .unwrap_or_else(|| "0".into()),
171 ),
172 (
173 "sonnet_reset",
174 sonnet_window
175 .map(|w| countdown::format(w.resets_at, input.now))
176 .unwrap_or_else(|| "—".into()),
177 ),
178 (
179 "sonnet_elapsed",
180 sonnet
181 .as_ref()
182 .map(|s| s.elapsed_pct.to_string())
183 .unwrap_or_else(|| "0".into()),
184 ),
185 ("sonnet_bar", sonnet_bar.clone()),
186 (
187 "extra_spent",
188 snap.extra
189 .map(|e| e.spent.fmt_dollars())
190 .unwrap_or_default(),
191 ),
192 (
193 "extra_limit",
194 snap.extra
195 .map(|e| e.limit.fmt_dollars())
196 .unwrap_or_default(),
197 ),
198 (
199 "extra_pct",
200 snap.extra
201 .map(|e| e.percent().to_string())
202 .unwrap_or_else(|| "0".into()),
203 ),
204 ("extra_bar", extra_bar),
205 ]);
206
207 insert_pace(&mut v, "session", &session, input.format_pace_color, theme);
208 insert_pace(&mut v, "weekly", &weekly, input.format_pace_color, theme);
209 if let Some(sp) = sonnet.as_ref() {
210 insert_pace(&mut v, "sonnet", sp, input.format_pace_color, theme);
211 } else {
212 insert_pace(
215 &mut v,
216 "sonnet",
217 &pacing::Pacing::neutral(),
218 input.format_pace_color,
219 theme,
220 );
221 }
222 v
223}
224
225fn insert_pace(
226 map: &mut HashMap<&'static str, String>,
227 prefix: &'static str,
228 p: &pacing::Pacing,
229 pace_color: bool,
230 theme: &Theme,
231) {
232 let pace_glyph = p.ratio_pace.glyph();
233 let indicator_glyph = p.point_pace.glyph();
234 let delta = p.delta.to_string();
235 let abs_delta = p.delta.unsigned_abs().to_string();
236 let pct = &p.ratio_label;
237 let pts = &p.point_label;
238
239 let wrap = |s: &str| -> String {
240 if pace_color {
241 let sev = pacing::pace_severity(p.delta);
242 let color = pango::severity_color(sev, theme);
243 color_span(color, s)
244 } else {
245 s.to_string()
246 }
247 };
248
249 let keys: [(&'static str, String); 6] = match prefix {
250 "session" => [
251 ("session_pace", wrap(pace_glyph)),
252 ("session_pace_indicator", wrap(indicator_glyph)),
253 ("session_pace_pct", wrap(pct)),
254 ("session_pace_pts", wrap(pts)),
255 ("session_pace_delta", wrap(&delta)),
256 ("session_pace_abs_delta", wrap(&abs_delta)),
257 ],
258 "weekly" => [
259 ("weekly_pace", wrap(pace_glyph)),
260 ("weekly_pace_indicator", wrap(indicator_glyph)),
261 ("weekly_pace_pct", wrap(pct)),
262 ("weekly_pace_pts", wrap(pts)),
263 ("weekly_pace_delta", wrap(&delta)),
264 ("weekly_pace_abs_delta", wrap(&abs_delta)),
265 ],
266 "sonnet" => [
267 ("sonnet_pace", wrap(pace_glyph)),
268 ("sonnet_pace_indicator", wrap(indicator_glyph)),
269 ("sonnet_pace_pct", wrap(pct)),
270 ("sonnet_pace_pts", wrap(pts)),
271 ("sonnet_pace_delta", wrap(&delta)),
272 ("sonnet_pace_abs_delta", wrap(&abs_delta)),
273 ],
274 _ => return,
275 };
276 for (k, v) in keys {
277 map.insert(k, v);
278 }
279}
280
281fn render_default_tooltip(input: &RenderInput) -> String {
283 let snap = &input.outcome.snapshot;
284 let theme = input.theme;
285 let blue = &theme.blue;
286 let dim = &theme.dim;
287 let fg = &theme.fg;
288
289 let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
290 let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
291
292 let session_pacing = pacing::calc(
293 snap.session.utilization_pct,
294 snap.session.resets_at,
295 input.now,
296 snap.session.window_duration,
297 input.pace_tolerance,
298 );
299 let weekly_pacing = pacing::calc(
300 snap.weekly.utilization_pct,
301 snap.weekly.resets_at,
302 input.now,
303 snap.weekly.window_duration,
304 input.pace_tolerance,
305 );
306
307 let session_bar = if input.tooltip_pace_pts {
308 pango::progress_bar(
309 snap.session.utilization_pct,
310 session_color,
311 theme,
312 Some(session_pacing.elapsed_pct),
313 )
314 } else {
315 pango::progress_bar(snap.session.utilization_pct, session_color, theme, None)
316 };
317 let weekly_bar = if input.tooltip_pace_pts {
318 pango::progress_bar(
319 snap.weekly.utilization_pct,
320 weekly_color,
321 theme,
322 Some(weekly_pacing.elapsed_pct),
323 )
324 } else {
325 pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None)
326 };
327
328 let session_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &session_pacing);
329 let weekly_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &weekly_pacing);
330
331 let mut lines: Vec<Line> = Vec::new();
332 let _ = pango::severity_color; lines.push(Line::Center(format!(
334 "<span font_weight='bold' foreground='{blue}'>Claude {plan}</span>",
335 plan = escape(&snap.plan)
336 )));
337 lines.push(Line::Sep);
338 lines.push(Line::Body("".into()));
339
340 lines.push(Line::Body(format!(
341 " <span foreground='{fg}'> Session</span>"
342 )));
343 lines.push(Line::Body(format!(
344 " {bar} <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
345 bar = session_bar,
346 color = session_color,
347 pct = snap.session.utilization_pct,
348 glyph = session_pace_glyph
349 )));
350 lines.push(Line::Body(format!(
351 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
352 cd = escape(&countdown::format(snap.session.resets_at, input.now))
353 )));
354 lines.push(Line::Body("".into()));
355
356 lines.push(Line::Body(format!(
357 " <span foreground='{fg}'> Weekly</span>"
358 )));
359 lines.push(Line::Body(format!(
360 " {bar} <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
361 bar = weekly_bar,
362 color = weekly_color,
363 pct = snap.weekly.utilization_pct,
364 glyph = weekly_pace_glyph
365 )));
366 lines.push(Line::Body(format!(
367 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
368 cd = escape(&countdown::format(snap.weekly.resets_at, input.now))
369 )));
370
371 if let Some(sw) = snap.sonnet.as_ref() {
372 let sonnet_color = pango::severity_color(severity_for(sw.utilization_pct), theme);
373 let sonnet_pacing = pacing::calc(
374 sw.utilization_pct,
375 sw.resets_at,
376 input.now,
377 sw.window_duration,
378 input.pace_tolerance,
379 );
380 let sonnet_bar = if input.tooltip_pace_pts {
381 pango::progress_bar(
382 sw.utilization_pct,
383 sonnet_color,
384 theme,
385 Some(sonnet_pacing.elapsed_pct),
386 )
387 } else {
388 pango::progress_bar(sw.utilization_pct, sonnet_color, theme, None)
389 };
390 lines.push(Line::Body("".into()));
391 lines.push(Line::Body(format!(
392 " <span foreground='{fg}'> Sonnet only</span>"
393 )));
394 lines.push(Line::Body(format!(
395 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>",
396 bar = sonnet_bar,
397 color = sonnet_color,
398 pct = sw.utilization_pct
399 )));
400 lines.push(Line::Body(format!(
401 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
402 cd = escape(&countdown::format(sw.resets_at, input.now))
403 )));
404 }
405
406 if let Some(extra) = snap.extra {
407 let extra_color = pango::severity_color(severity_for(extra.percent()), theme);
408 let extra_bar = pango::progress_bar(extra.percent(), extra_color, theme, None);
409 lines.push(Line::Body("".into()));
410 lines.push(Line::Sep);
411 lines.push(Line::Body(format!(
412 " <span foreground='{fg}'> Extra usage</span>"
413 )));
414 lines.push(Line::Body(format!(
415 " {bar} <span font_weight='bold' foreground='{color}'>{spent}</span>",
416 bar = extra_bar,
417 color = extra_color,
418 spent = escape(&extra.spent.fmt_dollars())
419 )));
420 lines.push(Line::Body(format!(
421 " <span foreground='{dim}'> Limit: {lim}</span>",
422 lim = escape(&extra.limit.fmt_dollars())
423 )));
424 }
425
426 if let Some((code, msg)) = input.outcome.last_error.as_ref() {
427 if *code != 0 {
428 let (icon, color) = if *code >= 500 {
429 ("", theme.red.as_str())
430 } else {
431 ("", theme.orange.as_str())
432 };
433 lines.push(Line::Body("".into()));
434 lines.push(Line::Sep);
435 lines.push(Line::Body(format!(
436 " <span foreground='{color}'> {icon} HTTP {code}</span>"
437 )));
438 for wrapped in wrap_words(&escape(msg), 35) {
439 lines.push(Line::Body(format!(
440 " <span foreground='{dim}'>{wrapped}</span>"
441 )));
442 }
443 }
444 }
445
446 let updated = updated_at_hm(input.now, input.outcome.cache_age);
447 lines.push(Line::Body("".into()));
448 lines.push(Line::Sep);
449 lines.push(Line::Body(format!(
450 " <span foreground='{dim}'> Updated {updated}</span>"
451 )));
452
453 tooltip::render_bordered(&lines, theme)
454}
455
456fn pick_pace_glyph(point_mode: bool, p: &pacing::Pacing) -> &'static str {
457 if point_mode {
458 p.point_pace.glyph()
459 } else {
460 p.ratio_pace.glyph()
461 }
462}
463
464fn wrap_words(s: &str, width: usize) -> Vec<String> {
467 let mut out = Vec::new();
468 let mut buf = String::new();
469 for word in s.split_whitespace() {
470 if buf.is_empty() {
471 buf = word.into();
472 } else if buf.len() + 1 + word.len() <= width {
473 buf.push(' ');
474 buf.push_str(word);
475 } else {
476 out.push(std::mem::take(&mut buf));
477 buf = word.into();
478 }
479 }
480 if !buf.is_empty() {
481 out.push(buf);
482 }
483 out
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::anthropic::fetch::FetchOutcome;
490 use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, UsageWindow};
491 use chrono::TimeZone;
492
493 fn now() -> DateTime<Utc> {
494 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
495 }
496
497 fn sample_outcome() -> FetchOutcome {
498 let session = UsageWindow {
499 utilization_pct: 62,
500 resets_at: Some(now() + chrono::Duration::minutes(90)),
501 window_duration: chrono::Duration::hours(5),
502 };
503 let weekly = UsageWindow {
504 utilization_pct: 27,
505 resets_at: Some(now() + chrono::Duration::days(4) + chrono::Duration::hours(1)),
506 window_duration: chrono::Duration::days(7),
507 };
508 let sonnet = UsageWindow {
509 utilization_pct: 4,
510 resets_at: Some(now() + chrono::Duration::hours(2) + chrono::Duration::minutes(24)),
511 window_duration: chrono::Duration::days(7),
512 };
513 let snap = AnthropicSnapshot {
514 plan: "Max 5x".into(),
515 session,
516 weekly,
517 sonnet: Some(sonnet),
518 extra: Some(ExtraUsage {
519 limit: Cents(5000),
520 spent: Cents(250),
521 }),
522 };
523 FetchOutcome {
524 snapshot: snap,
525 stale: false,
526 last_error: None,
527 cache_age: Some(std::time::Duration::from_secs(30)),
528 }
529 }
530
531 fn input<'a>(outcome: &'a FetchOutcome, theme: &'a Theme) -> RenderInput<'a> {
532 RenderInput {
533 outcome,
534 theme,
535 format: DEFAULT_FORMAT,
536 tooltip_format: None,
537 icon: None,
538 pace_tolerance: 5,
539 format_pace_color: false,
540 tooltip_pace_pts: false,
541 now: now(),
542 }
543 }
544
545 #[test]
546 fn default_format_renders_pct_and_reset() {
547 let oc = sample_outcome();
548 let theme = Theme::default();
549 let out = render_anthropic(&input(&oc, &theme));
550 assert!(out.text.contains("62%"));
553 assert!(out.text.contains("1h 30m"));
554 assert_eq!(out.class, Class::Mid); }
556
557 #[test]
558 fn stale_appends_pause_indicator() {
559 let mut oc = sample_outcome();
560 oc.stale = true;
561 let theme = Theme::default();
562 let out = render_anthropic(&input(&oc, &theme));
563 assert!(out.text.contains("⏸"));
564 }
565
566 #[test]
567 fn icon_prepends() {
568 let oc = sample_outcome();
569 let theme = Theme::default();
570 let mut inp = input(&oc, &theme);
571 inp.icon = Some("");
572 let out = render_anthropic(&inp);
573 assert!(out.text.contains(" "));
574 }
575
576 #[test]
577 fn custom_tooltip_format_uses_placeholders() {
578 let oc = sample_outcome();
579 let theme = Theme::default();
580 let mut inp = input(&oc, &theme);
581 inp.tooltip_format = Some("S:{session_pct} W:{weekly_pct}");
582 let out = render_anthropic(&inp);
583 assert_eq!(out.tooltip, "S:62 W:27");
584 }
585
586 #[test]
587 fn default_tooltip_contains_all_sections() {
588 let oc = sample_outcome();
589 let theme = Theme::default();
590 let out = render_anthropic(&input(&oc, &theme));
591 assert!(out.tooltip.contains("Claude Max 5x"));
592 assert!(out.tooltip.contains("Session"));
593 assert!(out.tooltip.contains("Weekly"));
594 assert!(out.tooltip.contains("Sonnet only"));
595 assert!(out.tooltip.contains("Extra usage"));
596 assert!(out.tooltip.contains("Updated"));
597 assert!(out.tooltip.contains("62%"));
598 assert!(out.tooltip.contains("27%"));
599 assert!(out.tooltip.contains("$2.50"));
600 assert!(out.tooltip.contains("$50.00"));
601 }
602
603 #[test]
604 fn tooltip_omits_sonnet_and_extra_when_absent() {
605 let mut oc = sample_outcome();
606 oc.snapshot.sonnet = None;
607 oc.snapshot.extra = None;
608 let theme = Theme::default();
609 let out = render_anthropic(&input(&oc, &theme));
610 assert!(!out.tooltip.contains("Sonnet only"));
611 assert!(!out.tooltip.contains("Extra usage"));
612 assert!(out.tooltip.contains("Session"));
614 assert!(out.tooltip.contains("Weekly"));
615 }
616
617 #[test]
618 fn tooltip_includes_http_error_when_last_error_present() {
619 let mut oc = sample_outcome();
620 oc.last_error = Some((429, "rate limited".into()));
621 let theme = Theme::default();
622 let out = render_anthropic(&input(&oc, &theme));
623 assert!(out.tooltip.contains("HTTP 429"));
624 assert!(out.tooltip.contains("rate limited"));
625 }
626
627 #[test]
628 fn tooltip_omits_http_zero() {
629 let mut oc = sample_outcome();
632 oc.last_error = Some((0, "n/a".into()));
633 let theme = Theme::default();
634 let out = render_anthropic(&input(&oc, &theme));
635 assert!(!out.tooltip.contains("HTTP 0"));
636 }
637
638 #[test]
639 fn worst_window_promotes_class_to_critical() {
640 let mut oc = sample_outcome();
641 oc.snapshot.weekly.utilization_pct = 95;
642 let theme = Theme::default();
643 let out = render_anthropic(&input(&oc, &theme));
644 assert_eq!(out.class, Class::Critical);
645 }
646
647 #[test]
648 fn pace_color_mode_uses_neutral_wrapper() {
649 let oc = sample_outcome();
650 let theme = Theme::default();
651 let mut inp = input(&oc, &theme);
652 inp.format = "{session_pct}% {session_pace}";
653 inp.format_pace_color = true;
654 let out = render_anthropic(&inp);
655 assert!(out.text.contains(&theme.fg));
657 }
658
659 #[test]
660 fn wrap_words_breaks_on_width_boundary() {
661 let lines = wrap_words("aaa bbb ccc ddd eee fff", 8);
662 assert_eq!(lines, vec!["aaa bbb", "ccc ddd", "eee fff"]);
664 }
665}