1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::gradient::{self, Colors, Gradient};
4use skia_safe::{Canvas, Color4f, Font, FontStyle, Point};
5
6use rustmotion_core::css::style::{
7 FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw,
8 WhiteSpace as CssWhiteSpace,
9};
10use rustmotion_core::css::CssStyle;
11use rustmotion_core::engine::animator::AnimatedProperties;
12use rustmotion_core::engine::layout_pass::BoxLayout;
13use rustmotion_core::engine::renderer::{
14 draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
15 parse_hex_color, typeface_with_fallback, wrap_text_with_tracking,
16};
17use rustmotion_core::schema::TimelineStep;
18use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
19
20fn default_colors() -> Vec<String> {
21 vec!["#3B82F6".to_string(), "#8B5CF6".to_string()]
22}
23
24fn default_angle() -> f32 {
25 90.0
26}
27
28fn default_speed() -> f32 {
29 0.5
30}
31
32#[derive(Debug, Serialize, Deserialize, JsonSchema)]
33pub struct GradientText {
34 pub content: String,
35 #[serde(default = "default_colors")]
36 pub colors: Vec<String>,
37 #[serde(default = "default_angle")]
38 pub angle: f32,
39 #[serde(default)]
40 pub animate_angle: bool,
41 #[serde(default = "default_speed")]
42 pub speed: f32,
43 #[serde(flatten)]
44 pub timing: TimingConfig,
45 #[serde(default)]
46 pub style: CssStyle,
47 #[serde(default)]
48 pub timeline: Vec<TimelineStep>,
49 #[serde(default)]
50 pub stagger: Option<f32>,
51}
52
53rustmotion_core::impl_traits!(GradientText {
54 Animatable => animation,
55 Timed => timing,
56 Styled => style,
57});
58
59impl GradientText {
60 fn resolve_typeface(&self) -> Option<skia_safe::Typeface> {
66 let font_family = self.style.font_family_or("Inter");
67
68 let slant = match self.style.font_style {
69 Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic,
70 Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique,
71 _ => skia_safe::font_style::Slant::Upright,
72 };
73 let weight = match &self.style.font_weight {
74 Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
75 skia_safe::font_style::Weight::BOLD
76 }
77 Some(CssFontWeight::Number(n)) => skia_safe::font_style::Weight::from(*n as i32),
78 _ => skia_safe::font_style::Weight::NORMAL,
79 };
80 let skia_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
81
82 typeface_with_fallback(font_family, skia_style).ok()
83 }
84}
85
86impl GradientText {
87 fn paint(
88 &self,
89 canvas: &Canvas,
90 layout_width: f32,
91 content_height: Option<f32>,
92 time: f64,
93 ctx: &PaintCtx,
94 ) {
95 if self.content.is_empty() || self.colors.is_empty() {
96 return;
97 }
98
99 let base_ctx = crate::intrinsic::font_size_ctx(
106 ctx.video_width as f32,
107 ctx.video_height as f32,
108 layout_width.max(0.0),
109 );
110 let mut font_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
111 let Some(typeface) = self.resolve_typeface() else {
112 return;
113 };
114 let type_ctx = rustmotion_core::css::units::LengthContext {
125 font_size,
126 ..base_ctx
127 };
128 let mut line_height_val = self.style.line_height_for_ctx(font_size, &type_ctx);
129 let mut letter_spacing = self.style.letter_spacing_px_ctx(&type_ctx);
130
131 let nowrap = matches!(
137 self.style.white_space,
138 Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
139 );
140 let box_width = (layout_width.is_finite() && layout_width > 0.0).then_some(layout_width);
141 let wrap_at = if nowrap { None } else { box_width };
142
143 if matches!(self.style.text_autofit, Some(true)) {
149 let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite());
150 let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit(
151 &self.content,
152 &typeface,
153 font_size,
154 letter_spacing,
155 line_height_val,
156 !nowrap,
157 box_width,
158 declared_height,
159 );
160 font_size = fs;
161 letter_spacing = ls;
162 line_height_val = lh;
163 }
164
165 let font = Font::from_typeface(typeface, font_size);
166 let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
167
168 let lines =
171 wrap_text_with_tracking(&self.content, &font, &emoji_font, wrap_at, letter_spacing);
172
173 let (_, metrics) = font.metrics();
178 let ascent = -metrics.ascent;
179 let descent = metrics.descent;
180 let text_w = lines
181 .iter()
182 .map(|l| measure_text_with_fallback(l, &font, &emoji_font, letter_spacing))
183 .fold(0.0f32, f32::max);
184 let text_h = (lines.len().max(1) - 1) as f32 * line_height_val + ascent + descent;
185
186 let angle = if self.animate_angle {
188 self.angle + time as f32 * self.speed * 360.0
189 } else {
190 self.angle
191 };
192
193 let angle_rad = angle * std::f32::consts::PI / 180.0;
195 let cx = text_w / 2.0;
196 let cy = text_h / 2.0;
197 let half_diag = (text_w.powi(2) + text_h.powi(2)).sqrt() / 2.0;
198 let start = Point::new(
199 cx - angle_rad.cos() * half_diag,
200 cy - angle_rad.sin() * half_diag,
201 );
202 let end = Point::new(
203 cx + angle_rad.cos() * half_diag,
204 cy + angle_rad.sin() * half_diag,
205 );
206
207 let skia_colors: Vec<skia_safe::Color> = self
209 .colors
210 .iter()
211 .map(|hex| {
212 let (r, g, b, a) = parse_hex_color(hex);
213 skia_safe::Color::from_argb(a, r, g, b)
214 })
215 .collect();
216
217 let positions: Option<&[f32]> = None;
219 let colors4f: Vec<Color4f> = skia_colors.iter().map(|c| Color4f::from(*c)).collect();
220 let stops = Colors::new(&colors4f, positions, skia_safe::TileMode::Clamp, None);
221 let grad = Gradient::new(stops, gradient::Interpolation::default());
222 let shader = gradient::shaders::linear_gradient((start, end), &grad, None);
223
224 let fill_paint = match shader {
225 Some(shader) => {
226 let mut p = skia_safe::Paint::default();
227 p.set_anti_alias(true);
228 p.set_shader(shader);
229 p
230 }
231 None => {
232 let mut p = paint_from_hex(&self.colors[0]);
234 p.set_anti_alias(true);
235 p
236 }
237 };
238
239 for (i, line) in lines.iter().enumerate() {
240 if line.is_empty() {
241 continue;
242 }
243 let y = i as f32 * line_height_val + ascent;
244 draw_text_with_fallback(
245 canvas,
246 line,
247 &font,
248 &emoji_font,
249 letter_spacing,
250 0.0,
251 y,
252 &fill_paint,
253 );
254 }
255 }
256}
257
258impl Painter for GradientText {
259 fn paint_content(
260 &self,
261 canvas: &Canvas,
262 layout: &BoxLayout,
263 _props: &AnimatedProperties,
264 ctx: &PaintCtx,
265 ) {
266 let (_, _, _, content_height) = layout.content_box();
269 let content_height =
270 (content_height > 0.0 && content_height.is_finite()).then_some(content_height);
271 self.paint(canvas, layout.width, content_height, ctx.time, ctx);
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use rustmotion_core::css::style::CssStyle;
279 use rustmotion_core::css::Length;
280
281 fn make_gradient_text(content: &str, white_space: Option<CssWhiteSpace>) -> GradientText {
282 GradientText {
283 content: content.into(),
284 colors: default_colors(),
285 angle: default_angle(),
286 animate_angle: false,
287 speed: default_speed(),
288 timing: Default::default(),
289 style: CssStyle {
290 font_size: Some(Length::Px(28.0)),
291 white_space,
292 ..Default::default()
293 },
294 timeline: Vec::new(),
295 stagger: None,
296 }
297 }
298
299 fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec<u8> {
300 let snapshot = surface.image_snapshot();
301 let info = skia_safe::ImageInfo::new(
302 (width, height),
303 skia_safe::ColorType::RGBA8888,
304 skia_safe::AlphaType::Premul,
305 None,
306 );
307 let mut buf = vec![0u8; (width * height * 4) as usize];
308 let ok = snapshot.read_pixels(
309 &info,
310 &mut buf,
311 (width * 4) as usize,
312 skia_safe::IPoint::new(0, 0),
313 skia_safe::image::CachingHint::Disallow,
314 );
315 assert!(ok, "pixel read should succeed");
316 (0..(width * height) as usize)
317 .map(|i| buf[i * 4 + 3])
318 .collect()
319 }
320
321 fn test_ctx() -> PaintCtx {
322 PaintCtx {
323 time: 0.0,
324 scenario_time: 0.0,
325 scene_duration: 1.0,
326 frame_index: 0,
327 fps: 30,
328 video_width: 1920,
329 video_height: 1080,
330 stagger_offset: 0.0,
331 }
332 }
333
334 fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool {
335 for y in y0..y1 {
336 for x in x0..x1 {
337 if grid[(y * surface_width + x) as usize] > 0 {
338 return true;
339 }
340 }
341 }
342 false
343 }
344
345 #[test]
346 fn nowrap_paints_a_single_line_past_the_layout_width() {
347 let gt = make_gradient_text(
350 "the quick brown fox jumps over the lazy dog",
351 Some(CssWhiteSpace::Nowrap),
352 );
353 const W: i32 = 600;
354 const H: i32 = 200;
355 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
356 let canvas = surface.canvas();
357 gt.paint(canvas, 80.0, None, 0.0, &test_ctx());
358 let grid = alpha_grid(&mut surface, W, H);
359
360 assert!(
361 has_ink_in(&grid, W, 300, W, 0, 45),
362 "nowrap gradient_text must paint past its 80px box on line 1"
363 );
364 assert!(
365 !has_ink_in(&grid, W, 0, W, 55, H),
366 "nowrap gradient_text must stay on a single line"
367 );
368 }
369
370 #[test]
371 fn normal_white_space_wraps_within_the_layout_width() {
372 let gt = make_gradient_text("the quick brown fox jumps over the lazy dog", None);
373 const W: i32 = 600;
374 const H: i32 = 200;
375 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
376 let canvas = surface.canvas();
377 gt.paint(canvas, 80.0, None, 0.0, &test_ctx());
378 let grid = alpha_grid(&mut surface, W, H);
379
380 assert!(
381 !has_ink_in(&grid, W, 300, W, 0, 45),
382 "wrapped gradient_text must not reach x∈[300,600) on line 1 within an 80px box"
383 );
384 assert!(
385 has_ink_in(&grid, W, 0, W, 55, H),
386 "wrapped gradient_text must spill onto a second line within the box width"
387 );
388 }
389
390 #[test]
391 fn rem_font_size_paints_visible_ink() {
392 let gt = GradientText {
396 content: "HELLO".into(),
397 colors: default_colors(),
398 angle: default_angle(),
399 animate_angle: false,
400 speed: default_speed(),
401 timing: Default::default(),
402 style: CssStyle {
403 font_size: Some(Length::String("2rem".into())),
404 ..Default::default()
405 },
406 timeline: Vec::new(),
407 stagger: None,
408 };
409 const W: i32 = 400;
410 const H: i32 = 200;
411 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
412 let canvas = surface.canvas();
413 gt.paint(canvas, 300.0, None, 0.0, &test_ctx());
414 let grid = alpha_grid(&mut surface, W, H);
415
416 assert!(
417 has_ink_in(&grid, W, 0, W, 0, 60),
418 "gradient_text at font-size: 2rem must paint visible ink"
419 );
420 }
421
422 fn autofit_gradient_text(
425 content: &str,
426 font_size: f32,
427 white_space: Option<CssWhiteSpace>,
428 ) -> GradientText {
429 let mut gt = make_gradient_text(content, white_space);
430 gt.style.font_size = Some(Length::Px(font_size));
431 gt.style.text_autofit = Some(true);
432 gt
433 }
434
435 fn max_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
436 let mut max_x: Option<i32> = None;
437 for y in 0..height {
438 for x in (0..surface_width).rev() {
439 if grid[(y * surface_width + x) as usize] > 0 {
440 max_x = Some(max_x.map_or(x, |m| m.max(x)));
441 break;
442 }
443 }
444 }
445 max_x
446 }
447
448 #[test]
449 fn autofit_shrinks_a_nowrap_line_to_fit_and_paint_agrees_with_measure() {
450 use crate::intrinsic::GradientTextIntrinsic;
451 use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
452
453 let gt = autofit_gradient_text(
454 "the quick brown fox jumps over the lazy dog",
455 90.0,
456 Some(CssWhiteSpace::Nowrap),
457 );
458 const BOX_W: f32 = 300.0; let (measured_w, _) = GradientTextIntrinsic::from_gradient_text(>).measure(
461 (None, None),
462 (AvailableSpace::Definite(BOX_W), AvailableSpace::MaxContent),
463 );
464 assert!(
465 measured_w <= BOX_W + 0.5,
466 "GradientTextIntrinsic itself must report a fit once autofit is on, got {measured_w}"
467 );
468
469 const W: i32 = 900;
470 const H: i32 = 300;
471 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
472 let canvas = surface.canvas();
473 gt.paint(canvas, BOX_W, None, 0.0, &test_ctx());
474 let grid = alpha_grid(&mut surface, W, H);
475 let ink_right = max_ink_x(&grid, W, H).expect("gradient_text must paint some ink");
476
477 assert!(
478 (ink_right as f32) <= measured_w + 3.0,
479 "painted ink (right edge {ink_right}) must not exceed the box the intrinsic reserved \
480 ({measured_w})"
481 );
482 assert!(
483 (ink_right as f32) >= measured_w - 15.0,
484 "painted ink (right edge {ink_right}) should land close to the measured width \
485 ({measured_w}) — a big gap means measure and paint disagree on the resolved size"
486 );
487 }
488
489 #[test]
490 fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() {
491 let gt = make_gradient_text(
495 "the quick brown fox jumps over the lazy dog",
496 Some(CssWhiteSpace::Nowrap),
497 );
498 const W: i32 = 600;
499 const H: i32 = 200;
500 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
501 let canvas = surface.canvas();
502 gt.paint(canvas, 80.0, Some(45.0), 0.0, &test_ctx());
503 let grid = alpha_grid(&mut surface, W, H);
504
505 assert!(
506 has_ink_in(&grid, W, 300, W, 0, 45),
507 "without text-autofit, nowrap gradient_text must still bleed past its box"
508 );
509 }
510
511 #[test]
512 fn autofit_is_stable_across_frames_for_fixed_content() {
513 let gt = autofit_gradient_text(
517 "the quick brown fox jumps over the lazy dog",
518 90.0,
519 Some(CssWhiteSpace::Nowrap),
520 );
521 const W: i32 = 900;
522 const H: i32 = 300;
523
524 let render_at = |t: f64| -> Vec<u8> {
525 let mut surface =
526 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
527 let canvas = surface.canvas();
528 gt.paint(canvas, 300.0, Some(60.0), t, &test_ctx());
529 alpha_grid(&mut surface, W, H)
530 };
531
532 let frame_a = render_at(0.0);
533 let frame_b = render_at(0.9);
534 assert_eq!(
535 frame_a, frame_b,
536 "fixed content in a fixed box must render byte-identically regardless of ctx.time"
537 );
538 }
539}