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