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