rustmotion 0.5.0

A CLI tool that renders motion design videos from JSON scenarios. No browser, no Node.js — just a single Rust binary.
use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::{Canvas, Font, FontStyle};

use crate::engine::renderer::{font_mgr, paint_from_hex, draw_text_with_fallback, measure_text_with_fallback, emoji_typeface};
use crate::layout::{Constraints, LayoutNode};
use crate::schema::{FontStyleType, FontWeight, LayerStyle, TextAlign};
use crate::traits::{RenderContext, TimingConfig, Widget};

/// A single styled span within a rich_text component.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RichTextSpan {
    pub text: String,
    #[serde(default)]
    pub color: Option<String>,
    #[serde(default, rename = "font-size")]
    pub font_size: Option<f32>,
    #[serde(default, rename = "font-weight")]
    pub font_weight: Option<FontWeight>,
    #[serde(default, rename = "font-family")]
    pub font_family: Option<String>,
    #[serde(default, rename = "font-style")]
    pub font_style: Option<FontStyleType>,
    #[serde(default, rename = "letter-spacing")]
    pub letter_spacing: Option<f32>,
}

/// Rich text component: renders multiple styled spans on the same line(s).
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct RichText {
    pub spans: Vec<RichTextSpan>,
    #[serde(default)]
    pub max_width: Option<f32>,
    #[serde(flatten)]
    pub timing: TimingConfig,
    #[serde(default)]
    pub style: LayerStyle,
}

crate::impl_traits!(RichText {
    Animatable => style,
    Timed => timing,
    Styled => style,
});

/// Resolve a font for a span, inheriting from parent style defaults.
fn make_font(
    family: &str,
    weight: &FontWeight,
    font_style_type: &FontStyleType,
    size: f32,
) -> Font {
    let fm = font_mgr();
    let slant = match font_style_type {
        FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
        FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
        FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
    };
    let weight_val = match weight {
        FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
        FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
        FontWeight::Weight(w) => skia_safe::font_style::Weight::from(*w as i32),
    };
    let skia_style = FontStyle::new(weight_val, skia_safe::font_style::Width::NORMAL, slant);
    let typeface = fm
        .match_family_style(family, skia_style)
        .or_else(|| fm.match_family_style("Helvetica", skia_style))
        .or_else(|| fm.match_family_style("Arial", skia_style))
        .or_else(|| fm.match_family_style("sans-serif", skia_style))
        .unwrap_or_else(|| {
            fm.match_family_style(&fm.family_name(0), skia_style).unwrap()
        });
    Font::from_typeface(typeface, size)
}

/// Resolve line height from optional value and font size.
fn resolve_line_height(line_height: Option<f32>, font_size: f32) -> f32 {
    match line_height {
        Some(v) if v <= 10.0 => font_size * v,
        Some(v) => v,
        None => font_size * 1.3,
    }
}

/// A prepared span ready for rendering (with resolved font, paint, measurements).
struct PreparedSpan {
    text: String,
    font: Font,
    color: String,
    letter_spacing: f32,
    width: f32,
}

