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    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    /// Family/weight/style resolution only, independent of `font_size` — so
61    /// `text-autofit` (below) can build a fresh `Font` at each candidate
62    /// size from the same resolved typeface without re-resolving the
63    /// family/weight/style lookup per candidate. `paint` builds the actual
64    /// `Font` itself once the final (possibly autofit-shrunk) size is known.
65    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        // `font-size` itself now resolves through `LengthContext` too (lot B,
100        // wave S) — it used to stay context-free (`font_size_px_or`),
101        // silently dropping `rem`/`vw`/`vh` font-size to 0px. `em`/`%` on
102        // `font-size` itself remain approximate — see
103        // `crate::intrinsic::font_size_ctx`'s doc comment (cascade.rs
104        // doesn't track the real parent font-size).
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        // `letter-spacing`/`line-height`'s `em`/`%` are relative to this
115        // element's own font-size, no cascade dependency — a real
116        // `LengthContext` is available here, so use the context-aware
117        // resolvers (issue #125 §2: correctly handles `vw`/`vh`/`rem`/
118        // line-height-`%`). `letter_spacing` itself: this component never
119        // read `style.letter-spacing` before this fix — the wrap/measure/
120        // draw calls below always tracked at 0.0 regardless of what was
121        // set, which is the same class of measure/paint disagreement issue
122        // #125 §1 describes elsewhere. It's threaded through consistently
123        // now.
124        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        // M1: `white-space: nowrap|pre` keeps the whole content on one line
132        // even past `layout_width` (it bleeds); anything else word-wraps at
133        // the box width — same rule `text.rs` uses. `gradient_text` has no
134        // `max_width` field of its own (unlike `text`/`caption`), so its box
135        // width is simply the resolved layout width, if any.
136        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        // `text-autofit` — see `text.rs::Text::paint`'s identical step and
144        // `resolve_text_autofit`'s doc comment for the shared-computation
145        // parity argument with `TextIntrinsic::measure`. Resolved before
146        // building the real `Font` below so the rest of this function draws
147        // at the (possibly shrunk) resolved size.
148        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        // Tracking-aware wrap (issue #125 §1), consistent with the
169        // real-tracking measurement/draw below.
170        let lines =
171            wrap_text_with_tracking(&self.content, &font, &emoji_font, wrap_at, letter_spacing);
172
173        // Measure the overall (possibly multi-line) bounding box. The
174        // gradient is defined once across this whole block rather than
175        // per-line, so wrapping reflows the text without fragmenting the
176        // colour transition.
177        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        // Compute angle (possibly animated)
187        let angle = if self.animate_angle {
188            self.angle + time as f32 * self.speed * 360.0
189        } else {
190            self.angle
191        };
192
193        // Compute gradient endpoints from angle, spanning the full block.
194        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        // Parse gradient colors
208        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        // Build linear gradient shader
218        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                // Fallback: draw with the first color, no gradient.
233                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        // See `Text::paint_content`'s identical step for why `None` here
267        // means "nothing to fit against" rather than "fit to zero".
268        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        // M1 render-level proof, gradient_text: `white-space: nowrap` stays
348        // on one line and bleeds past `layout_width`.
349        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        // Reproduction: `font-size: "2rem"` used to resolve to 0px via the
393        // context-free `font_size_px_or`, so `resolve_font` built a 0px font
394        // and nothing measurable painted.
395        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    // ─── text-autofit ───────────────────────────────────────────────────
423
424    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; // clears this sentence's floor-fit width
459
460        let (measured_w, _) = GradientTextIntrinsic::from_gradient_text(&gt).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        // Backward compatibility twin of `nowrap_paints_a_single_line_past_the_layout_width`,
492        // now also passing a real `content_height` — proves that alone
493        // doesn't trigger shrinking without the flag.
494        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        // Trap #2: `angle` can animate over time via `animate_angle`, but
514        // here it's static — the resolved font size must not depend on
515        // `time` at all for fixed content in a fixed box.
516        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}