Skip to main content

rustmotion_components/
icon.rs

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    /// Iconify identifier: "prefix:name" (e.g. "lucide:home", "mdi:account")
15    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);
44
45        let cache = asset_cache();
46        let img = if let Some(cached) = cache.get(&cache_key) {
47            cached.clone()
48        } else {
49            let Ok(svg_data) = fetch_icon_svg(&self.icon, color, render_w, render_h) else {
50                if crate::warn_once_for(&format!("icon-fetch-failed:{}", self.icon)) {
51                    eprintln!(
52                        "Warning: icon '{}' could not be loaded (checked the disk cache and \
53                         the network) — nothing will be painted for it.",
54                        self.icon
55                    );
56                }
57                return;
58            };
59
60            let opt = usvg::Options::default();
61            let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else {
62                return;
63            };
64
65            let svg_size = tree.size();
66            let Some(mut pixmap) = tiny_skia::Pixmap::new(render_w, render_h) else {
67                return;
68            };
69
70            let scale_x = render_w as f32 / svg_size.width();
71            let scale_y = render_h as f32 / svg_size.height();
72            let transform = tiny_skia::Transform::from_scale(scale_x, scale_y);
73
74            resvg::render(&tree, transform, &mut pixmap.as_mut());
75
76            let img_data = skia_safe::Data::new_copy(pixmap.data());
77            let img_info = ImageInfo::new(
78                (render_w as i32, render_h as i32),
79                ColorType::RGBA8888,
80                skia_safe::AlphaType::Premul,
81                None,
82            );
83            let Some(decoded) =
84                skia_safe::images::raster_from_data(&img_info, img_data, render_w as usize * 4)
85            else {
86                return;
87            };
88            cache.insert(cache_key, decoded.clone());
89            decoded
90        };
91
92        let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
93        let paint = Paint::default();
94        canvas.draw_image_rect_with_sampling_options(
95            img,
96            None,
97            dst,
98            SamplingOptions::from(skia_safe::CubicResampler::mitchell()),
99            &paint,
100        );
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn base_ctx() -> PaintCtx {
109        PaintCtx {
110            time: 0.0,
111            scenario_time: 0.0,
112            scene_duration: 1.0,
113            frame_index: 0,
114            fps: 30,
115            video_width: 100,
116            video_height: 100,
117            stagger_offset: 0.0,
118        }
119    }
120
121    fn solid_image() -> skia_safe::Image {
122        let px = [255u8, 0, 255, 255];
123        let mut data = Vec::with_capacity(4 * 4);
124        for _ in 0..4 {
125            data.extend_from_slice(&px);
126        }
127        let img_info = ImageInfo::new(
128            (2, 2),
129            ColorType::RGBA8888,
130            skia_safe::AlphaType::Premul,
131            None,
132        );
133        let skia_data = skia_safe::Data::new_copy(&data);
134        skia_safe::images::raster_from_data(&img_info, skia_data, 2 * 4).expect("sentinel image")
135    }
136
137    #[test]
138    fn painter_finds_the_entry_preload_would_have_written() {
139        let icon = Icon {
140            icon: "test-suite:icon-cache-key-agreement".to_string(),
141            timing: Default::default(),
142            style: CssStyle::default(),
143            timeline: Vec::new(),
144            stagger: None,
145        };
146        let target_w = 40u32;
147        let target_h = 40u32;
148        let color = icon.style.color_str_or("#FFFFFF");
149        let (_, _, key) = icon_cache_key(&icon.icon, color, target_w, target_h);
150
151        asset_cache().insert(key.clone(), solid_image());
152
153        let layout = BoxLayout {
154            width: target_w as f32,
155            height: target_h as f32,
156            ..Default::default()
157        };
158        let ctx = base_ctx();
159        let props = AnimatedProperties::default();
160        let mut surface =
161            skia_safe::surfaces::raster_n32_premul((target_w as i32, target_h as i32)).unwrap();
162        {
163            let canvas = surface.canvas();
164            icon.paint_content(canvas, &layout, &props, &ctx);
165        }
166
167        asset_cache().remove(&key);
168
169        let snapshot = surface.image_snapshot();
170        let info = ImageInfo::new(
171            (target_w as i32, target_h as i32),
172            ColorType::RGBA8888,
173            skia_safe::AlphaType::Premul,
174            None,
175        );
176        let mut buf = vec![0u8; (target_w * target_h * 4) as usize];
177        let ok = snapshot.read_pixels(
178            &info,
179            &mut buf,
180            (target_w * 4) as usize,
181            skia_safe::IPoint::new(0, 0),
182            skia_safe::image::CachingHint::Disallow,
183        );
184        assert!(ok, "pixel read should succeed");
185        let has_ink = buf.chunks(4).any(|px| px[3] > 0);
186        assert!(
187            has_ink,
188            "painter must have found and painted the cache entry preload.rs would have \
189             written under the same key — if the keys disagree, nothing paints"
190        );
191    }
192}