impl Widget for RichText {
    fn render(
        &self,
        canvas: &Canvas,
        layout: &LayoutNode,
        _ctx: &RenderContext,
        _props: &crate::engine::animator::AnimatedProperties,
    ) -> Result<()> {
        let default_size = self.style.font_size_or(48.0);
        let default_color = self.style.color_or("#FFFFFF");
        let default_family = self.style.font_family_or("Inter");
        let default_weight = self.style.font_weight_or(FontWeight::Normal);
        let default_font_style = self.style.font_style_or(FontStyleType::Normal);
        let align = self.style.text_align_or(TextAlign::Left);
        let line_height_val = resolve_line_height(self.style.line_height, default_size);

        let emoji_tf = emoji_typeface();

        // Prepare all spans with their fonts and measurements
        let prepared: Vec<PreparedSpan> = self.spans.iter().map(|span| {
            let size = span.font_size.unwrap_or(default_size);
            let family = span.font_family.as_deref().unwrap_or(default_family);
            let weight = span.font_weight.as_ref().unwrap_or(&default_weight);
            let fstyle = span.font_style.as_ref().unwrap_or(&default_font_style);
            let color = span.color.as_deref().unwrap_or(default_color).to_string();
            let letter_spacing = span.letter_spacing.unwrap_or(0.0);
            let font = make_font(family, weight, fstyle, size);
            let emoji_font = emoji_tf.as_ref().map(|tf| Font::from_typeface(tf.clone(), size));
            let width = measure_text_with_fallback(&span.text, &font, &emoji_font, letter_spacing);

            PreparedSpan { text: span.text.clone(), font, color, letter_spacing, width }
        }).collect();

        let wrap_width = if layout.width.is_finite() && layout.width > 0.0 {
            match self.max_width {
                Some(mw) => mw.min(layout.width),
                None => layout.width,
            }
        } else {
            self.max_width.unwrap_or(f32::INFINITY)
        };

        // Simple line-breaking: pack spans into lines
        // A span stays on the current line if it fits; otherwise start a new line
        struct LineSpan {
            span_idx: usize,
            x: f32,
        }
        struct Line {
            spans: Vec<LineSpan>,
            width: f32,
        }

        let mut lines: Vec<Line> = vec![Line { spans: vec![], width: 0.0 }];

        for (i, ps) in prepared.iter().enumerate() {
            let current = lines.last_mut().unwrap();
            if current.width + ps.width > wrap_width && !current.spans.is_empty() {
                // Start new line
                lines.push(Line {
                    spans: vec![LineSpan { span_idx: i, x: 0.0 }],
                    width: ps.width,
                });
            } else {
                let x = current.width;
                current.spans.push(LineSpan { span_idx: i, x });
                current.width += ps.width;
            }
        }

        let align_width = if layout.width.is_finite() && layout.width > 0.0 {
            layout.width
        } else {
            lines.iter().map(|l| l.width).fold(0.0f32, f32::max)
        };

        // Find max ascent for baseline alignment
        let max_ascent = prepared.iter().map(|ps| {
            let (_, m) = ps.font.metrics();
            -m.ascent
        }).fold(0.0f32, f32::max);

        let baseline_offset = (line_height_val + max_ascent) / 2.0;

        for (line_idx, line) in lines.iter().enumerate() {
            let line_x_offset = match align {
                TextAlign::Left => 0.0,
                TextAlign::Center => (align_width - line.width) / 2.0,
                TextAlign::Right => align_width - line.width,
            };

            let y = line_idx as f32 * line_height_val + baseline_offset;

            for ls in &line.spans {
                let ps = &prepared[ls.span_idx];
                let paint = paint_from_hex(&ps.color);
                let emoji_font = emoji_tf.as_ref().map(|tf| Font::from_typeface(tf.clone(), ps.font.size()));

                draw_text_with_fallback(
                    canvas,
                    &ps.text,
                    &ps.font,
                    &emoji_font,
                    ps.letter_spacing,
                    line_x_offset + ls.x,
                    y,
                    &paint,
                );
            }
        }

        Ok(())
    }

    fn measure(&self, _constraints: &Constraints) -> (f32, f32) {
        let default_size = self.style.font_size_or(48.0);
        let default_family = self.style.font_family_or("Inter");
        let default_weight = self.style.font_weight_or(FontWeight::Normal);
        let default_font_style = self.style.font_style_or(FontStyleType::Normal);
        let line_height_val = resolve_line_height(self.style.line_height, default_size);

        let emoji_tf = emoji_typeface();

        let mut total_width = 0.0f32;
        for span in &self.spans {
            let size = span.font_size.unwrap_or(default_size);
            let family = span.font_family.as_deref().unwrap_or(default_family);
            let weight = span.font_weight.as_ref().unwrap_or(&default_weight);
            let fstyle = span.font_style.as_ref().unwrap_or(&default_font_style);
            let letter_spacing = span.letter_spacing.unwrap_or(0.0);
            let font = make_font(family, weight, fstyle, size);
            let emoji_font = emoji_tf.as_ref().map(|tf| Font::from_typeface(tf.clone(), size));
            total_width += measure_text_with_fallback(&span.text, &font, &emoji_font, letter_spacing);
        }

        // Simple single-line measurement (wrapping handled at render time)
        let wrap_width = self.max_width.unwrap_or(total_width);
        let num_lines = (total_width / wrap_width).ceil().max(1.0) as usize;
        let h = num_lines as f32 * line_height_val;

        (total_width.min(wrap_width), h)
    }
}