Skip to main content

rustmotion_components/
stat.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::gradient::{self, Colors, Gradient};
4use skia_safe::{
5    Canvas, Color, Color4f, ColorType, ImageInfo, Paint, PaintStyle, PathBuilder, Point, Rect,
6};
7
8use rustmotion_core::css::CssStyle;
9use rustmotion_core::engine::animator::AnimatedProperties;
10use rustmotion_core::engine::layout_pass::BoxLayout;
11use rustmotion_core::engine::renderer::{
12    asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg,
13    measure_text_with_fallback, paint_from_hex, parse_hex_color, typeface_with_fallback,
14};
15use rustmotion_core::schema::TimelineStep;
16use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
17
18fn default_value_font_size() -> f32 {
19    48.0
20}
21
22fn default_label_font_size() -> f32 {
23    14.0
24}
25
26fn default_value_color() -> String {
27    "#FFFFFF".to_string()
28}
29
30fn default_label_color() -> String {
31    "#94A3B8".to_string()
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35#[serde(rename_all = "snake_case")]
36#[derive(Default)]
37pub enum TrendDirection {
38    Up,
39    Down,
40    #[default]
41    Neutral,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45pub struct StatTrend {
46    pub value: String,
47    #[serde(default)]
48    pub direction: TrendDirection,
49    #[serde(default)]
50    pub color: Option<String>,
51}
52
53#[derive(Debug, Serialize, Deserialize, JsonSchema)]
54pub struct Stat {
55    pub value: String,
56    #[serde(default)]
57    pub label: Option<String>,
58    #[serde(default)]
59    pub trend: Option<StatTrend>,
60    #[serde(default)]
61    pub sparkline_data: Vec<f64>,
62    #[serde(default)]
63    pub sparkline_color: Option<String>,
64    #[serde(default = "default_value_font_size")]
65    pub value_font_size: f32,
66    #[serde(default = "default_label_font_size")]
67    pub label_font_size: f32,
68    #[serde(default = "default_value_color")]
69    pub value_color: String,
70    #[serde(default = "default_label_color")]
71    pub label_color: String,
72    #[serde(flatten)]
73    pub timing: TimingConfig,
74    #[serde(default)]
75    pub style: CssStyle,
76    #[serde(default)]
77    pub timeline: Vec<TimelineStep>,
78    #[serde(default)]
79    pub stagger: Option<f32>,
80}
81
82rustmotion_core::impl_traits!(Stat {
83    Animatable => animation,
84    Timed => timing,
85    Styled => style,
86});
87
88impl Stat {
89    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32) {
90        let w = layout_w;
91        let h = layout_h;
92        if w <= 0.0 || h <= 0.0 {
93            return;
94        }
95
96        // Hard containment: whatever the math below computes, nothing gets
97        // to paint past the box the layout gave this component (issue
98        // #127). `stat` used to walk a `y_cursor` from a hardcoded
99        // `pad = 20.0` — label, then value, then a sparkline with a forced
100        // `.max(20.0)` minimum height — never once consulting `h`. A short
101        // box (the audit measured 512×48) still got the same ~120px stack
102        // every time, overflowing 70+px onto the next flex sibling. The
103        // clip below is the backstop; the scaling further down is what
104        // keeps it from ever needing to bite in practice.
105        canvas.save();
106        canvas.clip_rect(
107            Rect::from_xywh(0.0, 0.0, w, h),
108            skia_safe::ClipOp::Intersect,
109            true,
110        );
111
112        // Background if set
113        if let Some(bg) = self.style.background_color_str() {
114            let mut bg_paint = paint_from_hex(bg);
115            bg_paint.set_style(PaintStyle::Fill);
116            bg_paint.set_anti_alias(true);
117            let radius = self.style.border_radius_px_or(12.0);
118            let rect = Rect::from_xywh(0.0, 0.0, w, h);
119            let rrect = skia_safe::RRect::new_rect_xy(rect, radius, radius);
120            canvas.draw_rrect(rrect, &bg_paint);
121        }
122
123        // `pad` scales down with the box instead of a fixed 20px, and the
124        // label/value font sizes are scaled by `content_scale` so the
125        // label+value stack actually fits in the room `h` gives them. Both
126        // stay at their authored size (pad=20, scale=1) whenever the box is
127        // tall enough — which is every existing example — and only shrink
128        // for a box shorter than the natural stack (like #127's 512×48).
129        let pad = (h * 0.15).clamp(2.0, 20.0).min(w * 0.15).max(2.0);
130        let content_h = (h - pad * 2.0).max(0.0);
131
132        let label_natural_h = if self.label.is_some() {
133            self.label_font_size * 1.5
134        } else {
135            0.0
136        };
137        let value_natural_h = self.value_font_size * 1.2;
138        let text_natural_h = label_natural_h + value_natural_h;
139        let content_scale = if text_natural_h > 0.0 {
140            (content_h / text_natural_h).clamp(0.05, 1.0)
141        } else {
142            1.0
143        };
144
145        let eff_label_fs = self.label_font_size * content_scale;
146        let eff_value_fs = self.value_font_size * content_scale;
147
148        let mut y_cursor = pad;
149
150        // Label (top)
151        if let Some(label) = &self.label {
152            let font_style = skia_safe::FontStyle::normal();
153            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
154                canvas.restore();
155                return;
156            };
157            let font = skia_safe::Font::from_typeface(typeface, eff_label_fs);
158            let emoji_font =
159                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, eff_label_fs));
160            let (_, metrics) = font.metrics();
161
162            let mut label_paint = paint_from_hex(&self.label_color);
163            label_paint.set_anti_alias(true);
164
165            let ly = y_cursor + (-metrics.ascent);
166            draw_text_with_fallback(
167                canvas,
168                label,
169                &font,
170                &emoji_font,
171                0.0,
172                pad,
173                ly,
174                &label_paint,
175            );
176            y_cursor += eff_label_fs * 1.5;
177        }
178
179        // Value (large)
180        {
181            let font_style = skia_safe::FontStyle::bold();
182            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
183                canvas.restore();
184                return;
185            };
186            let font = skia_safe::Font::from_typeface(typeface, eff_value_fs);
187            let emoji_font =
188                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, eff_value_fs));
189            let (_, metrics) = font.metrics();
190
191            let mut val_paint = paint_from_hex(&self.value_color);
192            val_paint.set_anti_alias(true);
193
194            let vy = y_cursor + (-metrics.ascent);
195            draw_text_with_fallback(
196                canvas,
197                &self.value,
198                &font,
199                &emoji_font,
200                0.0,
201                pad,
202                vy,
203                &val_paint,
204            );
205
206            // Trend inline after value
207            if let Some(trend) = &self.trend {
208                let val_w = measure_text_with_fallback(&self.value, &font, &emoji_font, 0.0);
209                let trend_fs = eff_value_fs * 0.4;
210                let bold_style = skia_safe::FontStyle::bold();
211                let Ok(trend_typeface) = typeface_with_fallback("Inter", bold_style) else {
212                    canvas.restore();
213                    return;
214                };
215                let trend_font = skia_safe::Font::from_typeface(trend_typeface, trend_fs);
216                let trend_emoji =
217                    emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, trend_fs));
218
219                let trend_color = trend.color.as_deref().unwrap_or(match trend.direction {
220                    TrendDirection::Up => "#22C55E",
221                    TrendDirection::Down => "#EF4444",
222                    TrendDirection::Neutral => "#94A3B8",
223                });
224
225                let mut trend_paint = paint_from_hex(trend_color);
226                trend_paint.set_anti_alias(true);
227
228                let (_, trend_metrics) = trend_font.metrics();
229                let mut tx = pad + val_w + 12.0;
230                let ty = vy - eff_value_fs * 0.15 + trend_metrics.ascent * 0.2;
231
232                // Draw trend arrow icon
233                let icon_id = match trend.direction {
234                    TrendDirection::Up => Some("lucide:trending-up"),
235                    TrendDirection::Down => Some("lucide:trending-down"),
236                    TrendDirection::Neutral => None,
237                };
238
239                if let Some(icon_id) = icon_id {
240                    let icon_sz = (trend_fs * 1.0).round() as u32;
241                    let cache_key = format!(
242                        "stat_icon:{}:{}:{}x{}",
243                        icon_id, trend_color, icon_sz, icon_sz
244                    );
245                    let cache = asset_cache();
246
247                    let icon_img = if let Some(cached) = cache.get(&cache_key) {
248                        Some(cached.clone())
249                    } else if let Ok(svg_data) =
250                        fetch_icon_svg(icon_id, trend_color, icon_sz, icon_sz)
251                    {
252                        let opt = usvg::Options::default();
253                        if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) {
254                            let svg_size = tree.size();
255                            if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_sz, icon_sz) {
256                                let sx = icon_sz as f32 / svg_size.width();
257                                let sy = icon_sz as f32 / svg_size.height();
258                                resvg::render(
259                                    &tree,
260                                    tiny_skia::Transform::from_scale(sx, sy),
261                                    &mut pixmap.as_mut(),
262                                );
263                                let img_data = skia_safe::Data::new_copy(pixmap.data());
264                                let info = ImageInfo::new(
265                                    (icon_sz as i32, icon_sz as i32),
266                                    ColorType::RGBA8888,
267                                    skia_safe::AlphaType::Premul,
268                                    None,
269                                );
270                                if let Some(decoded) = skia_safe::images::raster_from_data(
271                                    &info,
272                                    img_data,
273                                    icon_sz as usize * 4,
274                                ) {
275                                    cache.insert(cache_key, decoded.clone());
276                                    Some(decoded)
277                                } else {
278                                    None
279                                }
280                            } else {
281                                None
282                            }
283                        } else {
284                            None
285                        }
286                    } else {
287                        None
288                    };
289
290                    if let Some(img) = icon_img {
291                        let icon_y = ty - trend_fs * 0.8;
292                        let dst = Rect::from_xywh(tx, icon_y, icon_sz as f32, icon_sz as f32);
293                        canvas.draw_image_rect(img, None, dst, &Paint::default());
294                        tx += icon_sz as f32 + 4.0;
295                    }
296                }
297
298                draw_text_with_fallback(
299                    canvas,
300                    &trend.value,
301                    &trend_font,
302                    &trend_emoji,
303                    0.0,
304                    tx,
305                    ty,
306                    &trend_paint,
307                );
308            }
309
310            y_cursor += eff_value_fs * 1.2;
311        }
312
313        // Sparkline (bottom) — only drawn if room is actually left. The
314        // previous `.max(20.0)` forced a sparkline into existence even when
315        // the label+value stack had already consumed the whole box, which
316        // is exactly what pushed ink onto the next flex sibling. `spark_y`
317        // itself eats 4px of gap below the value line, so that has to come
318        // out of the room budget too — the original formula measured room
319        // from `y_cursor`, not from `spark_y`, which let the sparkline's
320        // own bottom edge land 4px past `h - pad`.
321        let spark_y = y_cursor + 4.0;
322        let spark_room = (h - pad) - spark_y;
323        if self.sparkline_data.len() >= 2 && spark_room >= 8.0 {
324            let spark_h = spark_room;
325            let spark_w = w - pad * 2.0;
326
327            let max_v = self.sparkline_data.iter().fold(f64::MIN, |a, &b| a.max(b));
328            let min_v = self.sparkline_data.iter().fold(f64::MAX, |a, &b| a.min(b));
329            // A flat series has no span. Flooring the divisor instead normalises
330            // every point to 0, which glues the line to the bottom edge and reads
331            // as "collapsed to zero" rather than "unchanged" — centre it instead,
332            // matching `chart::line`'s handling of the same case.
333            let span = max_v - min_v;
334            let flat = span.abs() < f64::EPSILON;
335            let n = self.sparkline_data.len();
336
337            let spark_color = self.sparkline_color.as_deref().unwrap_or("#3B82F6");
338
339            let mut line_path = PathBuilder::new();
340            let mut fill_path = PathBuilder::new();
341
342            for (i, &val) in self.sparkline_data.iter().enumerate() {
343                let x = pad + (i as f32 / (n - 1) as f32) * spark_w;
344                let norm = if flat { 0.5 } else { (val - min_v) / span };
345                let y = spark_y + spark_h - norm as f32 * spark_h;
346
347                if i == 0 {
348                    line_path.move_to((x, y));
349                    fill_path.move_to((x, spark_y + spark_h));
350                    fill_path.line_to((x, y));
351                } else {
352                    line_path.line_to((x, y));
353                    fill_path.line_to((x, y));
354                }
355            }
356            fill_path.line_to((pad + spark_w, spark_y + spark_h));
357            fill_path.close();
358
359            // Gradient fill
360            let (r, g, b, _) = parse_hex_color(spark_color);
361            let top_color = Color::from_argb(50, r, g, b);
362            let bottom_color = Color::from_argb(0, r, g, b);
363            let colors4f = [Color4f::from(top_color), Color4f::from(bottom_color)];
364            let stops = Colors::new(&colors4f, None, skia_safe::TileMode::Clamp, None);
365            let grad = Gradient::new(stops, gradient::Interpolation::default());
366            let shader = gradient::shaders::linear_gradient(
367                (Point::new(0.0, spark_y), Point::new(0.0, spark_y + spark_h)),
368                &grad,
369                None,
370            );
371            if let Some(shader) = shader {
372                let mut fp = skia_safe::Paint::default();
373                fp.set_style(PaintStyle::Fill);
374                fp.set_anti_alias(true);
375                fp.set_shader(shader);
376                canvas.draw_path(&fill_path.detach(), &fp);
377            }
378
379            let mut line_paint = paint_from_hex(spark_color);
380            line_paint.set_style(PaintStyle::Stroke);
381            line_paint.set_stroke_width(2.0);
382            line_paint.set_anti_alias(true);
383            line_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
384            line_paint.set_stroke_join(skia_safe::paint::Join::Round);
385            canvas.draw_path(&line_path.detach(), &line_paint);
386        }
387
388        canvas.restore();
389    }
390}
391
392impl Painter for Stat {
393    fn paint_content(
394        &self,
395        canvas: &Canvas,
396        layout: &BoxLayout,
397        _props: &AnimatedProperties,
398        _ctx: &PaintCtx,
399    ) {
400        self.paint(canvas, layout.width, layout.height);
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn full_stat() -> Stat {
409        Stat {
410            value: "98.2%".to_string(),
411            label: Some("Tests Passing".to_string()),
412            trend: Some(StatTrend {
413                value: "+2.1%".to_string(),
414                direction: TrendDirection::Up,
415                color: None,
416            }),
417            sparkline_data: vec![80.0, 84.0, 82.0, 88.0, 90.0, 94.0, 98.0],
418            sparkline_color: None,
419            value_font_size: default_value_font_size(),
420            label_font_size: default_label_font_size(),
421            value_color: default_value_color(),
422            label_color: default_label_color(),
423            timing: Default::default(),
424            style: CssStyle::default(),
425            timeline: Vec::new(),
426            stagger: None,
427        }
428    }
429
430    /// Bounding box (min_x, max_x, min_y, max_y) of every non-transparent
431    /// pixel on the surface, or `None` if nothing was painted. Mirrors the
432    /// helper `caption.rs` already uses for the same kind of proof.
433    fn ink_bounds(
434        surface: &mut skia_safe::Surface,
435        w: i32,
436        h: i32,
437    ) -> Option<(i32, i32, i32, i32)> {
438        let snapshot = surface.image_snapshot();
439        let info = skia_safe::ImageInfo::new(
440            (w, h),
441            skia_safe::ColorType::RGBA8888,
442            skia_safe::AlphaType::Premul,
443            None,
444        );
445        let mut buf = vec![0u8; (w * h * 4) as usize];
446        let ok = snapshot.read_pixels(
447            &info,
448            &mut buf,
449            (w * 4) as usize,
450            skia_safe::IPoint::new(0, 0),
451            skia_safe::image::CachingHint::Disallow,
452        );
453        assert!(ok, "pixel read should succeed");
454        let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
455        for y in 0..h {
456            for x in 0..w {
457                if buf[((y * w + x) * 4 + 3) as usize] > 0 {
458                    minx = minx.min(x);
459                    maxx = maxx.max(x);
460                    miny = miny.min(y);
461                    maxy = maxy.max(y);
462                }
463            }
464        }
465        (minx <= maxx).then_some((minx, maxx, miny, maxy))
466    }
467
468    #[test]
469    fn ink_stays_within_a_tiny_assigned_box() {
470        // #127's exact repro: a 512×48 box (the measured "assigned box" for
471        // this component in a 560×300 card) used to take ~120px of ink —
472        // label + value + a forced-minimum sparkline — 72px onto whatever
473        // sits below it in the flex column. With a solid background (the
474        // same way the audit made the assigned box visible), the painted
475        // background rect *is* the box, so no ink should land outside it.
476        let stat = full_stat();
477        const W: i32 = 512;
478        const H: i32 = 48;
479        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
480        {
481            let canvas = surface.canvas();
482            stat.paint(canvas, W as f32, H as f32);
483        }
484        let (minx, maxx, miny, maxy) =
485            ink_bounds(&mut surface, W, H).expect("stat must paint something");
486        assert!(
487            minx >= 0 && miny >= 0,
488            "ink starts outside the box: ({minx},{miny})"
489        );
490        assert!(
491            maxx < W && maxy < H,
492            "ink escaped the {W}x{H} box: max=({maxx},{maxy})"
493        );
494    }
495
496    #[test]
497    fn ink_stays_within_box_even_without_a_background() {
498        // Same box, no `style.background` — the clip still has to hold
499        // even when there's no filled rect to visually anchor it to.
500        let mut stat = full_stat();
501        stat.style = CssStyle::default();
502        const W: i32 = 512;
503        const H: i32 = 48;
504        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
505        {
506            let canvas = surface.canvas();
507            stat.paint(canvas, W as f32, H as f32);
508        }
509        if let Some((minx, maxx, miny, maxy)) = ink_bounds(&mut surface, W, H) {
510            assert!(minx >= 0 && miny >= 0 && maxx < W && maxy < H);
511        }
512    }
513
514    #[test]
515    fn generous_box_keeps_the_original_full_size_layout() {
516        // A box as tall as the existing examples use (e.g. mega-showcase's
517        // 380×220 stat cards) must not be affected by the new scaling —
518        // `content_scale` should resolve to 1.0 and the value should still
519        // render at its authored `value_font_size`.
520        let stat = full_stat();
521        const W: i32 = 380;
522        const H: i32 = 220;
523        let pad = (H as f32 * 0.15)
524            .clamp(2.0, 20.0)
525            .min(W as f32 * 0.15)
526            .max(2.0);
527        let content_h = (H as f32 - pad * 2.0).max(0.0);
528        let text_natural_h = stat.label_font_size * 1.5 + stat.value_font_size * 1.2;
529        let scale = (content_h / text_natural_h).clamp(0.05, 1.0);
530        assert_eq!(
531            scale, 1.0,
532            "a generously sized box should never shrink the text"
533        );
534    }
535}