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 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 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);
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 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 {
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 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 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 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 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 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 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 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 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 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}