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    subpixel_font, 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)]
38#[deprecated(
39    since = "0.7.1",
40    note = "`marquee` is a frozen composition (issue #333). Compose continuous scroll from a \
41            `text`/`flex` row whose translate is keyframed/looped past the frame edge instead \
42            — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \
43            compatibility; scheduled for removal in a future major version via \
44            `rustmotion migrate` (#335)."
45)]
46pub struct Marquee {
47    pub content: String,
48    #[serde(default = "default_speed")]
49    pub speed: f32,
50    #[serde(default)]
51    pub direction: MarqueeDirection,
52    #[serde(default = "default_font_size")]
53    pub font_size: f32,
54    #[serde(default = "default_color")]
55    pub color: String,
56    #[serde(default)]
57    pub separator: Option<String>,
58    #[serde(flatten)]
59    pub timing: TimingConfig,
60    #[serde(default)]
61    pub style: CssStyle,
62    #[serde(default)]
63    pub timeline: Vec<TimelineStep>,
64    #[serde(default)]
65    pub stagger: Option<f32>,
66}
67
68rustmotion_core::impl_traits!(Marquee {
69    Animatable => animation,
70    Timed => timing,
71    Styled => style,
72});
73
74impl Marquee {
75    fn paint(
76        &self,
77        canvas: &Canvas,
78        layout_w: f32,
79        layout_h: f32,
80        time: f64,
81        ctx: &PaintCtx,
82    ) -> Result<()> {
83        let w = layout_w;
84        let h = layout_h;
85
86        let fs = self.style.font_size_px_ctx(
87            &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
88            self.font_size,
89        );
90        let font_style = skia_safe::FontStyle::normal();
91        let family = self.style.font_family_or("Inter");
92        let typeface = typeface_with_fallback(family, font_style)?;
93        let font = subpixel_font(typeface, fs);
94        let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, fs));
95
96        let separator = self.separator.as_deref().unwrap_or("     ");
97        let full_text = format!("{}{}", self.content, separator);
98        let text_w = measure_text_with_fallback(&full_text, &font, &emoji_font, 0.0);
99
100        if text_w < 1.0 {
101            return Ok(());
102        }
103
104        let color = self.style.color_str().unwrap_or(&self.color);
105        let mut text_paint = paint_from_hex(color);
106        text_paint.set_anti_alias(true);
107
108        let (_, metrics) = font.metrics();
109        let ascent = -metrics.ascent;
110        let text_y = (h + ascent) / 2.0;
111
112        let offset = (time as f32 * self.speed) % text_w;
113        let start_x = match self.direction {
114            MarqueeDirection::Left => -offset,
115            MarqueeDirection::Right => offset - text_w,
116        };
117
118        canvas.save();
119        canvas.clip_rect(
120            Rect::from_xywh(0.0, 0.0, w, h),
121            skia_safe::ClipOp::Intersect,
122            false,
123        );
124
125        let copies = ((w / text_w).ceil() as i32 + 2).max(2);
126        for i in 0..copies {
127            let x = start_x + i as f32 * text_w;
128            if x > w {
129                break;
130            }
131            if x + text_w < 0.0 {
132                continue;
133            }
134            draw_text_with_fallback(
135                canvas,
136                &full_text,
137                &font,
138                &emoji_font,
139                0.0,
140                x,
141                text_y,
142                &text_paint,
143            );
144        }
145
146        canvas.restore();
147        Ok(())
148    }
149}
150
151impl Painter for Marquee {
152    fn paint_content(
153        &self,
154        canvas: &Canvas,
155        layout: &BoxLayout,
156        _props: &AnimatedProperties,
157        ctx: &PaintCtx,
158    ) {
159        let _ = self.paint(canvas, layout.width, layout.height, ctx.time, ctx);
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use rustmotion_core::css::CssStyle;
167    use rustmotion_core::css::Length;
168
169    fn test_ctx() -> PaintCtx {
170        PaintCtx {
171            time: 0.0,
172            scenario_time: 0.0,
173            scene_duration: 1.0,
174            frame_index: 0,
175            fps: 30,
176            video_width: 400,
177            video_height: 200,
178            stagger_offset: 0.0,
179        }
180    }
181
182    #[test]
183    fn rem_font_size_paints_visible_ink() {
184        let marquee = Marquee {
185            content: "hello world".to_string(),
186            speed: default_speed(),
187            direction: MarqueeDirection::default(),
188            font_size: default_font_size(),
189            color: default_color(),
190            separator: None,
191            timing: Default::default(),
192            style: CssStyle {
193                font_size: Some(Length::String("2rem".into())),
194                ..Default::default()
195            },
196            timeline: Vec::new(),
197            stagger: None,
198        };
199        const W: i32 = 400;
200        const H: i32 = 100;
201        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
202        {
203            let canvas = surface.canvas();
204            marquee
205                .paint(canvas, W as f32, H as f32, 0.0, &test_ctx())
206                .expect("paint succeeds");
207        }
208        let snapshot = surface.image_snapshot();
209        let info = skia_safe::ImageInfo::new(
210            (W, H),
211            skia_safe::ColorType::RGBA8888,
212            skia_safe::AlphaType::Premul,
213            None,
214        );
215        let mut buf = vec![0u8; (W * H * 4) as usize];
216        let ok = snapshot.read_pixels(
217            &info,
218            &mut buf,
219            (W * 4) as usize,
220            skia_safe::IPoint::new(0, 0),
221            skia_safe::image::CachingHint::Disallow,
222        );
223        assert!(ok, "pixel read should succeed");
224        let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count();
225        assert!(
226            lit > 20,
227            "marquee at font-size: 2rem must paint visible ink, got {lit} lit pixels"
228        );
229    }
230}