1use rustmotion_core::error::Result;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use skia_safe::{Canvas, Font, FontStyle, Paint, PaintStyle, Rect};
5
6use rustmotion_core::css::style::{
7 FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw,
8 TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace,
9};
10use rustmotion_core::css::CssStyle;
11use rustmotion_core::engine::animator::AnimatedProperties;
12use rustmotion_core::engine::layout_pass::BoxLayout;
13use rustmotion_core::engine::renderer::{
14 draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
15 typeface_with_fallback, wrap_text_with_tracking,
16};
17use rustmotion_core::schema::{
18 CaretConfig, CaretShape, FontStyleType, FontWeight, Stroke, TextAlign, TextBackground,
19 TextShadow, TextState, TextSwapConfig, TimelineStep,
20};
21use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
22
23#[derive(Debug, Serialize, Deserialize, JsonSchema)]
24pub struct Text {
25 pub content: String,
26 #[serde(default)]
27 pub max_width: Option<f32>,
28 #[serde(flatten)]
29 pub timing: TimingConfig,
30 #[serde(default)]
31 pub style: CssStyle,
32 #[serde(default)]
33 pub timeline: Vec<TimelineStep>,
34 #[serde(default)]
35 pub stagger: Option<f32>,
36 #[serde(default, rename = "text-shadow")]
37 pub text_shadow: Option<TextShadow>,
38 #[serde(default)]
39 pub stroke: Option<Stroke>,
40 #[serde(default, rename = "text-background")]
41 pub text_background: Option<TextBackground>,
42 #[serde(default)]
45 pub caret: Option<CaretConfig>,
46 #[serde(default)]
48 pub states: Vec<TextState>,
49 #[serde(default)]
51 pub swap: Option<TextSwapConfig>,
52 #[serde(default)]
55 pub morph: Option<TextMorphConfig>,
56}
57
58rustmotion_core::impl_traits!(Text {
59 Animatable => animation,
60 Timed => timing,
61 Styled => style,
62});
63
64#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
68#[serde(rename_all = "snake_case")]
69pub enum TextMorphUnmatched {
70 #[default]
72 Fade,
73 Scramble,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
89#[serde(deny_unknown_fields)]
90pub struct TextMorphConfig {
91 #[serde(default = "default_morph_duration")]
93 pub duration: f64,
94 #[serde(default)]
96 pub unmatched: TextMorphUnmatched,
97 #[serde(default)]
99 pub seed: u32,
100}
101
102impl Default for TextMorphConfig {
103 fn default() -> Self {
104 Self {
105 duration: default_morph_duration(),
106 unmatched: TextMorphUnmatched::default(),
107 seed: 0,
108 }
109 }
110}
111
112fn default_morph_duration() -> f64 {
113 0.6
114}
115
116impl Text {
117 pub fn all_labels(&self) -> impl Iterator<Item = &str> {
118 std::iter::once(self.content.as_str()).chain(self.states.iter().map(|s| s.content.as_str()))
119 }
120
121 fn label_at(&self, time: f64) -> &str {
122 self.states
123 .iter()
124 .rfind(|s| s.at <= time)
125 .map(|s| s.content.as_str())
126 .unwrap_or(&self.content)
127 }
128
129 fn active_swap(&self, time: f64) -> Option<ActiveSwap> {
130 let cfg = self.swap.as_ref()?;
131 if cfg.duration <= 0.0 {
132 return None;
133 }
134 let (idx, state) = self
135 .states
136 .iter()
137 .enumerate()
138 .find(|(_, s)| time >= s.at && time < s.at + cfg.duration)?;
139 let from = if idx == 0 {
140 self.content.clone()
141 } else {
142 self.states[idx - 1].content.clone()
143 };
144 Some(ActiveSwap {
145 from,
146 to: state.content.clone(),
147 progress: ((time - state.at) / cfg.duration) as f32,
148 distance: cfg.distance,
149 blur: cfg.blur,
150 })
151 }
152
153 fn active_morph(&self, time: f64) -> Option<ActiveMorph> {
154 let cfg = self.morph.as_ref()?;
155 if cfg.duration <= 0.0 {
156 return None;
157 }
158 let (idx, state) = self
159 .states
160 .iter()
161 .enumerate()
162 .find(|(_, s)| time >= s.at && time < s.at + cfg.duration)?;
163 let from = if idx == 0 {
164 self.content.clone()
165 } else {
166 self.states[idx - 1].content.clone()
167 };
168 Some(ActiveMorph {
169 from,
170 to: state.content.clone(),
171 progress: ((time - state.at) / cfg.duration) as f32,
172 unmatched: cfg.unmatched,
173 seed: cfg.seed,
174 })
175 }
176
177 fn paint(
178 &self,
179 canvas: &Canvas,
180 layout_width: f32,
181 content_height: Option<f32>,
182 time: f64,
183 props: &AnimatedProperties,
184 ctx: &PaintCtx,
185 ) -> Result<()> {
186 let base_ctx = crate::intrinsic::font_size_ctx(
187 ctx.video_width as f32,
188 ctx.video_height as f32,
189 layout_width.max(0.0),
190 );
191 let (mut font_size, mut letter_spacing, mut line_height_val) =
192 self.style.typography_px_ctx(&base_ctx, 48.0);
193 if props.letter_spacing.is_finite() {
194 letter_spacing = props.letter_spacing;
195 }
196 let color = props
197 .color
198 .as_deref()
199 .unwrap_or_else(|| self.style.color_str_or("#FFFFFF"));
200 let font_family = self.style.font_family_or("Inter");
201 let font_weight = match &self.style.font_weight {
202 Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
203 FontWeight::Bold
204 }
205 Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
206 Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
207 _ => FontWeight::Normal,
208 };
209 let font_style_type = match self.style.font_style {
210 Some(CssFontStyle::Italic) => FontStyleType::Italic,
211 Some(CssFontStyle::Oblique) => FontStyleType::Oblique,
212 _ => FontStyleType::Normal,
213 };
214 let align = match self.style.text_align {
215 Some(CssTextAlign::Center) => TextAlign::Center,
216 Some(CssTextAlign::Right | CssTextAlign::End) => TextAlign::Right,
217 _ => TextAlign::Left,
218 };
219
220 let slant = match font_style_type {
221 FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
222 FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
223 FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
224 };
225 let weight = match font_weight {
226 FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
227 FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
228 FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32),
229 };
230 let skia_font_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
231
232 let typeface = typeface_with_fallback(font_family, skia_font_style)?;
233
234 let nowrap = matches!(
235 self.style.white_space,
236 Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
237 );
238 let box_width = if layout_width.is_finite() && layout_width > 0.0 {
239 Some(match self.max_width {
240 Some(mw) => mw.min(layout_width),
241 None => layout_width,
242 })
243 } else {
244 self.max_width
245 };
246
247 if matches!(self.style.text_autofit, Some(true)) {
248 let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite());
249 let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit(
250 &self.content,
251 &typeface,
252 font_size,
253 letter_spacing,
254 line_height_val,
255 !nowrap,
256 box_width,
257 declared_height,
258 );
259 font_size = fs;
260 letter_spacing = ls;
261 line_height_val = lh;
262 }
263
264 let type_ctx = rustmotion_core::css::units::LengthContext {
265 font_size,
266 ..base_ctx
267 };
268
269 let font = Font::from_typeface(typeface, font_size);
270 let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
271 let paint = paint_from_hex(color);
272
273 let wrap_width = if nowrap { None } else { box_width };
274
275 let label = self.label_at(time);
276 let content = if props.visible_chars_progress >= 0.0 {
277 let chars: Vec<char> = label.chars().collect();
278 let visible = (props.visible_chars_progress * chars.len() as f32).round() as usize;
279 let visible = visible.min(chars.len());
280 if visible == 0 && self.caret.is_none() {
281 return Ok(());
282 }
283 chars[..visible].iter().collect::<String>()
284 } else {
285 label.to_string()
286 };
287
288 let lines =
289 wrap_text_with_tracking(&content, &font, &emoji_font, wrap_width, letter_spacing);
290 let (_, metrics) = font.metrics();
291 let ascent = -metrics.ascent;
292 let descent = metrics.descent;
293 let baseline_offset = (line_height_val + ascent - descent) / 2.0;
294
295 let shadows: Vec<rustmotion_core::schema::TextShadow> = if let Some(s) = &self.text_shadow {
296 vec![s.clone()]
297 } else if let Some(list) = &self.style.text_shadow {
298 list.iter().map(|s| s.to_schema(&type_ctx)).collect()
299 } else {
300 Vec::new()
301 };
302 let shadow_paints: Vec<(skia_safe::Paint, f32, f32)> = shadows
303 .iter()
304 .map(|shadow| {
305 let mut p = paint_from_hex(&shadow.color);
306 if shadow.blur > 0.01 {
307 if let Some(filter) = skia_safe::image_filters::blur(
308 (shadow.blur, shadow.blur),
309 skia_safe::TileMode::Clamp,
310 None,
311 None,
312 ) {
313 p.set_image_filter(filter);
314 }
315 }
316 (p, shadow.offset_x, shadow.offset_y)
317 })
318 .collect();
319
320 let stroke_paint = self.stroke.as_ref().map(|stroke| {
321 let mut p = paint_from_hex(&stroke.color);
322 p.set_style(PaintStyle::Stroke);
323 p.set_stroke_width(stroke.width);
324 p
325 });
326
327 let align_width = if layout_width.is_finite() && layout_width > 0.0 {
328 layout_width
329 } else {
330 let mut max_w = 0.0f32;
331 for line in &lines {
332 let w = measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
333 max_w = max_w.max(w);
334 }
335 max_w
336 };
337
338 if let Some(ref resolved) = props.char_animation {
339 crate::intrinsic::render_char_animation(
340 canvas,
341 &font,
342 &emoji_font,
343 &paint,
344 letter_spacing,
345 align,
346 align_width,
347 line_height_val,
348 baseline_offset,
349 &lines,
350 resolved,
351 time,
352 );
353 return Ok(());
354 }
355
356 if let Some(morph) = self.active_morph(time) {
357 paint_morph(
358 canvas,
359 &morph,
360 &font,
361 &emoji_font,
362 &paint,
363 letter_spacing,
364 wrap_width,
365 align,
366 align_width,
367 line_height_val,
368 baseline_offset,
369 time,
370 );
371 return Ok(());
372 }
373
374 if let Some(swap) = self.active_swap(time) {
375 for (label, offset_y, blur, alpha) in swap.labels() {
376 let label_lines =
377 wrap_text_with_tracking(&label, &font, &emoji_font, wrap_width, letter_spacing);
378 let mut p = paint.clone();
379 p.set_alpha_f(alpha * paint.alpha_f());
380 if blur > 0.05 {
381 if let Some(filter) = skia_safe::image_filters::blur(
382 (blur, blur),
383 skia_safe::TileMode::Clamp,
384 None,
385 None,
386 ) {
387 p.set_image_filter(filter);
388 }
389 }
390 draw_text_lines(
391 canvas,
392 &label_lines,
393 &font,
394 &emoji_font,
395 &p,
396 &shadow_paints,
397 stroke_paint.as_ref(),
398 self.text_background.as_ref(),
399 letter_spacing,
400 &align,
401 align_width,
402 line_height_val,
403 baseline_offset,
404 offset_y,
405 );
406 }
407 return Ok(());
408 }
409
410 draw_text_lines(
411 canvas,
412 &lines,
413 &font,
414 &emoji_font,
415 &paint,
416 &shadow_paints,
417 stroke_paint.as_ref(),
418 self.text_background.as_ref(),
419 letter_spacing,
420 &align,
421 align_width,
422 line_height_val,
423 baseline_offset,
424 0.0,
425 );
426
427 if let Some(caret) = &self.caret {
428 let done = props.visible_chars_progress < 0.0 || props.visible_chars_progress >= 1.0;
429 if !(done && caret.hide_when_done) {
430 let last = lines.len().saturating_sub(1);
431 let line = lines.last().map(String::as_str).unwrap_or("");
432 let advance_width =
433 measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
434 let x = match align {
435 TextAlign::Left => 0.0,
436 TextAlign::Center => (align_width - advance_width) / 2.0,
437 TextAlign::Right => align_width - advance_width,
438 };
439 let baseline = last as f32 * line_height_val + baseline_offset;
440 draw_caret(
441 canvas,
442 caret,
443 x + advance_width,
444 baseline,
445 &font,
446 &paint,
447 time,
448 );
449 }
450 }
451
452 Ok(())
453 }
454}
455
456#[allow(clippy::too_many_arguments)]
457fn draw_text_lines(
458 canvas: &Canvas,
459 lines: &[String],
460 font: &Font,
461 emoji_font: &Option<Font>,
462 paint: &Paint,
463 shadow_paints: &[(Paint, f32, f32)],
464 stroke_paint: Option<&Paint>,
465 text_background: Option<&TextBackground>,
466 letter_spacing: f32,
467 align: &TextAlign,
468 align_width: f32,
469 line_height_val: f32,
470 baseline_offset: f32,
471 offset_y: f32,
472) {
473 for (i, line) in lines.iter().enumerate() {
474 if line.is_empty() {
475 continue;
476 }
477
478 let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing);
479
480 let x = match align {
481 TextAlign::Left => 0.0,
482 TextAlign::Center => (align_width - advance_width) / 2.0,
483 TextAlign::Right => align_width - advance_width,
484 };
485 let y = i as f32 * line_height_val + baseline_offset + offset_y;
486
487 if let Some(bg) = text_background {
488 let bg_paint = paint_from_hex(&bg.color);
489 let (_, font_rect) = font.measure_str(line, None);
490 let bg_rect = Rect::from_xywh(
491 x - bg.padding + font_rect.left,
492 y + font_rect.top - bg.padding / 2.0,
493 advance_width + bg.padding * 2.0,
494 -font_rect.top + font_rect.bottom + bg.padding,
495 );
496 if bg.corner_radius > 0.0 {
497 let rrect =
498 skia_safe::RRect::new_rect_xy(bg_rect, bg.corner_radius, bg.corner_radius);
499 canvas.draw_rrect(rrect, &bg_paint);
500 } else {
501 canvas.draw_rect(bg_rect, &bg_paint);
502 }
503 }
504
505 for (sp, ox, oy) in shadow_paints.iter().rev() {
506 draw_text_with_fallback(
507 canvas,
508 line,
509 font,
510 emoji_font,
511 letter_spacing,
512 x + ox,
513 y + oy,
514 sp,
515 );
516 }
517
518 if let Some(sp) = stroke_paint {
519 draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, sp);
520 }
521 draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, paint);
522 }
523}
524
525struct ActiveSwap {
526 from: String,
527 to: String,
528 progress: f32,
529 distance: f32,
530 blur: f32,
531}
532
533impl ActiveSwap {
534 fn labels(&self) -> [(String, f32, f32, f32); 2] {
535 let p = self.progress.clamp(0.0, 1.0);
536 [
537 (
538 self.from.clone(),
539 -self.distance * p,
540 self.blur * p,
541 1.0 - p,
542 ),
543 (
544 self.to.clone(),
545 self.distance * (1.0 - p),
546 self.blur * (1.0 - p),
547 p,
548 ),
549 ]
550 }
551}
552
553struct ActiveMorph {
554 from: String,
555 to: String,
556 progress: f32,
557 unmatched: TextMorphUnmatched,
558 seed: u32,
559}
560
561fn smoothstep(t: f32) -> f32 {
562 let t = t.clamp(0.0, 1.0);
563 t * t * (3.0 - 2.0 * t)
564}
565
566fn char_positions(
567 lines: &[String],
568 font: &Font,
569 emoji_font: &Option<Font>,
570 letter_spacing: f32,
571 align: &TextAlign,
572 align_width: f32,
573 line_height_val: f32,
574 baseline_offset: f32,
575) -> Vec<(f32, f32)> {
576 let mut out = Vec::new();
577 for (line_idx, line) in lines.iter().enumerate() {
578 let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing);
579 let line_x = match align {
580 TextAlign::Left => 0.0,
581 TextAlign::Center => (align_width - advance_width) / 2.0,
582 TextAlign::Right => align_width - advance_width,
583 };
584 let y = line_idx as f32 * line_height_val + baseline_offset;
585 let mut cursor_x = line_x;
586 for ch in line.chars() {
587 out.push((cursor_x, y));
588 let ch_str = ch.to_string();
589 let ch_width = measure_text_with_fallback(&ch_str, font, emoji_font, letter_spacing);
590 cursor_x += ch_width;
591 }
592 }
593 out
594}
595
596fn lcs_pairs(from: &[char], to: &[char]) -> Vec<(usize, usize)> {
597 let n = from.len();
598 let m = to.len();
599 let mut dp = vec![vec![0u32; m + 1]; n + 1];
600 for i in (0..n).rev() {
601 for j in (0..m).rev() {
602 dp[i][j] = if from[i] == to[j] {
603 dp[i + 1][j + 1] + 1
604 } else {
605 dp[i + 1][j].max(dp[i][j + 1])
606 };
607 }
608 }
609 let mut pairs = Vec::new();
610 let (mut i, mut j) = (0usize, 0usize);
611 while i < n && j < m {
612 if from[i] == to[j] {
613 pairs.push((i, j));
614 i += 1;
615 j += 1;
616 } else if dp[i + 1][j] >= dp[i][j + 1] {
617 i += 1;
618 } else {
619 j += 1;
620 }
621 }
622 pairs
623}
624
625const SCRAMBLE_POOL: &[char] = &[
626 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
627 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '#', '%',
628 '&', '*', '?',
629];
630
631fn scrambled_char(seed: u32, idx: usize, time: f64) -> char {
632 let bucket = (time * 20.0) as u64;
633 let mut h = (idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (seed as u64);
634 h ^= bucket.wrapping_mul(0xD1B5_4A32_D192_ED03);
635 h ^= h >> 33;
636 h = h.wrapping_mul(0xFF51_AFD7_ED55_8CCD);
637 h ^= h >> 33;
638 let pool_idx = (h % SCRAMBLE_POOL.len() as u64) as usize;
639 SCRAMBLE_POOL[pool_idx]
640}
641
642#[allow(clippy::too_many_arguments)]
643fn paint_morph(
644 canvas: &Canvas,
645 morph: &ActiveMorph,
646 font: &Font,
647 emoji_font: &Option<Font>,
648 paint: &Paint,
649 letter_spacing: f32,
650 wrap_width: Option<f32>,
651 align: TextAlign,
652 align_width: f32,
653 line_height_val: f32,
654 baseline_offset: f32,
655 time: f64,
656) {
657 let from_lines =
658 wrap_text_with_tracking(&morph.from, font, emoji_font, wrap_width, letter_spacing);
659 let to_lines = wrap_text_with_tracking(&morph.to, font, emoji_font, wrap_width, letter_spacing);
660
661 let from_positions = char_positions(
662 &from_lines,
663 font,
664 emoji_font,
665 letter_spacing,
666 &align,
667 align_width,
668 line_height_val,
669 baseline_offset,
670 );
671 let to_positions = char_positions(
672 &to_lines,
673 font,
674 emoji_font,
675 letter_spacing,
676 &align,
677 align_width,
678 line_height_val,
679 baseline_offset,
680 );
681
682 let from_chars: Vec<char> = from_lines.iter().flat_map(|l| l.chars()).collect();
683 let to_chars: Vec<char> = to_lines.iter().flat_map(|l| l.chars()).collect();
684
685 let pairs = lcs_pairs(&from_chars, &to_chars);
686 let mut matched_from = vec![false; from_chars.len()];
687 let mut matched_to = vec![false; to_chars.len()];
688 for &(i, j) in &pairs {
689 matched_from[i] = true;
690 matched_to[j] = true;
691 }
692
693 let p = smoothstep(morph.progress);
694
695 for &(i, j) in &pairs {
696 let (fx, fy) = from_positions[i];
697 let (tx, ty) = to_positions[j];
698 let x = fx + (tx - fx) * p;
699 let y = fy + (ty - fy) * p;
700 draw_text_with_fallback(
701 canvas,
702 &to_chars[j].to_string(),
703 font,
704 emoji_font,
705 0.0,
706 x,
707 y,
708 paint,
709 );
710 }
711
712 for (i, &(x, y)) in from_positions.iter().enumerate() {
713 if matched_from[i] {
714 continue;
715 }
716 let alpha = 1.0 - p;
717 if alpha <= 0.001 {
718 continue;
719 }
720 let mut ap = paint.clone();
721 ap.set_alpha_f(alpha * paint.alpha_f());
722 draw_text_with_fallback(
723 canvas,
724 &from_chars[i].to_string(),
725 font,
726 emoji_font,
727 0.0,
728 x,
729 y,
730 &ap,
731 );
732 }
733
734 for (j, &(x, y)) in to_positions.iter().enumerate() {
735 if matched_to[j] {
736 continue;
737 }
738 let alpha = p;
739 if alpha <= 0.001 {
740 continue;
741 }
742 let mut ap = paint.clone();
743 ap.set_alpha_f(alpha * paint.alpha_f());
744 let glyph = match morph.unmatched {
745 TextMorphUnmatched::Fade => to_chars[j],
746 TextMorphUnmatched::Scramble => {
747 if p < 0.7 {
748 scrambled_char(morph.seed, j, time)
749 } else {
750 to_chars[j]
751 }
752 }
753 };
754 draw_text_with_fallback(canvas, &glyph.to_string(), font, emoji_font, 0.0, x, y, &ap);
755 }
756}
757
758fn draw_caret(
759 canvas: &Canvas,
760 cfg: &CaretConfig,
761 x: f32,
762 baseline: f32,
763 font: &Font,
764 text_paint: &Paint,
765 time: f64,
766) {
767 if cfg.blink > 0.0 {
768 let phase = (time / cfg.blink as f64).rem_euclid(1.0);
769 if phase >= 0.5 {
770 return;
771 }
772 }
773
774 let (_, metrics) = font.metrics();
775 let ascent = -metrics.ascent;
776 let descent = metrics.descent;
777 let size = font.size();
778
779 let (width, gap) = match cfg.shape {
780 CaretShape::Line => ((size * 0.07).max(1.5), size * 0.05),
781 CaretShape::Block => (size * 0.55, size * 0.04),
782 };
783
784 let mut paint = match &cfg.color {
785 Some(hex) => paint_from_hex(hex),
786 None => text_paint.clone(),
787 };
788 paint.set_style(PaintStyle::Fill);
789 paint.set_anti_alias(true);
790 paint.set_image_filter(None);
791
792 canvas.draw_rect(
793 Rect::from_xywh(x + gap, baseline - ascent, width, ascent + descent),
794 &paint,
795 );
796}
797
798impl Painter for Text {
799 fn paint_content(
800 &self,
801 canvas: &Canvas,
802 layout: &BoxLayout,
803 props: &AnimatedProperties,
804 ctx: &PaintCtx,
805 ) {
806 let (_, _, _, content_height) = layout.content_box();
807 let content_height =
808 (content_height > 0.0 && content_height.is_finite()).then_some(content_height);
809 let _ = self.paint(canvas, layout.width, content_height, ctx.time, props, ctx);
810 }
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816 use crate::intrinsic::TextIntrinsic;
817 use rustmotion_core::css::style::CssStyle;
818 use rustmotion_core::css::Length;
819 use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
820 use rustmotion_core::schema::{
821 AnimationEffect, CharAnimationTiming, EasingType, TextAnimDirection, TextAnimGranularity,
822 };
823
824 fn make_text(content: &str, white_space: Option<CssWhiteSpace>) -> Text {
825 Text {
826 content: content.into(),
827 max_width: None,
828 timing: Default::default(),
829 style: CssStyle {
830 font_size: Some(Length::Px(28.0)),
831 color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
832 white_space,
833 ..Default::default()
834 },
835 timeline: Vec::new(),
836 stagger: None,
837 text_shadow: None,
838 stroke: None,
839 text_background: None,
840 caret: None,
841 states: Vec::new(),
842 swap: None,
843 morph: None,
844 }
845 }
846
847 fn test_ctx() -> PaintCtx {
848 PaintCtx {
849 time: 0.0,
850 scenario_time: 0.0,
851 scene_duration: 1.0,
852 frame_index: 0,
853 fps: 30,
854 video_width: 600,
855 video_height: 200,
856 stagger_offset: 0.0,
857 }
858 }
859
860 fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec<u8> {
861 let snapshot = surface.image_snapshot();
862 let info = skia_safe::ImageInfo::new(
863 (width, height),
864 skia_safe::ColorType::RGBA8888,
865 skia_safe::AlphaType::Premul,
866 None,
867 );
868 let mut buf = vec![0u8; (width * height * 4) as usize];
869 let ok = snapshot.read_pixels(
870 &info,
871 &mut buf,
872 (width * 4) as usize,
873 skia_safe::IPoint::new(0, 0),
874 skia_safe::image::CachingHint::Disallow,
875 );
876 assert!(ok, "pixel read should succeed");
877 (0..(width * height) as usize)
878 .map(|i| buf[i * 4 + 3])
879 .collect()
880 }
881
882 fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool {
883 for y in y0..y1 {
884 for x in x0..x1 {
885 if grid[(y * surface_width + x) as usize] > 0 {
886 return true;
887 }
888 }
889 }
890 false
891 }
892
893 #[test]
894 fn nowrap_paints_a_single_line_past_the_layout_width() {
895 let text = make_text(
896 "the quick brown fox jumps over the lazy dog",
897 Some(CssWhiteSpace::Nowrap),
898 );
899 const W: i32 = 600;
900 const H: i32 = 200;
901 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
902 let canvas = surface.canvas();
903 let ctx = test_ctx();
904 let props = AnimatedProperties::default();
905 text.paint(canvas, 80.0, None, 0.0, &props, &ctx)
906 .expect("paint succeeds");
907 let grid = alpha_grid(&mut surface, W, H);
908
909 assert!(
910 has_ink_in(&grid, W, 300, W, 0, 45),
911 "nowrap text must paint past its 80px box on line 1 (scanned x∈[300,600), y∈[0,45))"
912 );
913
914 assert!(
915 !has_ink_in(&grid, W, 0, W, 55, H),
916 "nowrap text must stay on a single line; found ink on what would be line 2"
917 );
918 }
919
920 #[test]
921 fn normal_white_space_wraps_within_the_layout_width() {
922 let text = make_text("the quick brown fox jumps over the lazy dog", None);
923 const W: i32 = 600;
924 const H: i32 = 200;
925 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
926 let canvas = surface.canvas();
927 let ctx = test_ctx();
928 let props = AnimatedProperties::default();
929 text.paint(canvas, 80.0, None, 0.0, &props, &ctx)
930 .expect("paint succeeds");
931 let grid = alpha_grid(&mut surface, W, H);
932
933 assert!(
934 !has_ink_in(&grid, W, 300, W, 0, 45),
935 "wrapped text must not reach x∈[300,600) on line 1 within an 80px box"
936 );
937 assert!(
938 has_ink_in(&grid, W, 0, W, 55, H),
939 "wrapped text must spill onto a second line within the box width"
940 );
941 }
942
943 #[test]
944 fn rem_font_size_paints_visible_ink() {
945 let text = Text {
946 content: "HELLO".into(),
947 max_width: None,
948 timing: Default::default(),
949 style: CssStyle {
950 font_size: Some(Length::String("2rem".into())),
951 color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
952 ..Default::default()
953 },
954 timeline: Vec::new(),
955 stagger: None,
956 text_shadow: None,
957 stroke: None,
958 text_background: None,
959 caret: None,
960 states: Vec::new(),
961 swap: None,
962 morph: None,
963 };
964 const W: i32 = 400;
965 const H: i32 = 200;
966 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
967 let canvas = surface.canvas();
968 let ctx = test_ctx();
969 let props = AnimatedProperties::default();
970 text.paint(canvas, 300.0, None, 0.0, &props, &ctx)
971 .expect("paint succeeds");
972 let grid = alpha_grid(&mut surface, W, H);
973
974 assert!(
975 has_ink_in(&grid, W, 0, W, 0, 60),
976 "font-size: 2rem must paint visible ink (32px glyphs), got none"
977 );
978 }
979
980 #[test]
981 fn vh_font_size_paints_visible_ink_scaled_to_the_real_viewport() {
982 let text = Text {
983 content: "HI".into(),
984 max_width: None,
985 timing: Default::default(),
986 style: CssStyle {
987 font_size: Some(Length::String("20vh".into())),
988 color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
989 ..Default::default()
990 },
991 timeline: Vec::new(),
992 stagger: None,
993 text_shadow: None,
994 stroke: None,
995 text_background: None,
996 caret: None,
997 states: Vec::new(),
998 swap: None,
999 morph: None,
1000 };
1001 const W: i32 = 400;
1002 const H: i32 = 200;
1003 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1004 let canvas = surface.canvas();
1005 let ctx = test_ctx();
1006 let props = AnimatedProperties::default();
1007 text.paint(canvas, 300.0, None, 0.0, &props, &ctx)
1008 .expect("paint succeeds");
1009 let grid = alpha_grid(&mut surface, W, H);
1010
1011 assert!(
1012 has_ink_in(&grid, W, 0, W, 0, 70),
1013 "font-size: 20vh (40px against a 200px-tall test viewport) must paint visible ink"
1014 );
1015 }
1016
1017 #[test]
1018 fn a_translucent_color_alpha_channel_survives_to_the_painted_pixels() {
1019 let mut text = make_text("A", Some(CssWhiteSpace::Nowrap));
1020 text.style.font_size = Some(Length::Px(120.0));
1021 text.style.color = Some(rustmotion_core::css::style::Color::String(
1022 "#FFFFFF12".into(),
1023 ));
1024
1025 const W: i32 = 300;
1026 const H: i32 = 200;
1027 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1028 let canvas = surface.canvas();
1029 let ctx = test_ctx();
1030 let props = AnimatedProperties::default();
1031 text.paint(canvas, W as f32, None, 0.0, &props, &ctx)
1032 .expect("paint succeeds");
1033 let grid = alpha_grid(&mut surface, W, H);
1034
1035 let max_alpha = grid.iter().copied().max().unwrap_or(0);
1036 assert!(max_alpha > 0, "the glyph must paint some ink");
1037 assert!(
1038 max_alpha <= 40,
1039 "style.color's alpha channel (0x12 = 18/255, about 7%) must survive to the painted \
1040 pixels instead of being forced fully opaque; got max alpha {max_alpha}"
1041 );
1042 }
1043
1044 fn soft_pixel_fraction(
1045 grid: &[u8],
1046 surface_width: i32,
1047 x0: i32,
1048 x1: i32,
1049 y0: i32,
1050 y1: i32,
1051 ) -> f32 {
1052 let mut inked = 0u32;
1053 let mut soft = 0u32;
1054 for y in y0..y1 {
1055 for x in x0..x1 {
1056 let a = grid[(y * surface_width + x) as usize];
1057 if a > 0 {
1058 inked += 1;
1059 if a < 250 {
1060 soft += 1;
1061 }
1062 }
1063 }
1064 }
1065 if inked == 0 {
1066 return 0.0;
1067 }
1068 soft as f32 / inked as f32
1069 }
1070
1071 fn props_for(text: &Text) -> AnimatedProperties {
1072 AnimatedProperties {
1073 char_animation: rustmotion_core::engine::animator::extract_effects(
1074 &text.style.animation,
1075 )
1076 .char_animation,
1077 ..Default::default()
1078 }
1079 }
1080
1081 fn inter_font(px: f32) -> Font {
1082 let style = FontStyle::new(
1083 skia_safe::font_style::Weight::NORMAL,
1084 skia_safe::font_style::Width::NORMAL,
1085 skia_safe::font_style::Slant::Upright,
1086 );
1087 let typeface = typeface_with_fallback("Inter", style).expect("typeface resolves");
1088 Font::from_typeface(typeface, px)
1089 }
1090
1091 #[test]
1092 fn char_blur_in_word_is_blurred_mid_reveal_and_sharp_when_settled() {
1093 let mut text = make_text("BLUR", None);
1094 text.style.font_size = Some(Length::Px(100.0));
1095 text.style.white_space = Some(CssWhiteSpace::Nowrap);
1096 text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1097 delay: 0.0,
1098 duration: 0.5,
1099 stagger: 0.03,
1100 granularity: TextAnimGranularity::Word,
1101 easing: EasingType::Linear,
1102 ..Default::default()
1103 })];
1104
1105 const W: i32 = 700;
1106 const H: i32 = 220;
1107 let ctx = test_ctx();
1108 let props = props_for(&text);
1109
1110 let render_at = |t: f64| -> Vec<u8> {
1111 let mut surface =
1112 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1113 {
1114 let canvas = surface.canvas();
1115 text.paint(canvas, W as f32, None, t, &props, &ctx)
1116 .expect("paint succeeds");
1117 }
1118 alpha_grid(&mut surface, W, H)
1119 };
1120
1121 let early = render_at(0.15);
1122 let settled = render_at(1.0);
1123
1124 assert!(
1125 has_ink_in(&early, W, 0, W, 0, H),
1126 "the word must have started painting by t=0.15"
1127 );
1128
1129 let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H);
1130 let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H);
1131
1132 assert!(
1133 early_soft > settled_soft + 0.15,
1134 "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \
1135 settled fraction ({settled_soft:.3}) — the word should read as blurred while \
1136 animating and sharp at rest"
1137 );
1138 assert!(
1139 settled_soft < 0.25,
1140 "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})"
1141 );
1142 }
1143
1144 #[test]
1145 fn char_blur_in_whitespace_gap_stays_empty_while_words_animate() {
1146 const FONT_PX: f32 = 90.0;
1147 let mut text = make_text("FIRST SECOND", None);
1148 text.style.font_size = Some(Length::Px(FONT_PX));
1149 text.style.white_space = Some(CssWhiteSpace::Nowrap);
1150 text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1151 delay: 0.0,
1152 duration: 0.4,
1153 stagger: 0.2,
1154 granularity: TextAnimGranularity::Word,
1155 easing: EasingType::Linear,
1156 blur: Some(18.0),
1157 ..Default::default()
1158 })];
1159
1160 const W: i32 = 1400;
1161 const H: i32 = 180;
1162 let ctx = test_ctx();
1163 let props = props_for(&text);
1164
1165 let font = inter_font(FONT_PX);
1166 let first_w = measure_text_with_fallback("FIRST", &font, &None, 0.0);
1167 let gap_w = measure_text_with_fallback(" ", &font, &None, 0.0);
1168 let margin = (gap_w / 3.0).max(40.0);
1169 let gap_x0 = (first_w + margin) as i32;
1170 let gap_x1 = (first_w + gap_w - margin) as i32;
1171 assert!(
1172 gap_x1 > gap_x0,
1173 "test setup: the space run must measure to a real gap (got [{gap_x0},{gap_x1}))"
1174 );
1175
1176 for &t in &[0.05_f64, 0.35, 1.0] {
1177 let mut surface =
1178 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1179 {
1180 let canvas = surface.canvas();
1181 text.paint(canvas, W as f32, None, t, &props, &ctx)
1182 .expect("paint succeeds");
1183 }
1184 let grid = alpha_grid(&mut surface, W, H);
1185 assert!(
1186 !has_ink_in(&grid, W, gap_x0, gap_x1, 0, H),
1187 "inter-word gap [{gap_x0},{gap_x1}) must stay empty at t={t}"
1188 );
1189 }
1190 }
1191
1192 #[test]
1193 fn char_blur_in_honors_word_delay_and_stagger() {
1194 const FONT_PX: f32 = 90.0;
1195 let mut text = make_text("ONE TWO", None);
1196 text.style.font_size = Some(Length::Px(FONT_PX));
1197 text.style.white_space = Some(CssWhiteSpace::Nowrap);
1198 text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1199 delay: 0.5,
1200 duration: 0.3,
1201 stagger: 0.6,
1202 granularity: TextAnimGranularity::Word,
1203 easing: EasingType::Linear,
1204 blur: Some(16.0),
1205 ..Default::default()
1206 })];
1207
1208 const W: i32 = 900;
1209 const H: i32 = 180;
1210 let ctx = test_ctx();
1211 let props = props_for(&text);
1212 let font = inter_font(FONT_PX);
1213 let word1_end = measure_text_with_fallback("ONE", &font, &None, 0.0) as i32;
1214
1215 let mut before = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1216 {
1217 let canvas = before.canvas();
1218 text.paint(canvas, W as f32, None, 0.1, &props, &ctx)
1219 .expect("paint succeeds");
1220 }
1221 let before_grid = alpha_grid(&mut before, W, H);
1222 assert!(
1223 !has_ink_in(&before_grid, W, 0, W, 0, H),
1224 "nothing should paint before `delay` has elapsed"
1225 );
1226
1227 let mut mid = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1228 {
1229 let canvas = mid.canvas();
1230 text.paint(canvas, W as f32, None, 0.65, &props, &ctx)
1231 .expect("paint succeeds");
1232 }
1233 let mid_grid = alpha_grid(&mut mid, W, H);
1234 assert!(
1235 has_ink_in(&mid_grid, W, 0, word1_end, 0, H),
1236 "word 1 should show ink by t=0.65 (mid-reveal)"
1237 );
1238 assert!(
1239 !has_ink_in(&mid_grid, W, word1_end + 70, W, 0, H),
1240 "word 2 (starts at delay+stagger=1.1s) must still be fully invisible at t=0.65"
1241 );
1242 }
1243
1244 fn autofit_text(content: &str, font_size: f32, white_space: Option<CssWhiteSpace>) -> Text {
1245 let mut t = make_text(content, white_space);
1246 t.style.font_size = Some(Length::Px(font_size));
1247 t.style.text_autofit = Some(true);
1248 t
1249 }
1250
1251 fn max_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
1252 let mut max_x: Option<i32> = None;
1253 for y in 0..height {
1254 for x in (0..surface_width).rev() {
1255 if grid[(y * surface_width + x) as usize] > 0 {
1256 max_x = Some(max_x.map_or(x, |m| m.max(x)));
1257 break;
1258 }
1259 }
1260 }
1261 max_x
1262 }
1263
1264 fn max_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
1265 for y in (0..height).rev() {
1266 for x in 0..surface_width {
1267 if grid[(y * surface_width + x) as usize] > 0 {
1268 return Some(y);
1269 }
1270 }
1271 }
1272 None
1273 }
1274
1275 #[test]
1276 fn measure_and_paint_agree_on_a_shrunk_nowrap_line() {
1277 let text = autofit_text(
1278 "the quick brown fox jumps over the lazy dog",
1279 90.0,
1280 Some(CssWhiteSpace::Nowrap),
1281 );
1282 const BOX_W: f32 = 300.0;
1283 const BOX_H: f32 = 60.0;
1284
1285 let (measured_w, _measured_h) = TextIntrinsic::from_text(&text).measure(
1286 (None, None),
1287 (
1288 AvailableSpace::Definite(BOX_W),
1289 AvailableSpace::Definite(BOX_H),
1290 ),
1291 );
1292 assert!(
1293 measured_w <= BOX_W + 0.5,
1294 "TextIntrinsic itself must report a fit once autofit is on, got {measured_w}"
1295 );
1296
1297 const W: i32 = 900;
1298 const H: i32 = 300;
1299 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1300 {
1301 let canvas = surface.canvas();
1302 let ctx = test_ctx();
1303 let props = AnimatedProperties::default();
1304 text.paint(canvas, BOX_W, Some(BOX_H), 0.0, &props, &ctx)
1305 .expect("paint succeeds");
1306 }
1307 let grid = alpha_grid(&mut surface, W, H);
1308 let ink_right = max_ink_x(&grid, W, H).expect("text must paint some ink");
1309
1310 assert!(
1311 (ink_right as f32) <= measured_w + 3.0,
1312 "painted ink (right edge {ink_right}) must not exceed the box TextIntrinsic reserved \
1313 ({measured_w}) — a wider paint than measure is exactly the class of bug this \
1314 workstream exists to close"
1315 );
1316 assert!(
1317 (ink_right as f32) >= measured_w - 15.0,
1318 "painted ink (right edge {ink_right}) should land close to what TextIntrinsic \
1319 measured ({measured_w}); a big gap would mean the two disagree on the resolved \
1320 font size in the other direction (paint drawing much smaller than reserved)"
1321 );
1322 }
1323
1324 #[test]
1325 fn measure_and_paint_agree_on_a_shrunk_wrapped_paragraph_height() {
1326 let text = autofit_text(
1327 "the quick brown fox jumps over the lazy dog and then keeps going for quite a while longer",
1328 60.0,
1329 None,
1330 );
1331 const BOX_W: f32 = 300.0;
1332 const BOX_H: f32 = 90.0;
1333
1334 let (_measured_w, measured_h) = TextIntrinsic::from_text(&text).measure(
1335 (None, None),
1336 (
1337 AvailableSpace::Definite(BOX_W),
1338 AvailableSpace::Definite(BOX_H),
1339 ),
1340 );
1341 assert!(
1342 measured_h <= BOX_H + 0.5,
1343 "TextIntrinsic itself must report a fit once autofit is on, got {measured_h}"
1344 );
1345
1346 const W: i32 = 500;
1347 const H: i32 = 400;
1348 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1349 {
1350 let canvas = surface.canvas();
1351 let ctx = test_ctx();
1352 let props = AnimatedProperties::default();
1353 text.paint(canvas, BOX_W, Some(BOX_H), 0.0, &props, &ctx)
1354 .expect("paint succeeds");
1355 }
1356 let grid = alpha_grid(&mut surface, W, H);
1357 let ink_bottom = max_ink_y(&grid, W, H).expect("text must paint some ink");
1358
1359 assert!(
1360 (ink_bottom as f32) <= measured_h + 6.0,
1361 "painted ink (bottom edge {ink_bottom}) must not exceed the box TextIntrinsic \
1362 reserved ({measured_h})"
1363 );
1364 }
1365
1366 #[test]
1367 fn autofit_size_is_stable_across_frames_for_fixed_content() {
1368 let text = autofit_text(
1369 "the quick brown fox jumps over the lazy dog",
1370 90.0,
1371 Some(CssWhiteSpace::Nowrap),
1372 );
1373 const W: i32 = 900;
1374 const H: i32 = 300;
1375 let ctx = test_ctx();
1376 let props = AnimatedProperties::default();
1377
1378 let render_at = |t: f64| -> Vec<u8> {
1379 let mut surface =
1380 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1381 {
1382 let canvas = surface.canvas();
1383 text.paint(canvas, 250.0, Some(60.0), t, &props, &ctx)
1384 .expect("paint succeeds");
1385 }
1386 alpha_grid(&mut surface, W, H)
1387 };
1388
1389 let frame_a = render_at(0.0);
1390 let frame_b = render_at(0.9);
1391 assert_eq!(
1392 frame_a, frame_b,
1393 "fixed content in a fixed box must render byte-identically regardless of ctx.time — \
1394 a per-frame drift here is exactly what the temporal-stability requirement forbids"
1395 );
1396 }
1397
1398 #[test]
1399 fn autofit_size_does_not_drift_during_a_typewriter_reveal() {
1400 let text = autofit_text(
1401 "the quick brown fox jumps over the lazy dog",
1402 90.0,
1403 Some(CssWhiteSpace::Nowrap),
1404 );
1405 const W: i32 = 900;
1406 const H: i32 = 300;
1407 let ctx = test_ctx();
1408
1409 let render_at = |progress: f32| -> Vec<u8> {
1410 let mut surface =
1411 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1412 let props = AnimatedProperties {
1413 visible_chars_progress: progress,
1414 ..Default::default()
1415 };
1416 {
1417 let canvas = surface.canvas();
1418 text.paint(canvas, 250.0, Some(60.0), 0.0, &props, &ctx)
1419 .expect("paint succeeds");
1420 }
1421 alpha_grid(&mut surface, W, H)
1422 };
1423
1424 let early = render_at(0.3);
1425 let full = render_at(1.0);
1426
1427 let early_bottom = max_ink_y(&early, W, H).expect("some ink must paint at 30% reveal");
1428 let full_bottom = max_ink_y(&full, W, H).expect("some ink must paint at full reveal");
1429 assert_eq!(
1430 early_bottom, full_bottom,
1431 "the resolved font size (line height, hence vertical ink footprint) must not change \
1432 as the typewriter reveal progresses: early={early_bottom}, full={full_bottom}"
1433 );
1434
1435 let early_right = max_ink_x(&early, W, H).expect("some ink at 30% reveal");
1436 let full_right = max_ink_x(&full, W, H).expect("some ink at full reveal");
1437 assert!(
1438 early_right < full_right,
1439 "test setup: 30% reveal should show measurably less horizontal ink than the full \
1440 line (early={early_right}, full={full_right})"
1441 );
1442 }
1443
1444 #[test]
1445 fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() {
1446 let text = make_text(
1447 "the quick brown fox jumps over the lazy dog",
1448 Some(CssWhiteSpace::Nowrap),
1449 );
1450 const W: i32 = 900;
1451 const H: i32 = 300;
1452 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1453 let canvas = surface.canvas();
1454 let ctx = test_ctx();
1455 let props = AnimatedProperties::default();
1456 text.paint(canvas, 250.0, Some(60.0), 0.0, &props, &ctx)
1457 .expect("paint succeeds");
1458 let grid = alpha_grid(&mut surface, W, H);
1459 assert!(
1460 has_ink_in(&grid, W, 260, W, 0, 45),
1461 "without text-autofit, nowrap must still bleed past its box exactly as before"
1462 );
1463 }
1464
1465 fn swapping_text(swap: Option<TextSwapConfig>) -> Text {
1466 let mut text = make_text("Saving draft", Some(CssWhiteSpace::Nowrap));
1467 text.style.font_size = Some(Length::Px(48.0));
1468 text.states = vec![TextState {
1469 at: 1.0,
1470 content: "Saved".into(),
1471 }];
1472 text.swap = swap;
1473 text
1474 }
1475
1476 fn render_plain(text: &Text, time: f64) -> Vec<u8> {
1477 let mut surface =
1478 skia_safe::surfaces::raster_n32_premul((CARET_W, CARET_H)).expect("raster surface");
1479 {
1480 let canvas = surface.canvas();
1481 text.paint(
1482 canvas,
1483 CARET_W as f32,
1484 None,
1485 time,
1486 &AnimatedProperties::default(),
1487 &test_ctx(),
1488 )
1489 .expect("paint succeeds");
1490 }
1491 alpha_grid(&mut surface, CARET_W, CARET_H)
1492 }
1493
1494 #[test]
1495 fn states_cut_over_at_their_own_time_without_a_swap_config() {
1496 let text = swapping_text(None);
1497 let before = render_plain(&text, 0.5);
1498 let after = render_plain(&text, 1.5);
1499
1500 let width_of = |g: &[u8]| max_ink_x(g, CARET_W, CARET_H).unwrap_or(0);
1501 assert!(
1502 width_of(&before) > width_of(&after) + 20,
1503 "\"Saving draft\" should be visibly wider than \"Saved\" — the label must actually \
1504 have changed at t=1.0"
1505 );
1506 assert_eq!(
1507 render_plain(&text, 1.01),
1508 render_plain(&text, 1.5),
1509 "without a `swap`, the new label must be fully in place immediately"
1510 );
1511 }
1512
1513 #[test]
1514 fn a_swap_puts_both_labels_on_screen_at_once() {
1515 let text = swapping_text(Some(TextSwapConfig::default()));
1516
1517 let rows_with_ink = |time: f64| -> usize {
1518 let grid = render_plain(&text, time);
1519 (0..CARET_H)
1520 .filter(|&y| (0..CARET_W).any(|x| grid[(y * CARET_W + x) as usize] > 0))
1521 .count()
1522 };
1523
1524 let settled = rows_with_ink(0.5);
1525 let mid = rows_with_ink(1.0 + 0.45 / 2.0);
1526 assert!(
1527 mid > settled,
1528 "mid-swap, the two offset labels should span more rows than one settled label \
1529 (settled={settled}, mid={mid})"
1530 );
1531 }
1532
1533 #[test]
1534 fn a_finished_swap_settles_on_the_incoming_label_alone() {
1535 let swapped = swapping_text(Some(TextSwapConfig::default()));
1536 let cut = swapping_text(None);
1537 assert_eq!(
1538 render_plain(&swapped, 2.0),
1539 render_plain(&cut, 2.0),
1540 "once the swap window has passed, the frame must match a plain cut exactly"
1541 );
1542 }
1543
1544 #[test]
1545 fn the_box_is_measured_for_the_widest_label_not_the_first() {
1546 let mut short_first = make_text("Saved", Some(CssWhiteSpace::Nowrap));
1547 short_first.states = vec![TextState {
1548 at: 1.0,
1549 content: "Saving draft".into(),
1550 }];
1551 let only_short = make_text("Saved", Some(CssWhiteSpace::Nowrap));
1552
1553 let measure = |t: &Text| {
1554 TextIntrinsic::from_text(t)
1555 .measure(
1556 (None, None),
1557 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1558 )
1559 .0
1560 };
1561
1562 assert!(
1563 measure(&short_first) > measure(&only_short) + 10.0,
1564 "the reserved width must cover the longest label the text can show \
1565 (with states={}, without={})",
1566 measure(&short_first),
1567 measure(&only_short)
1568 );
1569 }
1570
1571 const CARET_W: i32 = 900;
1572 const CARET_H: i32 = 160;
1573
1574 fn typewriter_text(caret: Option<CaretConfig>) -> Text {
1575 let mut text = make_text("HELLO WORLD", Some(CssWhiteSpace::Nowrap));
1576 text.style.font_size = Some(Length::Px(64.0));
1577 text.caret = caret;
1578 text
1579 }
1580
1581 fn render_reveal(text: &Text, progress: f32, time: f64) -> Vec<u8> {
1582 let props = AnimatedProperties {
1583 visible_chars_progress: progress,
1584 ..AnimatedProperties::default()
1585 };
1586 let mut surface =
1587 skia_safe::surfaces::raster_n32_premul((CARET_W, CARET_H)).expect("raster surface");
1588 {
1589 let canvas = surface.canvas();
1590 text.paint(canvas, CARET_W as f32, None, time, &props, &test_ctx())
1591 .expect("paint succeeds");
1592 }
1593 alpha_grid(&mut surface, CARET_W, CARET_H)
1594 }
1595
1596 #[test]
1597 fn the_caret_follows_the_reveal_head_instead_of_standing_still() {
1598 let text = typewriter_text(Some(CaretConfig {
1599 blink: 0.0,
1600 ..Default::default()
1601 }));
1602 let plain = typewriter_text(None);
1603
1604 let caret_x = |progress: f32| -> i32 {
1605 let with = render_reveal(&text, progress, 0.0);
1606 let without = render_reveal(&plain, progress, 0.0);
1607 let with_right = max_ink_x(&with, CARET_W, CARET_H).expect("caret paints");
1608 let without_right = max_ink_x(&without, CARET_W, CARET_H).unwrap_or(0);
1609 assert!(
1610 with_right > without_right,
1611 "the caret should extend past the last revealed glyph \
1612 (with={with_right}, without={without_right}) at progress {progress}"
1613 );
1614 with_right
1615 };
1616
1617 let early = caret_x(0.25);
1618 let late = caret_x(0.75);
1619 assert!(
1620 late > early + 40,
1621 "the caret should have travelled with the reveal head (early={early}, late={late})"
1622 );
1623 }
1624
1625 #[test]
1626 fn the_caret_blinks_off_for_half_of_each_period() {
1627 let text = typewriter_text(Some(CaretConfig {
1628 blink: 1.0,
1629 ..Default::default()
1630 }));
1631 let plain = typewriter_text(None);
1632
1633 let right_edge = |grid: &[u8]| max_ink_x(grid, CARET_W, CARET_H).unwrap_or(0);
1634 let baseline = right_edge(&render_reveal(&plain, 0.5, 0.0));
1635
1636 let on = right_edge(&render_reveal(&text, 0.5, 0.1));
1637 let off = right_edge(&render_reveal(&text, 0.5, 0.6));
1638 assert!(on > baseline, "caret should be visible at phase 0.1");
1639 assert_eq!(
1640 off, baseline,
1641 "caret should be blinked out at phase 0.6, leaving only the text's own ink"
1642 );
1643 }
1644
1645 #[test]
1646 fn hide_when_done_removes_the_caret_once_the_reveal_finishes() {
1647 let hiding = typewriter_text(Some(CaretConfig {
1648 blink: 0.0,
1649 hide_when_done: true,
1650 ..Default::default()
1651 }));
1652 let staying = typewriter_text(Some(CaretConfig {
1653 blink: 0.0,
1654 ..Default::default()
1655 }));
1656 let plain = typewriter_text(None);
1657
1658 let right_edge = |t: &Text, progress: f32| {
1659 max_ink_x(&render_reveal(t, progress, 0.0), CARET_W, CARET_H).unwrap_or(0)
1660 };
1661 let text_edge = right_edge(&plain, 1.0);
1662
1663 assert_eq!(
1664 right_edge(&hiding, 1.0),
1665 text_edge,
1666 "with hide_when_done, a finished reveal must leave no caret behind"
1667 );
1668 assert!(
1669 right_edge(&staying, 1.0) > text_edge,
1670 "without hide_when_done, the caret parks at the end of the text"
1671 );
1672 }
1673
1674 #[test]
1675 fn the_caret_is_there_before_the_first_character_is() {
1676 let text = typewriter_text(Some(CaretConfig {
1677 blink: 0.0,
1678 ..Default::default()
1679 }));
1680 let grid = render_reveal(&text, 0.0, 0.0);
1681 assert!(
1682 has_ink_in(&grid, CARET_W, 0, CARET_W, 0, CARET_H),
1683 "the caret must be painting at 0% reveal, before any glyph"
1684 );
1685 }
1686
1687 const TUNING_W: i32 = 520;
1688 const TUNING_H: i32 = 360;
1689
1690 fn tuned_slide_up(timing: CharAnimationTiming) -> Text {
1691 let mut text = make_text("GO", None);
1692 text.style.font_size = Some(Length::Px(90.0));
1693 text.style.white_space = Some(CssWhiteSpace::Nowrap);
1694 text.style.animation = vec![AnimationEffect::CharSlideUp(timing)];
1695 text
1696 }
1697
1698 fn render_alpha(text: &Text, time: f64) -> Vec<u8> {
1699 let mut surface =
1700 skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H)).expect("raster surface");
1701 {
1702 let canvas = surface.canvas();
1703 text.paint(
1704 canvas,
1705 TUNING_W as f32,
1706 None,
1707 time,
1708 &props_for(text),
1709 &test_ctx(),
1710 )
1711 .expect("paint succeeds");
1712 }
1713 alpha_grid(&mut surface, TUNING_W, TUNING_H)
1714 }
1715
1716 fn min_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
1717 (0..height)
1718 .find(|&y| (0..surface_width).any(|x| grid[(y * surface_width + x) as usize] > 0))
1719 }
1720
1721 #[test]
1722 fn direction_down_starts_the_unit_above_its_line_instead_of_below() {
1723 let base = || CharAnimationTiming {
1724 duration: 1.0,
1725 stagger: 0.0,
1726 granularity: TextAnimGranularity::Word,
1727 easing: EasingType::Linear,
1728 distance: Some(0.5),
1729 ..Default::default()
1730 };
1731 let up = tuned_slide_up(CharAnimationTiming {
1732 direction: TextAnimDirection::Up,
1733 ..base()
1734 });
1735 let down = tuned_slide_up(CharAnimationTiming {
1736 direction: TextAnimDirection::Down,
1737 ..base()
1738 });
1739
1740 let up_grid = render_alpha(&up, 0.5);
1741 let down_grid = render_alpha(&down, 0.5);
1742
1743 let up_top = min_ink_y(&up_grid, TUNING_W, TUNING_H).expect("up-travelling word paints");
1744 let down_top =
1745 min_ink_y(&down_grid, TUNING_W, TUNING_H).expect("down-travelling word paints");
1746
1747 assert!(
1748 down_top < up_top - 10,
1749 "at the same instant, a `down` unit should sit clearly higher on the canvas than an \
1750 `up` one (down_top={down_top}, up_top={up_top})"
1751 );
1752 }
1753
1754 #[test]
1755 fn distance_scales_how_far_the_unit_travels() {
1756 let base = || CharAnimationTiming {
1757 duration: 1.0,
1758 stagger: 0.0,
1759 granularity: TextAnimGranularity::Word,
1760 easing: EasingType::Linear,
1761 ..Default::default()
1762 };
1763 let close = tuned_slide_up(CharAnimationTiming {
1764 distance: Some(0.25),
1765 ..base()
1766 });
1767 let far = tuned_slide_up(CharAnimationTiming {
1768 distance: Some(1.0),
1769 ..base()
1770 });
1771
1772 let close_top =
1773 min_ink_y(&render_alpha(&close, 0.5), TUNING_W, TUNING_H).expect("close word paints");
1774 let far_top =
1775 min_ink_y(&render_alpha(&far, 0.5), TUNING_W, TUNING_H).expect("far word paints");
1776
1777 assert!(
1778 far_top > close_top + 10,
1779 "a `distance: 1.0` unit should still be further below its line than a `0.25` one at \
1780 the same instant (far_top={far_top}, close_top={close_top})"
1781 );
1782 }
1783
1784 #[test]
1785 fn settled_units_land_in_the_same_place_whatever_the_direction_and_distance() {
1786 let settled = |timing: CharAnimationTiming| {
1787 let grid = render_alpha(&tuned_slide_up(timing), 5.0);
1788 min_ink_y(&grid, TUNING_W, TUNING_H).expect("settled word paints")
1789 };
1790 let base = || CharAnimationTiming {
1791 duration: 1.0,
1792 stagger: 0.0,
1793 granularity: TextAnimGranularity::Word,
1794 easing: EasingType::Linear,
1795 ..Default::default()
1796 };
1797
1798 let plain = settled(base());
1799 let downward = settled(CharAnimationTiming {
1800 direction: TextAnimDirection::Down,
1801 distance: Some(1.85),
1802 ..base()
1803 });
1804 let sideways = settled(CharAnimationTiming {
1805 direction: TextAnimDirection::Right,
1806 distance: Some(1.85),
1807 ..base()
1808 });
1809
1810 assert_eq!(
1811 plain, downward,
1812 "a settled `down` unit must land on its line"
1813 );
1814 assert_eq!(
1815 plain, sideways,
1816 "a settled `right` unit must land on its line"
1817 );
1818 }
1819
1820 #[test]
1821 fn scale_from_shrinks_the_unit_at_the_start_and_releases_it_by_the_end() {
1822 let timing = CharAnimationTiming {
1823 duration: 1.0,
1824 stagger: 0.0,
1825 granularity: TextAnimGranularity::Word,
1826 easing: EasingType::Linear,
1827 distance: Some(0.0),
1828 scale_from: Some(0.5),
1829 ..Default::default()
1830 };
1831 let text = tuned_slide_up(timing);
1832
1833 let ink_width = |time: f64| -> i32 {
1834 let grid = render_alpha(&text, time);
1835 let left = (0..TUNING_W)
1836 .find(|&x| (0..TUNING_H).any(|y| grid[(y * TUNING_W + x) as usize] > 0));
1837 let right = (0..TUNING_W)
1838 .rev()
1839 .find(|&x| (0..TUNING_H).any(|y| grid[(y * TUNING_W + x) as usize] > 0));
1840 match (left, right) {
1841 (Some(l), Some(r)) => r - l,
1842 _ => 0,
1843 }
1844 };
1845
1846 let early = ink_width(0.35);
1847 let settled = ink_width(5.0);
1848 assert!(early > 0, "the word must be painting by t=0.35");
1849 assert!(
1850 (early as f32) < settled as f32 * 0.9,
1851 "a `scale_from: 0.5` unit should still be visibly narrower than its settled self \
1852 early on (early={early}px, settled={settled}px)"
1853 );
1854 }
1855
1856 #[test]
1857 fn ink_from_starts_at_the_given_colour_and_settles_to_the_texts_own() {
1858 let mut text = make_text("INK", None);
1859 text.style.font_size = Some(Length::Px(90.0));
1860 text.style.white_space = Some(CssWhiteSpace::Nowrap);
1861 text.style.color = Some(rustmotion_core::css::style::Color::String("#FFFFFF".into()));
1862 text.style.animation = vec![AnimationEffect::CharScaleIn(CharAnimationTiming {
1863 duration: 1.0,
1864 stagger: 0.0,
1865 granularity: TextAnimGranularity::Word,
1866 easing: EasingType::Linear,
1867 overshoot: Some(0.0),
1868 ink_from: Some("#FF0000".into()),
1869 ..Default::default()
1870 })];
1871
1872 let mean_green = |time: f64| -> f32 {
1873 let mut surface = skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H))
1874 .expect("raster surface");
1875 {
1876 let canvas = surface.canvas();
1877 text.paint(
1878 canvas,
1879 TUNING_W as f32,
1880 None,
1881 time,
1882 &props_for(&text),
1883 &test_ctx(),
1884 )
1885 .expect("paint succeeds");
1886 }
1887 let snapshot = surface.image_snapshot();
1888 let info = skia_safe::ImageInfo::new(
1889 (TUNING_W, TUNING_H),
1890 skia_safe::ColorType::RGBA8888,
1891 skia_safe::AlphaType::Unpremul,
1892 None,
1893 );
1894 let mut buf = vec![0u8; (TUNING_W * TUNING_H * 4) as usize];
1895 assert!(snapshot.read_pixels(
1896 &info,
1897 &mut buf,
1898 (TUNING_W * 4) as usize,
1899 skia_safe::IPoint::new(0, 0),
1900 skia_safe::image::CachingHint::Disallow,
1901 ));
1902 let (weighted, alpha_sum) =
1903 (0..(TUNING_W * TUNING_H) as usize).fold((0u64, 0u64), |(s, a), i| {
1904 let alpha = buf[i * 4 + 3] as u64;
1905 (s + buf[i * 4 + 1] as u64 * alpha, a + alpha)
1906 });
1907 assert!(alpha_sum > 0, "some inked pixels must exist at t={time}");
1908 weighted as f32 / alpha_sum as f32
1909 };
1910
1911 let early = mean_green(0.1);
1912 let mid = mean_green(0.5);
1913 let settled = mean_green(5.0);
1914
1915 assert!(
1916 early < 60.0,
1917 "at 10% the word should read nearly pure red (mean green {early:.1})"
1918 );
1919 assert!(
1920 mid > early + 40.0 && mid < settled - 40.0,
1921 "at 50% the word should be halfway between its start colour and the text colour \
1922 (early={early:.1}, mid={mid:.1}, settled={settled:.1})"
1923 );
1924 assert!(
1925 settled > 250.0,
1926 "once settled the word must be the text's own white, not a tint of it \
1927 (mean green {settled:.1})"
1928 );
1929 }
1930
1931 fn morphing_text(from: &str, to: &str, morph: Option<TextMorphConfig>) -> Text {
1932 let mut text = make_text(from, Some(CssWhiteSpace::Nowrap));
1933 text.style.font_size = Some(Length::Px(90.0));
1934 text.states = vec![TextState {
1935 at: 1.0,
1936 content: to.into(),
1937 }];
1938 text.morph = morph;
1939 text
1940 }
1941
1942 #[test]
1943 fn lcs_pairs_matches_identical_characters_left_to_right() {
1944 let from: Vec<char> = "AX".chars().collect();
1945 let to: Vec<char> = "YA".chars().collect();
1946 assert_eq!(
1947 lcs_pairs(&from, &to),
1948 vec![(0, 1)],
1949 "the shared 'A' must be paired even though it moves from index 0 to index 1"
1950 );
1951 }
1952
1953 #[test]
1954 fn scrambled_char_is_deterministic_and_varies_across_time() {
1955 let a = scrambled_char(4, 2, 0.31);
1956 let b = scrambled_char(4, 2, 0.31);
1957 assert_eq!(
1958 a, b,
1959 "the same seed/index/time must always scramble to the same glyph"
1960 );
1961
1962 let distinct: std::collections::HashSet<char> = (0..30)
1963 .map(|i| scrambled_char(4, 2, i as f64 * 0.05))
1964 .collect();
1965 assert!(
1966 distinct.len() > 1,
1967 "the scramble must vary as time advances, not freeze on one glyph"
1968 );
1969 }
1970
1971 #[test]
1972 fn morph_looks_different_from_a_hard_cut_mid_transition() {
1973 let morphed = morphing_text(
1974 "AX",
1975 "YA",
1976 Some(TextMorphConfig {
1977 duration: 1.0,
1978 unmatched: TextMorphUnmatched::Fade,
1979 seed: 0,
1980 }),
1981 );
1982 let cut = morphing_text("AX", "YA", None);
1983
1984 assert_ne!(
1985 render_plain(&morphed, 1.5),
1986 render_plain(&cut, 1.5),
1987 "50% through the morph, the frame must differ from an immediate hard cut to the \
1988 final label — the matched letter should still be travelling and the unmatched \
1989 letters should still be fading, not already fully settled"
1990 );
1991 }
1992
1993 #[test]
1994 fn morph_keeps_progressing_between_two_instants_in_the_window() {
1995 let morphed = morphing_text(
1996 "AX",
1997 "YA",
1998 Some(TextMorphConfig {
1999 duration: 1.0,
2000 unmatched: TextMorphUnmatched::Fade,
2001 seed: 0,
2002 }),
2003 );
2004
2005 assert_ne!(
2006 render_plain(&morphed, 1.1),
2007 render_plain(&morphed, 1.9),
2008 "the morph must keep changing across its window, not snap to one position and hold"
2009 );
2010 }
2011
2012 #[test]
2013 fn scramble_mode_paints_a_glyph_other_than_the_target_before_settling() {
2014 let seed = 3u32;
2015 let idx = 0usize;
2016 let visibly_faded_in_but_before_the_settle_threshold = 20..60;
2017 let sample_time = visibly_faded_in_but_before_the_settle_threshold
2018 .map(|i| 1.0 + i as f64 * 0.01)
2019 .find(|&t| scrambled_char(seed, idx, t) != 'B')
2020 .expect("at least one sampled instant must scramble to something other than 'B'");
2021
2022 let fade = morphing_text(
2023 "A",
2024 "B",
2025 Some(TextMorphConfig {
2026 duration: 1.0,
2027 unmatched: TextMorphUnmatched::Fade,
2028 seed,
2029 }),
2030 );
2031 let scramble = morphing_text(
2032 "A",
2033 "B",
2034 Some(TextMorphConfig {
2035 duration: 1.0,
2036 unmatched: TextMorphUnmatched::Scramble,
2037 seed,
2038 }),
2039 );
2040
2041 const W: i32 = 400;
2042 const H: i32 = 200;
2043 let ctx = test_ctx();
2044 let props = AnimatedProperties::default();
2045
2046 let render = |text: &Text| -> Vec<u8> {
2047 let mut surface =
2048 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
2049 text.paint(surface.canvas(), W as f32, None, sample_time, &props, &ctx)
2050 .expect("paint succeeds");
2051 alpha_grid(&mut surface, W, H)
2052 };
2053
2054 let fade_grid = render(&fade);
2055 let scramble_grid = render(&scramble);
2056
2057 assert_ne!(
2058 fade_grid, scramble_grid,
2059 "at t={sample_time}, `unmatched: scramble` must paint a different glyph than \
2060 `unmatched: fade` — scramble is supposed to cycle through placeholder characters \
2061 instead of just fading the real one in"
2062 );
2063 }
2064
2065 #[test]
2066 fn morph_settles_on_a_plain_cut_once_the_window_has_passed() {
2067 let morphed = morphing_text(
2068 "AX",
2069 "YA",
2070 Some(TextMorphConfig {
2071 duration: 1.0,
2072 unmatched: TextMorphUnmatched::Fade,
2073 seed: 0,
2074 }),
2075 );
2076 let cut = morphing_text("AX", "YA", None);
2077
2078 assert_eq!(
2079 render_plain(&morphed, 2.0),
2080 render_plain(&cut, 2.0),
2081 "once the morph window has passed, the frame must match a plain cut to the final label"
2082 );
2083 }
2084
2085 fn painted_width(letter_spacing: f32) -> usize {
2086 let mut text = make_text("IIIII", None);
2087 text.style.font_size = Some(Length::Px(40.0));
2088 let mut surface = skia_safe::surfaces::raster_n32_premul((600, 120)).unwrap();
2089 let props = AnimatedProperties {
2090 letter_spacing,
2091 ..Default::default()
2092 };
2093 let _ = text.paint(surface.canvas(), 600.0, None, 0.0, &props, &test_ctx());
2094
2095 let info = skia_safe::ImageInfo::new(
2096 (600, 120),
2097 skia_safe::ColorType::RGBA8888,
2098 skia_safe::AlphaType::Unpremul,
2099 None,
2100 );
2101 let mut buf = vec![0u8; 600 * 120 * 4];
2102 surface.read_pixels(&info, &mut buf, 600 * 4, (0, 0));
2103
2104 let mut lo = usize::MAX;
2105 let mut hi = 0usize;
2106 for y in 0..120 {
2107 for x in 0..600 {
2108 if buf[(y * 600 + x) * 4 + 3] > 60 {
2109 lo = lo.min(x);
2110 hi = hi.max(x);
2111 }
2112 }
2113 }
2114 if lo == usize::MAX {
2115 0
2116 } else {
2117 hi - lo
2118 }
2119 }
2120
2121 #[test]
2122 fn an_animated_letter_spacing_widens_the_painted_run() {
2123 let tight = painted_width(0.0);
2124 let loose = painted_width(16.0);
2125 assert!(tight > 0, "sanity: the tight run paints something");
2126 assert!(
2127 loose > tight + 40,
2128 "a keyframed letter_spacing must move the glyphs apart: tight={tight}px, \
2129 loose={loose}px over five glyphs at 16px extra each"
2130 );
2131 }
2132
2133 #[test]
2134 fn an_absent_letter_spacing_leaves_the_declared_one_alone() {
2135 let declared = {
2136 let mut text = make_text("IIIII", None);
2137 text.style.font_size = Some(Length::Px(40.0));
2138 text.style.letter_spacing = Some(Length::Px(16.0));
2139 let mut surface = skia_safe::surfaces::raster_n32_premul((600, 120)).unwrap();
2140 let _ = text.paint(
2141 surface.canvas(),
2142 600.0,
2143 None,
2144 0.0,
2145 &AnimatedProperties::default(),
2146 &test_ctx(),
2147 );
2148 let info = skia_safe::ImageInfo::new(
2149 (600, 120),
2150 skia_safe::ColorType::RGBA8888,
2151 skia_safe::AlphaType::Unpremul,
2152 None,
2153 );
2154 let mut buf = vec![0u8; 600 * 120 * 4];
2155 surface.read_pixels(&info, &mut buf, 600 * 4, (0, 0));
2156 buf
2157 };
2158 assert!(
2159 declared.iter().any(|b| *b > 60),
2160 "with no animated value, style.letter-spacing must still be honoured — the \
2161 animated field defaults to NaN precisely so absent means absent"
2162 );
2163 }
2164
2165 fn render_pipeline(
2166 component_json: serde_json::Value,
2167 box_x: f32,
2168 box_y: f32,
2169 w: i32,
2170 h: i32,
2171 time: f64,
2172 scene_duration: f64,
2173 ) -> Vec<u8> {
2174 use crate::box_builder::{build_scene_with_anim, BuildAnimationCtx};
2175 use crate::legacy_dispatch::LegacyPaintDispatcher;
2176 use crate::{ChildComponent, Component, PositionMode};
2177 use rustmotion_core::css::taffy_bridge::ConversionContext;
2178 use rustmotion_core::engine::layout_pass::run_layout;
2179 use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame};
2180
2181 let component: Component = serde_json::from_value(component_json).expect("deserialize");
2182 let child = ChildComponent {
2183 id: None,
2184 component,
2185 position: Some(PositionMode::Absolute { x: box_x, y: box_y }),
2186 x: None,
2187 y: None,
2188 z_index: None,
2189 bleed: false,
2190 };
2191 let children = vec![child];
2192
2193 let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
2194 let canvas = surface.canvas();
2195 canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0));
2196
2197 let built = build_scene_with_anim(
2198 &children,
2199 (w as f32, h as f32),
2200 BuildAnimationCtx {
2201 time,
2202 scenario_time: time,
2203 scene_duration,
2204 fps: 30,
2205 },
2206 );
2207 let layout = run_layout(
2208 &built.root,
2209 (w as f32, h as f32),
2210 &ConversionContext::default(),
2211 );
2212 let dispatcher = LegacyPaintDispatcher::for_scene(&built);
2213 let frame = PaintFrame {
2214 light: Default::default(),
2215 time,
2216 scenario_time: time,
2217 frame_index: (time * 30.0) as u32,
2218 fps: 30,
2219 video_width: w as u32,
2220 video_height: h as u32,
2221 scene_duration,
2222 camera: None,
2223 };
2224 paint_tree(canvas, &built.root, &layout, &frame, &dispatcher);
2225
2226 let row_bytes = w as usize * 4;
2227 let mut pixels = vec![0u8; row_bytes * h as usize];
2228 let info = skia_safe::ImageInfo::new(
2229 (w, h),
2230 skia_safe::ColorType::RGBA8888,
2231 skia_safe::AlphaType::Premul,
2232 None,
2233 );
2234 surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0));
2235 pixels
2236 }
2237
2238 fn ink_x_range(pixels: &[u8], w: i32, h: i32) -> Option<(i32, i32)> {
2239 let mut lo = None;
2240 let mut hi = None;
2241 for y in 0..h {
2242 for x in 0..w {
2243 let idx = ((y * w + x) * 4 + 3) as usize;
2244 if pixels[idx] > 0 {
2245 lo = Some(lo.map_or(x, |m: i32| m.min(x)));
2246 hi = Some(hi.map_or(x, |m: i32| m.max(x)));
2247 }
2248 }
2249 }
2250 lo.zip(hi)
2251 }
2252
2253 #[test]
2254 fn rotate_from_tilts_a_unit_and_widens_its_footprint_mid_animation() {
2255 const W: i32 = 400;
2256 const H: i32 = 220;
2257
2258 let json_for = |rotate_from: Option<f64>| {
2259 let mut anim = serde_json::json!({
2260 "name": "char_fade_in",
2261 "granularity": "char",
2262 "duration": 1.0,
2263 "stagger": 0.0,
2264 });
2265 if let Some(r) = rotate_from {
2266 anim["rotate_from"] = serde_json::json!(r);
2267 }
2268 serde_json::json!({
2269 "type": "text",
2270 "content": "I",
2271 "style": {
2272 "width": 300, "height": 200,
2273 "font-size": 120,
2274 "color": "#ffffff",
2275 "white-space": "nowrap",
2276 "animation": [anim]
2277 }
2278 })
2279 };
2280
2281 let plain = render_pipeline(json_for(None), 40.0, 10.0, W, H, 0.5, 1.0);
2282 let tilted = render_pipeline(json_for(Some(80.0)), 40.0, 10.0, W, H, 0.5, 1.0);
2283
2284 let (plain_lo, plain_hi) = ink_x_range(&plain, W, H).expect("plain glyph paints ink");
2285 let (tilted_lo, tilted_hi) = ink_x_range(&tilted, W, H).expect("tilted glyph paints ink");
2286
2287 let plain_width = plain_hi - plain_lo;
2288 let tilted_width = tilted_hi - tilted_lo;
2289
2290 assert!(
2291 tilted_width > plain_width + 20,
2292 "rotate_from must visibly tilt the unit mid-animation, widening its horizontal \
2293 footprint: plain width={plain_width}px, tilted width={tilted_width}px"
2294 );
2295 }
2296
2297 #[test]
2298 fn rotate_from_has_settled_to_upright_by_the_end_of_the_units_own_animation() {
2299 const W: i32 = 400;
2300 const H: i32 = 220;
2301
2302 let json_for = |rotate_from: Option<f64>| {
2303 let mut anim = serde_json::json!({
2304 "name": "char_fade_in",
2305 "granularity": "char",
2306 "duration": 1.0,
2307 "stagger": 0.0,
2308 });
2309 if let Some(r) = rotate_from {
2310 anim["rotate_from"] = serde_json::json!(r);
2311 }
2312 serde_json::json!({
2313 "type": "text",
2314 "content": "I",
2315 "style": {
2316 "width": 300, "height": 200,
2317 "font-size": 120,
2318 "color": "#ffffff",
2319 "white-space": "nowrap",
2320 "animation": [anim]
2321 }
2322 })
2323 };
2324
2325 let plain = render_pipeline(json_for(None), 40.0, 10.0, W, H, 1.0, 1.0);
2326 let settled = render_pipeline(json_for(Some(80.0)), 40.0, 10.0, W, H, 1.0, 1.0);
2327
2328 let plain_range = ink_x_range(&plain, W, H).expect("plain glyph paints ink");
2329 let settled_range = ink_x_range(&settled, W, H).expect("settled glyph paints ink");
2330
2331 assert_eq!(
2332 plain_range, settled_range,
2333 "rotate_from must straighten to upright by the end of the unit's own animation, \
2334 landing at the same horizontal footprint as no rotation at all"
2335 );
2336 }
2337
2338 #[test]
2339 fn scale_jitter_perturbs_a_units_born_size_and_settles_away_by_the_end() {
2340 const W: i32 = 300;
2341 const H: i32 = 200;
2342
2343 let json_for = |scale_jitter: Option<f64>| {
2344 let mut anim = serde_json::json!({
2345 "name": "char_fade_in",
2346 "granularity": "char",
2347 "duration": 1.0,
2348 "stagger": 0.0,
2349 "seed": 7,
2350 });
2351 if let Some(s) = scale_jitter {
2352 anim["scale_jitter"] = serde_json::json!(s);
2353 }
2354 serde_json::json!({
2355 "type": "text",
2356 "content": "O",
2357 "style": {
2358 "width": 200, "height": 150,
2359 "font-size": 100,
2360 "color": "#ffffff",
2361 "white-space": "nowrap",
2362 "animation": [anim]
2363 }
2364 })
2365 };
2366
2367 let plain_mid = render_pipeline(json_for(None), 30.0, 20.0, W, H, 0.5, 1.0);
2368 let jittered_mid = render_pipeline(json_for(Some(0.6)), 30.0, 20.0, W, H, 0.5, 1.0);
2369 assert_ne!(
2370 plain_mid, jittered_mid,
2371 "scale_jitter must visibly perturb a unit's size mid-animation"
2372 );
2373
2374 let plain_end = render_pipeline(json_for(None), 30.0, 20.0, W, H, 1.0, 1.0);
2375 let jittered_end = render_pipeline(json_for(Some(0.6)), 30.0, 20.0, W, H, 1.0, 1.0);
2376 let plain_range = ink_x_range(&plain_end, W, H).expect("plain glyph paints ink");
2377 let jittered_range = ink_x_range(&jittered_end, W, H).expect("jittered glyph paints ink");
2378 assert_eq!(
2379 plain_range, jittered_range,
2380 "scale_jitter must settle away by the end of the unit's own animation, landing at \
2381 the same footprint as no jitter at all — letters born at different sizes, then \
2382 aligning"
2383 );
2384 }
2385
2386 #[test]
2387 fn baseline_jitter_perturbs_a_units_born_baseline_and_settles_away_by_the_end() {
2388 const W: i32 = 300;
2389 const H: i32 = 200;
2390
2391 let json_for = |baseline_jitter: Option<f64>| {
2392 let mut anim = serde_json::json!({
2393 "name": "char_fade_in",
2394 "granularity": "char",
2395 "duration": 1.0,
2396 "stagger": 0.0,
2397 "seed": 11,
2398 });
2399 if let Some(b) = baseline_jitter {
2400 anim["baseline_jitter"] = serde_json::json!(b);
2401 }
2402 serde_json::json!({
2403 "type": "text",
2404 "content": "O",
2405 "style": {
2406 "width": 200, "height": 150,
2407 "font-size": 100,
2408 "color": "#ffffff",
2409 "white-space": "nowrap",
2410 "animation": [anim]
2411 }
2412 })
2413 };
2414
2415 let plain_mid = render_pipeline(json_for(None), 30.0, 20.0, W, H, 0.5, 1.0);
2416 let jittered_mid = render_pipeline(json_for(Some(0.3)), 30.0, 20.0, W, H, 0.5, 1.0);
2417 assert_ne!(
2418 plain_mid, jittered_mid,
2419 "baseline_jitter must visibly perturb a unit's baseline mid-animation"
2420 );
2421
2422 let plain_end = render_pipeline(json_for(None), 30.0, 20.0, W, H, 1.0, 1.0);
2423 let jittered_end = render_pipeline(json_for(Some(0.3)), 30.0, 20.0, W, H, 1.0, 1.0);
2424 assert_eq!(
2425 plain_end, jittered_end,
2426 "baseline_jitter must settle away by the end of the unit's own animation, landing \
2427 on the same laid-out baseline as no jitter at all"
2428 );
2429 }
2430
2431 #[test]
2432 fn reflow_keeps_a_centered_line_centred_while_it_is_being_written() {
2433 const W: i32 = 900;
2434 const H: i32 = 200;
2435 const BOX_X: f32 = 0.0;
2436 const BOX_W: f32 = 700.0;
2437 let box_center = BOX_X + BOX_W / 2.0;
2438
2439 let json_for = |reflow: bool| {
2440 serde_json::json!({
2441 "type": "text",
2442 "content": "reflowing centered text",
2443 "style": {
2444 "width": BOX_W, "height": 100,
2445 "font-size": 48,
2446 "color": "#ffffff",
2447 "text-align": "center",
2448 "white-space": "nowrap",
2449 "animation": [{
2450 "name": "char_fade_in",
2451 "granularity": "char",
2452 "duration": 0.05,
2453 "stagger": 0.05,
2454 "reflow": reflow,
2455 }]
2456 }
2457 })
2458 };
2459
2460 let sample_time = 0.5;
2461 let no_reflow = render_pipeline(json_for(false), BOX_X, 40.0, W, H, sample_time, 2.0);
2462 let reflowed = render_pipeline(json_for(true), BOX_X, 40.0, W, H, sample_time, 2.0);
2463
2464 let (lo_a, hi_a) =
2465 ink_x_range(&no_reflow, W, H).expect("no-reflow must paint some ink by this time");
2466 let (lo_b, hi_b) =
2467 ink_x_range(&reflowed, W, H).expect("reflow must paint some ink by this time");
2468
2469 let center_no_reflow = (lo_a + hi_a) as f32 / 2.0;
2470 let center_reflow = (lo_b + hi_b) as f32 / 2.0;
2471
2472 assert!(
2473 (center_reflow - box_center).abs() < 20.0,
2474 "reflow must keep the growing centred line's ink centred on the box: box \
2475 center={box_center}, got ink center={center_reflow}"
2476 );
2477 assert!(
2478 center_no_reflow < box_center - 40.0,
2479 "control: without reflow, a partially revealed centred line must still read left \
2480 of the box center, since every unit reserves its final (fully revealed) position \
2481 from frame one and only the early units have appeared yet — box \
2482 center={box_center}, got ink center={center_no_reflow}"
2483 );
2484 }
2485
2486 #[test]
2487 fn rotate_from_also_reaches_gradient_text_through_the_shared_render_path() {
2488 const W: i32 = 400;
2489 const H: i32 = 220;
2490
2491 let json_for = |rotate_from: Option<f64>| {
2492 let mut anim = serde_json::json!({
2493 "name": "char_fade_in",
2494 "granularity": "char",
2495 "duration": 1.0,
2496 "stagger": 0.0,
2497 });
2498 if let Some(r) = rotate_from {
2499 anim["rotate_from"] = serde_json::json!(r);
2500 }
2501 serde_json::json!({
2502 "type": "gradient_text",
2503 "content": "I",
2504 "colors": ["#FF4FB0", "#4B5BFF"],
2505 "style": {
2506 "width": 300, "height": 200,
2507 "font-size": 120,
2508 "white-space": "nowrap",
2509 "animation": [anim]
2510 }
2511 })
2512 };
2513
2514 let plain = render_pipeline(json_for(None), 40.0, 10.0, W, H, 0.5, 1.0);
2515 let tilted = render_pipeline(json_for(Some(80.0)), 40.0, 10.0, W, H, 0.5, 1.0);
2516
2517 let (plain_lo, plain_hi) = ink_x_range(&plain, W, H).expect("plain glyph paints ink");
2518 let (tilted_lo, tilted_hi) = ink_x_range(&tilted, W, H).expect("tilted glyph paints ink");
2519
2520 let plain_width = plain_hi - plain_lo;
2521 let tilted_width = tilted_hi - tilted_lo;
2522
2523 assert!(
2524 tilted_width > plain_width + 20,
2525 "rotate_from must also tilt a gradient_text unit through the shared \
2526 intrinsic::render_char_animation path, without any change to gradient_text.rs \
2527 itself: plain width={plain_width}px, tilted width={tilted_width}px"
2528 );
2529 }
2530}