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, FontStyle, Point};
5
6use rustmotion_core::css::style::{
7    FontStyle as CssFontStyle, TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace,
8};
9use rustmotion_core::css::CssStyle;
10use rustmotion_core::engine::animator::AnimatedProperties;
11use rustmotion_core::engine::layout_pass::BoxLayout;
12use rustmotion_core::engine::renderer::{
13    css_font_weight, draw_text_with_fallback, emoji_typeface, measure_text_with_fallback,
14    paint_from_hex, parse_hex_color, subpixel_font, typeface_with_fallback,
15    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 = css_font_weight(self.style.font_weight.as_ref());
84        let skia_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
85
86        typeface_with_fallback(font_family, skia_style).ok()
87    }
88}
89
90impl GradientText {
91    fn paint(
92        &self,
93        canvas: &Canvas,
94        layout_width: f32,
95        content_height: Option<f32>,
96        time: f64,
97        props: &AnimatedProperties,
98        ctx: &PaintCtx,
99    ) {
100        let has_stops = self.stops.as_ref().is_some_and(|s| !s.is_empty());
101        if self.content.is_empty() || (self.colors.is_empty() && !has_stops) {
102            return;
103        }
104
105        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 {
115            font_size,
116            ..base_ctx
117        };
118        let mut line_height_val = self.style.line_height_for_ctx(font_size, &type_ctx);
119        let mut letter_spacing = self.style.letter_spacing_px_ctx(&type_ctx);
120
121        let nowrap = matches!(
122            self.style.white_space,
123            Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
124        );
125        let box_width = (layout_width.is_finite() && layout_width > 0.0).then_some(layout_width);
126        let wrap_at = if nowrap { None } else { box_width };
127
128        if matches!(self.style.text_autofit, Some(true)) {
129            let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite());
130            let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit(
131                &self.content,
132                &typeface,
133                font_size,
134                letter_spacing,
135                line_height_val,
136                !nowrap,
137                box_width,
138                declared_height,
139            );
140            font_size = fs;
141            letter_spacing = ls;
142            line_height_val = lh;
143        }
144
145        let font = subpixel_font(typeface, font_size);
146        let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
147
148        let lines =
149            wrap_text_with_tracking(&self.content, &font, &emoji_font, wrap_at, letter_spacing);
150
151        let (_, metrics) = font.metrics();
152        let ascent = -metrics.ascent;
153        let descent = metrics.descent;
154        let text_w = lines
155            .iter()
156            .map(|l| measure_text_with_fallback(l, &font, &emoji_font, letter_spacing))
157            .fold(0.0f32, f32::max);
158        let text_h = (lines.len().max(1) - 1) as f32 * line_height_val + ascent + descent;
159
160        let align_width = if layout_width.is_finite() && layout_width > 0.0 {
161            layout_width
162        } else {
163            text_w
164        };
165        let align = self.style.text_align.unwrap_or(CssTextAlign::Left);
166        let line_x = |advance: f32| match align {
167            CssTextAlign::Center => (align_width - advance) / 2.0,
168            CssTextAlign::Right | CssTextAlign::End => align_width - advance,
169            _ => 0.0,
170        };
171        let block_x = line_x(text_w);
172
173        let angle = if self.animate_angle {
174            self.angle + time as f32 * self.speed * 360.0
175        } else {
176            self.angle
177        };
178
179        let angle_rad = angle * std::f32::consts::PI / 180.0;
180        let (dir_x, dir_y) = (angle_rad.sin(), -angle_rad.cos());
181        let cx = block_x + text_w / 2.0;
182        let cy = text_h / 2.0;
183        let half_len = ((text_w * angle_rad.sin()).abs() + (text_h * angle_rad.cos()).abs()) / 2.0;
184        let start = Point::new(cx - dir_x * half_len, cy - dir_y * half_len);
185        let end = Point::new(cx + dir_x * half_len, cy + dir_y * half_len);
186
187        let (skia_colors, positions_vec): (Vec<skia_safe::Color>, Option<Vec<f32>>) =
188            if let Some(explicit_stops) = self.stops.as_ref().filter(|s| !s.is_empty()) {
189                let colors = explicit_stops
190                    .iter()
191                    .map(|stop| {
192                        let (r, g, b, a) = parse_hex_color(&stop.color);
193                        skia_safe::Color::from_argb(a, r, g, b)
194                    })
195                    .collect();
196                let positions = explicit_stops.iter().map(|stop| stop.position).collect();
197                (colors, Some(positions))
198            } else {
199                let colors = self
200                    .colors
201                    .iter()
202                    .map(|hex| {
203                        let (r, g, b, a) = parse_hex_color(hex);
204                        skia_safe::Color::from_argb(a, r, g, b)
205                    })
206                    .collect();
207                (colors, None)
208            };
209
210        let colors4f: Vec<Color4f> = skia_colors.iter().map(|c| Color4f::from(*c)).collect();
211        let gradient_stops = Colors::new(
212            &colors4f,
213            positions_vec.as_deref(),
214            skia_safe::TileMode::Clamp,
215            None,
216        );
217        let grad = Gradient::new(gradient_stops, gradient::Interpolation::default());
218        let shader = gradient::shaders::linear_gradient((start, end), &grad, None);
219
220        let fill_paint = match shader {
221            Some(shader) => {
222                let mut p = skia_safe::Paint::default();
223                p.set_anti_alias(true);
224                p.set_shader(shader);
225                p
226            }
227            None => {
228                let fallback_hex = self
229                    .stops
230                    .as_ref()
231                    .and_then(|s| s.first())
232                    .map(|stop| stop.color.as_str())
233                    .or_else(|| self.colors.first().map(String::as_str))
234                    .unwrap_or("#FFFFFF");
235                let mut p = paint_from_hex(fallback_hex);
236                p.set_anti_alias(true);
237                p
238            }
239        };
240
241        if let Some(ref resolved) = props.char_animation {
242            let schema_align = match align {
243                CssTextAlign::Center => TextAlign::Center,
244                CssTextAlign::Right | CssTextAlign::End => TextAlign::Right,
245                _ => TextAlign::Left,
246            };
247            crate::intrinsic::render_char_animation(
248                canvas,
249                &font,
250                &emoji_font,
251                &fill_paint,
252                letter_spacing,
253                schema_align,
254                align_width,
255                line_height_val,
256                ascent,
257                &lines,
258                resolved,
259                time,
260            );
261            return;
262        }
263
264        for (i, line) in lines.iter().enumerate() {
265            if line.is_empty() {
266                continue;
267            }
268            let y = i as f32 * line_height_val + ascent;
269            let x = line_x(measure_text_with_fallback(
270                line,
271                &font,
272                &emoji_font,
273                letter_spacing,
274            ));
275            draw_text_with_fallback(
276                canvas,
277                line,
278                &font,
279                &emoji_font,
280                letter_spacing,
281                x,
282                y,
283                &fill_paint,
284            );
285        }
286    }
287}
288
289impl Painter for GradientText {
290    fn paint_content(
291        &self,
292        canvas: &Canvas,
293        layout: &BoxLayout,
294        props: &AnimatedProperties,
295        ctx: &PaintCtx,
296    ) {
297        let (_, _, _, content_height) = layout.content_box();
298        let content_height =
299            (content_height > 0.0 && content_height.is_finite()).then_some(content_height);
300        self.paint(canvas, layout.width, content_height, ctx.time, props, ctx);
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use rustmotion_core::css::style::CssStyle;
308    use rustmotion_core::css::Length;
309    use rustmotion_core::schema::{
310        AnimationEffect, CharAnimationTiming, EasingType, TextAnimGranularity,
311    };
312
313    fn make_gradient_text(content: &str, white_space: Option<CssWhiteSpace>) -> GradientText {
314        GradientText {
315            content: content.into(),
316            colors: default_colors(),
317            stops: None,
318            angle: default_angle(),
319            animate_angle: false,
320            speed: default_speed(),
321            timing: Default::default(),
322            style: CssStyle {
323                font_size: Some(Length::Px(28.0)),
324                white_space,
325                ..Default::default()
326            },
327            timeline: Vec::new(),
328            stagger: None,
329        }
330    }
331
332    fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec<u8> {
333        let snapshot = surface.image_snapshot();
334        let info = skia_safe::ImageInfo::new(
335            (width, height),
336            skia_safe::ColorType::RGBA8888,
337            skia_safe::AlphaType::Premul,
338            None,
339        );
340        let mut buf = vec![0u8; (width * height * 4) as usize];
341        let ok = snapshot.read_pixels(
342            &info,
343            &mut buf,
344            (width * 4) as usize,
345            skia_safe::IPoint::new(0, 0),
346            skia_safe::image::CachingHint::Disallow,
347        );
348        assert!(ok, "pixel read should succeed");
349        (0..(width * height) as usize)
350            .map(|i| buf[i * 4 + 3])
351            .collect()
352    }
353
354    fn test_ctx() -> PaintCtx {
355        PaintCtx {
356            time: 0.0,
357            scenario_time: 0.0,
358            scene_duration: 1.0,
359            frame_index: 0,
360            fps: 30,
361            video_width: 1920,
362            video_height: 1080,
363            stagger_offset: 0.0,
364        }
365    }
366
367    fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool {
368        for y in y0..y1 {
369            for x in x0..x1 {
370                if grid[(y * surface_width + x) as usize] > 0 {
371                    return true;
372                }
373            }
374        }
375        false
376    }
377
378    fn ink_x_span(grid: &[u8], surface_width: i32, height: i32) -> Option<(i32, i32)> {
379        let mut span: Option<(i32, i32)> = None;
380        for y in 0..height {
381            for x in 0..surface_width {
382                if grid[(y * surface_width + x) as usize] > 0 {
383                    span = Some(match span {
384                        None => (x, x),
385                        Some((lo, hi)) => (lo.min(x), hi.max(x)),
386                    });
387                }
388            }
389        }
390        span
391    }
392
393    fn ink_y_span(grid: &[u8], surface_width: i32, height: i32) -> Option<(i32, i32)> {
394        let mut span: Option<(i32, i32)> = None;
395        for y in 0..height {
396            for x in 0..surface_width {
397                if grid[(y * surface_width + x) as usize] > 0 {
398                    span = Some(match span {
399                        None => (y, y),
400                        Some((lo, hi)) => (lo.min(y), hi.max(y)),
401                    });
402                }
403            }
404        }
405        span
406    }
407
408    fn render_unpremul(gt: &GradientText, w: i32, h: i32) -> (Vec<u8>, Vec<u8>) {
409        render_unpremul_at(gt, w, h, 0.0, &AnimatedProperties::default())
410    }
411
412    fn render_unpremul_at(
413        gt: &GradientText,
414        w: i32,
415        h: i32,
416        time: f64,
417        props: &AnimatedProperties,
418    ) -> (Vec<u8>, Vec<u8>) {
419        let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
420        gt.paint(surface.canvas(), w as f32, None, time, props, &test_ctx());
421        let snapshot = surface.image_snapshot();
422        let info = skia_safe::ImageInfo::new(
423            (w, h),
424            skia_safe::ColorType::RGBA8888,
425            skia_safe::AlphaType::Unpremul,
426            None,
427        );
428        let mut buf = vec![0u8; (w * h * 4) as usize];
429        assert!(snapshot.read_pixels(
430            &info,
431            &mut buf,
432            (w * 4) as usize,
433            skia_safe::IPoint::new(0, 0),
434            skia_safe::image::CachingHint::Disallow,
435        ));
436        let alpha: Vec<u8> = (0..(w * h) as usize).map(|i| buf[i * 4 + 3]).collect();
437        (buf, alpha)
438    }
439
440    fn best_pixel_in_column(buf: &[u8], alpha: &[u8], w: i32, h: i32, x: i32) -> (u8, u8, u8) {
441        let (mut best_y, mut best_a) = (0i32, 0u8);
442        for y in 0..h {
443            let a = alpha[(y * w + x) as usize];
444            if a > best_a {
445                best_a = a;
446                best_y = y;
447            }
448        }
449        let i = ((best_y * w + x) * 4) as usize;
450        (buf[i], buf[i + 1], buf[i + 2])
451    }
452
453    fn best_pixel_in_row(
454        buf: &[u8],
455        alpha: &[u8],
456        w: i32,
457        y: i32,
458        x0: i32,
459        x1: i32,
460    ) -> (u8, u8, u8) {
461        let (mut best_x, mut best_a) = (x0, 0u8);
462        for x in x0..x1 {
463            let a = alpha[(y * w + x) as usize];
464            if a > best_a {
465                best_a = a;
466                best_x = x;
467            }
468        }
469        let i = ((y * w + best_x) * 4) as usize;
470        (buf[i], buf[i + 1], buf[i + 2])
471    }
472
473    fn nearest_inked_column(alpha: &[u8], w: i32, h: i32, target: i32) -> i32 {
474        for radius in 0..w {
475            for cand in [target - radius, target + radius] {
476                if cand < 0 || cand >= w {
477                    continue;
478                }
479                if (0..h).any(|y| alpha[(y * w + cand) as usize] > 0) {
480                    return cand;
481                }
482            }
483        }
484        target
485    }
486
487    #[test]
488    fn text_align_center_centres_the_line_in_the_box() {
489        const W: i32 = 700;
490        const H: i32 = 80;
491        const BOX_W: f32 = 600.0;
492
493        let mut gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
494        gt.style.text_align = Some(CssTextAlign::Center);
495
496        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
497        gt.paint(
498            surface.canvas(),
499            BOX_W,
500            None,
501            0.0,
502            &AnimatedProperties::default(),
503            &test_ctx(),
504        );
505        let grid = alpha_grid(&mut surface, W, H);
506        let (lo, hi) = ink_x_span(&grid, W, H).expect("centred gradient_text must paint something");
507
508        let ink_centre = (lo + hi) as f32 / 2.0;
509        let box_centre = BOX_W / 2.0;
510        assert!(
511            (ink_centre - box_centre).abs() <= 4.0,
512            "centred gradient_text should sit on the box centre {box_centre}, \
513             painted [{lo}, {hi}] with centre {ink_centre}"
514        );
515        assert!(
516            lo > 40,
517            "centred gradient_text must leave a left margin, first ink at {lo}"
518        );
519    }
520
521    #[test]
522    fn text_align_right_ends_the_line_on_the_box_edge() {
523        const W: i32 = 700;
524        const H: i32 = 80;
525        const BOX_W: f32 = 600.0;
526
527        let mut gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
528        gt.style.text_align = Some(CssTextAlign::Right);
529
530        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
531        gt.paint(
532            surface.canvas(),
533            BOX_W,
534            None,
535            0.0,
536            &AnimatedProperties::default(),
537            &test_ctx(),
538        );
539        let grid = alpha_grid(&mut surface, W, H);
540        let (lo, hi) = ink_x_span(&grid, W, H).expect("right-aligned gradient_text must paint");
541
542        assert!(
543            (hi as f32 - BOX_W).abs() <= 6.0,
544            "right-aligned gradient_text should end on the box edge {BOX_W}, \
545             painted [{lo}, {hi}]"
546        );
547    }
548
549    #[test]
550    fn no_text_align_still_starts_at_the_box_left_edge() {
551        const W: i32 = 700;
552        const H: i32 = 80;
553
554        let gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
555        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
556        gt.paint(
557            surface.canvas(),
558            600.0,
559            None,
560            0.0,
561            &AnimatedProperties::default(),
562            &test_ctx(),
563        );
564        let grid = alpha_grid(&mut surface, W, H);
565        let (lo, _) = ink_x_span(&grid, W, H).expect("gradient_text must paint");
566
567        assert!(
568            lo < 8,
569            "with no text-align the first ink should hug the left edge, got {lo}"
570        );
571    }
572
573    #[test]
574    fn centring_keeps_the_gradient_over_the_glyphs() {
575        const W: i32 = 700;
576        const H: i32 = 80;
577        const BOX_W: f32 = 600.0;
578
579        fn last_glyph_rgb(align: Option<CssTextAlign>) -> (u8, u8, u8) {
580            let mut gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap));
581            gt.style.text_align = align;
582            let mut surface =
583                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
584            gt.paint(
585                surface.canvas(),
586                BOX_W,
587                None,
588                0.0,
589                &AnimatedProperties::default(),
590                &test_ctx(),
591            );
592
593            let snapshot = surface.image_snapshot();
594            let info = skia_safe::ImageInfo::new(
595                (W, H),
596                skia_safe::ColorType::RGBA8888,
597                skia_safe::AlphaType::Premul,
598                None,
599            );
600            let mut buf = vec![0u8; (W * H * 4) as usize];
601            assert!(snapshot.read_pixels(
602                &info,
603                &mut buf,
604                (W * 4) as usize,
605                skia_safe::IPoint::new(0, 0),
606                skia_safe::image::CachingHint::Disallow,
607            ));
608            let alpha: Vec<u8> = (0..(W * H) as usize).map(|i| buf[i * 4 + 3]).collect();
609            let (_, hi) = ink_x_span(&alpha, W, H).expect("must paint");
610            let column_peak = |x: i32| {
611                (0..H)
612                    .map(|y| (alpha[(y * W + x) as usize], y))
613                    .max()
614                    .expect("H > 0")
615            };
616            let (peak_a, peak_y, peak_x) = (0..=hi)
617                .rev()
618                .find_map(|x| {
619                    let (a, y) = column_peak(x);
620                    (a >= 200).then_some((a, y, x))
621                })
622                .expect("the last glyph must have a covered column to read a colour from");
623            let i = ((peak_y * W + peak_x) * 4) as usize;
624            let straight = |c: u8| ((c as u32 * 255 + peak_a as u32 / 2) / peak_a as u32) as u8;
625            (straight(buf[i]), straight(buf[i + 1]), straight(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}