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 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 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 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 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 #[test]
184 fn rem_font_size_paints_visible_ink() {
185 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}