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