1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::font_style::Weight;
4use skia_safe::{Canvas, Font, FontStyle, Point, RRect, Rect, Typeface};
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, ResolvedCharAnimation};
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, subpixel_font, typeface_with_fallback,
15};
16use rustmotion_core::schema::{
17 FontStyleType, FontWeight, TextAlign, TextAnimGranularity, TimelineStep,
18};
19use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
20
21#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
26pub struct RichTextSpanPadding {
27 #[serde(default)]
29 pub top: f32,
30 #[serde(default)]
32 pub right: f32,
33 #[serde(default)]
35 pub bottom: f32,
36 #[serde(default)]
38 pub left: f32,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
43pub struct RichTextSpan {
44 pub text: String,
45 #[serde(default)]
46 pub color: Option<String>,
47 #[serde(default, rename = "font-size")]
48 pub font_size: Option<f32>,
49 #[serde(default, rename = "font-weight")]
50 pub font_weight: Option<FontWeight>,
51 #[serde(default, rename = "font-family")]
52 pub font_family: Option<String>,
53 #[serde(default, rename = "font-style")]
54 pub font_style: Option<FontStyleType>,
55 #[serde(default, rename = "letter-spacing")]
56 pub letter_spacing: Option<f32>,
57 #[serde(default)]
61 pub background: Option<String>,
62 #[serde(default)]
64 pub padding: Option<RichTextSpanPadding>,
65 #[serde(default, rename = "border-radius")]
67 pub border_radius: Option<f32>,
68 #[serde(default)]
72 pub rotation: Option<f32>,
73}
74
75impl RichTextSpan {
76 fn pill_padding(&self) -> RichTextSpanPadding {
77 self.padding.unwrap_or_default()
78 }
79}
80
81#[derive(Debug, Serialize, Deserialize, JsonSchema)]
83pub struct RichText {
84 pub spans: Vec<RichTextSpan>,
85 #[serde(default)]
86 pub max_width: Option<f32>,
87 #[serde(flatten)]
88 pub timing: TimingConfig,
89 #[serde(default)]
90 pub style: CssStyle,
91 #[serde(default)]
92 pub timeline: Vec<TimelineStep>,
93 #[serde(default)]
94 pub stagger: Option<f32>,
95}
96
97rustmotion_core::impl_traits!(RichText {
98 Animatable => animation,
99 Timed => timing,
100 Styled => style,
101});
102
103fn make_font(
104 family: &str,
105 weight: Weight,
106 font_style_type: &FontStyleType,
107 size: f32,
108) -> Option<Font> {
109 let slant = match font_style_type {
110 FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
111 FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
112 FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
113 };
114 let skia_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
115 let typeface = typeface_with_fallback(family, skia_style).ok()?;
116 Some(subpixel_font(typeface, size))
117}
118
119struct SpanFontInfo {
120 font: Font,
121 color: String,
122 letter_spacing: f32,
123}
124
125fn resolve_span_fonts(
126 spans: &[RichTextSpan],
127 style: &CssStyle,
128 default_size: f32,
129 default_letter_spacing: f32,
130) -> Vec<Option<SpanFontInfo>> {
131 let default_color = style.color_str_or("#FFFFFF");
132 let default_family = style.font_family_or("Inter");
133 let default_weight = css_font_weight(style.font_weight.as_ref());
134 let default_font_style = match style.font_style {
135 Some(CssFontStyle::Italic) => FontStyleType::Italic,
136 Some(CssFontStyle::Oblique) => FontStyleType::Oblique,
137 _ => FontStyleType::Normal,
138 };
139
140 spans
141 .iter()
142 .map(|span| {
143 let size = span.font_size.unwrap_or(default_size);
144 let family = span.font_family.as_deref().unwrap_or(default_family);
145 let weight = span
146 .font_weight
147 .as_ref()
148 .map_or(default_weight, |w| Weight::from(w.to_skia_weight()));
149 let fstyle = span.font_style.as_ref().unwrap_or(&default_font_style);
150 let color = span.color.as_deref().unwrap_or(default_color).to_string();
151 let letter_spacing = span.letter_spacing.unwrap_or(default_letter_spacing);
152 make_font(family, weight, fstyle, size).map(|font| SpanFontInfo {
153 font,
154 color,
155 letter_spacing,
156 })
157 })
158 .collect()
159}
160
161pub struct RichTextToken {
162 pub span_idx: usize,
163 pub text: String,
164 pub x: f32,
165 pub width: f32,
166}
167
168pub struct RichTextLine {
169 pub tokens: Vec<RichTextToken>,
170 pub width: f32,
171}
172
173pub struct RichTextLayout {
174 pub lines: Vec<RichTextLine>,
175 pub max_width: f32,
176 pub line_height: f32,
177 pub max_ascent: f32,
178 pub max_descent: f32,
179}
180
181impl RichText {
182 pub fn compute_layout(
183 spans: &[RichTextSpan],
184 style: &CssStyle,
185 viewport_width: f32,
186 viewport_height: f32,
187 wrap_width: Option<f32>,
188 visible_chars_progress: f32,
189 ) -> RichTextLayout {
190 let base_ctx = crate::intrinsic::font_size_ctx(
191 viewport_width,
192 viewport_height,
193 wrap_width.unwrap_or(0.0),
194 );
195 let (default_size, default_letter_spacing, line_height_val) =
196 style.typography_px_ctx(&base_ctx, 48.0);
197 let span_fonts = resolve_span_fonts(spans, style, default_size, default_letter_spacing);
198 let emoji_tf = emoji_typeface();
199
200 let texts: Vec<String> = if visible_chars_progress >= 0.0 {
201 let total_chars: usize = spans.iter().map(|s| s.text.chars().count()).sum();
202 let visible =
203 ((visible_chars_progress * total_chars as f32).round() as usize).min(total_chars);
204 let mut remaining = visible;
205 spans
206 .iter()
207 .map(|s| {
208 let char_count = s.text.chars().count();
209 if remaining >= char_count {
210 remaining -= char_count;
211 s.text.clone()
212 } else if remaining == 0 {
213 String::new()
214 } else {
215 let truncated: String = s.text.chars().take(remaining).collect();
216 remaining = 0;
217 truncated
218 }
219 })
220 .collect()
221 } else {
222 spans.iter().map(|s| s.text.clone()).collect()
223 };
224
225 struct Tok {
226 span_idx: usize,
227 text: String,
228 space_before: bool,
229 }
230 let literal_whitespace = matches!(
231 style.white_space,
232 Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
233 );
234 let mut tokens: Vec<Tok> = Vec::new();
235 if literal_whitespace {
236 for (span_idx, text) in texts.iter().enumerate() {
237 if span_fonts.get(span_idx).and_then(|f| f.as_ref()).is_none() || text.is_empty() {
238 continue;
239 }
240 tokens.push(Tok {
241 span_idx,
242 text: text.clone(),
243 space_before: false,
244 });
245 }
246 } else {
247 let mut prev_trailing_ws = true;
248 for (span_idx, text) in texts.iter().enumerate() {
249 if span_fonts.get(span_idx).and_then(|f| f.as_ref()).is_none() || text.is_empty() {
250 continue;
251 }
252 let starts_ws = text.chars().next().is_some_and(char::is_whitespace);
253 for (wi, w) in text.split_whitespace().enumerate() {
254 let space_before = if tokens.is_empty() {
255 false
256 } else if wi > 0 {
257 true
258 } else {
259 prev_trailing_ws || starts_ws
260 };
261 tokens.push(Tok {
262 span_idx,
263 text: w.to_string(),
264 space_before,
265 });
266 }
267 prev_trailing_ws = text.chars().last().is_none_or(char::is_whitespace);
268 }
269 }
270
271 let effective_wrap = if literal_whitespace {
272 f32::INFINITY
273 } else {
274 wrap_width.unwrap_or(f32::INFINITY)
275 };
276 let mut lines: Vec<RichTextLine> = vec![RichTextLine {
277 tokens: Vec::new(),
278 width: 0.0,
279 }];
280
281 for tok in &tokens {
282 let sf = span_fonts[tok.span_idx]
283 .as_ref()
284 .expect("font presence checked during tokenization");
285 let emoji_font = emoji_tf
286 .as_ref()
287 .map(|tf| subpixel_font(tf.clone(), sf.font.size()));
288 let tok_width =
289 measure_text_with_fallback(&tok.text, &sf.font, &emoji_font, sf.letter_spacing);
290 let space_width = if tok.space_before {
291 measure_text_with_fallback(" ", &sf.font, &emoji_font, 0.0)
292 } else {
293 0.0
294 };
295
296 let current = lines.last_mut().unwrap();
297 let has_content = !current.tokens.is_empty();
298 let extra = if has_content { space_width } else { 0.0 };
299 let projected = current.width + extra + tok_width;
300
301 if projected > effective_wrap && has_content {
302 lines.push(RichTextLine {
303 tokens: vec![RichTextToken {
304 span_idx: tok.span_idx,
305 text: tok.text.clone(),
306 x: 0.0,
307 width: tok_width,
308 }],
309 width: tok_width,
310 });
311 } else {
312 let x = current.width + extra;
313 current.tokens.push(RichTextToken {
314 span_idx: tok.span_idx,
315 text: tok.text.clone(),
316 x,
317 width: tok_width,
318 });
319 current.width = x + tok_width;
320 }
321 }
322
323 for line in &mut lines {
324 let mut extra = 0.0f32;
325 let n = line.tokens.len();
326 for i in 0..n {
327 let span_idx = line.tokens[i].span_idx;
328 let is_pill = spans.get(span_idx).is_some_and(|s| s.background.is_some());
329 if is_pill && (i == 0 || line.tokens[i - 1].span_idx != span_idx) {
330 extra += spans[span_idx].pill_padding().left;
331 }
332 line.tokens[i].x += extra;
333 if is_pill && (i + 1 == n || line.tokens[i + 1].span_idx != span_idx) {
334 extra += spans[span_idx].pill_padding().right;
335 }
336 }
337 line.width += extra;
338 }
339
340 let max_width = lines.iter().map(|l| l.width).fold(0.0f32, f32::max);
341 let max_ascent = span_fonts
342 .iter()
343 .flatten()
344 .map(|sf| {
345 let (_, m) = sf.font.metrics();
346 -m.ascent
347 })
348 .fold(0.0f32, f32::max);
349 let max_descent = span_fonts
350 .iter()
351 .flatten()
352 .map(|sf| {
353 let (_, m) = sf.font.metrics();
354 m.descent
355 })
356 .fold(0.0f32, f32::max);
357
358 RichTextLayout {
359 lines,
360 max_width,
361 line_height: line_height_val,
362 max_ascent,
363 max_descent,
364 }
365 }
366
367 fn paint(
368 &self,
369 canvas: &Canvas,
370 layout_width: f32,
371 props: &AnimatedProperties,
372 ctx: &PaintCtx,
373 ) {
374 let align = match self.style.text_align {
375 Some(CssTextAlign::Center) => TextAlign::Center,
376 Some(CssTextAlign::Right | CssTextAlign::End) => TextAlign::Right,
377 _ => TextAlign::Left,
378 };
379
380 let wrap_width = if layout_width.is_finite() && layout_width > 0.0 {
381 match self.max_width {
382 Some(mw) => Some(mw.min(layout_width)),
383 None => Some(layout_width),
384 }
385 } else {
386 self.max_width
387 };
388
389 let layout = RichText::compute_layout(
390 &self.spans,
391 &self.style,
392 ctx.video_width as f32,
393 ctx.video_height as f32,
394 wrap_width,
395 props.visible_chars_progress,
396 );
397 if layout.lines.iter().all(|l| l.tokens.is_empty()) {
398 return;
399 }
400
401 let base_ctx = crate::intrinsic::font_size_ctx(
402 ctx.video_width as f32,
403 ctx.video_height as f32,
404 wrap_width.unwrap_or(0.0),
405 );
406 let (default_size, default_letter_spacing, _) =
407 self.style.typography_px_ctx(&base_ctx, 48.0);
408 let span_fonts = resolve_span_fonts(
409 &self.spans,
410 &self.style,
411 default_size,
412 default_letter_spacing,
413 );
414 let emoji_tf = emoji_typeface();
415
416 let align_width = if layout_width.is_finite() && layout_width > 0.0 {
417 layout_width
418 } else {
419 layout.max_width
420 };
421
422 let baseline_offset = (layout.line_height + layout.max_ascent - layout.max_descent) / 2.0;
423
424 if let Some(ref resolved) = props.char_animation {
425 render_rich_text_char_animation(
426 canvas,
427 &layout,
428 &self.spans,
429 &span_fonts,
430 &emoji_tf,
431 align,
432 align_width,
433 baseline_offset,
434 resolved,
435 ctx.time,
436 );
437 return;
438 }
439
440 for (line_idx, line) in layout.lines.iter().enumerate() {
441 let line_x_offset = match align {
442 TextAlign::Left => 0.0,
443 TextAlign::Center => (align_width - line.width) / 2.0,
444 TextAlign::Right => align_width - line.width,
445 };
446 let y = line_idx as f32 * layout.line_height + baseline_offset;
447
448 let mut i = 0;
449 while i < line.tokens.len() {
450 let span_idx = line.tokens[i].span_idx;
451 let mut j = i + 1;
452 while j < line.tokens.len() && line.tokens[j].span_idx == span_idx {
453 j += 1;
454 }
455 let run = &line.tokens[i..j];
456
457 if self.spans[span_idx].background.is_some() {
458 self.paint_pill_run(
459 canvas,
460 &self.spans[span_idx],
461 run,
462 &span_fonts,
463 &emoji_tf,
464 line_x_offset,
465 y,
466 );
467 } else {
468 for tok in run {
469 Self::paint_token(canvas, tok, &span_fonts, &emoji_tf, line_x_offset, y);
470 }
471 }
472 i = j;
473 }
474 }
475 }
476
477 fn paint_token(
478 canvas: &Canvas,
479 tok: &RichTextToken,
480 span_fonts: &[Option<SpanFontInfo>],
481 emoji_tf: &Option<Typeface>,
482 line_x_offset: f32,
483 y: f32,
484 ) {
485 let sf = span_fonts[tok.span_idx]
486 .as_ref()
487 .expect("font presence matches compute_layout's tokenization");
488 let paint = paint_from_hex(&sf.color);
489 let emoji_font = emoji_tf
490 .as_ref()
491 .map(|tf| subpixel_font(tf.clone(), sf.font.size()));
492
493 draw_text_with_fallback(
494 canvas,
495 &tok.text,
496 &sf.font,
497 &emoji_font,
498 sf.letter_spacing,
499 line_x_offset + tok.x,
500 y,
501 &paint,
502 );
503 }
504
505 #[allow(clippy::too_many_arguments)]
506 fn paint_pill_run(
507 &self,
508 canvas: &Canvas,
509 span: &RichTextSpan,
510 run: &[RichTextToken],
511 span_fonts: &[Option<SpanFontInfo>],
512 emoji_tf: &Option<Typeface>,
513 line_x_offset: f32,
514 y: f32,
515 ) {
516 let (Some(first), Some(last)) = (run.first(), run.last()) else {
517 return;
518 };
519 let sf = span_fonts[first.span_idx]
520 .as_ref()
521 .expect("font presence matches compute_layout's tokenization");
522 let (_, metrics) = sf.font.metrics();
523 let ascent = -metrics.ascent;
524 let descent = metrics.descent;
525 let padding = span.pill_padding();
526
527 let x0 = line_x_offset + first.x - padding.left;
528 let x1 = line_x_offset + last.x + last.width + padding.right;
529 let y0 = y - ascent - padding.top;
530 let y1 = y + descent + padding.bottom;
531 let rect = Rect::from_ltrb(x0, y0, x1, y1);
532 let radius = span.border_radius.unwrap_or(0.0).max(0.0);
533 let rrect = RRect::new_rect_xy(rect, radius, radius);
534 let center = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0);
535 let rotation = span.rotation.unwrap_or(0.0);
536
537 canvas.save();
538 if rotation != 0.0 {
539 canvas.rotate(rotation, Some(center));
540 }
541
542 let background = span
543 .background
544 .as_deref()
545 .expect("paint_pill_run is only called for spans with a background");
546 let bg_paint = paint_from_hex(background);
547 canvas.draw_rrect(rrect, &bg_paint);
548
549 for tok in run {
550 Self::paint_token(canvas, tok, span_fonts, emoji_tf, line_x_offset, y);
551 }
552
553 canvas.restore();
554 }
555}
556
557fn paint_pill_background(
558 canvas: &Canvas,
559 span: &RichTextSpan,
560 run: &[RichTextToken],
561 span_fonts: &[Option<SpanFontInfo>],
562 line_x_offset: f32,
563 y: f32,
564) {
565 let (Some(first), Some(last)) = (run.first(), run.last()) else {
566 return;
567 };
568 let sf = span_fonts[first.span_idx]
569 .as_ref()
570 .expect("font presence matches compute_layout's tokenization");
571 let (_, metrics) = sf.font.metrics();
572 let ascent = -metrics.ascent;
573 let descent = metrics.descent;
574 let padding = span.pill_padding();
575
576 let x0 = line_x_offset + first.x - padding.left;
577 let x1 = line_x_offset + last.x + last.width + padding.right;
578 let y0 = y - ascent - padding.top;
579 let y1 = y + descent + padding.bottom;
580 let rect = Rect::from_ltrb(x0, y0, x1, y1);
581 let radius = span.border_radius.unwrap_or(0.0).max(0.0);
582 let rrect = RRect::new_rect_xy(rect, radius, radius);
583 let center = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0);
584 let rotation = span.rotation.unwrap_or(0.0);
585
586 let background = span
587 .background
588 .as_deref()
589 .expect("paint_pill_background is only called for spans with a background");
590 let bg_paint = paint_from_hex(background);
591
592 canvas.save();
593 if rotation != 0.0 {
594 canvas.rotate(rotation, Some(center));
595 }
596 canvas.draw_rrect(rrect, &bg_paint);
597 canvas.restore();
598}
599
600#[allow(clippy::too_many_arguments)]
601fn render_rich_text_char_animation(
602 canvas: &Canvas,
603 layout: &RichTextLayout,
604 spans: &[RichTextSpan],
605 span_fonts: &[Option<SpanFontInfo>],
606 emoji_tf: &Option<Typeface>,
607 align: TextAlign,
608 align_width: f32,
609 baseline_offset: f32,
610 char_anim: &ResolvedCharAnimation,
611 time: f64,
612) {
613 let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word);
614 let mut global_unit_idx = 0usize;
615
616 for (line_idx, line) in layout.lines.iter().enumerate() {
617 if line.tokens.is_empty() {
618 continue;
619 }
620 let line_x_offset = match align {
621 TextAlign::Left => 0.0,
622 TextAlign::Center => (align_width - line.width) / 2.0,
623 TextAlign::Right => align_width - line.width,
624 };
625 let y = line_idx as f32 * layout.line_height + baseline_offset;
626
627 let mut i = 0;
628 while i < line.tokens.len() {
629 let span_idx = line.tokens[i].span_idx;
630 let mut j = i + 1;
631 while j < line.tokens.len() && line.tokens[j].span_idx == span_idx {
632 j += 1;
633 }
634 let run = &line.tokens[i..j];
635 if spans[span_idx].background.is_some() {
636 paint_pill_background(canvas, &spans[span_idx], run, span_fonts, line_x_offset, y);
637 }
638 i = j;
639 }
640
641 for tok in &line.tokens {
642 let sf = span_fonts[tok.span_idx]
643 .as_ref()
644 .expect("font presence matches compute_layout's tokenization");
645 let paint = paint_from_hex(&sf.color);
646 let emoji_font = emoji_tf
647 .as_ref()
648 .map(|tf| subpixel_font(tf.clone(), sf.font.size()));
649
650 if is_word_mode {
651 let t = crate::intrinsic::unit_progress(char_anim, global_unit_idx, time);
652 canvas.save();
653 crate::intrinsic::apply_text_anim_preset(
654 canvas,
655 &tok.text,
656 &sf.font,
657 &emoji_font,
658 &paint,
659 line_x_offset + tok.x,
660 y,
661 tok.width,
662 sf.letter_spacing,
663 char_anim,
664 t,
665 time,
666 global_unit_idx,
667 sf.font.size(),
668 );
669 canvas.restore();
670 global_unit_idx += 1;
671 } else {
672 let mut cursor_x = line_x_offset + tok.x;
673 for ch in tok.text.chars() {
674 let ch_str = ch.to_string();
675 let (ch_width, _) = sf.font.measure_str(&ch_str, None);
676 let ch_width = ch_width + sf.letter_spacing;
677
678 let t = crate::intrinsic::unit_progress(char_anim, global_unit_idx, time);
679 canvas.save();
680 crate::intrinsic::apply_text_anim_preset(
681 canvas,
682 &ch_str,
683 &sf.font,
684 &emoji_font,
685 &paint,
686 cursor_x,
687 y,
688 ch_width,
689 0.0,
690 char_anim,
691 t,
692 time,
693 global_unit_idx,
694 sf.font.size(),
695 );
696 canvas.restore();
697
698 cursor_x += ch_width;
699 global_unit_idx += 1;
700 }
701 }
702 }
703 }
704}
705
706impl Painter for RichText {
707 fn paint_content(
708 &self,
709 canvas: &Canvas,
710 layout: &BoxLayout,
711 props: &AnimatedProperties,
712 ctx: &PaintCtx,
713 ) {
714 self.paint(canvas, layout.width, props, ctx);
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use rustmotion_core::css::CssStyle;
722 use rustmotion_core::schema::{
723 AnimationEffect, CharAnimationTiming, EasingType, TextAnimGranularity,
724 };
725
726 fn span(text: &str) -> RichTextSpan {
727 RichTextSpan {
728 text: text.into(),
729 color: None,
730 font_size: None,
731 font_weight: None,
732 font_family: None,
733 font_style: None,
734 letter_spacing: None,
735 background: None,
736 padding: None,
737 border_radius: None,
738 rotation: None,
739 }
740 }
741
742 fn pill_span(text: &str, background: &str, padding: RichTextSpanPadding) -> RichTextSpan {
743 RichTextSpan {
744 background: Some(background.into()),
745 padding: Some(padding),
746 ..span(text)
747 }
748 }
749
750 fn style(font_px: f32) -> CssStyle {
751 CssStyle {
752 font_size: Some(rustmotion_core::css::Length::Px(font_px)),
753 ..Default::default()
754 }
755 }
756
757 #[test]
758 fn single_long_span_wraps_into_multiple_lines_at_constrained_width() {
759 let spans = vec![span(
760 "the quick brown fox jumps over the lazy dog and keeps going",
761 )];
762 let s = style(24.0);
763 let unconstrained = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
764 assert_eq!(
765 unconstrained.lines.len(),
766 1,
767 "unconstrained width must fit on one line"
768 );
769
770 let constrained = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, Some(150.0), -1.0);
771 assert!(
772 constrained.lines.len() > 1,
773 "a single long span must wrap into multiple lines at 150px, got {} line(s)",
774 constrained.lines.len()
775 );
776 for line in &constrained.lines {
777 assert!(
778 line.width <= 150.0 + 0.5,
779 "each wrapped line must fit the constraint, got {}",
780 line.width
781 );
782 }
783 }
784
785 #[test]
786 fn spans_glue_without_extra_space_when_source_has_none() {
787 let glued = vec![span("Total:"), span("42"), span(" items")];
788 let s = style(20.0);
789 let layout = RichText::compute_layout(&glued, &s, 1920.0, 1080.0, None, -1.0);
790 assert_eq!(layout.lines.len(), 1);
791 let tokens = &layout.lines[0].tokens;
792 assert_eq!(
793 tokens.iter().map(|t| t.text.as_str()).collect::<Vec<_>>(),
794 vec!["Total:", "42", "items"]
795 );
796 assert_eq!(tokens[1].x, tokens[0].width, "no space between glued spans");
797 assert!(
798 tokens[2].x > tokens[1].x + tokens[1].width,
799 "a space must separate '42' and 'items' (source had a leading space)"
800 );
801 }
802
803 #[test]
804 fn typewriter_truncation_hides_tail_tokens() {
805 let spans = vec![span("Hello "), span("world")];
806 let s = style(20.0);
807 let full = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
808 let half = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, 0.5);
809 let none = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, 0.0);
810
811 let full_tokens: usize = full.lines.iter().map(|l| l.tokens.len()).sum();
812 let half_tokens: usize = half.lines.iter().map(|l| l.tokens.len()).sum();
813 let none_tokens: usize = none.lines.iter().map(|l| l.tokens.len()).sum();
814
815 assert!(full_tokens >= half_tokens);
816 assert_eq!(none_tokens, 0, "progress 0.0 must show nothing");
817 assert!(
818 half_tokens >= 1,
819 "progress 0.5 must show at least one token"
820 );
821 }
822
823 #[test]
824 fn empty_spans_produce_one_empty_line_not_a_panic() {
825 let spans: Vec<RichTextSpan> = vec![];
826 let s = style(20.0);
827 let layout = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
828 assert_eq!(layout.lines.len(), 1);
829 assert_eq!(layout.max_width, 0.0);
830 }
831
832 #[test]
833 fn white_space_pre_keeps_leading_spaces_as_a_single_literal_token() {
834 let spans = vec![span(" AB")];
835 let mut s = style(20.0);
836 s.white_space = Some(CssWhiteSpace::Pre);
837 let layout = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, Some(80.0), -1.0);
838
839 assert_eq!(layout.lines.len(), 1, "pre must not wrap onto extra lines");
840 let tokens = &layout.lines[0].tokens;
841 assert_eq!(
842 tokens.len(),
843 1,
844 "pre must not split the span into words, losing the run of spaces"
845 );
846 assert_eq!(
847 tokens[0].text, " AB",
848 "white-space: pre must keep the leading spaces verbatim"
849 );
850 }
851
852 fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec<u8> {
853 let snapshot = surface.image_snapshot();
854 let info = skia_safe::ImageInfo::new(
855 (width, height),
856 skia_safe::ColorType::RGBA8888,
857 skia_safe::AlphaType::Premul,
858 None,
859 );
860 let mut buf = vec![0u8; (width * height * 4) as usize];
861 let ok = snapshot.read_pixels(
862 &info,
863 &mut buf,
864 (width * 4) as usize,
865 skia_safe::IPoint::new(0, 0),
866 skia_safe::image::CachingHint::Disallow,
867 );
868 assert!(ok, "pixel read should succeed");
869 (0..(width * height) as usize)
870 .map(|i| buf[i * 4 + 3])
871 .collect()
872 }
873
874 fn min_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
875 for x in 0..surface_width {
876 for y in 0..height {
877 if grid[(y * surface_width + x) as usize] > 0 {
878 return Some(x);
879 }
880 }
881 }
882 None
883 }
884
885 fn min_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
886 for y in 0..height {
887 for x in 0..surface_width {
888 if grid[(y * surface_width + x) as usize] > 0 {
889 return Some(y);
890 }
891 }
892 }
893 None
894 }
895
896 fn test_ctx() -> PaintCtx {
897 PaintCtx {
898 time: 0.0,
899 scenario_time: 0.0,
900 scene_duration: 1.0,
901 frame_index: 0,
902 fps: 30,
903 video_width: 900,
904 video_height: 400,
905 stagger_offset: 0.0,
906 }
907 }
908
909 #[test]
910 fn white_space_pre_preserves_leading_spaces_at_the_pixel_level() {
911 let plain = RichText {
912 spans: vec![span("AB")],
913 max_width: None,
914 timing: Default::default(),
915 style: style(60.0),
916 timeline: Vec::new(),
917 stagger: None,
918 };
919 let padded = RichText {
920 spans: vec![span(" AB")],
921 max_width: None,
922 timing: Default::default(),
923 style: CssStyle {
924 white_space: Some(CssWhiteSpace::Pre),
925 ..style(60.0)
926 },
927 timeline: Vec::new(),
928 stagger: None,
929 };
930
931 const W: i32 = 400;
932 const H: i32 = 150;
933 let ctx = test_ctx();
934 let props = AnimatedProperties::default();
935
936 let plain_x = {
937 let mut surface =
938 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
939 plain.paint(surface.canvas(), W as f32, &props, &ctx);
940 let grid = alpha_grid(&mut surface, W, H);
941 min_ink_x(&grid, W, H).expect("plain text paints")
942 };
943 let padded_x = {
944 let mut surface =
945 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
946 padded.paint(surface.canvas(), W as f32, &props, &ctx);
947 let grid = alpha_grid(&mut surface, W, H);
948 min_ink_x(&grid, W, H).expect("padded text paints")
949 };
950
951 assert!(
952 padded_x > plain_x + 20,
953 "white-space: pre must preserve the 4 leading spaces, shifting first ink right \
954 (plain first ink at {plain_x}, padded first ink at {padded_x})"
955 );
956 }
957
958 #[test]
959 fn baseline_offset_matches_the_text_components_formula() {
960 let content = "Hamburgefonts";
961 let font_px = 72.0;
962 let s = style(font_px);
963 let rt = RichText {
964 spans: vec![span(content)],
965 max_width: None,
966 timing: Default::default(),
967 style: s.clone(),
968 timeline: Vec::new(),
969 stagger: None,
970 };
971
972 const W: i32 = 900;
973 const H: i32 = 200;
974 let ctx = test_ctx();
975 let props = AnimatedProperties::default();
976
977 let rich_text_top = {
978 let mut surface =
979 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
980 rt.paint(surface.canvas(), W as f32, &props, &ctx);
981 let grid = alpha_grid(&mut surface, W, H);
982 min_ink_y(&grid, W, H).expect("rich_text must paint ink")
983 };
984
985 let typeface =
986 typeface_with_fallback("Inter", FontStyle::default()).expect("typeface resolves");
987 let font = subpixel_font(typeface, font_px);
988 let (_, m) = font.metrics();
989 let ascent = -m.ascent;
990 let descent = m.descent;
991 let base_ctx = crate::intrinsic::font_size_ctx(1920.0, 1080.0, 0.0);
992 let (_, _, line_height) = s.typography_px_ctx(&base_ctx, 48.0);
993 let expected_baseline = (line_height + ascent - descent) / 2.0;
994
995 let reference_top = {
996 let mut surface =
997 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
998 let paint = paint_from_hex("#FFFFFF");
999 draw_text_with_fallback(
1000 surface.canvas(),
1001 content,
1002 &font,
1003 &None,
1004 0.0,
1005 0.0,
1006 expected_baseline,
1007 &paint,
1008 );
1009 let grid = alpha_grid(&mut surface, W, H);
1010 min_ink_y(&grid, W, H).expect("reference draw must paint ink")
1011 };
1012
1013 assert!(
1014 (rich_text_top - reference_top).abs() <= 1,
1015 "rich_text's baseline must be computed as (line_height + ascent - descent) / 2, the \
1016 same formula text.rs uses — got top {rich_text_top}, expected {reference_top} \
1017 (formula gave baseline {expected_baseline})"
1018 );
1019 }
1020
1021 fn pixel_rgba(
1022 surface: &mut skia_safe::Surface,
1023 w: i32,
1024 h: i32,
1025 x: f32,
1026 y: f32,
1027 ) -> (u8, u8, u8, u8) {
1028 let snapshot = surface.image_snapshot();
1029 let info = skia_safe::ImageInfo::new(
1030 (w, h),
1031 skia_safe::ColorType::RGBA8888,
1032 skia_safe::AlphaType::Unpremul,
1033 None,
1034 );
1035 let mut buf = vec![0u8; (w * h * 4) as usize];
1036 let ok = snapshot.read_pixels(
1037 &info,
1038 &mut buf,
1039 (w * 4) as usize,
1040 skia_safe::IPoint::new(0, 0),
1041 skia_safe::image::CachingHint::Disallow,
1042 );
1043 assert!(ok, "pixel read should succeed");
1044 let ix = x.round() as i32;
1045 let iy = y.round() as i32;
1046 let idx = ((iy * w + ix) * 4) as usize;
1047 (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3])
1048 }
1049
1050 fn any_pixel_near_white(surface: &mut skia_safe::Surface, w: i32, h: i32) -> bool {
1051 let snapshot = surface.image_snapshot();
1052 let info = skia_safe::ImageInfo::new(
1053 (w, h),
1054 skia_safe::ColorType::RGBA8888,
1055 skia_safe::AlphaType::Unpremul,
1056 None,
1057 );
1058 let mut buf = vec![0u8; (w * h * 4) as usize];
1059 let ok = snapshot.read_pixels(
1060 &info,
1061 &mut buf,
1062 (w * 4) as usize,
1063 skia_safe::IPoint::new(0, 0),
1064 skia_safe::image::CachingHint::Disallow,
1065 );
1066 assert!(ok, "pixel read should succeed");
1067 buf.as_chunks::<4>()
1068 .0
1069 .iter()
1070 .any(|p| p[0] > 200 && p[1] > 200 && p[2] > 200 && p[3] > 200)
1071 }
1072
1073 #[test]
1074 fn pill_padding_grows_the_line_advance_and_shifts_the_following_span() {
1075 let s = style(40.0);
1076 let padding = RichTextSpanPadding {
1077 top: 4.0,
1078 right: 16.0,
1079 bottom: 6.0,
1080 left: 16.0,
1081 };
1082
1083 let plain = vec![span("tag"), span(" rest")];
1084 let with_pill = vec![pill_span("tag", "#1F6FEB", padding), span(" rest")];
1085
1086 let plain_layout = RichText::compute_layout(&plain, &s, 1920.0, 1080.0, None, -1.0);
1087 let pill_layout = RichText::compute_layout(&with_pill, &s, 1920.0, 1080.0, None, -1.0);
1088
1089 let plain_rest_x = plain_layout.lines[0].tokens[1].x;
1090 let pill_rest_x = pill_layout.lines[0].tokens[1].x;
1091 let expected_shift = padding.left + padding.right;
1092
1093 assert!(
1094 (pill_rest_x - plain_rest_x - expected_shift).abs() < 0.5,
1095 "a pill's horizontal padding must be added to the line's advance, moving the \
1096 following span over by left+right padding: plain x={plain_rest_x}, pill \
1097 x={pill_rest_x}, expected shift {expected_shift}"
1098 );
1099 }
1100
1101 #[test]
1102 fn a_pill_that_wraps_gets_its_own_padding_per_line_fragment() {
1103 let s = style(40.0);
1104 let padding = RichTextSpanPadding {
1105 top: 4.0,
1106 right: 16.0,
1107 bottom: 4.0,
1108 left: 16.0,
1109 };
1110 const WRAP: f32 = 100.0;
1111
1112 let plain = vec![span("aaaa bbbb")];
1113 let plain_layout = RichText::compute_layout(&plain, &s, 1920.0, 1080.0, Some(WRAP), -1.0);
1114 assert_eq!(
1115 plain_layout.lines.len(),
1116 2,
1117 "the reference text must wrap into two lines for this test to be meaningful"
1118 );
1119
1120 let pill = vec![pill_span("aaaa bbbb", "#1F6FEB", padding)];
1121 let pill_layout = RichText::compute_layout(&pill, &s, 1920.0, 1080.0, Some(WRAP), -1.0);
1122 assert_eq!(
1123 pill_layout.lines.len(),
1124 2,
1125 "padding must not change where the raw text wraps"
1126 );
1127
1128 assert!(
1129 (pill_layout.lines[0].tokens[0].x - padding.left).abs() < 0.5,
1130 "the first line's fragment must get its own left padding, got x={}",
1131 pill_layout.lines[0].tokens[0].x
1132 );
1133 assert!(
1134 (pill_layout.lines[1].tokens[0].x - padding.left).abs() < 0.5,
1135 "the second line's fragment must ALSO get its own left padding — \
1136 box-decoration-break: clone — got x={}",
1137 pill_layout.lines[1].tokens[0].x
1138 );
1139
1140 let expected_extra = padding.left + padding.right;
1141 assert!(
1142 (pill_layout.lines[0].width - plain_layout.lines[0].width - expected_extra).abs() < 0.5,
1143 "line 1's fragment must grow by its own left+right padding"
1144 );
1145 assert!(
1146 (pill_layout.lines[1].width - plain_layout.lines[1].width - expected_extra).abs() < 0.5,
1147 "line 2's fragment must ALSO grow by its own left+right padding"
1148 );
1149 }
1150
1151 #[test]
1152 fn pill_background_paints_behind_the_glyphs_not_in_front() {
1153 let padding = RichTextSpanPadding {
1154 top: 4.0,
1155 right: 16.0,
1156 bottom: 4.0,
1157 left: 16.0,
1158 };
1159 let s = CssStyle {
1160 color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
1161 ..style(60.0)
1162 };
1163 let spans = vec![pill_span("Tag", "#000000", padding)];
1164 let rt = RichText {
1165 spans: spans.clone(),
1166 max_width: None,
1167 timing: Default::default(),
1168 style: s.clone(),
1169 timeline: Vec::new(),
1170 stagger: None,
1171 };
1172
1173 const W: i32 = 400;
1174 const H: i32 = 200;
1175 let ctx = test_ctx();
1176 let props = AnimatedProperties::default();
1177
1178 let layout = RichText::compute_layout(
1179 &spans,
1180 &s,
1181 ctx.video_width as f32,
1182 ctx.video_height as f32,
1183 Some(W as f32),
1184 -1.0,
1185 );
1186 let baseline_offset = (layout.line_height + layout.max_ascent - layout.max_descent) / 2.0;
1187 let token = &layout.lines[0].tokens[0];
1188 let box_x0 = token.x - padding.left;
1189 let box_y0 = baseline_offset - layout.max_ascent - padding.top;
1190
1191 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1192 {
1193 let canvas = surface.canvas();
1194 canvas.clear(skia_safe::Color::from_argb(255, 0, 255, 0));
1195 rt.paint(canvas, W as f32, &props, &ctx);
1196 }
1197
1198 let inside_padding = pixel_rgba(&mut surface, W, H, box_x0 + 4.0, box_y0 + 4.0);
1199 assert_eq!(
1200 inside_padding,
1201 (0, 0, 0, 255),
1202 "just inside the pill's padding, clear of any glyph, must show the pill's own \
1203 background colour, got {inside_padding:?}"
1204 );
1205
1206 assert!(
1207 any_pixel_near_white(&mut surface, W, H),
1208 "the glyphs must still paint their own (white) colour on top of the pill's black \
1209 background — a background painted in front of the text would leave no white pixel"
1210 );
1211 }
1212
1213 #[test]
1214 fn pill_rotation_turns_the_box_and_text_without_touching_layout() {
1215 let s = style(50.0);
1216 let padding = RichTextSpanPadding {
1217 top: 6.0,
1218 right: 10.0,
1219 bottom: 6.0,
1220 left: 10.0,
1221 };
1222 let flat = vec![pill_span("Hi", "#1F6FEB", padding)];
1223 let mut rotated_spans = flat.clone();
1224 rotated_spans[0].rotation = Some(45.0);
1225
1226 let flat_layout = RichText::compute_layout(&flat, &s, 1920.0, 1080.0, None, -1.0);
1227 let rotated_layout =
1228 RichText::compute_layout(&rotated_spans, &s, 1920.0, 1080.0, None, -1.0);
1229 assert_eq!(
1230 flat_layout.lines[0].tokens[0].x, rotated_layout.lines[0].tokens[0].x,
1231 "rotation must not move the token — it is a paint-only transform"
1232 );
1233 assert_eq!(
1234 flat_layout.lines[0].width, rotated_layout.lines[0].width,
1235 "rotation must not change the line's advance"
1236 );
1237
1238 const W: i32 = 300;
1239 const H: i32 = 200;
1240 let ctx = test_ctx();
1241 let props = AnimatedProperties::default();
1242 let baseline_offset =
1243 (flat_layout.line_height + flat_layout.max_ascent - flat_layout.max_descent) / 2.0;
1244 let token = &flat_layout.lines[0].tokens[0];
1245 let box_x1 = token.x + token.width + padding.right;
1246 let box_y1 = baseline_offset + flat_layout.max_descent + padding.bottom;
1247
1248 let render_at = |rotation: Option<f32>| {
1249 let mut spans = flat.clone();
1250 spans[0].rotation = rotation;
1251 let rt = RichText {
1252 spans,
1253 max_width: None,
1254 timing: Default::default(),
1255 style: s.clone(),
1256 timeline: Vec::new(),
1257 stagger: None,
1258 };
1259 let mut surface =
1260 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1261 {
1262 let canvas = surface.canvas();
1263 canvas.clear(skia_safe::Color::from_argb(255, 0, 255, 0));
1264 rt.paint(canvas, W as f32, &props, &ctx);
1265 }
1266 surface
1267 };
1268
1269 let mut unrotated = render_at(None);
1270 let corner_flat = pixel_rgba(&mut unrotated, W, H, box_x1 - 2.0, box_y1 - 2.0);
1271 assert_eq!(
1272 corner_flat,
1273 (0x1F, 0x6F, 0xEB, 255),
1274 "unrotated: the bottom-right corner of the pill box must be filled, got {corner_flat:?}"
1275 );
1276
1277 let mut rotated = render_at(Some(45.0));
1278 let corner_rotated = pixel_rgba(&mut rotated, W, H, box_x1 - 2.0, box_y1 - 2.0);
1279 assert_ne!(
1280 corner_rotated, corner_flat,
1281 "rotating the pill 45° about its own centre must move its corner away from the \
1282 unrotated position, got the same pixel {corner_rotated:?} at both"
1283 );
1284 }
1285
1286 fn richtext_with_char_anim(
1287 spans: Vec<RichTextSpan>,
1288 effect: AnimationEffect,
1289 font_px: f32,
1290 ) -> RichText {
1291 RichText {
1292 spans,
1293 max_width: None,
1294 timing: Default::default(),
1295 style: CssStyle {
1296 font_size: Some(rustmotion_core::css::Length::Px(font_px)),
1297 white_space: Some(CssWhiteSpace::Nowrap),
1298 animation: vec![effect],
1299 ..Default::default()
1300 },
1301 timeline: Vec::new(),
1302 stagger: None,
1303 }
1304 }
1305
1306 fn props_for(rt: &RichText) -> AnimatedProperties {
1307 AnimatedProperties {
1308 char_animation: rustmotion_core::engine::animator::extract_effects(&rt.style.animation)
1309 .char_animation,
1310 ..Default::default()
1311 }
1312 }
1313
1314 fn ctx_at(time: f64, video_width: u32, video_height: u32) -> PaintCtx {
1315 PaintCtx {
1316 time,
1317 scenario_time: time,
1318 scene_duration: 2.0,
1319 frame_index: 0,
1320 fps: 30,
1321 video_width,
1322 video_height,
1323 stagger_offset: 0.0,
1324 }
1325 }
1326
1327 fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool {
1328 for y in y0..y1 {
1329 for x in x0..x1 {
1330 if grid[(y * surface_width + x) as usize] > 0 {
1331 return true;
1332 }
1333 }
1334 }
1335 false
1336 }
1337
1338 fn soft_pixel_fraction(
1339 grid: &[u8],
1340 surface_width: i32,
1341 x0: i32,
1342 x1: i32,
1343 y0: i32,
1344 y1: i32,
1345 ) -> f32 {
1346 let mut inked = 0u32;
1347 let mut soft = 0u32;
1348 for y in y0..y1 {
1349 for x in x0..x1 {
1350 let a = grid[(y * surface_width + x) as usize];
1351 if a > 0 {
1352 inked += 1;
1353 if a < 250 {
1354 soft += 1;
1355 }
1356 }
1357 }
1358 }
1359 if inked == 0 {
1360 return 0.0;
1361 }
1362 soft as f32 / inked as f32
1363 }
1364
1365 fn inter_font_px(px: f32) -> Font {
1366 let typeface = typeface_with_fallback("Inter", FontStyle::default()).expect("resolves");
1367 subpixel_font(typeface, px)
1368 }
1369
1370 #[test]
1371 fn char_blur_in_animates_a_rich_text_word_instead_of_painting_it_sharp_immediately() {
1372 let font_px = 100.0;
1373 let rt = richtext_with_char_anim(
1374 vec![span("BLUR")],
1375 AnimationEffect::CharBlurIn(CharAnimationTiming {
1376 delay: 0.0,
1377 duration: 0.5,
1378 stagger: 0.03,
1379 granularity: TextAnimGranularity::Word,
1380 easing: EasingType::Linear,
1381 ..Default::default()
1382 }),
1383 font_px,
1384 );
1385
1386 const W: i32 = 700;
1387 const H: i32 = 220;
1388 let props = props_for(&rt);
1389
1390 let render_at = |t: f64| -> Vec<u8> {
1391 let mut surface =
1392 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1393 {
1394 let canvas = surface.canvas();
1395 rt.paint(canvas, W as f32, &props, &ctx_at(t, W as u32, H as u32));
1396 }
1397 alpha_grid(&mut surface, W, H)
1398 };
1399
1400 let early = render_at(0.15);
1401 let settled = render_at(1.0);
1402
1403 assert!(
1404 has_ink_in(&early, W, 0, W, 0, H),
1405 "the word must have started painting by t=0.15 — a char_* preset on rich_text used \
1406 to be silently ignored and paint the word fully sharp from frame 0"
1407 );
1408
1409 let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H);
1410 let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H);
1411
1412 assert!(
1413 early_soft > settled_soft + 0.15,
1414 "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \
1415 settled fraction ({settled_soft:.3}) — rich_text must actually blur while animating, \
1416 not just render the sharp glyph unconditionally"
1417 );
1418 assert!(
1419 settled_soft < 0.25,
1420 "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})"
1421 );
1422 }
1423
1424 #[test]
1425 fn char_blur_in_word_stagger_carries_across_a_span_boundary() {
1426 let font_px = 90.0;
1427 let rt = richtext_with_char_anim(
1428 vec![span("ONE "), span("TWO")],
1429 AnimationEffect::CharBlurIn(CharAnimationTiming {
1430 delay: 0.5,
1431 duration: 0.3,
1432 stagger: 0.6,
1433 granularity: TextAnimGranularity::Word,
1434 easing: EasingType::Linear,
1435 blur: Some(16.0),
1436 ..Default::default()
1437 }),
1438 font_px,
1439 );
1440
1441 const W: i32 = 900;
1442 const H: i32 = 180;
1443 let props = props_for(&rt);
1444 let font = inter_font_px(font_px);
1445 let word1_end = measure_text_with_fallback("ONE", &font, &None, 0.0) as i32;
1446
1447 let mut before = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1448 {
1449 let canvas = before.canvas();
1450 rt.paint(canvas, W as f32, &props, &ctx_at(0.1, W as u32, H as u32));
1451 }
1452 let before_grid = alpha_grid(&mut before, W, H);
1453 assert!(
1454 !has_ink_in(&before_grid, W, 0, W, 0, H),
1455 "nothing should paint before `delay` has elapsed"
1456 );
1457
1458 let mut mid = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1459 {
1460 let canvas = mid.canvas();
1461 rt.paint(canvas, W as f32, &props, &ctx_at(0.65, W as u32, H as u32));
1462 }
1463 let mid_grid = alpha_grid(&mut mid, W, H);
1464 assert!(
1465 has_ink_in(&mid_grid, W, 0, word1_end, 0, H),
1466 "the first span's word should show ink by t=0.65 (mid-reveal)"
1467 );
1468 assert!(
1469 !has_ink_in(&mid_grid, W, word1_end + 70, W, 0, H),
1470 "the SECOND span's word (a different RichTextSpan, starting at delay+stagger=1.1s) \
1471 must still be fully invisible at t=0.65 — the stagger index must run across the span \
1472 boundary rather than resetting per span"
1473 );
1474 }
1475
1476 #[test]
1477 fn ink_from_converges_to_each_spans_own_colour_not_a_shared_default() {
1478 let font_px = 90.0;
1479 let spans = vec![
1480 RichTextSpan {
1481 color: Some("#00FF00".into()),
1482 ..span("AAAA")
1483 },
1484 RichTextSpan {
1485 color: Some("#0000FF".into()),
1486 ..span(" BBBB")
1487 },
1488 ];
1489 let rt = richtext_with_char_anim(
1490 spans.clone(),
1491 AnimationEffect::CharFadeIn(CharAnimationTiming {
1492 delay: 0.0,
1493 duration: 1.0,
1494 stagger: 0.0,
1495 granularity: TextAnimGranularity::Word,
1496 easing: EasingType::Linear,
1497 ink_from: Some("#FF0000".into()),
1498 ..Default::default()
1499 }),
1500 font_px,
1501 );
1502
1503 const W: i32 = 900;
1504 const H: i32 = 200;
1505 let props = props_for(&rt);
1506
1507 let layout = RichText::compute_layout(&spans, &rt.style, W as f32, H as f32, None, -1.0);
1508 let win = |idx: usize| {
1509 let tok = &layout.lines[0].tokens[idx];
1510 (tok.x as i32, (tok.x + tok.width) as i32)
1511 };
1512 let (w1_x0, w1_x1) = win(0);
1513 let (w2_x0, w2_x1) = win(1);
1514
1515 let mean_rgb = |time: f64, x0: i32, x1: i32| -> (f32, f32, f32) {
1516 let mut surface =
1517 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1518 {
1519 let canvas = surface.canvas();
1520 rt.paint(canvas, W as f32, &props, &ctx_at(time, W as u32, H as u32));
1521 }
1522 let snapshot = surface.image_snapshot();
1523 let info = skia_safe::ImageInfo::new(
1524 (W, H),
1525 skia_safe::ColorType::RGBA8888,
1526 skia_safe::AlphaType::Unpremul,
1527 None,
1528 );
1529 let mut buf = vec![0u8; (W * H * 4) as usize];
1530 assert!(snapshot.read_pixels(
1531 &info,
1532 &mut buf,
1533 (W * 4) as usize,
1534 skia_safe::IPoint::new(0, 0),
1535 skia_safe::image::CachingHint::Disallow,
1536 ));
1537 let mut sum = (0u64, 0u64, 0u64);
1538 let mut alpha_sum = 0u64;
1539 for y in 0..H {
1540 for x in x0.max(0)..x1.min(W) {
1541 let i = ((y * W + x) * 4) as usize;
1542 let a = buf[i + 3] as u64;
1543 sum.0 += buf[i] as u64 * a;
1544 sum.1 += buf[i + 1] as u64 * a;
1545 sum.2 += buf[i + 2] as u64 * a;
1546 alpha_sum += a;
1547 }
1548 }
1549 assert!(
1550 alpha_sum > 0,
1551 "some inked pixels must exist in this window at t={time}"
1552 );
1553 (
1554 sum.0 as f32 / alpha_sum as f32,
1555 sum.1 as f32 / alpha_sum as f32,
1556 sum.2 as f32 / alpha_sum as f32,
1557 )
1558 };
1559
1560 let (r1_early, g1_early, b1_early) = mean_rgb(0.05, w1_x0, w1_x1);
1561 let (r2_early, g2_early, b2_early) = mean_rgb(0.05, w2_x0, w2_x1);
1562 assert!(
1563 r1_early > g1_early && r1_early > b1_early,
1564 "span 1 should read close to ink_from (red) early on, got rgb=({r1_early},{g1_early},{b1_early})"
1565 );
1566 assert!(
1567 r2_early > g2_early && r2_early > b2_early,
1568 "span 2 should read close to ink_from (red) early on, got rgb=({r2_early},{g2_early},{b2_early})"
1569 );
1570
1571 let (r1, g1, b1) = mean_rgb(5.0, w1_x0, w1_x1);
1572 let (r2, g2, b2) = mean_rgb(5.0, w2_x0, w2_x1);
1573 assert!(
1574 g1 > r1 && g1 > b1,
1575 "settled span 1 must converge to ITS OWN colour (green), got rgb=({r1},{g1},{b1})"
1576 );
1577 assert!(
1578 b2 > r2 && b2 > g2,
1579 "settled span 2 must converge to ITS OWN colour (blue), not span 1's — a shared \
1580 default would fail this, got rgb=({r2},{g2},{b2})"
1581 );
1582 }
1583}