Skip to main content

rustmotion_components/
gradient_text.rs

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    TextAlign as CssTextAlign, 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::{TextAlign, 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/// An explicit color stop on a `gradient_text` ramp.
33#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
34pub struct GradientTextStop {
35    /// Hex color at this stop, e.g. `"#7C3AED"`.
36    pub color: String,
37    /// Position along the gradient line, from `0.0` (first stop) to `1.0` (last).
38    /// `offset`, the name a background's gradient stops use for the same
39    /// value, is also accepted.
40    #[serde(alias = "offset")]
41    pub position: f32,
42}
43
44#[derive(Debug, Serialize, Deserialize, JsonSchema)]
45pub struct GradientText {
46    pub content: String,
47    #[serde(default = "default_colors")]
48    pub colors: Vec<String>,
49    /// Explicit stop positions along the ramp. When omitted, `colors` are spaced evenly.
50    #[serde(default)]
51    pub stops: Option<Vec<GradientTextStop>>,
52    #[serde(default = "default_angle")]
53    pub angle: f32,
54    #[serde(default)]
55    pub animate_angle: bool,
56    #[serde(default = "default_speed")]
57    pub speed: f32,
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!(GradientText {
69    Animatable => animation,
70    Timed => timing,
71    Styled => style,
72});
73
74impl GradientText {
75    fn resolve_typeface(&self) -> Option<skia_safe::Typeface> {
76        let font_family = self.style.font_family_or("Inter");
77
78        let slant = match self.style.font_style {
79            Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic,
80            Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique,
81            _ => skia_safe::font_style::Slant::Upright,
82        };
83        let weight = match &self.style.font_weight {
84            Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
85                skia_safe::font_style::Weight::BOLD
86            }
87            Some(CssFontWeight::Number(n)) => skia_safe::font_style::Weight::from(*n as i32),
88            _ => skia_safe::font_style::Weight::NORMAL,
89        };
90        let skia_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
91
92        typeface_with_fallback(font_family, skia_style).ok()
93    }
94}
95
96impl GradientText {
97    fn paint(
98        &self,
99        canvas: &Canvas,
100        layout_width: f32,
101        content_height: Option<f32>,
102        time: f64,
103        props: &AnimatedProperties,
104        ctx: &PaintCtx,
105    ) {
106        let has_stops = self.stops.as_ref().is_some_and(|s| !s.is_empty());
107        if self.content.is_empty() || (self.colors.is_empty() && !has_stops) {
108            return;
109        }
110
111        let base_ctx = crate::intrinsic::font_size_ctx(
112            ctx.video_width as f32,
113            ctx.video_height as f32,
114            layout_width.max(0.0),
115        );
116        let mut font_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
117        let Some(typeface) = self.resolve_typeface() else {
118            return;
119        };
120        let type_ctx = rustmotion_core::css::units::LengthContext {
121            font_size,
122            ..base_ctx
123        };
124        let mut line_height_val = self.style.line_height_for_ctx(font_size, &type_ctx);
125        let mut letter_spacing = self.style.letter_spacing_px_ctx(&type_ctx);
126
127        let nowrap = matches!(
128            self.style.white_space,
129            Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
130        );
131        let box_width = (layout_width.is_finite() && layout_width > 0.0).then_some(layout_width);
132        let wrap_at = if nowrap { None } else { box_width };
133
134        if matches!(self.style.text_autofit, Some(true)) {
135            let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite());
136            let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit(
137                &self.content,
138                &typeface,
139                font_size,
140                letter_spacing,
141                line_height_val,
142                !nowrap,
143                box_width,
144                declared_height,
145            );
146            font_size = fs;
147            letter_spacing = ls;
148            line_height_val = lh;
149        }
150
151        let font = Font::from_typeface(typeface, font_size);
152        let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
153
154        let lines =
155            wrap_text_with_tracking(&self.content, &font, &emoji_font, wrap_at, letter_spacing);
156
157        let (_, metrics) = font.metrics();
158        let ascent = -metrics.ascent;
159        let descent = metrics.descent;
160        let text_w = lines
161            .iter()
162            .map(|l| measure_text_with_fallback(l, &font, &emoji_font, letter_spacing))
163            .fold(0.0f32, f32::max);
164        let text_h = (lines.len().max(1) - 1) as f32 * line_height_val + ascent + descent;
165
166        let align_width = if layout_width.is_finite() && layout_width > 0.0 {
167            layout_width
168        } else {
169            text_w
170        };
171        let align = self.style.text_align.unwrap_or(CssTextAlign::Left);
172        let line_x = |advance: f32| match align {
173            CssTextAlign::Center => (align_width - advance) / 2.0,
174            CssTextAlign::Right | CssTextAlign::End => align_width - advance,
175            _ => 0.0,
176        };
177        let block_x = line_x(text_w);
178
179        let angle = if self.animate_angle {
180            self.angle + time as f32 * self.speed * 360.0
181        } else {
182            self.angle
183        };
184
185        let angle_rad = angle * std::f32::consts::PI / 180.0;
186        let (dir_x, dir_y) = (angle_rad.sin(), -angle_rad.cos());
187        let cx = block_x + text_w / 2.0;
188        let cy = text_h / 2.0;
189        let half_len = ((text_w * angle_rad.sin()).abs() + (text_h * angle_rad.cos()).abs()) / 2.0;
190        let start = Point::new(cx - dir_x * half_len, cy - dir_y * half_len);
191        let end = Point::new(cx + dir_x * half_len, cy + dir_y * half_len);
192
193        let (skia_colors, positions_vec): (Vec<skia_safe::Color>, Option<Vec<f32>>) =
194            if let Some(explicit_stops) = self.stops.as_ref().filter(|s| !s.is_empty()) {
195                let colors = explicit_stops
196                    .iter()
197                    .map(|stop| {
198                        let (r, g, b, a) = parse_hex_color(&stop.color);
199                        skia_safe::Color::from_argb(a, r, g, b)
200                    })
201                    .collect();
202                let positions = explicit_stops.iter().map(|stop| stop.position).collect();
203                (colors, Some(positions))
204            } else {
205                let colors = self
206                    .colors
207                    .iter()
208                    .map(|hex| {
209                        let (r, g, b, a) = parse_hex_color(hex);
210                        skia_safe::Color::from_argb(a, r, g, b)
211                    })
212                    .collect();
213                (colors, None)
214            };
215
216        let colors4f: Vec<Color4f> = skia_colors.iter().map(|c| Color4f::from(*c)).collect();
217        let gradient_stops = Colors::new(
218            &colors4f,
219            positions_vec.as_deref(),
220            skia_safe::TileMode::Clamp,
221            None,
222        );
223        let grad = Gradient::new(gradient_stops, gradient::Interpolation::default());
224        let shader = gradient::shaders::linear_gradient((start, end), &grad, None);
225
226        let fill_paint = match shader {
227            Some(shader) => {
228                let mut p = skia_safe::Paint::default();
229                p.set_anti_alias(true);
230                p.set_shader(shader);
231                p
232            }
233            None => {
234                let fallback_hex = self
235                    .stops
236                    .as_ref()
237                    .and_then(|s| s.first())
238                    .map(|stop| stop.color.as_str())
239                    .or_else(|| self.colors.first().map(String::as_str))
240                    .unwrap_or("#FFFFFF");
241                let mut p = paint_from_hex(fallback_hex);
242                p.set_anti_alias(true);
243                p
244            }
245        };
246
247        if let Some(ref resolved) = props.char_animation {
248            let schema_align = match align {
249                CssTextAlign::Center => TextAlign::Center,
250                CssTextAlign::Right | CssTextAlign::End => TextAlign::Right,
251                _ => TextAlign::Left,
252            };
253            crate::intrinsic::render_char_animation(
254                canvas,
255                &font,
256                &emoji_font,
257                &fill_paint,
258                letter_spacing,
259                schema_align,
260                align_width,
261                line_height_val,
262                ascent,
263                &lines,
264                resolved,
265                time,
266            );
267            return;
268        }
269
270        for (i, line) in lines.iter().enumerate() {
271            if line.is_empty() {
272                continue;
273            }
274            let y = i as f32 * line_height_val + ascent;
275            let x = line_x(measure_text_with_fallback(
276                line,
277                &font,
278                &emoji_font,
279                letter_spacing,
280            ));
281            draw_text_with_fallback(
282                canvas,
283                line,
284                &font,
285                &emoji_font,
286                letter_spacing,
287                x,
288                y,
289                &fill_paint,
290            );
291        }
292    }
293}
294
295impl Painter for GradientText {
296    fn paint_content(
297        &self,
298        canvas: &Canvas,
299        layout: &BoxLayout,
300        props: &AnimatedProperties,
301        ctx: &PaintCtx,
302    ) {
303        let (_, _, _, content_height) = layout.content_box();
304        let content_height =
305            (content_height > 0.0 && content_height.is_finite()).then_some(content_height);
306        self.paint(canvas, layout.width, content_height, ctx.time, props, ctx);
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use rustmotion_core::css::style::CssStyle;
314    use rustmotion_core::css::Length;
315    use rustmotion_core::schema::{
316        AnimationEffect, CharAnimationTiming, EasingType, TextAnimGranularity,
317    };
318
319    fn make_gradient_text(content: &str, white_space: Option<CssWhiteSpace>) -> GradientText {
320        GradientText {
321            content: content.into(),
322            colors: default_colors(),
323            stops: None,
324            angle: default_angle(),
325            animate_angle: false,
326            speed: default_speed(),
327            timing: Default::default(),
328            style: CssStyle {
329                font_size: Some(Length::Px(28.0)),
330                white_space,
331                ..Default::default()
332            },
333            timeline: Vec::new(),
334            stagger: None,
335        }
336    }
337
338    fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec<u8> {
339        let snapshot = surface.image_snapshot();
340        let info = skia_safe::ImageInfo::new(
341            (width, height),
342            skia_safe::ColorType::RGBA8888,
343            skia_safe::AlphaType::Premul,
344            None,
345        );
346        let mut buf = vec![0u8; (width * height * 4) as usize];
347        let ok = snapshot.read_pixels(
348            &info,
349            &mut buf,
350            (width * 4) as usize,
351            skia_safe::IPoint::new(0, 0),
352            skia_safe::image::CachingHint::Disallow,
353        );
354        assert!(ok, "pixel read should succeed");
355        (0..(width * height) as usize)
356            .map(|i| buf[i * 4 + 3])
357            .collect()
358    }
359
360    fn test_ctx() -> PaintCtx {
361        PaintCtx {
362            time: 0.0,
363            scenario_time: 0.0,
364            scene_duration: 1.0,
365            frame_index: 0,
366            fps: 30,
367            video_width: 1920,
368            video_height: 1080,
369            stagger_offset: 0.0,
370        }
371    }
372
373    fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool {
374        for y in y0..y1 {
375            for x in x0..x1 {
376                if grid[(y * surface_width + x) as usize] > 0 {
377                    return true;
378                }
379            }
380        }
381        false
382    }
383
384    fn ink_x_span(grid: &[u8], surface_width: i32, height: i32) -> Option<(i32, i32)> {
385        let mut span: Option<(i32, i32)> = None;
386        for y in 0..height {
387            for x in 0..surface_width {
388                if grid[(y * surface_width + x) as usize] > 0 {
389                    span = Some(match span {
390                        None => (x, x),
391                        Some((lo, hi)) => (lo.min(x), hi.max(x)),
392                    });
393                }
394            }
395        }
396        span
397    }
398
399    fn ink_y_span(grid: &[u8], surface_width: i32, height: i32) -> Option<(i32, i32)> {
400        let mut span: Option<(i32, i32)> = None;
401        for y in 0..height {
402            for x in 0..surface_width {
403                if grid[(y * surface_width + x) as usize] > 0 {
404                    span = Some(match span {
405                        None => (y, y),
406                        Some((lo, hi)) => (lo.min(y), hi.max(y)),
407                    });
408                }
409            }
410        }
411        span
412    }
413
414    fn render_unpremul(gt: &GradientText, w: i32, h: i32) -> (Vec<u8>, Vec<u8>) {
415        render_unpremul_at(gt, w, h, 0.0, &AnimatedProperties::default())
416    }
417
418    fn render_unpremul_at(
419        gt: &GradientText,
420        w: i32,
421        h: i32,
422        time: f64,
423        props: &AnimatedProperties,
424    ) -> (Vec<u8>, Vec<u8>) {
425        let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
426        gt.paint(surface.canvas(), w as f32, None, time, props, &test_ctx());
427        let snapshot = surface.image_snapshot();
428        let info = skia_safe::ImageInfo::new(
429            (w, h),
430            skia_safe::ColorType::RGBA8888,
431            skia_safe::AlphaType::Unpremul,
432            None,
433        );
434        let mut buf = vec![0u8; (w * h * 4) as usize];
435        assert!(snapshot.read_pixels(
436            &info,
437            &mut buf,
438            (w * 4) as usize,
439            skia_safe::IPoint::new(0, 0),
440            skia_safe::image::CachingHint::Disallow,
441        ));
442        let alpha: Vec<u8> = (0..(w * h) as usize).map(|i| buf[i * 4 + 3]).collect();
443        (buf, alpha)
444    }
445
446    fn best_pixel_in_column(buf: &[u8], alpha: &[u8], w: i32, h: i32, x: i32) -> (u8, u8, u8) {
447        let (mut best_y, mut best_a) = (0i32, 0u8);
448        for y in 0..h {
449            let a = alpha[(y * w + x) as usize];
450            if a > best_a {
451                best_a = a;
452                best_y = y;
453            }
454        }
455        let i = ((best_y * w + x) * 4) as usize;
456        (buf[i], buf[i + 1], buf[i + 2])
457    }
458
459    fn best_pixel_in_row(
460        buf: &[u8],
461        alpha: &[u8],
462        w: i32,
463        y: i32,
464        x0: i32,
465        x1: i32,
466    ) -> (u8, u8, u8) {
467        let (mut best_x, mut best_a) = (x0, 0u8);
468        for x in x0..x1 {
469            let a = alpha[(y * w + x) as usize];
470            if a > best_a {
471                best_a = a;
472                best_x = x;
473            }
474        }
475        let i = ((y * w + best_x) * 4) as usize;
476        (buf[i], buf[i + 1], buf[i + 2])
477    }
478
479    fn nearest_inked_column(alpha: &[u8], w: i32, h: i32, target: i32) -> i32 {
480        for radius in 0..w {
481            for cand in [target - radius, target + radius] {
482                if cand < 0 || cand >= w {
483                    continue;
484                }
485                if (0..h).any(|y| alpha[(y * w + cand) as usize] > 0) {
486                    return cand;
487                }
488            }
489        }
490        target
491    }
492
493    #[test]
494    fn text_align_center_centres_the_line_in_the_box() {
495        const W: i32 = 700;
496        const H: i32 = 80;
497        const BOX_W: f32 = 600.0;
498
499        let mut gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
500        gt.style.text_align = Some(CssTextAlign::Center);
501
502        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
503        gt.paint(
504            surface.canvas(),
505            BOX_W,
506            None,
507            0.0,
508            &AnimatedProperties::default(),
509            &test_ctx(),
510        );
511        let grid = alpha_grid(&mut surface, W, H);
512        let (lo, hi) = ink_x_span(&grid, W, H).expect("centred gradient_text must paint something");
513
514        let ink_centre = (lo + hi) as f32 / 2.0;
515        let box_centre = BOX_W / 2.0;
516        assert!(
517            (ink_centre - box_centre).abs() <= 4.0,
518            "centred gradient_text should sit on the box centre {box_centre}, \
519             painted [{lo}, {hi}] with centre {ink_centre}"
520        );
521        assert!(
522            lo > 40,
523            "centred gradient_text must leave a left margin, first ink at {lo}"
524        );
525    }
526
527    #[test]
528    fn text_align_right_ends_the_line_on_the_box_edge() {
529        const W: i32 = 700;
530        const H: i32 = 80;
531        const BOX_W: f32 = 600.0;
532
533        let mut gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
534        gt.style.text_align = Some(CssTextAlign::Right);
535
536        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
537        gt.paint(
538            surface.canvas(),
539            BOX_W,
540            None,
541            0.0,
542            &AnimatedProperties::default(),
543            &test_ctx(),
544        );
545        let grid = alpha_grid(&mut surface, W, H);
546        let (lo, hi) = ink_x_span(&grid, W, H).expect("right-aligned gradient_text must paint");
547
548        assert!(
549            (hi as f32 - BOX_W).abs() <= 6.0,
550            "right-aligned gradient_text should end on the box edge {BOX_W}, \
551             painted [{lo}, {hi}]"
552        );
553    }
554
555    #[test]
556    fn no_text_align_still_starts_at_the_box_left_edge() {
557        const W: i32 = 700;
558        const H: i32 = 80;
559
560        let gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
561        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
562        gt.paint(
563            surface.canvas(),
564            600.0,
565            None,
566            0.0,
567            &AnimatedProperties::default(),
568            &test_ctx(),
569        );
570        let grid = alpha_grid(&mut surface, W, H);
571        let (lo, _) = ink_x_span(&grid, W, H).expect("gradient_text must paint");
572
573        assert!(
574            lo < 8,
575            "with no text-align the first ink should hug the left edge, got {lo}"
576        );
577    }
578
579    #[test]
580    fn centring_keeps_the_gradient_over_the_glyphs() {
581        const W: i32 = 700;
582        const H: i32 = 80;
583        const BOX_W: f32 = 600.0;
584
585        fn last_glyph_rgb(align: Option<CssTextAlign>) -> (u8, u8, u8) {
586            let mut gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
587            gt.style.text_align = align;
588            let mut surface =
589                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
590            gt.paint(
591                surface.canvas(),
592                BOX_W,
593                None,
594                0.0,
595                &AnimatedProperties::default(),
596                &test_ctx(),
597            );
598
599            let snapshot = surface.image_snapshot();
600            let info = skia_safe::ImageInfo::new(
601                (W, H),
602                skia_safe::ColorType::RGBA8888,
603                skia_safe::AlphaType::Premul,
604                None,
605            );
606            let mut buf = vec![0u8; (W * H * 4) as usize];
607            assert!(snapshot.read_pixels(
608                &info,
609                &mut buf,
610                (W * 4) as usize,
611                skia_safe::IPoint::new(0, 0),
612                skia_safe::image::CachingHint::Disallow,
613            ));
614            let alpha: Vec<u8> = (0..(W * H) as usize).map(|i| buf[i * 4 + 3]).collect();
615            let (_, hi) = ink_x_span(&alpha, W, H).expect("must paint");
616            let (mut best_y, mut best_a) = (0i32, 0u8);
617            for y in 0..H {
618                let a = alpha[(y * W + hi) as usize];
619                if a > best_a {
620                    best_a = a;
621                    best_y = y;
622                }
623            }
624            let i = ((best_y * W + hi) * 4) as usize;
625            (buf[i], buf[i + 1], buf[i + 2])
626        }
627
628        let left = last_glyph_rgb(None);
629        let centre = last_glyph_rgb(Some(CssTextAlign::Center));
630        let d = |a: u8, b: u8| (a as i32 - b as i32).abs();
631        assert!(
632            d(left.0, centre.0) <= 12 && d(left.1, centre.1) <= 12 && d(left.2, centre.2) <= 12,
633            "the gradient must follow the aligned block: last glyph is {left:?} \
634             left-aligned but {centre:?} centred"
635        );
636    }
637
638    #[test]
639    fn nowrap_paints_a_single_line_past_the_layout_width() {
640        let gt = make_gradient_text(
641            "the quick brown fox jumps over the lazy dog",
642            Some(CssWhiteSpace::Nowrap),
643        );
644        const W: i32 = 600;
645        const H: i32 = 200;
646        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
647        let canvas = surface.canvas();
648        gt.paint(
649            canvas,
650            80.0,
651            None,
652            0.0,
653            &AnimatedProperties::default(),
654            &test_ctx(),
655        );
656        let grid = alpha_grid(&mut surface, W, H);
657
658        assert!(
659            has_ink_in(&grid, W, 300, W, 0, 45),
660            "nowrap gradient_text must paint past its 80px box on line 1"
661        );
662        assert!(
663            !has_ink_in(&grid, W, 0, W, 55, H),
664            "nowrap gradient_text must stay on a single line"
665        );
666    }
667
668    #[test]
669    fn normal_white_space_wraps_within_the_layout_width() {
670        let gt = make_gradient_text("the quick brown fox jumps over the lazy dog", None);
671        const W: i32 = 600;
672        const H: i32 = 200;
673        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
674        let canvas = surface.canvas();
675        gt.paint(
676            canvas,
677            80.0,
678            None,
679            0.0,
680            &AnimatedProperties::default(),
681            &test_ctx(),
682        );
683        let grid = alpha_grid(&mut surface, W, H);
684
685        assert!(
686            !has_ink_in(&grid, W, 300, W, 0, 45),
687            "wrapped gradient_text must not reach x∈[300,600) on line 1 within an 80px box"
688        );
689        assert!(
690            has_ink_in(&grid, W, 0, W, 55, H),
691            "wrapped gradient_text must spill onto a second line within the box width"
692        );
693    }
694
695    #[test]
696    fn rem_font_size_paints_visible_ink() {
697        let gt = GradientText {
698            content: "HELLO".into(),
699            colors: default_colors(),
700            stops: None,
701            angle: default_angle(),
702            animate_angle: false,
703            speed: default_speed(),
704            timing: Default::default(),
705            style: CssStyle {
706                font_size: Some(Length::String("2rem".into())),
707                ..Default::default()
708            },
709            timeline: Vec::new(),
710            stagger: None,
711        };
712        const W: i32 = 400;
713        const H: i32 = 200;
714        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
715        let canvas = surface.canvas();
716        gt.paint(
717            canvas,
718            300.0,
719            None,
720            0.0,
721            &AnimatedProperties::default(),
722            &test_ctx(),
723        );
724        let grid = alpha_grid(&mut surface, W, H);
725
726        assert!(
727            has_ink_in(&grid, W, 0, W, 0, 60),
728            "gradient_text at font-size: 2rem must paint visible ink"
729        );
730    }
731
732    fn autofit_gradient_text(
733        content: &str,
734        font_size: f32,
735        white_space: Option<CssWhiteSpace>,
736    ) -> GradientText {
737        let mut gt = make_gradient_text(content, white_space);
738        gt.style.font_size = Some(Length::Px(font_size));
739        gt.style.text_autofit = Some(true);
740        gt
741    }
742
743    fn max_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
744        let mut max_x: Option<i32> = None;
745        for y in 0..height {
746            for x in (0..surface_width).rev() {
747                if grid[(y * surface_width + x) as usize] > 0 {
748                    max_x = Some(max_x.map_or(x, |m| m.max(x)));
749                    break;
750                }
751            }
752        }
753        max_x
754    }
755
756    #[test]
757    fn autofit_shrinks_a_nowrap_line_to_fit_and_paint_agrees_with_measure() {
758        use crate::intrinsic::GradientTextIntrinsic;
759        use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
760
761        let gt = autofit_gradient_text(
762            "the quick brown fox jumps over the lazy dog",
763            90.0,
764            Some(CssWhiteSpace::Nowrap),
765        );
766        const BOX_W: f32 = 300.0;
767
768        let (measured_w, _) = GradientTextIntrinsic::from_gradient_text(&gt).measure(
769            (None, None),
770            (AvailableSpace::Definite(BOX_W), AvailableSpace::MaxContent),
771        );
772        assert!(
773            measured_w <= BOX_W + 0.5,
774            "GradientTextIntrinsic itself must report a fit once autofit is on, got {measured_w}"
775        );
776
777        const W: i32 = 900;
778        const H: i32 = 300;
779        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
780        let canvas = surface.canvas();
781        gt.paint(
782            canvas,
783            BOX_W,
784            None,
785            0.0,
786            &AnimatedProperties::default(),
787            &test_ctx(),
788        );
789        let grid = alpha_grid(&mut surface, W, H);
790        let ink_right = max_ink_x(&grid, W, H).expect("gradient_text must paint some ink");
791
792        assert!(
793            (ink_right as f32) <= measured_w + 3.0,
794            "painted ink (right edge {ink_right}) must not exceed the box the intrinsic reserved \
795             ({measured_w})"
796        );
797        assert!(
798            (ink_right as f32) >= measured_w - 15.0,
799            "painted ink (right edge {ink_right}) should land close to the measured width \
800             ({measured_w}) — a big gap means measure and paint disagree on the resolved size"
801        );
802    }
803
804    #[test]
805    fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() {
806        let gt = make_gradient_text(
807            "the quick brown fox jumps over the lazy dog",
808            Some(CssWhiteSpace::Nowrap),
809        );
810        const W: i32 = 600;
811        const H: i32 = 200;
812        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
813        let canvas = surface.canvas();
814        gt.paint(
815            canvas,
816            80.0,
817            Some(45.0),
818            0.0,
819            &AnimatedProperties::default(),
820            &test_ctx(),
821        );
822        let grid = alpha_grid(&mut surface, W, H);
823
824        assert!(
825            has_ink_in(&grid, W, 300, W, 0, 45),
826            "without text-autofit, nowrap gradient_text must still bleed past its box"
827        );
828    }
829
830    #[test]
831    fn autofit_is_stable_across_frames_for_fixed_content() {
832        let gt = autofit_gradient_text(
833            "the quick brown fox jumps over the lazy dog",
834            90.0,
835            Some(CssWhiteSpace::Nowrap),
836        );
837        const W: i32 = 900;
838        const H: i32 = 300;
839
840        let render_at = |t: f64| -> Vec<u8> {
841            let mut surface =
842                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
843            let canvas = surface.canvas();
844            gt.paint(
845                canvas,
846                300.0,
847                Some(60.0),
848                t,
849                &AnimatedProperties::default(),
850                &test_ctx(),
851            );
852            alpha_grid(&mut surface, W, H)
853        };
854
855        let frame_a = render_at(0.0);
856        let frame_b = render_at(0.9);
857        assert_eq!(
858            frame_a, frame_b,
859            "fixed content in a fixed box must render byte-identically regardless of ctx.time"
860        );
861    }
862
863    fn two_color_gradient(angle: f32) -> GradientText {
864        GradientText {
865            content: "IIIIIIIIIIIIIIII".into(),
866            colors: vec!["#FF0000".into(), "#0000FF".into()],
867            stops: None,
868            angle,
869            animate_angle: false,
870            speed: default_speed(),
871            timing: Default::default(),
872            style: CssStyle {
873                font_size: Some(Length::Px(120.0)),
874                ..Default::default()
875            },
876            timeline: Vec::new(),
877            stagger: None,
878        }
879    }
880
881    #[test]
882    fn default_angle_ramps_left_to_right_across_the_glyphs() {
883        let gt = two_color_gradient(default_angle());
884
885        const W: i32 = 1200;
886        const H: i32 = 300;
887        let (buf, alpha) = render_unpremul(&gt, W, H);
888        let (lo, hi) = ink_x_span(&alpha, W, H).expect("must paint some ink");
889
890        let left = best_pixel_in_column(&buf, &alpha, W, H, (lo + 2).min(hi));
891        let right = best_pixel_in_column(&buf, &alpha, W, H, (hi - 2).max(lo));
892
893        assert!(
894            left.0 as i32 - left.2 as i32 > 60,
895            "with the default angle (CSS 90deg = to right), the left glyph edge should read \
896             close to the first color (red), got {left:?}"
897        );
898        assert!(
899            right.2 as i32 - right.0 as i32 > 60,
900            "with the default angle (CSS 90deg = to right), the right glyph edge should read \
901             close to the last color (blue), got {right:?}"
902        );
903    }
904
905    #[test]
906    fn gradient_line_length_is_the_box_projection_not_the_diagonal() {
907        let gt = two_color_gradient(0.0);
908
909        const W: i32 = 1200;
910        const H: i32 = 300;
911        let (buf, alpha) = render_unpremul(&gt, W, H);
912        let (top, bottom) = ink_y_span(&alpha, W, H).expect("must paint some ink");
913        let (lo_x, hi_x) = ink_x_span(&alpha, W, H).expect("must paint some ink");
914
915        let top_row = (top + 3).min(bottom);
916        let bottom_row = (bottom - 3).max(top);
917        let top_px = best_pixel_in_row(&buf, &alpha, W, top_row, lo_x, hi_x + 1);
918        let bottom_px = best_pixel_in_row(&buf, &alpha, W, bottom_row, lo_x, hi_x + 1);
919
920        assert!(
921            bottom_px.0 as i32 - bottom_px.2 as i32 > 60,
922            "angle: 0 (CSS 'to top') must put the first color at the bottom edge of the glyph \
923             box, got {bottom_px:?}"
924        );
925        assert!(
926            top_px.2 as i32 - top_px.0 as i32 > 60,
927            "angle: 0 (CSS 'to top') must reach the last color at the top edge — a gradient \
928             line as long as the box diagonal would leave the glyphs sampling only the \
929             unsaturated middle of the ramp, got {top_px:?}"
930        );
931    }
932
933    #[test]
934    fn explicit_stops_override_even_color_spacing() {
935        fn three_color_gradient(stops: Option<Vec<GradientTextStop>>) -> GradientText {
936            GradientText {
937                content: "IIIIIIIIIIIIIIII".into(),
938                colors: vec!["#FF0000".into(), "#00FF00".into(), "#0000FF".into()],
939                stops,
940                angle: 90.0,
941                animate_angle: false,
942                speed: default_speed(),
943                timing: Default::default(),
944                style: CssStyle {
945                    font_size: Some(Length::Px(120.0)),
946                    ..Default::default()
947                },
948                timeline: Vec::new(),
949                stagger: None,
950            }
951        }
952
953        const W: i32 = 1200;
954        const H: i32 = 300;
955
956        let even = three_color_gradient(None);
957        let (buf_even, alpha_even) = render_unpremul(&even, W, H);
958        let (lo, hi) = ink_x_span(&alpha_even, W, H).expect("must paint some ink");
959        let mid_x = nearest_inked_column(&alpha_even, W, H, (lo + hi) / 2);
960        let mid_even = best_pixel_in_column(&buf_even, &alpha_even, W, H, mid_x);
961
962        let skewed = three_color_gradient(Some(vec![
963            GradientTextStop {
964                color: "#FF0000".into(),
965                position: 0.0,
966            },
967            GradientTextStop {
968                color: "#00FF00".into(),
969                position: 0.9,
970            },
971            GradientTextStop {
972                color: "#0000FF".into(),
973                position: 1.0,
974            },
975        ]));
976        let (buf_skewed, alpha_skewed) = render_unpremul(&skewed, W, H);
977        let mid_skewed = best_pixel_in_column(&buf_skewed, &alpha_skewed, W, H, mid_x);
978
979        assert!(
980            mid_even.1 as i32 > mid_skewed.1 as i32 + 40,
981            "moving the middle stop from its even-spacing position (0.5) to 0.9 must move the \
982             ramp: the text's midpoint should read far less green than the even-spacing case \
983             (even={mid_even:?}, skewed={mid_skewed:?})"
984        );
985    }
986
987    #[test]
988    fn a_stop_written_with_offset_deserializes_the_same_as_one_written_with_position() {
989        let json = r##"{"color": "#7C3AED", "offset": 0.7}"##;
990        let stop: GradientTextStop =
991            serde_json::from_str(json).expect("offset must be accepted as an alias for position");
992        assert_eq!(
993            stop.position, 0.7,
994            "a stop using the background gradient's `offset` vocabulary must resolve to the \
995             same `position` field gradient_text reads at paint time"
996        );
997    }
998
999    fn props_for(gt: &GradientText) -> AnimatedProperties {
1000        AnimatedProperties {
1001            char_animation: rustmotion_core::engine::animator::extract_effects(&gt.style.animation)
1002                .char_animation,
1003            ..Default::default()
1004        }
1005    }
1006
1007    fn soft_pixel_fraction(
1008        grid: &[u8],
1009        surface_width: i32,
1010        x0: i32,
1011        x1: i32,
1012        y0: i32,
1013        y1: i32,
1014    ) -> f32 {
1015        let mut inked = 0u32;
1016        let mut soft = 0u32;
1017        for y in y0..y1 {
1018            for x in x0..x1 {
1019                let a = grid[(y * surface_width + x) as usize];
1020                if a > 0 {
1021                    inked += 1;
1022                    if a < 250 {
1023                        soft += 1;
1024                    }
1025                }
1026            }
1027        }
1028        if inked == 0 {
1029            return 0.0;
1030        }
1031        soft as f32 / inked as f32
1032    }
1033
1034    #[test]
1035    fn char_blur_in_animates_a_gradient_text_word_instead_of_painting_it_sharp_immediately() {
1036        let mut gt = make_gradient_text("BLUR", Some(CssWhiteSpace::Nowrap));
1037        gt.style.font_size = Some(Length::Px(100.0));
1038        gt.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1039            delay: 0.0,
1040            duration: 0.5,
1041            stagger: 0.03,
1042            granularity: TextAnimGranularity::Word,
1043            easing: EasingType::Linear,
1044            ..Default::default()
1045        })];
1046
1047        const W: i32 = 700;
1048        const H: i32 = 220;
1049        let props = props_for(&gt);
1050
1051        let early = alpha_grid(
1052            &mut {
1053                let mut s = skia_safe::surfaces::raster_n32_premul((W, H)).expect("surface");
1054                gt.paint(s.canvas(), W as f32, None, 0.15, &props, &test_ctx());
1055                s
1056            },
1057            W,
1058            H,
1059        );
1060        let settled = alpha_grid(
1061            &mut {
1062                let mut s = skia_safe::surfaces::raster_n32_premul((W, H)).expect("surface");
1063                gt.paint(s.canvas(), W as f32, None, 1.0, &props, &test_ctx());
1064                s
1065            },
1066            W,
1067            H,
1068        );
1069
1070        assert!(
1071            has_ink_in(&early, W, 0, W, 0, H),
1072            "the word must have started painting by t=0.15 — a char_* preset on gradient_text \
1073             used to be silently ignored and paint the word fully sharp from frame 0"
1074        );
1075
1076        let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H);
1077        let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H);
1078        assert!(
1079            early_soft > settled_soft + 0.15,
1080            "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \
1081             settled fraction ({settled_soft:.3}) — gradient_text must actually blur while \
1082             animating, not just render the sharp glyph unconditionally"
1083        );
1084        assert!(
1085            settled_soft < 0.25,
1086            "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})"
1087        );
1088    }
1089
1090    #[test]
1091    fn char_animation_never_restarts_the_gradient_ramp_per_unit() {
1092        let mut gt = two_color_gradient(default_angle());
1093        gt.content = "IIII IIII IIII IIII".into();
1094        gt.style.white_space = Some(CssWhiteSpace::Nowrap);
1095        gt.style.animation = vec![AnimationEffect::CharFadeIn(CharAnimationTiming {
1096            delay: 0.0,
1097            duration: 0.1,
1098            stagger: 0.0,
1099            granularity: TextAnimGranularity::Word,
1100            easing: EasingType::Linear,
1101            ..Default::default()
1102        })];
1103
1104        const W: i32 = 1400;
1105        const H: i32 = 300;
1106        let props = props_for(&gt);
1107        let (buf, alpha) = render_unpremul_at(&gt, W, H, 5.0, &props);
1108        let (lo, hi) = ink_x_span(&alpha, W, H).expect("must paint some ink");
1109
1110        let left = best_pixel_in_column(&buf, &alpha, W, H, (lo + 2).min(hi));
1111        let right = best_pixel_in_column(&buf, &alpha, W, H, (hi - 2).max(lo));
1112
1113        assert!(
1114            left.0 as i32 - left.2 as i32 > 60,
1115            "the leftmost word must still read close to the ramp's first colour (red) once \
1116             settled — got {left:?}"
1117        );
1118        assert!(
1119            right.2 as i32 - right.0 as i32 > 60,
1120            "the rightmost word must still read close to the ramp's last colour (blue) — \
1121             splitting the run into per-word animated units must not restart the gradient for \
1122             each word, got {right:?}"
1123        );
1124    }
1125}