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