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