rustmotion-components 0.6.1

Component library for rustmotion (51 components)
use rustmotion_core::css::CssStyle;
use rustmotion_core::error::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::{Canvas, ColorType, ImageInfo, Paint, PaintStyle, Rect};

use rustmotion_core::engine::animator::AnimatedProperties;
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::engine::renderer::{
    asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, paint_from_hex,
    typeface_with_fallback,
};
use rustmotion_core::schema::TimelineStep;
use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};

fn default_gap() -> f32 {
    16.0
}
fn default_icon_size() -> f32 {
    20.0
}
fn default_icon_color() -> String {
    "#22C55E".to_string()
}
fn default_unchecked_color() -> String {
    "#6B7280".to_string()
}
fn default_width() -> f32 {
    400.0
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ListVariant {
    #[default]
    Bullet,
    Numbered,
    Checklist,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ListItem {
    pub text: String,
    #[serde(default)]
    pub icon: Option<String>,
    #[serde(default)]
    pub checked: Option<bool>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct List {
    pub items: Vec<ListItem>,
    #[serde(default)]
    pub variant: ListVariant,
    #[serde(default = "default_gap")]
    pub gap: f32,
    #[serde(default = "default_icon_size")]
    pub icon_size: f32,
    #[serde(default = "default_icon_color")]
    pub icon_color: String,
    #[serde(default = "default_unchecked_color")]
    pub unchecked_color: String,
    #[serde(default = "default_width")]
    pub width: f32,
    #[serde(flatten)]
    pub timing: TimingConfig,
    #[serde(default)]
    pub style: CssStyle,
    #[serde(default)]
    pub timeline: Vec<TimelineStep>,
    #[serde(default)]
    pub stagger: Option<f32>,
}

rustmotion_core::impl_traits!(List {
    Animatable => animation,
    Timed => timing,
    Styled => style,
});

impl List {
    /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/
    /// `vh` now resolve instead of silently dropping to 0px — lot B, wave
    /// S). `em`/`%` on `font-size` itself remain approximate — see
    /// `crate::intrinsic::font_size_ctx`'s doc comment.
    fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 {
        self.style.font_size_px_ctx(
            &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
            16.0,
        )
    }

    fn make_font(&self, ctx: &PaintCtx) -> Option<skia_safe::Font> {
        let font_style = skia_safe::FontStyle::normal();
        let family = self.style.font_family.as_deref().unwrap_or("Inter");
        let typeface = typeface_with_fallback(family, font_style).ok()?;
        Some(skia_safe::Font::from_typeface(
            typeface,
            self.resolved_font_size(ctx),
        ))
    }

    fn render_icon_svg(
        &self,
        canvas: &Canvas,
        icon_id: &str,
        color: &str,
        x: f32,
        y: f32,
    ) -> Result<()> {
        let icon_w = self.icon_size.round() as u32;
        let icon_h = self.icon_size.round() as u32;
        let cache_key = format!("icon:{}:{}:{}x{}", icon_id, color, icon_w, icon_h);

        let cache = asset_cache();
        let img = if let Some(cached) = cache.get(&cache_key) {
            cached.clone()
        } else if let Ok(svg_data) = fetch_icon_svg(icon_id, color, icon_w, icon_h) {
            let opt = usvg::Options::default();
            if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) {
                let svg_size = tree.size();
                if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_w, icon_h) {
                    let sx = icon_w as f32 / svg_size.width();
                    let sy = icon_h as f32 / svg_size.height();
                    resvg::render(
                        &tree,
                        tiny_skia::Transform::from_scale(sx, sy),
                        &mut pixmap.as_mut(),
                    );
                    let img_data = skia_safe::Data::new_copy(pixmap.data());
                    let info = ImageInfo::new(
                        (icon_w as i32, icon_h as i32),
                        ColorType::RGBA8888,
                        skia_safe::AlphaType::Premul,
                        None,
                    );
                    if let Some(decoded) =
                        skia_safe::images::raster_from_data(&info, img_data, icon_w as usize * 4)
                    {
                        cache.insert(cache_key, decoded.clone());
                        decoded
                    } else {
                        return Ok(());
                    }
                } else {
                    return Ok(());
                }
            } else {
                return Ok(());
            }
        } else {
            return Ok(());
        };

        let dst = Rect::from_xywh(x, y, self.icon_size, self.icon_size);
        canvas.draw_image_rect(img, None, dst, &Paint::default());
        Ok(())
    }
}

impl List {
    fn paint(&self, canvas: &Canvas, ctx: &PaintCtx) -> Result<()> {
        let Some(font) = self.make_font(ctx) else {
            return Ok(());
        };
        let font_size = self.resolved_font_size(ctx);
        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
        let text_color = self.style.color_str_or("#FFFFFF");
        let mut text_paint = paint_from_hex(text_color);
        text_paint.set_anti_alias(true);

        let (_, metrics) = font.metrics();
        let line_height = font_size * 1.3;
        let left_margin = self.icon_size + 12.0;

        let mut y_offset = 0.0;

        for (i, item) in self.items.iter().enumerate() {
            let text_y = y_offset + (line_height + (-metrics.ascent)) / 2.0;
            let icon_y = y_offset + (line_height - self.icon_size) / 2.0;

            match &self.variant {
                ListVariant::Bullet => {
                    let bullet_r = 4.0;
                    let bullet_cx = self.icon_size / 2.0;
                    let bullet_cy = y_offset + line_height / 2.0;
                    let mut bullet_paint = paint_from_hex(&self.icon_color);
                    bullet_paint.set_style(PaintStyle::Fill);
                    bullet_paint.set_anti_alias(true);
                    canvas.draw_circle((bullet_cx, bullet_cy), bullet_r, &bullet_paint);
                }
                ListVariant::Numbered => {
                    let num_text = format!("{}.", i + 1);
                    let mut num_paint = paint_from_hex(&self.icon_color);
                    num_paint.set_anti_alias(true);
                    draw_text_with_fallback(
                        canvas,
                        &num_text,
                        &font,
                        &emoji_font,
                        0.0,
                        0.0,
                        text_y,
                        &num_paint,
                    );
                }
                ListVariant::Checklist => {
                    let checked = item.checked.unwrap_or(false);
                    if let Some(icon_id) = &item.icon {
                        let color = if checked {
                            &self.icon_color
                        } else {
                            &self.unchecked_color
                        };
                        self.render_icon_svg(canvas, icon_id, color, 0.0, icon_y)?;
                    } else {
                        let circle_r = self.icon_size / 2.0 - 2.0;
                        let circle_cx = self.icon_size / 2.0;
                        let circle_cy = y_offset + line_height / 2.0;
                        if checked {
                            let mut fill_paint = paint_from_hex(&self.icon_color);
                            fill_paint.set_style(PaintStyle::Fill);
                            fill_paint.set_anti_alias(true);
                            canvas.draw_circle((circle_cx, circle_cy), circle_r, &fill_paint);
                        } else {
                            let mut stroke_paint = paint_from_hex(&self.unchecked_color);
                            stroke_paint.set_style(PaintStyle::Stroke);
                            stroke_paint.set_stroke_width(2.0);
                            stroke_paint.set_anti_alias(true);
                            canvas.draw_circle((circle_cx, circle_cy), circle_r, &stroke_paint);
                        }
                    }
                }
            }

            draw_text_with_fallback(
                canvas,
                &item.text,
                &font,
                &emoji_font,
                0.0,
                left_margin,
                text_y,
                &text_paint,
            );

            y_offset += line_height + self.gap;
        }

        Ok(())
    }
}

impl Painter for List {
    fn paint_content(
        &self,
        canvas: &Canvas,
        _layout: &BoxLayout,
        _props: &AnimatedProperties,
        ctx: &PaintCtx,
    ) {
        let _ = self.paint(canvas, ctx);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rustmotion_core::css::CssStyle;
    use rustmotion_core::css::Length;

    fn test_ctx() -> PaintCtx {
        PaintCtx {
            time: 0.0,
            scenario_time: 0.0,
            scene_duration: 1.0,
            frame_index: 0,
            fps: 30,
            video_width: 400,
            video_height: 200,
            stagger_offset: 0.0,
        }
    }

    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────

    #[test]
    fn rem_font_size_paints_visible_ink() {
        // Reproduction: `font-size: "2rem"` used to resolve to 0px via the
        // context-free `font_size_px_or`.
        let list = List {
            items: vec![ListItem {
                text: "hello".to_string(),
                icon: None,
                checked: None,
            }],
            variant: ListVariant::Bullet,
            gap: default_gap(),
            icon_size: default_icon_size(),
            icon_color: default_icon_color(),
            unchecked_color: default_unchecked_color(),
            width: default_width(),
            timing: Default::default(),
            style: CssStyle {
                font_size: Some(Length::String("2rem".into())),
                color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
                ..Default::default()
            },
            timeline: Vec::new(),
            stagger: None,
        };
        const W: i32 = 400;
        const H: i32 = 200;
        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
        {
            let canvas = surface.canvas();
            list.paint(canvas, &test_ctx()).expect("paint succeeds");
        }
        let snapshot = surface.image_snapshot();
        let info = skia_safe::ImageInfo::new(
            (W, H),
            skia_safe::ColorType::RGBA8888,
            skia_safe::AlphaType::Premul,
            None,
        );
        let mut buf = vec![0u8; (W * H * 4) as usize];
        let ok = snapshot.read_pixels(
            &info,
            &mut buf,
            (W * 4) as usize,
            skia_safe::IPoint::new(0, 0),
            skia_safe::image::CachingHint::Disallow,
        );
        assert!(ok, "pixel read should succeed");
        let lit = buf.chunks_exact(4).filter(|p| p[3] > 0).count();
        assert!(
            lit > 20,
            "list at font-size: 2rem must paint visible ink, got {lit} lit pixels"
        );
    }
}