Skip to main content

rustmotion_components/
rich_text.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, Font, FontStyle};
4
5use rustmotion_core::css::style::{
6    FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, TextAlign as CssTextAlign,
7};
8use rustmotion_core::css::CssStyle;
9use rustmotion_core::engine::animator::AnimatedProperties;
10use rustmotion_core::engine::layout_pass::BoxLayout;
11use rustmotion_core::engine::renderer::{
12    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
13    typeface_with_fallback,
14};
15use rustmotion_core::schema::{FontStyleType, FontWeight, TextAlign, TimelineStep};
16use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
17
18/// A single styled span within a rich_text component.
19#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
20pub struct RichTextSpan {
21    pub text: String,
22    #[serde(default)]
23    pub color: Option<String>,
24    #[serde(default, rename = "font-size")]
25    pub font_size: Option<f32>,
26    #[serde(default, rename = "font-weight")]
27    pub font_weight: Option<FontWeight>,
28    #[serde(default, rename = "font-family")]
29    pub font_family: Option<String>,
30    #[serde(default, rename = "font-style")]
31    pub font_style: Option<FontStyleType>,
32    #[serde(default, rename = "letter-spacing")]
33    pub letter_spacing: Option<f32>,
34}
35
36/// Rich text component: renders multiple styled spans on the same line(s).
37#[derive(Debug, Serialize, Deserialize, JsonSchema)]
38pub struct RichText {
39    pub spans: Vec<RichTextSpan>,
40    #[serde(default)]
41    pub max_width: Option<f32>,
42    #[serde(flatten)]
43    pub timing: TimingConfig,
44    #[serde(default)]
45    pub style: CssStyle,
46    #[serde(default)]
47    pub timeline: Vec<TimelineStep>,
48    #[serde(default)]
49    pub stagger: Option<f32>,
50}
51
52rustmotion_core::impl_traits!(RichText {
53    Animatable => animation,
54    Timed => timing,
55    Styled => style,
56});
57
58/// Resolve a font for a span, inheriting from parent style defaults.
59fn make_font(
60    family: &str,
61    weight: &FontWeight,
62    font_style_type: &FontStyleType,
63    size: f32,
64) -> Option<Font> {
65    let slant = match font_style_type {
66        FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
67        FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
68        FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
69    };
70    let weight_val = match weight {
71        FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
72        FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
73        FontWeight::Weight(w) => skia_safe::font_style::Weight::from(*w as i32),
74    };
75    let skia_style = FontStyle::new(weight_val, skia_safe::font_style::Width::NORMAL, slant);
76    let typeface = typeface_with_fallback(family, skia_style).ok()?;
77    Some(Font::from_typeface(typeface, size))
78}
79
80/// Resolved font/paint metadata for one span.
81struct SpanFontInfo {
82    font: Font,
83    color: String,
84    letter_spacing: f32,
85}
86
87/// Resolve each span's font/color/letter-spacing, falling back to the
88/// component-level style for anything a span doesn't override. `None` at
89/// index `i` means the span's font failed to load — that span is skipped
90/// during tokenization (same behaviour as the original `filter_map`).
91///
92/// `default_size` is resolved by the caller (once, against a real
93/// `LengthContext` where one is available) rather than re-derived here —
94/// this used to call the context-free `style.font_size_px_or(48.0)`
95/// independently of `compute_layout`'s own resolution of the same value, a
96/// duplicate computation that silently diverged for relative units (lot B,
97/// wave S).
98fn resolve_span_fonts(
99    spans: &[RichTextSpan],
100    style: &CssStyle,
101    default_size: f32,
102) -> Vec<Option<SpanFontInfo>> {
103    let default_color = style.color_str_or("#FFFFFF");
104    let default_family = style.font_family_or("Inter");
105    let default_weight = match &style.font_weight {
106        Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => FontWeight::Bold,
107        Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
108        Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
109        _ => FontWeight::Normal,
110    };
111    let default_font_style = match style.font_style {
112        Some(CssFontStyle::Italic) => FontStyleType::Italic,
113        Some(CssFontStyle::Oblique) => FontStyleType::Oblique,
114        _ => FontStyleType::Normal,
115    };
116
117    spans
118        .iter()
119        .map(|span| {
120            let size = span.font_size.unwrap_or(default_size);
121            let family = span.font_family.as_deref().unwrap_or(default_family);
122            let weight = span.font_weight.as_ref().unwrap_or(&default_weight);
123            let fstyle = span.font_style.as_ref().unwrap_or(&default_font_style);
124            let color = span.color.as_deref().unwrap_or(default_color).to_string();
125            let letter_spacing = span.letter_spacing.unwrap_or(0.0);
126            make_font(family, weight, fstyle, size).map(|font| SpanFontInfo {
127                font,
128                color,
129                letter_spacing,
130            })
131        })
132        .collect()
133}
134
135/// One word-wrapped token ready to be measured or drawn.
136pub struct RichTextToken {
137    pub span_idx: usize,
138    pub text: String,
139    pub x: f32,
140    pub width: f32,
141}
142
143/// One wrapped line of a [`RichText`] layout.
144pub struct RichTextLine {
145    pub tokens: Vec<RichTextToken>,
146    pub width: f32,
147}
148
149/// Word-wrapped layout for a [`RichText`], shared by
150/// [`crate::intrinsic::RichTextIntrinsic`] and the painter so the box taffy
151/// reserves always matches what gets drawn.
152pub struct RichTextLayout {
153    pub lines: Vec<RichTextLine>,
154    pub max_width: f32,
155    pub line_height: f32,
156    pub max_ascent: f32,
157}
158
159impl RichText {
160    /// Word-wrap `spans` at `wrap_width` (`None` = unconstrained/natural
161    /// width). `visible_chars_progress >= 0.0` truncates the content for the
162    /// typewriter effect (same char-count semantics as `text`); `-1.0` shows
163    /// everything (used for intrinsic/natural-size measurement, which must
164    /// not shrink as the typewriter plays out — layout space is reserved for
165    /// the full content up front).
166    ///
167    /// Unlike the original implementation (which only ever broke a line at a
168    /// span boundary — a single long span never wrapped internally), this
169    /// tokenizes every span's text into words and packs them greedily across
170    /// span boundaries, so a long single span wraps like any other text.
171    /// Whitespace runs collapse to a single rendered space (CSS
172    /// `white-space: normal` semantics, matching `text`'s default wrap
173    /// behaviour); the exact inter-word spacing of source whitespace is not
174    /// preserved, matching how `wrap_text_with_fallback` already treats
175    /// plain `text` content.
176    ///
177    /// `viewport_width`/`viewport_height` resolve `rem`/`vw`/`vh` on
178    /// `style.font-size` (lot B, wave S — this used to go through the
179    /// context-free `font_size_px_or`, which silently resolved those units
180    /// to 0px). Callers with no real per-frame viewport (intrinsic
181    /// measurement, which runs before layout) should pass a stand-in — see
182    /// `intrinsic::measure_time_font_size_ctx`'s doc comment for why 0px is
183    /// worse than an approximation.
184    pub fn compute_layout(
185        spans: &[RichTextSpan],
186        style: &CssStyle,
187        viewport_width: f32,
188        viewport_height: f32,
189        wrap_width: Option<f32>,
190        visible_chars_progress: f32,
191    ) -> RichTextLayout {
192        let base_ctx = crate::intrinsic::font_size_ctx(
193            viewport_width,
194            viewport_height,
195            wrap_width.unwrap_or(0.0),
196        );
197        let (default_size, _letter_spacing_unused, line_height_val) =
198            style.typography_px_ctx(&base_ctx, 48.0);
199        let span_fonts = resolve_span_fonts(spans, style, default_size);
200        let emoji_tf = emoji_typeface();
201
202        // Typewriter truncation operates on each span's text by char count
203        // (mirrors `text`'s approach), producing a truncated copy that is
204        // then tokenized below exactly like the full content would be.
205        let texts: Vec<String> = if visible_chars_progress >= 0.0 {
206            let total_chars: usize = spans.iter().map(|s| s.text.chars().count()).sum();
207            let visible =
208                ((visible_chars_progress * total_chars as f32).round() as usize).min(total_chars);
209            let mut remaining = visible;
210            spans
211                .iter()
212                .map(|s| {
213                    let char_count = s.text.chars().count();
214                    if remaining >= char_count {
215                        remaining -= char_count;
216                        s.text.clone()
217                    } else if remaining == 0 {
218                        String::new()
219                    } else {
220                        let truncated: String = s.text.chars().take(remaining).collect();
221                        remaining = 0;
222                        truncated
223                    }
224                })
225                .collect()
226        } else {
227            spans.iter().map(|s| s.text.clone()).collect()
228        };
229
230        // Tokenize into words, tracking whether a rendered space separates
231        // each token from the previous one (within a span: any whitespace
232        // run; across spans: only if either side's source text had
233        // whitespace at the boundary — otherwise the spans are "glued", e.g.
234        // `"Total: "` followed by `"42"` followed by `" items"`).
235        struct Tok {
236            span_idx: usize,
237            text: String,
238            space_before: bool,
239        }
240        let mut tokens: Vec<Tok> = Vec::new();
241        let mut prev_trailing_ws = true;
242        for (span_idx, text) in texts.iter().enumerate() {
243            if span_fonts.get(span_idx).and_then(|f| f.as_ref()).is_none() || text.is_empty() {
244                continue;
245            }
246            let starts_ws = text.chars().next().is_some_and(char::is_whitespace);
247            for (wi, w) in text.split_whitespace().enumerate() {
248                let space_before = if tokens.is_empty() {
249                    false
250                } else if wi > 0 {
251                    true
252                } else {
253                    prev_trailing_ws || starts_ws
254                };
255                tokens.push(Tok {
256                    span_idx,
257                    text: w.to_string(),
258                    space_before,
259                });
260            }
261            prev_trailing_ws = text.chars().last().is_none_or(char::is_whitespace);
262        }
263
264        // Greedy line packing, mirroring `wrap_text_with_fallback`'s rule: a
265        // token always fits on an otherwise-empty line, even if it alone
266        // exceeds `wrap_width` (never split a single word).
267        let effective_wrap = wrap_width.unwrap_or(f32::INFINITY);
268        let mut lines: Vec<RichTextLine> = vec![RichTextLine {
269            tokens: Vec::new(),
270            width: 0.0,
271        }];
272
273        for tok in &tokens {
274            let sf = span_fonts[tok.span_idx]
275                .as_ref()
276                .expect("font presence checked during tokenization");
277            let emoji_font = emoji_tf
278                .as_ref()
279                .map(|tf| Font::from_typeface(tf.clone(), sf.font.size()));
280            let tok_width =
281                measure_text_with_fallback(&tok.text, &sf.font, &emoji_font, sf.letter_spacing);
282            let space_width = if tok.space_before {
283                measure_text_with_fallback(" ", &sf.font, &emoji_font, 0.0)
284            } else {
285                0.0
286            };
287
288            let current = lines.last_mut().unwrap();
289            let has_content = !current.tokens.is_empty();
290            let extra = if has_content { space_width } else { 0.0 };
291            let projected = current.width + extra + tok_width;
292
293            if projected > effective_wrap && has_content {
294                lines.push(RichTextLine {
295                    tokens: vec![RichTextToken {
296                        span_idx: tok.span_idx,
297                        text: tok.text.clone(),
298                        x: 0.0,
299                        width: tok_width,
300                    }],
301                    width: tok_width,
302                });
303            } else {
304                let x = current.width + extra;
305                current.tokens.push(RichTextToken {
306                    span_idx: tok.span_idx,
307                    text: tok.text.clone(),
308                    x,
309                    width: tok_width,
310                });
311                current.width = x + tok_width;
312            }
313        }
314
315        let max_width = lines.iter().map(|l| l.width).fold(0.0f32, f32::max);
316        let max_ascent = span_fonts
317            .iter()
318            .flatten()
319            .map(|sf| {
320                let (_, m) = sf.font.metrics();
321                -m.ascent
322            })
323            .fold(0.0f32, f32::max);
324
325        RichTextLayout {
326            lines,
327            max_width,
328            line_height: line_height_val,
329            max_ascent,
330        }
331    }
332
333    fn paint(
334        &self,
335        canvas: &Canvas,
336        layout_width: f32,
337        props: &AnimatedProperties,
338        ctx: &PaintCtx,
339    ) {
340        let align = match self.style.text_align {
341            Some(CssTextAlign::Center) => TextAlign::Center,
342            Some(CssTextAlign::Right | CssTextAlign::End) => TextAlign::Right,
343            _ => TextAlign::Left,
344        };
345
346        let wrap_width = if layout_width.is_finite() && layout_width > 0.0 {
347            match self.max_width {
348                Some(mw) => Some(mw.min(layout_width)),
349                None => Some(layout_width),
350            }
351        } else {
352            self.max_width
353        };
354
355        let layout = RichText::compute_layout(
356            &self.spans,
357            &self.style,
358            ctx.video_width as f32,
359            ctx.video_height as f32,
360            wrap_width,
361            props.visible_chars_progress,
362        );
363        if layout.lines.iter().all(|l| l.tokens.is_empty()) {
364            return;
365        }
366
367        // Same `default_size` resolution `compute_layout` used above (real
368        // viewport, same `wrap_width`-derived parent size) — kept as a
369        // second call rather than threading `span_fonts` back out of
370        // `RichTextLayout`, but now via the same context-aware accessor so
371        // the two can no longer diverge on a relative `font-size` the way
372        // they structurally could before (lot B, wave S).
373        let base_ctx = crate::intrinsic::font_size_ctx(
374            ctx.video_width as f32,
375            ctx.video_height as f32,
376            wrap_width.unwrap_or(0.0),
377        );
378        let default_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
379        let span_fonts = resolve_span_fonts(&self.spans, &self.style, default_size);
380        let emoji_tf = emoji_typeface();
381
382        let align_width = if layout_width.is_finite() && layout_width > 0.0 {
383            layout_width
384        } else {
385            layout.max_width
386        };
387
388        let baseline_offset = (layout.line_height + layout.max_ascent) / 2.0;
389
390        for (line_idx, line) in layout.lines.iter().enumerate() {
391            let line_x_offset = match align {
392                TextAlign::Left => 0.0,
393                TextAlign::Center => (align_width - line.width) / 2.0,
394                TextAlign::Right => align_width - line.width,
395            };
396            let y = line_idx as f32 * layout.line_height + baseline_offset;
397
398            for tok in &line.tokens {
399                let sf = span_fonts[tok.span_idx]
400                    .as_ref()
401                    .expect("font presence matches compute_layout's tokenization");
402                let paint = paint_from_hex(&sf.color);
403                let emoji_font = emoji_tf
404                    .as_ref()
405                    .map(|tf| Font::from_typeface(tf.clone(), sf.font.size()));
406
407                draw_text_with_fallback(
408                    canvas,
409                    &tok.text,
410                    &sf.font,
411                    &emoji_font,
412                    sf.letter_spacing,
413                    line_x_offset + tok.x,
414                    y,
415                    &paint,
416                );
417            }
418        }
419    }
420}
421
422impl Painter for RichText {
423    fn paint_content(
424        &self,
425        canvas: &Canvas,
426        layout: &BoxLayout,
427        props: &AnimatedProperties,
428        ctx: &PaintCtx,
429    ) {
430        self.paint(canvas, layout.width, props, ctx);
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use rustmotion_core::css::CssStyle;
438
439    fn span(text: &str) -> RichTextSpan {
440        RichTextSpan {
441            text: text.into(),
442            color: None,
443            font_size: None,
444            font_weight: None,
445            font_family: None,
446            font_style: None,
447            letter_spacing: None,
448        }
449    }
450
451    fn style(font_px: f32) -> CssStyle {
452        CssStyle {
453            font_size: Some(rustmotion_core::css::Length::Px(font_px)),
454            ..Default::default()
455        }
456    }
457
458    #[test]
459    fn single_long_span_wraps_into_multiple_lines_at_constrained_width() {
460        // M2's second ask: line-breaking must not be limited to span
461        // boundaries — a single long span must wrap word-by-word.
462        let spans = vec![span(
463            "the quick brown fox jumps over the lazy dog and keeps going",
464        )];
465        let s = style(24.0);
466        let unconstrained = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
467        assert_eq!(
468            unconstrained.lines.len(),
469            1,
470            "unconstrained width must fit on one line"
471        );
472
473        let constrained = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, Some(150.0), -1.0);
474        assert!(
475            constrained.lines.len() > 1,
476            "a single long span must wrap into multiple lines at 150px, got {} line(s)",
477            constrained.lines.len()
478        );
479        for line in &constrained.lines {
480            assert!(
481                line.width <= 150.0 + 0.5,
482                "each wrapped line must fit the constraint, got {}",
483                line.width
484            );
485        }
486    }
487
488    #[test]
489    fn spans_glue_without_extra_space_when_source_has_none() {
490        // "Total: " + "42" + " items" — no extra space should appear between
491        // "Total:" and "42" beyond the one already in the first span's text,
492        // and none at all between "42" and " items" beyond the leading space
493        // already in the third span.
494        let glued = vec![span("Total:"), span("42"), span(" items")];
495        let s = style(20.0);
496        let layout = RichText::compute_layout(&glued, &s, 1920.0, 1080.0, None, -1.0);
497        assert_eq!(layout.lines.len(), 1);
498        let tokens = &layout.lines[0].tokens;
499        assert_eq!(
500            tokens.iter().map(|t| t.text.as_str()).collect::<Vec<_>>(),
501            vec!["Total:", "42", "items"]
502        );
503        // "Total:" is glued directly to "42" (no whitespace at the
504        // boundary), so token 1 starts exactly where token 0's glyphs end.
505        assert_eq!(tokens[1].x, tokens[0].width, "no space between glued spans");
506        // "42" and " items" DO have a boundary space (leading space in the
507        // third span's source text), so token 2 must start strictly after
508        // token 1 ends.
509        assert!(
510            tokens[2].x > tokens[1].x + tokens[1].width,
511            "a space must separate '42' and 'items' (source had a leading space)"
512        );
513    }
514
515    #[test]
516    fn typewriter_truncation_hides_tail_tokens() {
517        let spans = vec![span("Hello "), span("world")];
518        let s = style(20.0);
519        let full = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
520        let half = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, 0.5);
521        let none = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, 0.0);
522
523        let full_tokens: usize = full.lines.iter().map(|l| l.tokens.len()).sum();
524        let half_tokens: usize = half.lines.iter().map(|l| l.tokens.len()).sum();
525        let none_tokens: usize = none.lines.iter().map(|l| l.tokens.len()).sum();
526
527        assert!(full_tokens >= half_tokens);
528        assert_eq!(none_tokens, 0, "progress 0.0 must show nothing");
529        assert!(
530            half_tokens >= 1,
531            "progress 0.5 must show at least one token"
532        );
533    }
534
535    #[test]
536    fn empty_spans_produce_one_empty_line_not_a_panic() {
537        let spans: Vec<RichTextSpan> = vec![];
538        let s = style(20.0);
539        let layout = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
540        assert_eq!(layout.lines.len(), 1);
541        assert_eq!(layout.max_width, 0.0);
542    }
543}