1use std::collections::BTreeMap;
5
6use serde_json::Value;
7
8pub const MAX_STARS_PER_PROVIDER: usize = 2;
10pub const MAX_BARS: usize = 4;
12pub const BARS_POINT_SIDE: u32 = 18;
14pub const BARS_PIXEL_SIDE: u32 = BARS_POINT_SIDE * 2;
17#[cfg_attr(not(test), allow(dead_code))]
20pub const BARS_SCALES: [u32; 3] = [1, 2, 3];
21
22#[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
46pub type Stars = BTreeMap<String, Vec<String>>;
48
49#[derive(Debug, Clone, PartialEq)]
51pub struct StripMetric {
52 pub provider_id: String,
53 pub provider_name: String,
54 pub key: String,
55 pub label: String,
56 pub value: String,
57 pub fraction: f64,
59 pub bounded: bool,
60}
61
62#[derive(Debug, Clone, PartialEq)]
64pub struct StripContent {
65 pub groups: Vec<(String, String, Vec<StripMetric>)>,
66 pub bars: Vec<StripMetric>,
67}
68
69impl StripContent {
70 pub fn is_empty(&self) -> bool {
71 self.groups.is_empty()
72 }
73
74 pub fn title_line(&self) -> String {
77 let mut parts = Vec::new();
78 for (_, name, metrics) in &self.groups {
79 let values: Vec<&str> = metrics.iter().map(|m| m.value.as_str()).collect();
80 if values.is_empty() {
81 continue;
82 }
83 parts.push(format!("{name} {}", values.join(" ")));
84 }
85 parts.join(" ")
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct BarFill {
93 pub fill_w: f64,
94 pub remainder_w: f64,
95 pub divider_x: Option<f64>,
96}
97
98pub fn visual_fraction(fraction: f64) -> f64 {
101 if !fraction.is_finite() {
102 return 0.0;
103 }
104 let clamped = fraction.clamp(0.0, 1.0);
105 if clamped > 0.7 && clamped < 1.0 {
106 let remainder = 1.0 - clamped;
107 let quantized = ((remainder / 0.15).ceil() * 0.15).min(1.0);
108 (1.0 - quantized).max(0.0)
109 } else {
110 clamped
111 }
112}
113
114pub fn bar_fill(track_w: f64, fraction: f64) -> BarFill {
115 if !fraction.is_finite() || fraction <= 0.0 {
116 return BarFill {
117 fill_w: 0.0,
118 remainder_w: 0.0,
119 divider_x: None,
120 };
121 }
122 let visual = visual_fraction(fraction);
123 if visual >= 1.0 {
124 return BarFill {
125 fill_w: track_w,
126 remainder_w: 0.0,
127 divider_x: None,
128 };
129 }
130 let min_visible = (track_w * 0.2).round().max(4.0);
131 let max_fill_w = (track_w - min_visible).max(1.0);
132 let fill_w = (track_w * visual).round().clamp(1.0, max_fill_w);
133 let true_remainder = track_w - fill_w;
134 let remainder_w = true_remainder.max(min_visible).min(track_w - 1.0);
135 BarFill {
136 fill_w,
137 remainder_w,
138 divider_x: Some(track_w - remainder_w),
139 }
140}
141
142pub fn parse_strip_ipc(value: &Value) -> (StripStyle, Stars, Vec<String>) {
145 let style = StripStyle::Bars;
146 let mut stars = Stars::new();
147 if let Some(map) = value.get("stars").and_then(Value::as_object) {
148 for (id, keys) in map {
149 let id = id.trim();
150 if id.is_empty() {
151 continue;
152 }
153 let mut list = Vec::new();
154 if let Some(arr) = keys.as_array() {
155 for key in arr {
156 let Some(key) = key.as_str() else { continue };
157 let key = key.trim();
158 if key.is_empty() || list.iter().any(|k| k == key) {
159 continue;
160 }
161 list.push(key.to_string());
162 if list.len() == MAX_STARS_PER_PROVIDER {
163 break;
164 }
165 }
166 }
167 if !list.is_empty() {
168 stars.insert(id.to_string(), list);
169 }
170 }
171 }
172 let mut order = Vec::new();
173 if let Some(arr) = value.get("order").and_then(Value::as_array) {
174 for id in arr {
175 let Some(id) = id.as_str() else { continue };
176 let id = id.trim();
177 if id.is_empty() || order.iter().any(|existing| existing == id) {
178 continue;
179 }
180 order.push(id.to_string());
181 }
182 }
183 (style, stars, order)
184}
185
186pub fn content_from_payload(payload: &Value, stars: &Stars, order: &[String]) -> StripContent {
191 let Some(entries) = payload.get("entries").and_then(Value::as_array) else {
192 return StripContent {
193 groups: Vec::new(),
194 bars: Vec::new(),
195 };
196 };
197 let mut by_id = std::collections::HashMap::new();
198 let mut payload_ids = Vec::new();
199 for entry in entries {
200 let Some(id) = entry.get("id").and_then(Value::as_str) else {
201 continue;
202 };
203 payload_ids.push(id.to_string());
204 by_id.insert(id.to_string(), entry);
205 }
206 let mut walk = Vec::new();
207 let mut seen = std::collections::HashSet::new();
208 for id in order {
209 if by_id.contains_key(id) && seen.insert(id.clone()) {
210 walk.push(id.clone());
211 }
212 }
213 for id in payload_ids {
214 if seen.insert(id.clone()) {
215 walk.push(id);
216 }
217 }
218 let mut groups = Vec::new();
219 for id in walk {
220 let Some(entry) = by_id.get(&id) else {
221 continue;
222 };
223 if entry.get("status").and_then(Value::as_str) == Some("error") {
224 continue;
225 }
226 let name = entry
227 .get("display_name")
228 .or_else(|| entry.get("name"))
229 .and_then(Value::as_str)
230 .unwrap_or(id.as_str())
231 .to_string();
232 let metrics = metrics_for_entry(entry, &id, &name);
233 let wanted = if stars.is_empty() {
234 metrics
235 .iter()
236 .filter(|m| m.bounded)
237 .take(MAX_STARS_PER_PROVIDER)
238 .map(|m| m.key.clone())
239 .collect()
240 } else {
241 stars.get(&id).cloned().unwrap_or_default()
242 };
243 if wanted.is_empty() {
244 continue;
245 }
246 let mut picked = Vec::new();
247 for key in wanted {
248 if let Some(metric) = metrics.iter().find(|m| m.key == key) {
249 picked.push(metric.clone());
250 }
251 }
252 if picked.is_empty() {
253 continue;
254 }
255 groups.push((id, name, picked));
256 }
257 let bars: Vec<StripMetric> = groups
258 .iter()
259 .flat_map(|(_, _, metrics)| metrics.iter().cloned())
260 .filter(|m| m.bounded)
261 .take(MAX_BARS)
262 .collect();
263 StripContent { groups, bars }
264}
265
266fn metrics_for_entry(entry: &Value, id: &str, name: &str) -> Vec<StripMetric> {
267 let Some(sections) = entry.get("sections").and_then(Value::as_array) else {
268 return Vec::new();
269 };
270 let mut group = String::new();
271 let mut rows = Vec::new();
272 let mut seen: BTreeMap<String, u32> = BTreeMap::new();
273 for section in sections {
274 let kind = section.get("type").and_then(Value::as_str).unwrap_or("");
275 if kind == "text" {
276 let label = section.get("label").and_then(Value::as_str).unwrap_or("");
277 let value = section.get("value").and_then(Value::as_str).unwrap_or("");
278 if !label.is_empty() && value.is_empty() {
279 group = label.to_string();
280 }
281 continue;
282 }
283 if kind != "metric" {
284 continue;
285 }
286 let raw_label = section.get("label").and_then(Value::as_str).unwrap_or("");
287 let label = metric_label(id, raw_label);
288 let label = if group.is_empty() {
289 label
290 } else {
291 format!("{label} ({group})")
292 };
293 let percent = section
294 .get("percent")
295 .and_then(Value::as_f64)
296 .unwrap_or(0.0);
297 let value = section
298 .get("value")
299 .and_then(Value::as_str)
300 .map(str::to_string)
301 .unwrap_or_else(|| format!("{}%", percent.round() as i64));
302 let mut key = format!("metric:{label}");
303 let count = seen.entry(key.clone()).or_insert(0);
304 *count += 1;
305 if *count > 1 {
306 key = format!("{key} #{count}");
307 }
308 rows.push(StripMetric {
309 provider_id: id.to_string(),
310 provider_name: name.to_string(),
311 key,
312 label,
313 value,
314 fraction: (percent / 100.0).clamp(0.0, 1.0),
315 bounded: true,
316 });
317 }
318 rows
319}
320
321fn metric_label(entry_id: &str, label: &str) -> String {
322 let slug = entry_id
323 .split('@')
324 .next()
325 .unwrap_or(entry_id)
326 .to_ascii_lowercase();
327 if slug == "supergrok" {
328 let trimmed = regex_strip_build_credits(label);
329 return trimmed;
330 }
331 label.to_string()
332}
333
334fn regex_strip_build_credits(label: &str) -> String {
335 const SUFFIX: &str = " build credits";
336 let lower = label.to_ascii_lowercase();
337 if let Some(idx) = lower.rfind(SUFFIX)
338 && idx + SUFFIX.len() == lower.len()
339 {
340 return label[..idx].to_string();
341 }
342 label.to_string()
343}
344
345#[derive(Debug, Clone, Copy)]
347pub struct BarsLayout {
348 pub n: usize,
349 pub track_x: f64,
350 pub track_w: f64,
351 pub track_h: f64,
352 pub rx: f64,
353 pub y_offset: f64,
354 pub gap: f64,
355}
356
357pub fn bars_layout(count: usize, side: f64) -> BarsLayout {
358 let n = count.clamp(1, MAX_BARS);
359 let pad = (side * 0.08).round().max(1.0);
360 let gap = (side * 0.03).round().max(1.0);
361 let track_x = pad;
362 let track_w = side - 2.0 * pad;
363 let layout_n = n.max(2) as f64;
364 let track_h = ((side - 2.0 * pad - (layout_n - 1.0) * gap) / layout_n)
365 .floor()
366 .max(1.0);
367 let rx = (track_h / 3.0).floor().max(1.0);
368 let total_h = n as f64 * track_h + (n as f64 - 1.0) * gap;
369 let y_offset = pad + ((side - 2.0 * pad - total_h) / 2.0).floor();
370 BarsLayout {
371 n,
372 track_x,
373 track_w,
374 track_h,
375 rx,
376 y_offset,
377 gap,
378 }
379}
380
381pub fn bars_rgba(fractions: &[f64], side: u32) -> Vec<u8> {
384 let side = side.max(8);
385 let mut buf = vec![0u8; side as usize * side as usize * 4];
386 if fractions.is_empty() {
387 return buf;
388 }
389 let size = f64::from(side);
390 let layout = bars_layout(fractions.len(), size);
391 let n = layout.n;
392
393 for (i, fraction) in fractions.iter().copied().take(n).enumerate() {
394 let y = layout.y_offset + i as f64 * (layout.track_h + layout.gap) + 1.0;
395 stamp_round_rect(
396 &mut buf,
397 side,
398 RoundBar {
399 x: layout.track_x,
400 y,
401 w: layout.track_w,
402 h: layout.track_h,
403 r_left: layout.rx,
404 r_right: layout.rx,
405 },
406 41, );
408 let fill = bar_fill(layout.track_w, fraction);
409 if fill.fill_w > 0.0 {
410 let trailing = if fill.fill_w >= layout.track_w {
411 layout.rx
412 } else {
413 (layout.rx * 0.35).floor().max(0.0)
414 };
415 stamp_round_rect(
416 &mut buf,
417 side,
418 RoundBar {
419 x: layout.track_x,
420 y,
421 w: fill.fill_w,
422 h: layout.track_h,
423 r_left: layout.rx,
424 r_right: trailing,
425 },
426 255,
427 );
428 }
429 if fill.fill_w > 0.0
430 && fill.remainder_w > 0.0
431 && let Some(divider_x) = fill.divider_x
432 {
433 stamp_round_rect(
434 &mut buf,
435 side,
436 RoundBar {
437 x: layout.track_x + divider_x,
438 y,
439 w: fill.remainder_w,
440 h: layout.track_h,
441 r_left: (layout.rx * 0.2).floor().max(0.0),
442 r_right: layout.rx,
443 },
444 61, );
446 }
447 }
448 buf
449}
450
451struct RoundBar {
452 x: f64,
453 y: f64,
454 w: f64,
455 h: f64,
456 r_left: f64,
457 r_right: f64,
458}
459
460fn stamp_round_rect(buf: &mut [u8], side: u32, bar: RoundBar, alpha: u8) {
461 if bar.w <= 0.0 || bar.h <= 0.0 {
462 return;
463 }
464 let side_i = side as i32;
465 let min_x = bar.x.floor().max(0.0) as i32;
466 let min_y = bar.y.floor().max(0.0) as i32;
467 let max_x = (bar.x + bar.w).ceil().min(f64::from(side)) as i32;
468 let max_y = (bar.y + bar.h).ceil().min(f64::from(side)) as i32;
469 let target = f64::from(alpha);
470 for py in min_y..max_y {
471 for px in min_x..max_x {
472 if px < 0 || py < 0 || px >= side_i || py >= side_i {
473 continue;
474 }
475 let cover = sample_round_rect(f64::from(px) + 0.5, f64::from(py) + 0.5, &bar);
476 if cover <= 0.0 {
477 continue;
478 }
479 let idx = ((py as u32 * side + px as u32) * 4) as usize;
480 let stamped = (target * cover).round().clamp(0.0, 255.0) as u8;
481 if stamped > buf[idx + 3] {
482 buf[idx] = 0;
483 buf[idx + 1] = 0;
484 buf[idx + 2] = 0;
485 buf[idx + 3] = stamped;
486 }
487 }
488 }
489}
490
491fn sample_round_rect(px: f64, py: f64, bar: &RoundBar) -> f64 {
492 (0.5 - sd_round_box(px, py, bar)).clamp(0.0, 1.0)
493}
494
495fn sd_round_box(px: f64, py: f64, bar: &RoundBar) -> f64 {
496 let r = if px < bar.x + bar.w * 0.5 {
497 bar.r_left
498 } else {
499 bar.r_right
500 };
501 let r = r.max(0.0).min(bar.h * 0.5).min(bar.w * 0.5);
502 let cx = bar.x + bar.w * 0.5;
503 let cy = bar.y + bar.h * 0.5;
504 let dx = (px - cx).abs() - (bar.w * 0.5 - r);
505 let dy = (py - cy).abs() - (bar.h * 0.5 - r);
506 let outside = (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt();
507 outside + dx.min(0.0).max(dy.min(0.0)) - r
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use serde_json::json;
514
515 #[test]
516 fn zero_or_negative_fraction_draws_nothing() {
517 assert_eq!(bar_fill(100.0, 0.0).fill_w, 0.0);
518 assert_eq!(bar_fill(100.0, -0.5).fill_w, 0.0);
519 }
520
521 #[test]
522 fn full_fraction_fills_track_with_no_remainder() {
523 let fill = bar_fill(100.0, 1.0);
524 assert_eq!(fill.fill_w, 100.0);
525 assert_eq!(fill.remainder_w, 0.0);
526 assert_eq!(fill.divider_x, None);
527 }
528
529 #[test]
530 fn near_full_keeps_a_visible_tail() {
531 let fill = bar_fill(100.0, 0.97);
532 assert!(fill.fill_w < 100.0);
533 assert!(fill.remainder_w >= 20.0);
534 assert_eq!(fill.divider_x, Some(fill.fill_w));
535 }
536
537 #[test]
538 fn visual_fraction_quantizes_near_full_in_fifteen_percent_steps() {
539 assert!((visual_fraction(0.97) - 0.85).abs() < 0.0001);
540 assert_eq!(visual_fraction(1.0), 1.0);
541 assert_eq!(visual_fraction(0.0), 0.0);
542 assert!((visual_fraction(0.5) - 0.5).abs() < 0.0001);
543 }
544
545 fn sample_payload() -> Value {
546 json!({
547 "entries": [
548 {
549 "id": "anthropic",
550 "display_name": "Claude",
551 "status": "ready",
552 "sections": [
553 {"type": "metric", "label": "Weekly", "percent": 19, "value": "19%"},
554 {"type": "metric", "label": "Session", "percent": 41, "value": "41%"}
555 ]
556 },
557 {
558 "id": "openai",
559 "display_name": "Codex",
560 "status": "ready",
561 "sections": [
562 {"type": "metric", "label": "Codex weekly", "percent": 2, "value": "2%"}
563 ]
564 },
565 {
566 "id": "cursor",
567 "display_name": "Cursor",
568 "status": "error",
569 "sections": [
570 {"type": "metric", "label": "Cursor Models", "percent": 90, "value": "90%"}
571 ]
572 }
573 ]
574 })
575 }
576
577 #[test]
578 fn empty_stars_fall_back_to_first_two_bounded_metrics() {
579 let content = content_from_payload(&sample_payload(), &Stars::new(), &[]);
580 assert_eq!(content.groups.len(), 2);
581 assert_eq!(content.groups[0].2.len(), 2);
582 assert_eq!(content.groups[0].2[0].key, "metric:Weekly");
583 assert_eq!(content.groups[0].2[1].key, "metric:Session");
584 assert_eq!(content.groups[1].2[0].key, "metric:Codex weekly");
585 assert_eq!(content.bars.len(), 3);
586 assert!(!content.title_line().is_empty());
587 }
588
589 #[test]
590 fn stars_select_named_metrics_and_skip_errors() {
591 let mut stars = Stars::new();
592 stars.insert("anthropic".into(), vec!["metric:Session".into()]);
593 stars.insert("cursor".into(), vec!["metric:Cursor Models".into()]);
594 let content = content_from_payload(&sample_payload(), &stars, &[]);
595 assert_eq!(content.groups.len(), 1);
596 assert_eq!(content.groups[0].2[0].key, "metric:Session");
597 assert_eq!(content.groups[0].2[0].fraction, 0.41);
598 assert_eq!(content.bars.len(), 1);
599 }
600
601 #[test]
602 fn grouped_metric_keys_match_the_popover() {
603 let payload = json!({
604 "entries": [{
605 "id": "antigravity",
606 "display_name": "Antigravity",
607 "status": "ready",
608 "sections": [
609 {"type": "text", "label": "Session", "value": ""},
610 {"type": "metric", "label": "Gemini", "percent": 4, "value": "4%"},
611 {"type": "text", "label": "Weekly", "value": ""},
612 {"type": "metric", "label": "Gemini", "percent": 11, "value": "11%"}
613 ]
614 }]
615 });
616 let content = content_from_payload(&payload, &Stars::new(), &[]);
617 assert_eq!(content.groups[0].2[0].key, "metric:Gemini (Session)");
618 assert_eq!(content.groups[0].2[1].key, "metric:Gemini (Weekly)");
619 }
620
621 #[test]
622 fn parse_strip_ipc_caps_stars_at_two_per_provider() {
623 let value = json!({
624 "style": "text",
625 "stars": {
626 "anthropic": ["metric:Weekly", "metric:Session", "metric:Extra"],
627 "": ["x"],
628 "openai": ["metric:Codex weekly"]
629 }
630 });
631 let (style, stars, order) = parse_strip_ipc(&value);
632 assert_eq!(style, StripStyle::Bars);
633 assert_eq!(stars["anthropic"].len(), 2);
634 assert_eq!(stars["openai"], vec!["metric:Codex weekly".to_string()]);
635 assert!(!stars.contains_key(""));
636 assert!(order.is_empty());
637 }
638
639 #[test]
640 fn strip_follows_popover_card_order() {
641 let mut stars = Stars::new();
642 stars.insert("anthropic".into(), vec!["metric:Weekly".into()]);
643 stars.insert("openai".into(), vec!["metric:Codex weekly".into()]);
644 let content = content_from_payload(
645 &sample_payload(),
646 &stars,
647 &["openai".into(), "anthropic".into()],
648 );
649 assert_eq!(content.groups[0].0, "openai");
650 assert_eq!(content.groups[1].0, "anthropic");
651 assert_eq!(content.bars[0].provider_id, "openai");
652 assert_eq!(content.bars[1].provider_id, "anthropic");
653 }
654
655 #[test]
656 fn bars_rgba_is_square_black_and_anti_aliased() {
657 let bytes = bars_rgba(&[0.4, 0.97], BARS_PIXEL_SIDE);
658 assert_eq!(
659 bytes.len(),
660 (BARS_PIXEL_SIDE * BARS_PIXEL_SIDE * 4) as usize
661 );
662 assert_eq!(bytes[3], 0, "top-left stays transparent");
663 let last = bytes.len() - 1;
664 assert_eq!(bytes[last], 0, "bottom-right stays transparent");
665 let mut inked = 0usize;
666 let mut soft = 0usize;
667 for pixel in bytes.as_chunks::<4>().0 {
668 if pixel[3] > 0 {
669 inked += 1;
670 assert_eq!(&pixel[..3], &[0, 0, 0]);
671 }
672 if pixel[3] > 0 && pixel[3] < 255 {
673 soft += 1;
674 }
675 }
676 assert!(inked > 40, "glyph too sparse: {inked}");
677 assert!(soft > 0, "expected anti-aliased edges");
678 }
679
680 #[test]
681 fn empty_fractions_are_fully_transparent() {
682 let bytes = bars_rgba(&[], BARS_PIXEL_SIDE);
683 assert!(bytes.as_chunks::<4>().0.iter().all(|p| p[3] == 0));
684 }
685
686 #[test]
687 fn bars_rgba_at_each_status_item_scale() {
688 for scale in BARS_SCALES {
689 let side = BARS_POINT_SIDE * scale;
690 let bytes = bars_rgba(&[0.4, 0.97], side);
691 assert_eq!(bytes.len(), (side * side * 4) as usize, "scale {scale}");
692 assert!(
693 bytes.as_chunks::<4>().0.iter().any(|p| p[3] > 0),
694 "scale {scale} produced an empty glyph"
695 );
696 }
697 }
698}