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