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