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