Skip to main content

ai_usagebar/tray/
strip.rs

1//! Menu-bar strip: starred metrics, OpenUsage bar geometry, and a template
2//! RGBA glyph. Pure JSON in, pixels out — no AppKit, no HWND.
3
4use std::collections::BTreeMap;
5
6use serde_json::Value;
7
8/// At most two starred metrics per provider, matching OpenUsage.
9pub const MAX_STARS_PER_PROVIDER: usize = 2;
10/// Compact Bars glyph shows at most four bounded metrics.
11pub const MAX_BARS: usize = 4;
12/// Logical size of the Bars glyph, matching NSStatusItem's 18 pt slot.
13pub const BARS_POINT_SIDE: u32 = 18;
14/// Pixel size of the Bars glyph at 2× (18 pt). Used by hosts that can only
15/// ship one raster; macOS paints 1×/2×/3× from [`BARS_POINT_SIDE`].
16pub const BARS_PIXEL_SIDE: u32 = BARS_POINT_SIDE * 2;
17/// Backing scales packed into the macOS template image so a 1× external
18/// and a 2×/3× Retina each get a native raster instead of a downsample.
19#[cfg_attr(not(test), allow(dead_code))]
20pub const BARS_SCALES: [u32; 3] = [1, 2, 3];
21
22/// How the status item renders starred metrics.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum StripStyle {
25    Text,
26    Bars,
27}
28
29impl StripStyle {
30    pub fn parse(name: &str) -> Self {
31        if name.eq_ignore_ascii_case("text") {
32            Self::Text
33        } else {
34            Self::Bars
35        }
36    }
37
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Self::Text => "text",
41            Self::Bars => "bars",
42        }
43    }
44}
45
46/// Stars the popover persists: provider id → metric keys, in star order.
47pub type Stars = BTreeMap<String, Vec<String>>;
48
49/// One resolved starred metric, ready to draw.
50#[derive(Debug, Clone, PartialEq)]
51pub struct StripMetric {
52    pub provider_id: String,
53    pub provider_name: String,
54    pub key: String,
55    pub label: String,
56    pub value: String,
57    /// 0..=1 fill for bounded metrics (used fraction).
58    pub fraction: f64,
59    pub bounded: bool,
60    /// The remaining share as text (`82%`) for a percentage row, the way the
61    /// popover's Left reading shows it; `None` for a value headline or a row
62    /// with no percent, which read the same either way.
63    pub left_value: Option<String>,
64    /// Under a group heading (SuperGrok's product slices, the Claude entry's
65    /// CLI sessions): a breakdown, not a quota window of its own.
66    pub grouped: bool,
67}
68
69/// One provider's visible metrics: entry id, display name, and metrics.
70pub type StripGroup = (String, String, Vec<StripMetric>);
71
72/// Resolved strip contents. `groups` drives Text; `bars` drives Bars.
73#[derive(Debug, Clone, PartialEq)]
74pub struct StripContent {
75    pub groups: Vec<StripGroup>,
76    pub bars: Vec<StripMetric>,
77}
78
79impl StripContent {
80    pub fn is_empty(&self) -> bool {
81        self.groups.is_empty()
82    }
83
84    /// One-line Text fallback when we cannot paint stacked type (Windows
85    /// NotifyIcon, or a host that only has `set_title`).
86    pub fn title_line(&self) -> String {
87        let mut parts = Vec::new();
88        for (_, name, metrics) in &self.groups {
89            let values: Vec<&str> = metrics.iter().map(|m| m.value.as_str()).collect();
90            if values.is_empty() {
91                continue;
92            }
93            parts.push(format!("{name} {}", values.join(" ")));
94        }
95        parts.join("   ")
96    }
97}
98
99/// Fill geometry for one bar of the compact glyph. A 1:1 port of OpenUsage
100/// `MenuBarBarGeometry`.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct BarFill {
103    pub fill_w: f64,
104    pub remainder_w: f64,
105    pub divider_x: Option<f64>,
106}
107
108/// Quantize near-full (0.7–1.0) bars by remainder in 15% steps, so a
109/// nearly-full bar still leaves a visible tail instead of reading as 100%.
110pub fn visual_fraction(fraction: f64) -> f64 {
111    if !fraction.is_finite() {
112        return 0.0;
113    }
114    let clamped = fraction.clamp(0.0, 1.0);
115    if clamped > 0.7 && clamped < 1.0 {
116        let remainder = 1.0 - clamped;
117        let quantized = ((remainder / 0.15).ceil() * 0.15).min(1.0);
118        (1.0 - quantized).max(0.0)
119    } else {
120        clamped
121    }
122}
123
124pub fn bar_fill(track_w: f64, fraction: f64) -> BarFill {
125    if !fraction.is_finite() || fraction <= 0.0 {
126        return BarFill {
127            fill_w: 0.0,
128            remainder_w: 0.0,
129            divider_x: None,
130        };
131    }
132    let visual = visual_fraction(fraction);
133    if visual >= 1.0 {
134        return BarFill {
135            fill_w: track_w,
136            remainder_w: 0.0,
137            divider_x: None,
138        };
139    }
140    let min_visible = (track_w * 0.2).round().max(4.0);
141    let max_fill_w = (track_w - min_visible).max(1.0);
142    let fill_w = (track_w * visual).round().clamp(1.0, max_fill_w);
143    let true_remainder = track_w - fill_w;
144    let remainder_w = true_remainder.max(min_visible).min(track_w - 1.0);
145    BarFill {
146        fill_w,
147        remainder_w,
148        divider_x: Some(track_w - remainder_w),
149    }
150}
151
152/// Parse `{style, stars, order}` from the popover's `strip` IPC.
153/// Icon style is locked to Bars; `style` is ignored if present.
154pub fn parse_strip_ipc(value: &Value) -> (StripStyle, Stars, Vec<String>) {
155    let style = StripStyle::Bars;
156    let mut stars = Stars::new();
157    if let Some(map) = value.get("stars").and_then(Value::as_object) {
158        for (id, keys) in map {
159            let id = id.trim();
160            if id.is_empty() {
161                continue;
162            }
163            let mut list = Vec::new();
164            if let Some(arr) = keys.as_array() {
165                for key in arr {
166                    let Some(key) = key.as_str() else { continue };
167                    let key = key.trim();
168                    if key.is_empty() || list.iter().any(|k| k == key) {
169                        continue;
170                    }
171                    list.push(key.to_string());
172                    if list.len() == MAX_STARS_PER_PROVIDER {
173                        break;
174                    }
175                }
176            }
177            if !list.is_empty() {
178                stars.insert(id.to_string(), list);
179            }
180        }
181    }
182    let mut order = Vec::new();
183    if let Some(arr) = value.get("order").and_then(Value::as_array) {
184        for id in arr {
185            let Some(id) = id.as_str() else { continue };
186            let id = id.trim();
187            if id.is_empty() || order.iter().any(|existing| existing == id) {
188                continue;
189            }
190            order.push(id.to_string());
191        }
192    }
193    (style, stars, order)
194}
195
196/// Resolve starred metrics from a host payload. `order` is the popover's
197/// visible card order; empty means payload order. Missing stars fall back to
198/// the first two bounded metrics of each ready entry so the glyph has
199/// something to draw before the WebView sends its layout.
200pub fn content_from_payload(payload: &Value, stars: &Stars, order: &[String]) -> StripContent {
201    let Some(entries) = payload.get("entries").and_then(Value::as_array) else {
202        return StripContent {
203            groups: Vec::new(),
204            bars: Vec::new(),
205        };
206    };
207    let mut by_id = std::collections::HashMap::new();
208    let mut payload_ids = Vec::new();
209    for entry in entries {
210        let Some(id) = entry.get("id").and_then(Value::as_str) else {
211            continue;
212        };
213        payload_ids.push(id.to_string());
214        by_id.insert(id.to_string(), entry);
215    }
216    let mut walk = Vec::new();
217    let mut seen = std::collections::HashSet::new();
218    for id in order {
219        if by_id.contains_key(id) && seen.insert(id.clone()) {
220            walk.push(id.clone());
221        }
222    }
223    for id in payload_ids {
224        if seen.insert(id.clone()) {
225            walk.push(id);
226        }
227    }
228    let groups: Vec<StripGroup> = walk
229        .into_iter()
230        .filter_map(|id| {
231            let entry = by_id.get(&id)?;
232            let starred =
233                (!stars.is_empty()).then(|| stars.get(&id).map(Vec::as_slice).unwrap_or_default());
234            entry_group(&id, entry, starred)
235        })
236        .collect();
237    let bars: Vec<StripMetric> = groups
238        .iter()
239        .flat_map(|(_, _, metrics)| metrics.iter().cloned())
240        .filter(|m| m.bounded)
241        .take(MAX_BARS)
242        .collect();
243    StripContent { groups, bars }
244}
245
246/// Every quota window of one payload entry that has a value, stars aside,
247/// the set the Quattro bar's `selectMetric` picks from: grouped rows only
248/// when the entry has nothing else. The name look draws its chip from this,
249/// so it agrees with the popover tab that selects it, whichever window is
250/// the busiest (Z.AI's third, monthly MCP window counts like its 5h and
251/// weekly ones). Only the macOS menu bar draws the name look, so other hosts
252/// compile it unused.
253#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
254pub fn quota_group(payload: &Value, id: &str) -> Option<StripGroup> {
255    let entry = payload
256        .get("entries")?
257        .as_array()?
258        .iter()
259        .find(|entry| entry.get("id").and_then(Value::as_str) == Some(id))?;
260    if entry.get("status").and_then(Value::as_str) == Some("error") {
261        return None;
262    }
263    let name = entry_name(entry, id);
264    let shown: Vec<StripMetric> = metrics_for_entry(entry, id, &name)
265        .into_iter()
266        .filter(|m| !m.value.trim().is_empty())
267        .collect();
268    let windows: Vec<StripMetric> = shown.iter().filter(|m| !m.grouped).cloned().collect();
269    let metrics = if windows.is_empty() { shown } else { windows };
270    (!metrics.is_empty()).then(|| (id.to_string(), name, metrics))
271}
272
273fn entry_name(entry: &Value, id: &str) -> String {
274    entry
275        .get("display_name")
276        .or_else(|| entry.get("name"))
277        .and_then(Value::as_str)
278        .unwrap_or(id)
279        .to_string()
280}
281
282/// Resolve one entry's visible metrics. `starred` is the provider's star
283/// keys, or `None` before any star exists, which picks the first bounded
284/// metrics. `None` for an errored entry or one with nothing to show.
285fn entry_group(id: &str, entry: &Value, starred: Option<&[String]>) -> Option<StripGroup> {
286    if entry.get("status").and_then(Value::as_str) == Some("error") {
287        return None;
288    }
289    let name = entry_name(entry, id);
290    let metrics = metrics_for_entry(entry, id, &name);
291    let wanted: Vec<String> = match starred {
292        Some(keys) => keys.to_vec(),
293        None => metrics
294            .iter()
295            .filter(|m| m.bounded)
296            .take(MAX_STARS_PER_PROVIDER)
297            .map(|m| m.key.clone())
298            .collect(),
299    };
300    let picked: Vec<StripMetric> = wanted
301        .iter()
302        .filter_map(|key| {
303            metrics
304                .iter()
305                .find(|m| &m.key == key && !m.value.trim().is_empty())
306                .cloned()
307        })
308        .collect();
309    (!picked.is_empty()).then(|| (id.to_string(), name, picked))
310}
311
312fn metrics_for_entry(entry: &Value, id: &str, name: &str) -> Vec<StripMetric> {
313    let Some(sections) = entry.get("sections").and_then(Value::as_array) else {
314        return Vec::new();
315    };
316    let mut group = String::new();
317    let mut rows = Vec::new();
318    let mut seen: BTreeMap<String, u32> = BTreeMap::new();
319    for section in sections {
320        let kind = section.get("type").and_then(Value::as_str).unwrap_or("");
321        if kind == "text" {
322            let label = section.get("label").and_then(Value::as_str).unwrap_or("");
323            let value = section.get("value").and_then(Value::as_str).unwrap_or("");
324            if !label.is_empty() && value.is_empty() {
325                group = label.to_string();
326            }
327            continue;
328        }
329        if kind != "metric" {
330            continue;
331        }
332        let raw_label = section.get("label").and_then(Value::as_str).unwrap_or("");
333        // A metric can name its own group in the report (SuperGrok's product
334        // slices, the Claude entry's CLI-session rows); that field wins over
335        // the positional heading in effect, mirroring the popover's
336        // `projectCards` so both derive the same starred-metric key.
337        let field_group = section.get("group").and_then(Value::as_str).unwrap_or("");
338        let effective_group: &str = if field_group.is_empty() {
339            group.as_str()
340        } else {
341            field_group
342        };
343        let label = metric_label(id, raw_label);
344        let label = if effective_group.is_empty() {
345            label
346        } else {
347            format!("{label} ({effective_group})")
348        };
349        let percent = section.get("percent").and_then(Value::as_f64);
350        let value = section
351            .get("value")
352            .and_then(Value::as_str)
353            .filter(|value| !value.trim().is_empty())
354            .map(str::to_string)
355            .or_else(|| percent.map(|percent| format!("{}%", percent.round() as i64)))
356            .unwrap_or_default();
357        let left_value = percent
358            .filter(|_| section.get("headline").and_then(Value::as_str) != Some("value"))
359            .map(|percent| format!("{}%", (100.0 - percent).max(0.0).round() as i64));
360        let mut key = metric_key(id, raw_label, effective_group);
361        let count = seen.entry(key.clone()).or_insert(0);
362        *count += 1;
363        if *count > 1 {
364            key = format!("{key} #{count}");
365        }
366        rows.push(StripMetric {
367            provider_id: id.to_string(),
368            provider_name: name.to_string(),
369            key,
370            label,
371            value,
372            fraction: (percent.unwrap_or(0.0) / 100.0).clamp(0.0, 1.0),
373            bounded: true,
374            left_value,
375            grouped: !effective_group.is_empty(),
376        });
377    }
378    rows
379}
380
381/// The key a starred metric is stored under, as the popover derives it
382/// (`metricRowKey` in windows/popover/src/model.js). A mismatch drops a starred
383/// bar without a trace, so both sides test against
384/// tests/fixtures/strip_metric_keys.json.
385fn metric_key(entry_id: &str, raw_label: &str, group: &str) -> String {
386    let label = metric_label(entry_id, raw_label);
387    if group.is_empty() {
388        format!("metric:{label}")
389    } else {
390        format!("metric:{label} ({group})")
391    }
392}
393
394/// SuperGrok's overall meter arrives as "<Window> usage" (older builds: "<Window>
395/// Build credits"); the card title already names the product, so the key keeps
396/// only the window. Mirrors the popover's `metricLabel`.
397fn metric_label(entry_id: &str, label: &str) -> String {
398    let slug = entry_id
399        .split('@')
400        .next()
401        .unwrap_or(entry_id)
402        .to_ascii_lowercase();
403    if slug != "supergrok" {
404        return label.to_string();
405    }
406    let label = strip_trailing_word(label, "build credits");
407    strip_trailing_word(&label, "usage")
408}
409
410/// `label` without a trailing `word` (ASCII case-insensitive) and the
411/// whitespace before it; the JS `/\s+word$/i`. Needs at least one space, so a
412/// label that merely ends in the letters is kept.
413fn strip_trailing_word(label: &str, word: &str) -> String {
414    if label.len() > word.len() && label.to_ascii_lowercase().ends_with(word) {
415        let head = &label[..label.len() - word.len()];
416        let trimmed = head.trim_end();
417        if trimmed.len() < head.len() {
418            return trimmed.to_string();
419        }
420    }
421    label.to_string()
422}
423
424/// Layout of the compact Bars glyph in the given square, OpenUsage `MenuBarBars`.
425#[derive(Debug, Clone, Copy)]
426pub struct BarsLayout {
427    pub n: usize,
428    pub track_x: f64,
429    pub track_w: f64,
430    pub track_h: f64,
431    pub rx: f64,
432    pub y_offset: f64,
433    pub gap: f64,
434}
435
436pub fn bars_layout(count: usize, side: f64) -> BarsLayout {
437    let n = count.clamp(1, MAX_BARS);
438    let pad = (side * 0.08).round().max(1.0);
439    let gap = (side * 0.03).round().max(1.0);
440    let track_x = pad;
441    let track_w = side - 2.0 * pad;
442    let layout_n = n.max(2) as f64;
443    let track_h = ((side - 2.0 * pad - (layout_n - 1.0) * gap) / layout_n)
444        .floor()
445        .max(1.0);
446    let rx = (track_h / 3.0).floor().max(1.0);
447    let total_h = n as f64 * track_h + (n as f64 - 1.0) * gap;
448    let y_offset = pad + ((side - 2.0 * pad - total_h) / 2.0).floor();
449    BarsLayout {
450        n,
451        track_x,
452        track_w,
453        track_h,
454        rx,
455        y_offset,
456        gap,
457    }
458}
459
460/// Template RGBA (black ink, alpha as coverage) for the compact Bars glyph.
461/// Empty fractions yield a fully transparent square.
462pub fn bars_rgba(fractions: &[f64], side: u32) -> Vec<u8> {
463    let side = side.max(8);
464    let mut buf = vec![0u8; side as usize * side as usize * 4];
465    if fractions.is_empty() {
466        return buf;
467    }
468    let size = f64::from(side);
469    let layout = bars_layout(fractions.len(), size);
470    let n = layout.n;
471
472    for (i, fraction) in fractions.iter().copied().take(n).enumerate() {
473        let y = layout.y_offset + i as f64 * (layout.track_h + layout.gap) + 1.0;
474        stamp_round_rect(
475            &mut buf,
476            side,
477            RoundBar {
478                x: layout.track_x,
479                y,
480                w: layout.track_w,
481                h: layout.track_h,
482                r_left: layout.rx,
483                r_right: layout.rx,
484            },
485            41, // 0.16 * 255
486        );
487        let fill = bar_fill(layout.track_w, fraction);
488        if fill.fill_w > 0.0 {
489            let trailing = if fill.fill_w >= layout.track_w {
490                layout.rx
491            } else {
492                (layout.rx * 0.35).floor().max(0.0)
493            };
494            stamp_round_rect(
495                &mut buf,
496                side,
497                RoundBar {
498                    x: layout.track_x,
499                    y,
500                    w: fill.fill_w,
501                    h: layout.track_h,
502                    r_left: layout.rx,
503                    r_right: trailing,
504                },
505                255,
506            );
507        }
508        if fill.fill_w > 0.0
509            && fill.remainder_w > 0.0
510            && let Some(divider_x) = fill.divider_x
511        {
512            stamp_round_rect(
513                &mut buf,
514                side,
515                RoundBar {
516                    x: layout.track_x + divider_x,
517                    y,
518                    w: fill.remainder_w,
519                    h: layout.track_h,
520                    r_left: (layout.rx * 0.2).floor().max(0.0),
521                    r_right: layout.rx,
522                },
523                61, // 0.24 * 255
524            );
525        }
526    }
527    buf
528}
529
530struct RoundBar {
531    x: f64,
532    y: f64,
533    w: f64,
534    h: f64,
535    r_left: f64,
536    r_right: f64,
537}
538
539fn stamp_round_rect(buf: &mut [u8], side: u32, bar: RoundBar, alpha: u8) {
540    if bar.w <= 0.0 || bar.h <= 0.0 {
541        return;
542    }
543    let side_i = side as i32;
544    let min_x = bar.x.floor().max(0.0) as i32;
545    let min_y = bar.y.floor().max(0.0) as i32;
546    let max_x = (bar.x + bar.w).ceil().min(f64::from(side)) as i32;
547    let max_y = (bar.y + bar.h).ceil().min(f64::from(side)) as i32;
548    let target = f64::from(alpha);
549    for py in min_y..max_y {
550        for px in min_x..max_x {
551            if px < 0 || py < 0 || px >= side_i || py >= side_i {
552                continue;
553            }
554            let cover = sample_round_rect(f64::from(px) + 0.5, f64::from(py) + 0.5, &bar);
555            if cover <= 0.0 {
556                continue;
557            }
558            let idx = ((py as u32 * side + px as u32) * 4) as usize;
559            let stamped = (target * cover).round().clamp(0.0, 255.0) as u8;
560            if stamped > buf[idx + 3] {
561                buf[idx] = 0;
562                buf[idx + 1] = 0;
563                buf[idx + 2] = 0;
564                buf[idx + 3] = stamped;
565            }
566        }
567    }
568}
569
570fn sample_round_rect(px: f64, py: f64, bar: &RoundBar) -> f64 {
571    (0.5 - sd_round_box(px, py, bar)).clamp(0.0, 1.0)
572}
573
574fn sd_round_box(px: f64, py: f64, bar: &RoundBar) -> f64 {
575    let r = if px < bar.x + bar.w * 0.5 {
576        bar.r_left
577    } else {
578        bar.r_right
579    };
580    let r = r.max(0.0).min(bar.h * 0.5).min(bar.w * 0.5);
581    let cx = bar.x + bar.w * 0.5;
582    let cy = bar.y + bar.h * 0.5;
583    let dx = (px - cx).abs() - (bar.w * 0.5 - r);
584    let dy = (py - cy).abs() - (bar.h * 0.5 - r);
585    let outside = (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt();
586    outside + dx.min(0.0).max(dy.min(0.0)) - r
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use serde_json::json;
593
594    #[test]
595    fn zero_or_negative_fraction_draws_nothing() {
596        assert_eq!(bar_fill(100.0, 0.0).fill_w, 0.0);
597        assert_eq!(bar_fill(100.0, -0.5).fill_w, 0.0);
598    }
599
600    #[test]
601    fn full_fraction_fills_track_with_no_remainder() {
602        let fill = bar_fill(100.0, 1.0);
603        assert_eq!(fill.fill_w, 100.0);
604        assert_eq!(fill.remainder_w, 0.0);
605        assert_eq!(fill.divider_x, None);
606    }
607
608    #[test]
609    fn near_full_keeps_a_visible_tail() {
610        let fill = bar_fill(100.0, 0.97);
611        assert!(fill.fill_w < 100.0);
612        assert!(fill.remainder_w >= 20.0);
613        assert_eq!(fill.divider_x, Some(fill.fill_w));
614    }
615
616    #[test]
617    fn visual_fraction_quantizes_near_full_in_fifteen_percent_steps() {
618        assert!((visual_fraction(0.97) - 0.85).abs() < 0.0001);
619        assert_eq!(visual_fraction(1.0), 1.0);
620        assert_eq!(visual_fraction(0.0), 0.0);
621        assert!((visual_fraction(0.5) - 0.5).abs() < 0.0001);
622    }
623
624    fn sample_payload() -> Value {
625        json!({
626            "entries": [
627                {
628                    "id": "anthropic",
629                    "display_name": "Claude",
630                    "status": "ready",
631                    "sections": [
632                        {"type": "metric", "label": "Weekly", "percent": 19, "value": "19%"},
633                        {"type": "metric", "label": "Session", "percent": 41, "value": "41%"}
634                    ]
635                },
636                {
637                    "id": "openai",
638                    "display_name": "Codex",
639                    "status": "ready",
640                    "sections": [
641                        {"type": "metric", "label": "Codex weekly", "percent": 2, "value": "2%"}
642                    ]
643                },
644                {
645                    "id": "cursor",
646                    "display_name": "Cursor",
647                    "status": "error",
648                    "sections": [
649                        {"type": "metric", "label": "Cursor Models", "percent": 90, "value": "90%"}
650                    ]
651                }
652            ]
653        })
654    }
655
656    /// The popover stores stars under these keys; a label rule changed on one
657    /// side only must fail here, not drop a starred bar from the menu bar.
658    #[test]
659    fn metric_keys_match_the_popover_fixture() {
660        let fixture: Value =
661            serde_json::from_str(include_str!("../../tests/fixtures/strip_metric_keys.json"))
662                .expect("fixture is JSON");
663        let cases = fixture["cases"].as_array().expect("cases");
664        assert!(!cases.is_empty());
665        for case in cases {
666            let id = case["id"].as_str().unwrap();
667            let label = case["label"].as_str().unwrap();
668            let group = case["group"].as_str().unwrap_or("");
669            assert_eq!(
670                metric_key(id, label, group),
671                case["key"].as_str().unwrap(),
672                "{case}"
673            );
674        }
675    }
676
677    /// SuperGrok's meter was renamed "Weekly usage" while the host kept
678    /// stripping only "Build credits": a star saved as `metric:Weekly` stopped
679    /// painting its bar.
680    #[test]
681    fn a_starred_supergrok_meter_is_painted() {
682        let payload = serde_json::json!({ "entries": [
683            { "id": "supergrok", "status": "ready", "sections": [
684                { "type": "metric", "label": "Weekly usage", "percent": 3 }
685            ]}
686        ]});
687        let mut stars = Stars::new();
688        stars.insert("supergrok".into(), vec!["metric:Weekly".into()]);
689        let content = content_from_payload(&payload, &stars, &["supergrok".into()]);
690        assert_eq!(content.bars.len(), 1);
691        assert_eq!(content.bars[0].key, "metric:Weekly");
692    }
693
694    #[test]
695    fn empty_stars_fall_back_to_first_two_bounded_metrics() {
696        let content = content_from_payload(&sample_payload(), &Stars::new(), &[]);
697        assert_eq!(content.groups.len(), 2);
698        assert_eq!(content.groups[0].2.len(), 2);
699        assert_eq!(content.groups[0].2[0].key, "metric:Weekly");
700        assert_eq!(content.groups[0].2[1].key, "metric:Session");
701        assert_eq!(content.groups[1].2[0].key, "metric:Codex weekly");
702        assert_eq!(content.bars.len(), 3);
703        assert!(!content.title_line().is_empty());
704    }
705
706    #[test]
707    fn stars_select_named_metrics_and_skip_errors() {
708        let mut stars = Stars::new();
709        stars.insert("anthropic".into(), vec!["metric:Session".into()]);
710        stars.insert("cursor".into(), vec!["metric:Cursor Models".into()]);
711        let content = content_from_payload(&sample_payload(), &stars, &[]);
712        assert_eq!(content.groups.len(), 1);
713        assert_eq!(content.groups[0].2[0].key, "metric:Session");
714        assert_eq!(content.groups[0].2[0].fraction, 0.41);
715        assert_eq!(content.bars.len(), 1);
716    }
717
718    #[test]
719    fn grouped_metric_keys_match_the_popover() {
720        let payload = json!({
721            "entries": [{
722                "id": "antigravity",
723                "display_name": "Antigravity",
724                "status": "ready",
725                "sections": [
726                    {"type": "text", "label": "Session", "value": ""},
727                    {"type": "metric", "label": "Gemini", "percent": 4, "value": "4%"},
728                    {"type": "text", "label": "Weekly", "value": ""},
729                    {"type": "metric", "label": "Gemini", "percent": 11, "value": "11%"}
730                ]
731            }]
732        });
733        let content = content_from_payload(&payload, &Stars::new(), &[]);
734        assert_eq!(content.groups[0].2[0].key, "metric:Gemini (Session)");
735        assert_eq!(content.groups[0].2[1].key, "metric:Gemini (Weekly)");
736    }
737
738    /// A metric that names its own group (#213's report field — SuperGrok's
739    /// slices, #255's Claude CLI sessions) keys and labels exactly as the same
740    /// group arriving positionally would, so a star saved from the popover's
741    /// grouped row still paints the menu-bar bar.
742    #[test]
743    fn metric_level_groups_key_like_positional_ones() {
744        let payload = json!({
745            "entries": [{
746                "id": "anthropic",
747                "display_name": "Claude",
748                "status": "ready",
749                "sections": [
750                    {"type": "metric", "label": "Weekly (7d)", "percent": 32, "value": "32%"},
751                    {"type": "metric", "label": "ship the release", "percent": 91,
752                     "value": "91%", "group": "Sessions"},
753                    {"type": "metric", "label": "Grok Build", "percent": 94,
754                     "value": "94%", "group": "Breakdown"}
755                ]
756            }]
757        });
758        let mut stars = Stars::new();
759        stars.insert(
760            "anthropic".into(),
761            vec![
762                "metric:ship the release (Sessions)".into(),
763                "metric:Grok Build (Breakdown)".into(),
764            ],
765        );
766        let content = content_from_payload(&payload, &stars, &[]);
767        let bars = &content.groups[0].2;
768        assert_eq!(bars.len(), 2);
769        assert_eq!(bars[0].key, "metric:ship the release (Sessions)");
770        assert_eq!(bars[0].label, "ship the release (Sessions)");
771        assert_eq!(bars[1].key, "metric:Grok Build (Breakdown)");
772        assert_eq!(bars[1].label, "Grok Build (Breakdown)");
773    }
774
775    /// The Left reading's text matches the popover's `leftPercent`
776    /// (`max(0, 100 - percent)`); a value headline keeps its value.
777    #[test]
778    fn left_value_is_the_remaining_percent() {
779        let payload = json!({"entries":[{"id":"zai", "status":"ready", "sections":[
780            {"type":"metric", "label":"Weekly", "percent":18, "value":"18%"},
781            {"type":"metric", "label":"Over", "percent":130, "value":"130%"},
782            {"type":"metric", "label":"Credits", "percent":40, "value":"$4 of $10", "headline":"value"},
783            {"type":"metric", "label":"Balance", "value":"$40"}]}]});
784        let (_, _, metrics) = quota_group(&payload, "zai").unwrap();
785        let left: Vec<Option<&str>> = metrics.iter().map(|m| m.left_value.as_deref()).collect();
786        assert_eq!(left, vec![Some("82%"), Some("0%"), None, None]);
787    }
788
789    #[test]
790    fn parse_strip_ipc_caps_stars_at_two_per_provider() {
791        let value = json!({
792            "style": "text",
793            "stars": {
794                "anthropic": ["metric:Weekly", "metric:Session", "metric:Extra"],
795                "": ["x"],
796                "openai": ["metric:Codex weekly"]
797            }
798        });
799        let (style, stars, order) = parse_strip_ipc(&value);
800        assert_eq!(style, StripStyle::Bars);
801        assert_eq!(stars["anthropic"].len(), 2);
802        assert_eq!(stars["openai"], vec!["metric:Codex weekly".to_string()]);
803        assert!(!stars.contains_key(""));
804        assert!(order.is_empty());
805    }
806
807    #[test]
808    fn strip_follows_popover_card_order() {
809        let mut stars = Stars::new();
810        stars.insert("anthropic".into(), vec!["metric:Weekly".into()]);
811        stars.insert("openai".into(), vec!["metric:Codex weekly".into()]);
812        let content = content_from_payload(
813            &sample_payload(),
814            &stars,
815            &["openai".into(), "anthropic".into()],
816        );
817        assert_eq!(content.groups[0].0, "openai");
818        assert_eq!(content.groups[1].0, "anthropic");
819        assert_eq!(content.bars[0].provider_id, "openai");
820        assert_eq!(content.bars[1].provider_id, "anthropic");
821    }
822
823    #[test]
824    fn bars_rgba_is_square_black_and_anti_aliased() {
825        let bytes = bars_rgba(&[0.4, 0.97], BARS_PIXEL_SIDE);
826        assert_eq!(
827            bytes.len(),
828            (BARS_PIXEL_SIDE * BARS_PIXEL_SIDE * 4) as usize
829        );
830        assert_eq!(bytes[3], 0, "top-left stays transparent");
831        let last = bytes.len() - 1;
832        assert_eq!(bytes[last], 0, "bottom-right stays transparent");
833        let mut inked = 0usize;
834        let mut soft = 0usize;
835        for pixel in bytes.as_chunks::<4>().0 {
836            if pixel[3] > 0 {
837                inked += 1;
838                assert_eq!(&pixel[..3], &[0, 0, 0]);
839            }
840            if pixel[3] > 0 && pixel[3] < 255 {
841                soft += 1;
842            }
843        }
844        assert!(inked > 40, "glyph too sparse: {inked}");
845        assert!(soft > 0, "expected anti-aliased edges");
846    }
847
848    #[test]
849    fn empty_fractions_are_fully_transparent() {
850        let bytes = bars_rgba(&[], BARS_PIXEL_SIDE);
851        assert!(bytes.as_chunks::<4>().0.iter().all(|p| p[3] == 0));
852    }
853
854    #[test]
855    fn bars_rgba_at_each_status_item_scale() {
856        for scale in BARS_SCALES {
857            let side = BARS_POINT_SIDE * scale;
858            let bytes = bars_rgba(&[0.4, 0.97], side);
859            assert_eq!(bytes.len(), (side * side * 4) as usize, "scale {scale}");
860            assert!(
861                bytes.as_chunks::<4>().0.iter().any(|p| p[3] > 0),
862                "scale {scale} produced an empty glyph"
863            );
864        }
865    }
866}