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