Skip to main content

rustmotion_components/
marquee.rs

1use rustmotion_core::css::CssStyle;
2use rustmotion_core::error::Result;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use skia_safe::{Canvas, Rect};
6
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
11    typeface_with_fallback,
12};
13use rustmotion_core::schema::TimelineStep;
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16fn default_speed() -> f32 {
17    100.0
18}
19
20fn default_font_size() -> f32 {
21    24.0
22}
23
24fn default_color() -> String {
25    "#FFFFFF".to_string()
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
29#[serde(rename_all = "snake_case")]
30#[derive(Default)]
31pub enum MarqueeDirection {
32    #[default]
33    Left,
34    Right,
35}
36
37#[derive(Debug, Serialize, Deserialize, JsonSchema)]
38pub struct Marquee {
39    pub content: String,
40    #[serde(default = "default_speed")]
41    pub speed: f32,
42    #[serde(default)]
43    pub direction: MarqueeDirection,
44    #[serde(default = "default_font_size")]
45    pub font_size: f32,
46    #[serde(default = "default_color")]
47    pub color: String,
48    #[serde(default)]
49    pub separator: Option<String>,
50    #[serde(flatten)]
51    pub timing: TimingConfig,
52    #[serde(default)]
53    pub style: CssStyle,
54    #[serde(default)]
55    pub timeline: Vec<TimelineStep>,
56    #[serde(default)]
57    pub stagger: Option<f32>,
58}
59
60rustmotion_core::impl_traits!(Marquee {
61    Animatable => animation,
62    Timed => timing,
63    Styled => style,
64});
65
66impl Marquee {
67    fn paint(
68        &self,
69        canvas: &Canvas,
70        layout_w: f32,
71        layout_h: f32,
72        time: f64,
73        ctx: &PaintCtx,
74    ) -> Result<()> {
75        let w = layout_w;
76        let h = layout_h;
77
78        // Resolved against a real per-frame viewport (`rem`/`vw`/`vh` now
79        // resolve instead of silently dropping to 0px — lot B, wave S).
80        // `em`/`%` on `font-size` itself remain approximate — see
81        // `crate::intrinsic::font_size_ctx`'s doc comment.
82        let fs = self.style.font_size_px_ctx(
83            &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
84            self.font_size,
85        );
86        let font_style = skia_safe::FontStyle::normal();
87        let family = self.style.font_family_or("Inter");
88        let typeface = typeface_with_fallback(family, font_style)?;
89        let font = skia_safe::Font::from_typeface(typeface, fs);
90        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, fs));
91
92        let separator = self.separator.as_deref().unwrap_or("     ");
93        let full_text = format!("{}{}", self.content, separator);
94        let text_w = measure_text_with_fallback(&full_text, &font, &emoji_font, 0.0);
95
96        if text_w < 1.0 {
97            return Ok(());
98        }
99
100        let color = self.style.color_str().unwrap_or(&self.color);
101        let mut text_paint = paint_from_hex(color);
102        text_paint.set_anti_alias(true);
103
104        let (_, metrics) = font.metrics();
105        let ascent = -metrics.ascent;
106        let text_y = (h + ascent) / 2.0;
107
108        // Calculate offset based on time and direction
109        let offset = (time as f32 * self.speed) % text_w;
110        let start_x = match self.direction {
111            MarqueeDirection::Left => -offset,
112            MarqueeDirection::Right => offset - text_w,
113        };
114
115        // Clip to bounds
116        canvas.save();
117        canvas.clip_rect(
118            Rect::from_xywh(0.0, 0.0, w, h),
119            skia_safe::ClipOp::Intersect,
120            false,
121        );
122
123        // Draw enough copies to fill the width
124        let copies = ((w / text_w).ceil() as i32 + 2).max(2);
125        for i in 0..copies {
126            let x = start_x + i as f32 * text_w;
127            if x > w {
128                break;
129            }
130            if x + text_w < 0.0 {
131                continue;
132            }
133            draw_text_with_fallback(
134                canvas,
135                &full_text,
136                &font,
137                &emoji_font,
138                0.0,
139                x,
140                text_y,
141                &text_paint,
142            );
143        }
144
145        canvas.restore();
146        Ok(())
147    }
148}
149
150impl Painter for Marquee {
151    fn paint_content(
152        &self,
153        canvas: &Canvas,
154        layout: &BoxLayout,
155        _props: &AnimatedProperties,
156        ctx: &PaintCtx,
157    ) {
158        let _ = self.paint(canvas, layout.width, layout.height, ctx.time, ctx);
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use rustmotion_core::css::CssStyle;
166    use rustmotion_core::css::Length;
167
168    fn test_ctx() -> PaintCtx {
169        PaintCtx {
170            time: 0.0,
171            scenario_time: 0.0,
172            scene_duration: 1.0,
173            frame_index: 0,
174            fps: 30,
175            video_width: 400,
176            video_height: 200,
177            stagger_offset: 0.0,
178        }
179    }
180
181    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────
182
183    #[test]
184    fn rem_font_size_paints_visible_ink() {
185        // Reproduction: `font-size: "2rem"` used to resolve to 0px via the
186        // context-free `font_size_px_or`.
187        let marquee = Marquee {
188            content: "hello world".to_string(),
189            speed: default_speed(),
190            direction: MarqueeDirection::default(),
191            font_size: default_font_size(),
192            color: default_color(),
193            separator: None,
194            timing: Default::default(),
195            style: CssStyle {
196                font_size: Some(Length::String("2rem".into())),
197                ..Default::default()
198            },
199            timeline: Vec::new(),
200            stagger: None,
201        };
202        const W: i32 = 400;
203        const H: i32 = 100;
204        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
205        {
206            let canvas = surface.canvas();
207            marquee
208                .paint(canvas, W as f32, H as f32, 0.0, &test_ctx())
209                .expect("paint succeeds");
210        }
211        let snapshot = surface.image_snapshot();
212        let info = skia_safe::ImageInfo::new(
213            (W, H),
214            skia_safe::ColorType::RGBA8888,
215            skia_safe::AlphaType::Premul,
216            None,
217        );
218        let mut buf = vec![0u8; (W * H * 4) as usize];
219        let ok = snapshot.read_pixels(
220            &info,
221            &mut buf,
222            (W * 4) as usize,
223            skia_safe::IPoint::new(0, 0),
224            skia_safe::image::CachingHint::Disallow,
225        );
226        assert!(ok, "pixel read should succeed");
227        let lit = buf.chunks_exact(4).filter(|p| p[3] > 0).count();
228        assert!(
229            lit > 20,
230            "marquee at font-size: 2rem must paint visible ink, got {lit} lit pixels"
231        );
232    }
233}