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