Skip to main content

rustmotion_components/
list.rs

1use rustmotion_core::css::CssStyle;
2use rustmotion_core::error::Result;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use skia_safe::{Canvas, ColorType, ImageInfo, Paint, PaintStyle, Rect};
6
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10    asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, paint_from_hex,
11    typeface_with_fallback,
12};
13use rustmotion_core::schema::TimelineStep;
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16fn default_gap() -> f32 {
17    16.0
18}
19fn default_icon_size() -> f32 {
20    20.0
21}
22fn default_icon_color() -> String {
23    "#22C55E".to_string()
24}
25fn default_unchecked_color() -> String {
26    "#6B7280".to_string()
27}
28fn default_width() -> f32 {
29    400.0
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34#[derive(Default)]
35pub enum ListVariant {
36    #[default]
37    Bullet,
38    Numbered,
39    Checklist,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
43pub struct ListItem {
44    pub text: String,
45    #[serde(default)]
46    pub icon: Option<String>,
47    #[serde(default)]
48    pub checked: Option<bool>,
49}
50
51#[derive(Debug, Serialize, Deserialize, JsonSchema)]
52#[deprecated(
53    since = "0.7.1",
54    note = "`list` is a frozen composition (issue #333). Compose each row from `icon` + \
55            `text` inside a `for-each` instead of the dedicated list — see \
56            crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
57            scheduled for removal in a future major version via `rustmotion migrate` (#335)."
58)]
59pub struct List {
60    pub items: Vec<ListItem>,
61    #[serde(default)]
62    pub variant: ListVariant,
63    #[serde(default = "default_gap")]
64    pub gap: f32,
65    #[serde(default = "default_icon_size")]
66    pub icon_size: f32,
67    #[serde(default = "default_icon_color")]
68    pub icon_color: String,
69    #[serde(default = "default_unchecked_color")]
70    pub unchecked_color: String,
71    #[serde(default = "default_width")]
72    pub width: f32,
73    #[serde(flatten)]
74    pub timing: TimingConfig,
75    #[serde(default)]
76    pub style: CssStyle,
77    #[serde(default)]
78    pub timeline: Vec<TimelineStep>,
79    #[serde(default)]
80    pub stagger: Option<f32>,
81}
82
83rustmotion_core::impl_traits!(List {
84    Animatable => animation,
85    Timed => timing,
86    Styled => style,
87});
88
89impl List {
90    fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 {
91        self.style.font_size_px_ctx(
92            &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
93            16.0,
94        )
95    }
96
97    fn make_font(&self, ctx: &PaintCtx) -> Option<skia_safe::Font> {
98        let font_style = skia_safe::FontStyle::normal();
99        let family = self.style.font_family.as_deref().unwrap_or("Inter");
100        let typeface = typeface_with_fallback(family, font_style).ok()?;
101        Some(skia_safe::Font::from_typeface(
102            typeface,
103            self.resolved_font_size(ctx),
104        ))
105    }
106
107    fn render_icon_svg(
108        &self,
109        canvas: &Canvas,
110        icon_id: &str,
111        color: &str,
112        x: f32,
113        y: f32,
114    ) -> Result<()> {
115        let icon_w = self.icon_size.round() as u32;
116        let icon_h = self.icon_size.round() as u32;
117        let cache_key = format!("icon:{}:{}:{}x{}", icon_id, color, icon_w, icon_h);
118
119        let cache = asset_cache();
120        let img = if let Some(cached) = cache.get(&cache_key) {
121            cached.clone()
122        } else if let Ok(svg_data) = fetch_icon_svg(icon_id, color, icon_w, icon_h) {
123            let opt = usvg::Options::default();
124            if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) {
125                let svg_size = tree.size();
126                if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_w, icon_h) {
127                    let sx = icon_w as f32 / svg_size.width();
128                    let sy = icon_h as f32 / svg_size.height();
129                    resvg::render(
130                        &tree,
131                        tiny_skia::Transform::from_scale(sx, sy),
132                        &mut pixmap.as_mut(),
133                    );
134                    let img_data = skia_safe::Data::new_copy(pixmap.data());
135                    let info = ImageInfo::new(
136                        (icon_w as i32, icon_h as i32),
137                        ColorType::RGBA8888,
138                        skia_safe::AlphaType::Premul,
139                        None,
140                    );
141                    if let Some(decoded) =
142                        skia_safe::images::raster_from_data(&info, img_data, icon_w as usize * 4)
143                    {
144                        cache.insert(cache_key, decoded.clone());
145                        decoded
146                    } else {
147                        return Ok(());
148                    }
149                } else {
150                    return Ok(());
151                }
152            } else {
153                return Ok(());
154            }
155        } else {
156            return Ok(());
157        };
158
159        let dst = Rect::from_xywh(x, y, self.icon_size, self.icon_size);
160        canvas.draw_image_rect(img, None, dst, &Paint::default());
161        Ok(())
162    }
163}
164
165impl List {
166    fn paint(&self, canvas: &Canvas, ctx: &PaintCtx) -> Result<()> {
167        let Some(font) = self.make_font(ctx) else {
168            return Ok(());
169        };
170        let font_size = self.resolved_font_size(ctx);
171        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
172        let text_color = self.style.color_str_or("#FFFFFF");
173        let mut text_paint = paint_from_hex(text_color);
174        text_paint.set_anti_alias(true);
175
176        let (_, metrics) = font.metrics();
177        let line_height = font_size * 1.3;
178        let left_margin = self.icon_size + 12.0;
179
180        let mut y_offset = 0.0;
181
182        for (i, item) in self.items.iter().enumerate() {
183            let text_y = y_offset + (line_height + (-metrics.ascent)) / 2.0;
184            let icon_y = y_offset + (line_height - self.icon_size) / 2.0;
185
186            match &self.variant {
187                ListVariant::Bullet => {
188                    let bullet_r = 4.0;
189                    let bullet_cx = self.icon_size / 2.0;
190                    let bullet_cy = y_offset + line_height / 2.0;
191                    let mut bullet_paint = paint_from_hex(&self.icon_color);
192                    bullet_paint.set_style(PaintStyle::Fill);
193                    bullet_paint.set_anti_alias(true);
194                    canvas.draw_circle((bullet_cx, bullet_cy), bullet_r, &bullet_paint);
195                }
196                ListVariant::Numbered => {
197                    let num_text = format!("{}.", i + 1);
198                    let mut num_paint = paint_from_hex(&self.icon_color);
199                    num_paint.set_anti_alias(true);
200                    draw_text_with_fallback(
201                        canvas,
202                        &num_text,
203                        &font,
204                        &emoji_font,
205                        0.0,
206                        0.0,
207                        text_y,
208                        &num_paint,
209                    );
210                }
211                ListVariant::Checklist => {
212                    let checked = item.checked.unwrap_or(false);
213                    if let Some(icon_id) = &item.icon {
214                        let color = if checked {
215                            &self.icon_color
216                        } else {
217                            &self.unchecked_color
218                        };
219                        self.render_icon_svg(canvas, icon_id, color, 0.0, icon_y)?;
220                    } else {
221                        let circle_r = self.icon_size / 2.0 - 2.0;
222                        let circle_cx = self.icon_size / 2.0;
223                        let circle_cy = y_offset + line_height / 2.0;
224                        if checked {
225                            let mut fill_paint = paint_from_hex(&self.icon_color);
226                            fill_paint.set_style(PaintStyle::Fill);
227                            fill_paint.set_anti_alias(true);
228                            canvas.draw_circle((circle_cx, circle_cy), circle_r, &fill_paint);
229                        } else {
230                            let mut stroke_paint = paint_from_hex(&self.unchecked_color);
231                            stroke_paint.set_style(PaintStyle::Stroke);
232                            stroke_paint.set_stroke_width(2.0);
233                            stroke_paint.set_anti_alias(true);
234                            canvas.draw_circle((circle_cx, circle_cy), circle_r, &stroke_paint);
235                        }
236                    }
237                }
238            }
239
240            draw_text_with_fallback(
241                canvas,
242                &item.text,
243                &font,
244                &emoji_font,
245                0.0,
246                left_margin,
247                text_y,
248                &text_paint,
249            );
250
251            y_offset += line_height + self.gap;
252        }
253
254        Ok(())
255    }
256}
257
258impl Painter for List {
259    fn paint_content(
260        &self,
261        canvas: &Canvas,
262        _layout: &BoxLayout,
263        _props: &AnimatedProperties,
264        ctx: &PaintCtx,
265    ) {
266        let _ = self.paint(canvas, ctx);
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use rustmotion_core::css::CssStyle;
274    use rustmotion_core::css::Length;
275
276    fn test_ctx() -> PaintCtx {
277        PaintCtx {
278            time: 0.0,
279            scenario_time: 0.0,
280            scene_duration: 1.0,
281            frame_index: 0,
282            fps: 30,
283            video_width: 400,
284            video_height: 200,
285            stagger_offset: 0.0,
286        }
287    }
288
289    #[test]
290    fn rem_font_size_paints_visible_ink() {
291        let list = List {
292            items: vec![ListItem {
293                text: "hello".to_string(),
294                icon: None,
295                checked: None,
296            }],
297            variant: ListVariant::Bullet,
298            gap: default_gap(),
299            icon_size: default_icon_size(),
300            icon_color: default_icon_color(),
301            unchecked_color: default_unchecked_color(),
302            width: default_width(),
303            timing: Default::default(),
304            style: CssStyle {
305                font_size: Some(Length::String("2rem".into())),
306                color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
307                ..Default::default()
308            },
309            timeline: Vec::new(),
310            stagger: None,
311        };
312        const W: i32 = 400;
313        const H: i32 = 200;
314        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
315        {
316            let canvas = surface.canvas();
317            list.paint(canvas, &test_ctx()).expect("paint succeeds");
318        }
319        let snapshot = surface.image_snapshot();
320        let info = skia_safe::ImageInfo::new(
321            (W, H),
322            skia_safe::ColorType::RGBA8888,
323            skia_safe::AlphaType::Premul,
324            None,
325        );
326        let mut buf = vec![0u8; (W * H * 4) as usize];
327        let ok = snapshot.read_pixels(
328            &info,
329            &mut buf,
330            (W * 4) as usize,
331            skia_safe::IPoint::new(0, 0),
332            skia_safe::image::CachingHint::Disallow,
333        );
334        assert!(ok, "pixel read should succeed");
335        let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count();
336        assert!(
337            lit > 20,
338            "list at font-size: 2rem must paint visible ink, got {lit} lit pixels"
339        );
340    }
341}