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)]
54#[deprecated(
55    since = "0.7.1",
56    note = "`stat` is a frozen composition (issue #333). Compose a `card` + `icon` + \
57            `text` (value, large) + `text` (label, small) instead — see \
58            crates/rustmotion/skills/rules/composition-recipes.md's \"KPI card\" recipe \
59            and examples/composition-kpi-row.json. Kept for compatibility; scheduled for \
60            removal in a future major version via `rustmotion migrate` (#335)."
61)]
62pub struct Stat {
63    pub value: String,
64    #[serde(default)]
65    pub label: Option<String>,
66    #[serde(default)]
67    pub trend: Option<StatTrend>,
68    #[serde(default)]
69    pub sparkline_data: Vec<f64>,
70    #[serde(default)]
71    pub sparkline_color: Option<String>,
72    #[serde(default = "default_value_font_size")]
73    pub value_font_size: f32,
74    #[serde(default = "default_label_font_size")]
75    pub label_font_size: f32,
76    #[serde(default = "default_value_color")]
77    pub value_color: String,
78    #[serde(default = "default_label_color")]
79    pub label_color: String,
80    #[serde(flatten)]
81    pub timing: TimingConfig,
82    #[serde(default)]
83    pub style: CssStyle,
84    #[serde(default)]
85    pub timeline: Vec<TimelineStep>,
86    #[serde(default)]
87    pub stagger: Option<f32>,
88}
89
90rustmotion_core::impl_traits!(Stat {
91    Animatable => animation,
92    Timed => timing,
93    Styled => style,
94});
95
96impl Stat {
97    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32) {
98        let w = layout_w;
99        let h = layout_h;
100        if w <= 0.0 || h <= 0.0 {
101            return;
102        }
103
104        canvas.save();
105        canvas.clip_rect(
106            Rect::from_xywh(0.0, 0.0, w, h),
107            skia_safe::ClipOp::Intersect,
108            true,
109        );
110
111        if let Some(bg) = self.style.background_color_str() {
112            let mut bg_paint = paint_from_hex(bg);
113            bg_paint.set_style(PaintStyle::Fill);
114            bg_paint.set_anti_alias(true);
115            let radius = self.style.border_radius_px_or(12.0);
116            let rect = Rect::from_xywh(0.0, 0.0, w, h);
117            let rrect = skia_safe::RRect::new_rect_xy(rect, radius, radius);
118            canvas.draw_rrect(rrect, &bg_paint);
119        }
120
121        let pad = (h * 0.15).clamp(2.0, 20.0).min(w * 0.15).max(2.0);
122        let content_h = (h - pad * 2.0).max(0.0);
123
124        let label_natural_h = if self.label.is_some() {
125            self.label_font_size * 1.5
126        } else {
127            0.0
128        };
129        let value_natural_h = self.value_font_size * 1.2;
130        let text_natural_h = label_natural_h + value_natural_h;
131        let content_scale = if text_natural_h > 0.0 {
132            (content_h / text_natural_h).clamp(0.05, 1.0)
133        } else {
134            1.0
135        };
136
137        let eff_label_fs = self.label_font_size * content_scale;
138        let eff_value_fs = self.value_font_size * content_scale;
139
140        let mut y_cursor = pad;
141
142        if let Some(label) = &self.label {
143            let font_style = skia_safe::FontStyle::normal();
144            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
145                canvas.restore();
146                return;
147            };
148            let font = skia_safe::Font::from_typeface(typeface, eff_label_fs);
149            let emoji_font =
150                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, eff_label_fs));
151            let (_, metrics) = font.metrics();
152
153            let mut label_paint = paint_from_hex(&self.label_color);
154            label_paint.set_anti_alias(true);
155
156            let ly = y_cursor + (-metrics.ascent);
157            draw_text_with_fallback(
158                canvas,
159                label,
160                &font,
161                &emoji_font,
162                0.0,
163                pad,
164                ly,
165                &label_paint,
166            );
167            y_cursor += eff_label_fs * 1.5;
168        }
169
170        {
171            let font_style = skia_safe::FontStyle::bold();
172            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
173                canvas.restore();
174                return;
175            };
176            let font = skia_safe::Font::from_typeface(typeface, eff_value_fs);
177            let emoji_font =
178                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, eff_value_fs));
179            let (_, metrics) = font.metrics();
180
181            let mut val_paint = paint_from_hex(&self.value_color);
182            val_paint.set_anti_alias(true);
183
184            let vy = y_cursor + (-metrics.ascent);
185            draw_text_with_fallback(
186                canvas,
187                &self.value,
188                &font,
189                &emoji_font,
190                0.0,
191                pad,
192                vy,
193                &val_paint,
194            );
195
196            if let Some(trend) = &self.trend {
197                let val_w = measure_text_with_fallback(&self.value, &font, &emoji_font, 0.0);
198                let trend_fs = eff_value_fs * 0.4;
199                let bold_style = skia_safe::FontStyle::bold();
200                let Ok(trend_typeface) = typeface_with_fallback("Inter", bold_style) else {
201                    canvas.restore();
202                    return;
203                };
204                let trend_font = skia_safe::Font::from_typeface(trend_typeface, trend_fs);
205                let trend_emoji =
206                    emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, trend_fs));
207
208                let trend_color = trend.color.as_deref().unwrap_or(match trend.direction {
209                    TrendDirection::Up => "#22C55E",
210                    TrendDirection::Down => "#EF4444",
211                    TrendDirection::Neutral => "#94A3B8",
212                });
213
214                let mut trend_paint = paint_from_hex(trend_color);
215                trend_paint.set_anti_alias(true);
216
217                let (_, trend_metrics) = trend_font.metrics();
218                let mut tx = pad + val_w + 12.0;
219                let ty = vy - eff_value_fs * 0.15 + trend_metrics.ascent * 0.2;
220
221                let icon_id = match trend.direction {
222                    TrendDirection::Up => Some("lucide:trending-up"),
223                    TrendDirection::Down => Some("lucide:trending-down"),
224                    TrendDirection::Neutral => None,
225                };
226
227                if let Some(icon_id) = icon_id {
228                    let icon_sz = (trend_fs * 1.0).round() as u32;
229                    let cache_key = format!(
230                        "stat_icon:{}:{}:{}x{}",
231                        icon_id, trend_color, icon_sz, icon_sz
232                    );
233                    let cache = asset_cache();
234
235                    let icon_img = if let Some(cached) = cache.get(&cache_key) {
236                        Some(cached.clone())
237                    } else if let Ok(svg_data) =
238                        fetch_icon_svg(icon_id, trend_color, icon_sz, icon_sz)
239                    {
240                        let opt = usvg::Options::default();
241                        if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) {
242                            let svg_size = tree.size();
243                            if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_sz, icon_sz) {
244                                let sx = icon_sz as f32 / svg_size.width();
245                                let sy = icon_sz as f32 / svg_size.height();
246                                resvg::render(
247                                    &tree,
248                                    tiny_skia::Transform::from_scale(sx, sy),
249                                    &mut pixmap.as_mut(),
250                                );
251                                let img_data = skia_safe::Data::new_copy(pixmap.data());
252                                let info = ImageInfo::new(
253                                    (icon_sz as i32, icon_sz as i32),
254                                    ColorType::RGBA8888,
255                                    skia_safe::AlphaType::Premul,
256                                    None,
257                                );
258                                if let Some(decoded) = skia_safe::images::raster_from_data(
259                                    &info,
260                                    img_data,
261                                    icon_sz as usize * 4,
262                                ) {
263                                    cache.insert(cache_key, decoded.clone());
264                                    Some(decoded)
265                                } else {
266                                    None
267                                }
268                            } else {
269                                None
270                            }
271                        } else {
272                            None
273                        }
274                    } else {
275                        None
276                    };
277
278                    if let Some(img) = icon_img {
279                        let icon_y = ty - trend_fs * 0.8;
280                        let dst = Rect::from_xywh(tx, icon_y, icon_sz as f32, icon_sz as f32);
281                        canvas.draw_image_rect(img, None, dst, &Paint::default());
282                        tx += icon_sz as f32 + 4.0;
283                    }
284                }
285
286                draw_text_with_fallback(
287                    canvas,
288                    &trend.value,
289                    &trend_font,
290                    &trend_emoji,
291                    0.0,
292                    tx,
293                    ty,
294                    &trend_paint,
295                );
296            }
297
298            y_cursor += eff_value_fs * 1.2;
299        }
300
301        let spark_y = y_cursor + 4.0;
302        let spark_room = (h - pad) - spark_y;
303        if self.sparkline_data.len() >= 2 && spark_room >= 8.0 {
304            let spark_h = spark_room;
305            let spark_w = w - pad * 2.0;
306
307            let max_v = self.sparkline_data.iter().fold(f64::MIN, |a, &b| a.max(b));
308            let min_v = self.sparkline_data.iter().fold(f64::MAX, |a, &b| a.min(b));
309            let span = max_v - min_v;
310            let flat = span.abs() < f64::EPSILON;
311            let n = self.sparkline_data.len();
312
313            let spark_color = self.sparkline_color.as_deref().unwrap_or("#3B82F6");
314
315            let mut line_path = PathBuilder::new();
316            let mut fill_path = PathBuilder::new();
317
318            for (i, &val) in self.sparkline_data.iter().enumerate() {
319                let x = pad + (i as f32 / (n - 1) as f32) * spark_w;
320                let norm = if flat { 0.5 } else { (val - min_v) / span };
321                let y = spark_y + spark_h - norm as f32 * spark_h;
322
323                if i == 0 {
324                    line_path.move_to((x, y));
325                    fill_path.move_to((x, spark_y + spark_h));
326                    fill_path.line_to((x, y));
327                } else {
328                    line_path.line_to((x, y));
329                    fill_path.line_to((x, y));
330                }
331            }
332            fill_path.line_to((pad + spark_w, spark_y + spark_h));
333            fill_path.close();
334
335            let (r, g, b, _) = parse_hex_color(spark_color);
336            let top_color = Color::from_argb(50, r, g, b);
337            let bottom_color = Color::from_argb(0, r, g, b);
338            let colors4f = [Color4f::from(top_color), Color4f::from(bottom_color)];
339            let stops = Colors::new(&colors4f, None, skia_safe::TileMode::Clamp, None);
340            let grad = Gradient::new(stops, gradient::Interpolation::default());
341            let shader = gradient::shaders::linear_gradient(
342                (Point::new(0.0, spark_y), Point::new(0.0, spark_y + spark_h)),
343                &grad,
344                None,
345            );
346            if let Some(shader) = shader {
347                let mut fp = skia_safe::Paint::default();
348                fp.set_style(PaintStyle::Fill);
349                fp.set_anti_alias(true);
350                fp.set_shader(shader);
351                canvas.draw_path(&fill_path.detach(), &fp);
352            }
353
354            let mut line_paint = paint_from_hex(spark_color);
355            line_paint.set_style(PaintStyle::Stroke);
356            line_paint.set_stroke_width(2.0);
357            line_paint.set_anti_alias(true);
358            line_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
359            line_paint.set_stroke_join(skia_safe::paint::Join::Round);
360            canvas.draw_path(&line_path.detach(), &line_paint);
361        }
362
363        canvas.restore();
364    }
365}
366
367impl Painter for Stat {
368    fn paint_content(
369        &self,
370        canvas: &Canvas,
371        layout: &BoxLayout,
372        _props: &AnimatedProperties,
373        _ctx: &PaintCtx,
374    ) {
375        self.paint(canvas, layout.width, layout.height);
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    fn full_stat() -> Stat {
384        Stat {
385            value: "98.2%".to_string(),
386            label: Some("Tests Passing".to_string()),
387            trend: Some(StatTrend {
388                value: "+2.1%".to_string(),
389                direction: TrendDirection::Up,
390                color: None,
391            }),
392            sparkline_data: vec![80.0, 84.0, 82.0, 88.0, 90.0, 94.0, 98.0],
393            sparkline_color: None,
394            value_font_size: default_value_font_size(),
395            label_font_size: default_label_font_size(),
396            value_color: default_value_color(),
397            label_color: default_label_color(),
398            timing: Default::default(),
399            style: CssStyle::default(),
400            timeline: Vec::new(),
401            stagger: None,
402        }
403    }
404
405    fn ink_bounds(
406        surface: &mut skia_safe::Surface,
407        w: i32,
408        h: i32,
409    ) -> Option<(i32, i32, i32, i32)> {
410        let snapshot = surface.image_snapshot();
411        let info = skia_safe::ImageInfo::new(
412            (w, h),
413            skia_safe::ColorType::RGBA8888,
414            skia_safe::AlphaType::Premul,
415            None,
416        );
417        let mut buf = vec![0u8; (w * h * 4) as usize];
418        let ok = snapshot.read_pixels(
419            &info,
420            &mut buf,
421            (w * 4) as usize,
422            skia_safe::IPoint::new(0, 0),
423            skia_safe::image::CachingHint::Disallow,
424        );
425        assert!(ok, "pixel read should succeed");
426        let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
427        for y in 0..h {
428            for x in 0..w {
429                if buf[((y * w + x) * 4 + 3) as usize] > 0 {
430                    minx = minx.min(x);
431                    maxx = maxx.max(x);
432                    miny = miny.min(y);
433                    maxy = maxy.max(y);
434                }
435            }
436        }
437        (minx <= maxx).then_some((minx, maxx, miny, maxy))
438    }
439
440    #[test]
441    fn ink_stays_within_a_tiny_assigned_box() {
442        let stat = full_stat();
443        const W: i32 = 512;
444        const H: i32 = 48;
445        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
446        {
447            let canvas = surface.canvas();
448            stat.paint(canvas, W as f32, H as f32);
449        }
450        let (minx, maxx, miny, maxy) =
451            ink_bounds(&mut surface, W, H).expect("stat must paint something");
452        assert!(
453            minx >= 0 && miny >= 0,
454            "ink starts outside the box: ({minx},{miny})"
455        );
456        assert!(
457            maxx < W && maxy < H,
458            "ink escaped the {W}x{H} box: max=({maxx},{maxy})"
459        );
460    }
461
462    #[test]
463    fn ink_stays_within_box_even_without_a_background() {
464        let mut stat = full_stat();
465        stat.style = CssStyle::default();
466        const W: i32 = 512;
467        const H: i32 = 48;
468        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
469        {
470            let canvas = surface.canvas();
471            stat.paint(canvas, W as f32, H as f32);
472        }
473        if let Some((minx, maxx, miny, maxy)) = ink_bounds(&mut surface, W, H) {
474            assert!(minx >= 0 && miny >= 0 && maxx < W && maxy < H);
475        }
476    }
477
478    #[test]
479    fn generous_box_keeps_the_original_full_size_layout() {
480        let stat = full_stat();
481        const W: i32 = 380;
482        const H: i32 = 220;
483        let pad = (H as f32 * 0.15)
484            .clamp(2.0, 20.0)
485            .min(W as f32 * 0.15)
486            .max(2.0);
487        let content_h = (H as f32 - pad * 2.0).max(0.0);
488        let text_natural_h = stat.label_font_size * 1.5 + stat.value_font_size * 1.2;
489        let scale = (content_h / text_natural_h).clamp(0.05, 1.0);
490        assert_eq!(
491            scale, 1.0,
492            "a generously sized box should never shrink the text"
493        );
494    }
495}