1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect, SamplingOptions};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::{asset_cache, fetch_icon_svg, icon_cache_key};
9use rustmotion_core::schema::TimelineStep;
10use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
11
12#[derive(Debug, Serialize, Deserialize, JsonSchema)]
13pub struct Icon {
14 pub icon: String,
16 #[serde(flatten)]
17 pub timing: TimingConfig,
18 #[serde(default)]
19 pub style: CssStyle,
20 #[serde(default)]
21 pub timeline: Vec<TimelineStep>,
22 #[serde(default)]
23 pub stagger: Option<f32>,
24}
25
26rustmotion_core::impl_traits!(Icon {
27 Animatable => animation,
28 Timed => timing,
29 Styled => style,
30});
31
32impl Painter for Icon {
33 fn paint_content(
34 &self,
35 canvas: &Canvas,
36 layout: &BoxLayout,
37 _props: &AnimatedProperties,
38 _ctx: &PaintCtx,
39 ) {
40 let color = self.style.color_str_or("#FFFFFF");
41 let target_w = (layout.width as u32).max(1);
42 let target_h = (layout.height as u32).max(1);
43 let (render_w, render_h, cache_key) = icon_cache_key(&self.icon, color, target_w, target_h);
50
51 let cache = asset_cache();
52 let img = if let Some(cached) = cache.get(&cache_key) {
53 cached.clone()
54 } else {
55 let Ok(svg_data) = fetch_icon_svg(&self.icon, color, render_w, render_h) else {
56 if crate::warn_once_for(&format!("icon-fetch-failed:{}", self.icon)) {
65 eprintln!(
66 "Warning: icon '{}' could not be loaded (checked the disk cache and \
67 the network) — nothing will be painted for it.",
68 self.icon
69 );
70 }
71 return;
72 };
73
74 let opt = usvg::Options::default();
75 let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else {
76 return;
77 };
78
79 let svg_size = tree.size();
80 let Some(mut pixmap) = tiny_skia::Pixmap::new(render_w, render_h) else {
81 return;
82 };
83
84 let scale_x = render_w as f32 / svg_size.width();
85 let scale_y = render_h as f32 / svg_size.height();
86 let transform = tiny_skia::Transform::from_scale(scale_x, scale_y);
87
88 resvg::render(&tree, transform, &mut pixmap.as_mut());
89
90 let img_data = skia_safe::Data::new_copy(pixmap.data());
91 let img_info = ImageInfo::new(
92 (render_w as i32, render_h as i32),
93 ColorType::RGBA8888,
94 skia_safe::AlphaType::Premul,
95 None,
96 );
97 let Some(decoded) =
98 skia_safe::images::raster_from_data(&img_info, img_data, render_w as usize * 4)
99 else {
100 return;
101 };
102 cache.insert(cache_key, decoded.clone());
103 decoded
104 };
105
106 let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
107 let paint = Paint::default();
108 canvas.draw_image_rect_with_sampling_options(
109 img,
110 None,
111 dst,
112 SamplingOptions::from(skia_safe::CubicResampler::mitchell()),
113 &paint,
114 );
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 fn base_ctx() -> PaintCtx {
123 PaintCtx {
124 time: 0.0,
125 scenario_time: 0.0,
126 scene_duration: 1.0,
127 frame_index: 0,
128 fps: 30,
129 video_width: 100,
130 video_height: 100,
131 stagger_offset: 0.0,
132 }
133 }
134
135 fn solid_image() -> skia_safe::Image {
136 let px = [255u8, 0, 255, 255];
137 let mut data = Vec::with_capacity(4 * 4);
138 for _ in 0..4 {
139 data.extend_from_slice(&px);
140 }
141 let img_info = ImageInfo::new(
142 (2, 2),
143 ColorType::RGBA8888,
144 skia_safe::AlphaType::Premul,
145 None,
146 );
147 let skia_data = skia_safe::Data::new_copy(&data);
148 skia_safe::images::raster_from_data(&img_info, skia_data, 2 * 4).expect("sentinel image")
149 }
150
151 #[test]
158 fn painter_finds_the_entry_preload_would_have_written() {
159 let icon = Icon {
160 icon: "test-suite:icon-cache-key-agreement".to_string(),
161 timing: Default::default(),
162 style: CssStyle::default(),
163 timeline: Vec::new(),
164 stagger: None,
165 };
166 let target_w = 40u32;
167 let target_h = 40u32;
168 let color = icon.style.color_str_or("#FFFFFF");
169 let (_, _, key) = icon_cache_key(&icon.icon, color, target_w, target_h);
170
171 asset_cache().insert(key.clone(), solid_image());
172
173 let layout = BoxLayout {
174 width: target_w as f32,
175 height: target_h as f32,
176 ..Default::default()
177 };
178 let ctx = base_ctx();
179 let props = AnimatedProperties::default();
180 let mut surface =
181 skia_safe::surfaces::raster_n32_premul((target_w as i32, target_h as i32)).unwrap();
182 {
183 let canvas = surface.canvas();
184 icon.paint_content(canvas, &layout, &props, &ctx);
185 }
186
187 asset_cache().remove(&key);
190
191 let snapshot = surface.image_snapshot();
192 let info = ImageInfo::new(
193 (target_w as i32, target_h as i32),
194 ColorType::RGBA8888,
195 skia_safe::AlphaType::Premul,
196 None,
197 );
198 let mut buf = vec![0u8; (target_w * target_h * 4) as usize];
199 let ok = snapshot.read_pixels(
200 &info,
201 &mut buf,
202 (target_w * 4) as usize,
203 skia_safe::IPoint::new(0, 0),
204 skia_safe::image::CachingHint::Disallow,
205 );
206 assert!(ok, "pixel read should succeed");
207 let has_ink = buf.chunks(4).any(|px| px[3] > 0);
208 assert!(
209 has_ink,
210 "painter must have found and painted the cache entry preload.rs would have \
211 written under the same key — if the keys disagree, nothing paints"
212 );
213 }
214}