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)]
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 = skia_safe::Font::from_typeface(typeface, fs);
94 let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(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}