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::engine::animator::ease;
7
8use rustmotion_core::css::style::{
9 FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw,
10 TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace,
11};
12use rustmotion_core::css::CssStyle;
13use rustmotion_core::engine::animator::{AnimatedProperties, ResolvedCharAnimation};
14use rustmotion_core::engine::layout_pass::BoxLayout;
15use rustmotion_core::engine::renderer::{
16 draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
17 typeface_with_fallback, wrap_text_with_tracking,
18};
19use rustmotion_core::schema::{
20 CaretConfig, CaretShape, CharAnimPreset, FontStyleType, FontWeight, Stroke, TextAlign,
21 TextAnimGranularity, TextBackground, TextShadow, TextState, TextSwapConfig, TimelineStep,
22};
23use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
24
25#[derive(Debug, Serialize, Deserialize, JsonSchema)]
26pub struct Text {
27 pub content: String,
28 #[serde(default)]
29 pub max_width: Option<f32>,
30 #[serde(flatten)]
31 pub timing: TimingConfig,
32 #[serde(default)]
33 pub style: CssStyle,
34 #[serde(default)]
35 pub timeline: Vec<TimelineStep>,
36 #[serde(default)]
37 pub stagger: Option<f32>,
38 #[serde(default, rename = "text-shadow")]
39 pub text_shadow: Option<TextShadow>,
40 #[serde(default)]
41 pub stroke: Option<Stroke>,
42 #[serde(default, rename = "text-background")]
43 pub text_background: Option<TextBackground>,
44 #[serde(default)]
47 pub caret: Option<CaretConfig>,
48 #[serde(default)]
50 pub states: Vec<TextState>,
51 #[serde(default)]
53 pub swap: Option<TextSwapConfig>,
54}
55
56rustmotion_core::impl_traits!(Text {
57 Animatable => animation,
58 Timed => timing,
59 Styled => style,
60});
61
62fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 {
65 let unit_start = cfg.unit_start(idx);
66 let unit_end = unit_start + cfg.duration as f64;
67 let raw_t = if time <= unit_start {
68 0.0
69 } else if time >= unit_end {
70 1.0
71 } else {
72 (time - unit_start) / (unit_end - unit_start)
73 };
74 ease(raw_t, &cfg.easing) as f32
75}
76
77fn ink_paint(cfg: &ResolvedCharAnimation, paint: &Paint, t: f32) -> Option<Paint> {
83 let from = cfg.ink_from.as_deref()?;
84 let start = paint_from_hex(from).color();
85 let end = paint.color();
86 let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t.clamp(0.0, 1.0)) as u8;
87 let mut p = paint.clone();
88 p.set_color(skia_safe::Color::from_argb(
89 end.a(),
90 lerp(start.r(), end.r()),
91 lerp(start.g(), end.g()),
92 lerp(start.b(), end.b()),
93 ));
94 Some(p)
95}
96
97fn apply_text_anim_preset(
100 canvas: &Canvas,
101 text: &str,
102 font: &Font,
103 emoji_font: &Option<Font>,
104 paint: &Paint,
105 cursor_x: f32,
106 line_y: f32,
107 unit_width: f32,
108 letter_spacing: f32,
113 cfg: &ResolvedCharAnimation,
114 t: f32,
115 time: f64,
116 unit_idx: usize,
117 font_size: f32,
118) {
119 let preset = &cfg.preset;
120 let overshoot = cfg.overshoot;
121 let blur_radius = cfg.blur;
122 let center_x = cursor_x + unit_width / 2.0;
123 let center_y = line_y;
124
125 let inked = ink_paint(cfg, paint, t);
129 let paint = inked.as_ref().unwrap_or(paint);
130 if let Some(from) = cfg.scale_from {
131 if !matches!(preset, CharAnimPreset::ScaleIn | CharAnimPreset::Bounce) {
134 let s = from + (1.0 - from) * t.clamp(0.0, 1.0);
135 canvas.translate((center_x, center_y));
136 canvas.scale((s, s));
137 canvas.translate((-center_x, -center_y));
138 }
139 }
140
141 match preset {
142 CharAnimPreset::ScaleIn => {
143 let scale = if overshoot > 0.001 {
145 if t < 0.7 {
146 let p = t / 0.7;
147 p * (1.0 + overshoot)
148 } else {
149 let p = (t - 0.7) / 0.3;
150 (1.0 + overshoot) - overshoot * p
151 }
152 } else {
153 t
154 };
155 if scale < 0.001 {
156 return;
157 }
158 canvas.translate((center_x, center_y));
159 canvas.scale((scale, scale));
160 canvas.translate((-center_x, -center_y));
161 draw_text_with_fallback(
162 canvas,
163 text,
164 font,
165 emoji_font,
166 letter_spacing,
167 cursor_x,
168 line_y,
169 paint,
170 );
171 }
172 CharAnimPreset::FadeIn => {
173 let mut p = paint.clone();
174 p.set_alpha_f(t * paint.alpha_f());
175 draw_text_with_fallback(
176 canvas,
177 text,
178 font,
179 emoji_font,
180 letter_spacing,
181 cursor_x,
182 line_y,
183 &p,
184 );
185 }
186 CharAnimPreset::Wave => {
187 let wave_offset =
188 (time as f32 * 4.0 + unit_idx as f32 * 0.5).sin() * 8.0 * (1.0 - t * 0.5);
189 let mut p = paint.clone();
190 p.set_alpha_f(t.min(1.0) * paint.alpha_f());
191 draw_text_with_fallback(
192 canvas,
193 text,
194 font,
195 emoji_font,
196 letter_spacing,
197 cursor_x,
198 line_y + wave_offset,
199 &p,
200 );
201 }
202 CharAnimPreset::Bounce => {
203 let peak = 1.0 + overshoot.max(0.3); let scale = if t < 0.5 {
205 t * 2.0 * peak
206 } else {
207 peak - (peak - 1.0) * ((t - 0.5) * 2.0)
208 };
209 let scale = scale.max(0.001);
210 canvas.translate((center_x, center_y));
211 canvas.scale((scale, scale));
212 canvas.translate((-center_x, -center_y));
213 draw_text_with_fallback(
214 canvas,
215 text,
216 font,
217 emoji_font,
218 letter_spacing,
219 cursor_x,
220 line_y,
221 paint,
222 );
223 }
224 CharAnimPreset::RotateIn => {
225 let angle = (1.0 - t) * -90.0;
226 let mut p = paint.clone();
227 p.set_alpha_f(t * paint.alpha_f());
228 canvas.translate((center_x, center_y));
229 canvas.rotate(angle, None);
230 canvas.translate((-center_x, -center_y));
231 draw_text_with_fallback(
232 canvas,
233 text,
234 font,
235 emoji_font,
236 letter_spacing,
237 cursor_x,
238 line_y,
239 &p,
240 );
241 }
242 CharAnimPreset::SlideUp => {
243 let travel = (1.0 - t) * font_size * 0.8 * cfg.distance;
246 let (dx, dy) = cfg.direction.offset(travel);
247 let mut p = paint.clone();
248 p.set_alpha_f(t * paint.alpha_f());
249 draw_text_with_fallback(
250 canvas,
251 text,
252 font,
253 emoji_font,
254 letter_spacing,
255 cursor_x + dx,
256 line_y + dy,
257 &p,
258 );
259 }
260 CharAnimPreset::BlurIn => {
261 let tt = t.clamp(0.0, 1.0);
265 let travel = (1.0 - tt) * font_size * 0.12 * cfg.distance;
266 let (dx, dy) = cfg.direction.offset(travel);
267 let sigma = ((1.0 - tt) * blur_radius).max(0.0);
268 let mut p = paint.clone();
269 p.set_alpha_f(tt * paint.alpha_f());
270 if sigma > 0.05 {
271 if let Some(filter) = skia_safe::image_filters::blur(
272 (sigma, sigma),
273 skia_safe::TileMode::Clamp,
274 None,
275 None,
276 ) {
277 p.set_image_filter(filter);
278 }
279 }
280 draw_text_with_fallback(
281 canvas,
282 text,
283 font,
284 emoji_font,
285 letter_spacing,
286 cursor_x + dx,
287 line_y + dy,
288 &p,
289 );
290 }
291 }
292}
293
294fn render_char_animation(
296 canvas: &Canvas,
297 _content: &str,
298 font: &Font,
299 emoji_font: &Option<Font>,
300 paint: &Paint,
301 letter_spacing: f32,
302 align: TextAlign,
303 align_width: f32,
304 line_height_val: f32,
305 baseline_offset: f32,
306 lines: &[String],
307 char_anim: &ResolvedCharAnimation,
308 time: f64,
309) {
310 let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word);
311 let mut global_unit_idx = 0usize;
312
313 for (line_idx, line) in lines.iter().enumerate() {
314 if line.is_empty() {
315 continue;
316 }
317
318 let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing);
319 let line_x = match align {
320 TextAlign::Left => 0.0,
321 TextAlign::Center => (align_width - advance_width) / 2.0,
322 TextAlign::Right => align_width - advance_width,
323 };
324 let line_y = line_idx as f32 * line_height_val + baseline_offset;
325
326 if is_word_mode {
327 let mut cursor_x = line_x;
329 let mut chars = line.chars().peekable();
330
331 while chars.peek().is_some() {
332 let mut spaces = String::new();
334 while let Some(&c) = chars.peek() {
335 if c.is_whitespace() {
336 spaces.push(c);
337 chars.next();
338 } else {
339 break;
340 }
341 }
342 if !spaces.is_empty() {
343 let space_w =
344 measure_text_with_fallback(&spaces, font, emoji_font, letter_spacing);
345 draw_text_with_fallback(
347 canvas, &spaces, font, emoji_font, 0.0, cursor_x, line_y, paint,
348 );
349 cursor_x += space_w;
350 }
351
352 let mut word = String::new();
354 while let Some(&c) = chars.peek() {
355 if c.is_whitespace() {
356 break;
357 }
358 word.push(c);
359 chars.next();
360 }
361 if word.is_empty() {
362 continue;
363 }
364
365 let word_width =
366 measure_text_with_fallback(&word, font, emoji_font, letter_spacing);
367
368 let t = unit_progress(char_anim, global_unit_idx, time);
370
371 canvas.save();
372 apply_text_anim_preset(
373 canvas,
374 &word,
375 font,
376 emoji_font,
377 paint,
378 cursor_x,
379 line_y,
380 word_width,
381 letter_spacing,
382 char_anim,
383 t,
384 time,
385 global_unit_idx,
386 font.size(),
387 );
388 canvas.restore();
389
390 cursor_x += word_width;
391 global_unit_idx += 1;
392 }
393 } else {
394 let mut cursor_x = line_x;
396 for ch in line.chars() {
397 let ch_str = ch.to_string();
398 let (ch_width, _) = font.measure_str(&ch_str, None);
399 let ch_width = ch_width + letter_spacing;
400
401 let t = unit_progress(char_anim, global_unit_idx, time);
402
403 canvas.save();
404 apply_text_anim_preset(
405 canvas,
406 &ch_str,
407 font,
408 emoji_font,
409 paint,
410 cursor_x,
411 line_y,
412 ch_width,
413 0.0,
418 char_anim,
419 t,
420 time,
421 global_unit_idx,
422 font.size(),
423 );
424 canvas.restore();
425
426 cursor_x += ch_width;
427 global_unit_idx += 1;
428 }
429 }
430 }
431}
432
433impl Text {
434 pub fn all_labels(&self) -> impl Iterator<Item = &str> {
441 std::iter::once(self.content.as_str()).chain(self.states.iter().map(|s| s.content.as_str()))
442 }
443
444 fn label_at(&self, time: f64) -> &str {
447 self.states
448 .iter()
449 .rfind(|s| s.at <= time)
450 .map(|s| s.content.as_str())
451 .unwrap_or(&self.content)
452 }
453
454 fn active_swap(&self, time: f64) -> Option<ActiveSwap> {
461 let cfg = self.swap.as_ref()?;
462 if cfg.duration <= 0.0 {
463 return None;
464 }
465 let (idx, state) = self
466 .states
467 .iter()
468 .enumerate()
469 .find(|(_, s)| time >= s.at && time < s.at + cfg.duration)?;
470 let from = if idx == 0 {
471 self.content.clone()
472 } else {
473 self.states[idx - 1].content.clone()
474 };
475 Some(ActiveSwap {
476 from,
477 to: state.content.clone(),
478 progress: ((time - state.at) / cfg.duration) as f32,
479 distance: cfg.distance,
480 blur: cfg.blur,
481 })
482 }
483
484 fn paint(
485 &self,
486 canvas: &Canvas,
487 layout_width: f32,
488 content_height: Option<f32>,
489 time: f64,
490 props: &AnimatedProperties,
491 ctx: &PaintCtx,
492 ) -> Result<()> {
493 let base_ctx = crate::intrinsic::font_size_ctx(
512 ctx.video_width as f32,
513 ctx.video_height as f32,
514 layout_width.max(0.0),
515 );
516 let (mut font_size, mut letter_spacing, mut line_height_val) =
517 self.style.typography_px_ctx(&base_ctx, 48.0);
518 let color = props
521 .color
522 .as_deref()
523 .unwrap_or_else(|| self.style.color_str_or("#FFFFFF"));
524 let font_family = self.style.font_family_or("Inter");
525 let font_weight = match &self.style.font_weight {
526 Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
527 FontWeight::Bold
528 }
529 Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
530 Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
531 _ => FontWeight::Normal,
532 };
533 let font_style_type = match self.style.font_style {
534 Some(CssFontStyle::Italic) => FontStyleType::Italic,
535 Some(CssFontStyle::Oblique) => FontStyleType::Oblique,
536 _ => FontStyleType::Normal,
537 };
538 let align = match self.style.text_align {
539 Some(CssTextAlign::Center) => TextAlign::Center,
540 Some(CssTextAlign::Right | CssTextAlign::End) => TextAlign::Right,
541 _ => TextAlign::Left,
542 };
543
544 let slant = match font_style_type {
545 FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
546 FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
547 FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
548 };
549 let weight = match font_weight {
550 FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
551 FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
552 FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32),
553 };
554 let skia_font_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
555
556 let typeface = typeface_with_fallback(font_family, skia_font_style)?;
557
558 let nowrap = matches!(
565 self.style.white_space,
566 Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
567 );
568 let box_width = if layout_width.is_finite() && layout_width > 0.0 {
569 Some(match self.max_width {
570 Some(mw) => mw.min(layout_width),
571 None => layout_width,
572 })
573 } else {
574 self.max_width
575 };
576
577 if matches!(self.style.text_autofit, Some(true)) {
585 let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite());
586 let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit(
587 &self.content,
588 &typeface,
589 font_size,
590 letter_spacing,
591 line_height_val,
592 !nowrap,
593 box_width,
594 declared_height,
595 );
596 font_size = fs;
597 letter_spacing = ls;
598 line_height_val = lh;
599 }
600
601 let type_ctx = rustmotion_core::css::units::LengthContext {
608 font_size,
609 ..base_ctx
610 };
611
612 let font = Font::from_typeface(typeface, font_size);
613 let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
614 let mut paint = paint_from_hex(color);
615 paint.set_alpha_f(1.0);
616
617 let wrap_width = if nowrap { None } else { box_width };
626
627 let label = self.label_at(time);
629 let content = if props.visible_chars_progress >= 0.0 {
630 let chars: Vec<char> = label.chars().collect();
631 let visible = (props.visible_chars_progress * chars.len() as f32).round() as usize;
632 let visible = visible.min(chars.len());
633 if visible == 0 && self.caret.is_none() {
634 return Ok(());
635 }
636 chars[..visible].iter().collect::<String>()
641 } else {
642 label.to_string()
643 };
644
645 let lines =
651 wrap_text_with_tracking(&content, &font, &emoji_font, wrap_width, letter_spacing);
652 let (_, metrics) = font.metrics();
653 let ascent = -metrics.ascent;
654 let descent = metrics.descent;
655 let baseline_offset = (line_height_val + ascent - descent) / 2.0;
656
657 let shadows: Vec<rustmotion_core::schema::TextShadow> = if let Some(s) = &self.text_shadow {
661 vec![s.clone()]
662 } else if let Some(list) = &self.style.text_shadow {
663 list.iter().map(|s| s.to_schema(&type_ctx)).collect()
664 } else {
665 Vec::new()
666 };
667 let shadow_paints: Vec<(skia_safe::Paint, f32, f32)> = shadows
668 .iter()
669 .map(|shadow| {
670 let mut p = paint_from_hex(&shadow.color);
671 if shadow.blur > 0.01 {
672 if let Some(filter) = skia_safe::image_filters::blur(
673 (shadow.blur, shadow.blur),
674 skia_safe::TileMode::Clamp,
675 None,
676 None,
677 ) {
678 p.set_image_filter(filter);
679 }
680 }
681 (p, shadow.offset_x, shadow.offset_y)
682 })
683 .collect();
684
685 let stroke_paint = self.stroke.as_ref().map(|stroke| {
686 let mut p = paint_from_hex(&stroke.color);
687 p.set_style(PaintStyle::Stroke);
688 p.set_stroke_width(stroke.width);
689 p
690 });
691
692 let align_width = if layout_width.is_finite() && layout_width > 0.0 {
694 layout_width
695 } else {
696 let mut max_w = 0.0f32;
697 for line in &lines {
698 let w = measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
699 max_w = max_w.max(w);
700 }
701 max_w
702 };
703
704 if let Some(ref resolved) = props.char_animation {
709 render_char_animation(
710 canvas,
711 &content,
712 &font,
713 &emoji_font,
714 &paint,
715 letter_spacing,
716 align,
717 align_width,
718 line_height_val,
719 baseline_offset,
720 &lines,
721 resolved,
722 time,
723 );
724 return Ok(());
725 }
726
727 if let Some(swap) = self.active_swap(time) {
732 for (label, offset_y, blur, alpha) in swap.labels() {
733 let label_lines =
734 wrap_text_with_tracking(&label, &font, &emoji_font, wrap_width, letter_spacing);
735 let mut p = paint.clone();
736 p.set_alpha_f(alpha * paint.alpha_f());
737 if blur > 0.05 {
738 if let Some(filter) = skia_safe::image_filters::blur(
739 (blur, blur),
740 skia_safe::TileMode::Clamp,
741 None,
742 None,
743 ) {
744 p.set_image_filter(filter);
745 }
746 }
747 draw_text_lines(
748 canvas,
749 &label_lines,
750 &font,
751 &emoji_font,
752 &p,
753 &shadow_paints,
754 stroke_paint.as_ref(),
755 self.text_background.as_ref(),
756 letter_spacing,
757 &align,
758 align_width,
759 line_height_val,
760 baseline_offset,
761 offset_y,
762 );
763 }
764 return Ok(());
765 }
766
767 draw_text_lines(
768 canvas,
769 &lines,
770 &font,
771 &emoji_font,
772 &paint,
773 &shadow_paints,
774 stroke_paint.as_ref(),
775 self.text_background.as_ref(),
776 letter_spacing,
777 &align,
778 align_width,
779 line_height_val,
780 baseline_offset,
781 0.0,
782 );
783
784 if let Some(caret) = &self.caret {
787 let done = props.visible_chars_progress < 0.0 || props.visible_chars_progress >= 1.0;
788 if !(done && caret.hide_when_done) {
789 let last = lines.len().saturating_sub(1);
790 let line = lines.last().map(String::as_str).unwrap_or("");
791 let advance_width =
792 measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
793 let x = match align {
794 TextAlign::Left => 0.0,
795 TextAlign::Center => (align_width - advance_width) / 2.0,
796 TextAlign::Right => align_width - advance_width,
797 };
798 let baseline = last as f32 * line_height_val + baseline_offset;
799 draw_caret(
800 canvas,
801 caret,
802 x + advance_width,
803 baseline,
804 &font,
805 &paint,
806 time,
807 );
808 }
809 }
810
811 Ok(())
812 }
813}
814
815#[allow(clippy::too_many_arguments)]
822fn draw_text_lines(
823 canvas: &Canvas,
824 lines: &[String],
825 font: &Font,
826 emoji_font: &Option<Font>,
827 paint: &Paint,
828 shadow_paints: &[(Paint, f32, f32)],
829 stroke_paint: Option<&Paint>,
830 text_background: Option<&TextBackground>,
831 letter_spacing: f32,
832 align: &TextAlign,
833 align_width: f32,
834 line_height_val: f32,
835 baseline_offset: f32,
836 offset_y: f32,
837) {
838 for (i, line) in lines.iter().enumerate() {
839 if line.is_empty() {
840 continue;
841 }
842
843 let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing);
844
845 let x = match align {
846 TextAlign::Left => 0.0,
847 TextAlign::Center => (align_width - advance_width) / 2.0,
848 TextAlign::Right => align_width - advance_width,
849 };
850 let y = i as f32 * line_height_val + baseline_offset + offset_y;
851
852 if let Some(bg) = text_background {
854 let bg_paint = paint_from_hex(&bg.color);
855 let (_, font_rect) = font.measure_str(line, None);
856 let bg_rect = Rect::from_xywh(
857 x - bg.padding + font_rect.left,
858 y + font_rect.top - bg.padding / 2.0,
859 advance_width + bg.padding * 2.0,
860 -font_rect.top + font_rect.bottom + bg.padding,
861 );
862 if bg.corner_radius > 0.0 {
863 let rrect =
864 skia_safe::RRect::new_rect_xy(bg_rect, bg.corner_radius, bg.corner_radius);
865 canvas.draw_rrect(rrect, &bg_paint);
866 } else {
867 canvas.draw_rect(bg_rect, &bg_paint);
868 }
869 }
870
871 for (sp, ox, oy) in shadow_paints.iter().rev() {
873 draw_text_with_fallback(
874 canvas,
875 line,
876 font,
877 emoji_font,
878 letter_spacing,
879 x + ox,
880 y + oy,
881 sp,
882 );
883 }
884
885 if let Some(sp) = stroke_paint {
886 draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, sp);
887 }
888 draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, paint);
889 }
890}
891
892struct ActiveSwap {
895 from: String,
896 to: String,
897 progress: f32,
898 distance: f32,
899 blur: f32,
900}
901
902impl ActiveSwap {
903 fn labels(&self) -> [(String, f32, f32, f32); 2] {
909 let p = self.progress.clamp(0.0, 1.0);
910 [
911 (
912 self.from.clone(),
913 -self.distance * p,
914 self.blur * p,
915 1.0 - p,
916 ),
917 (
918 self.to.clone(),
919 self.distance * (1.0 - p),
920 self.blur * (1.0 - p),
921 p,
922 ),
923 ]
924 }
925}
926
927fn draw_caret(
929 canvas: &Canvas,
930 cfg: &CaretConfig,
931 x: f32,
932 baseline: f32,
933 font: &Font,
934 text_paint: &Paint,
935 time: f64,
936) {
937 if cfg.blink > 0.0 {
941 let phase = (time / cfg.blink as f64).rem_euclid(1.0);
942 if phase >= 0.5 {
943 return;
944 }
945 }
946
947 let (_, metrics) = font.metrics();
948 let ascent = -metrics.ascent;
949 let descent = metrics.descent;
950 let size = font.size();
951
952 let (width, gap) = match cfg.shape {
953 CaretShape::Line => ((size * 0.07).max(1.5), size * 0.05),
956 CaretShape::Block => (size * 0.55, size * 0.04),
958 };
959
960 let mut paint = match &cfg.color {
961 Some(hex) => paint_from_hex(hex),
962 None => text_paint.clone(),
963 };
964 paint.set_style(PaintStyle::Fill);
965 paint.set_anti_alias(true);
966 paint.set_image_filter(None);
969
970 canvas.draw_rect(
971 Rect::from_xywh(x + gap, baseline - ascent, width, ascent + descent),
972 &paint,
973 );
974}
975
976impl Painter for Text {
977 fn paint_content(
978 &self,
979 canvas: &Canvas,
980 layout: &BoxLayout,
981 props: &AnimatedProperties,
982 ctx: &PaintCtx,
983 ) {
984 let (_, _, _, content_height) = layout.content_box();
990 let content_height =
991 (content_height > 0.0 && content_height.is_finite()).then_some(content_height);
992 let _ = self.paint(canvas, layout.width, content_height, ctx.time, props, ctx);
993 }
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999 use crate::intrinsic::TextIntrinsic;
1000 use rustmotion_core::css::style::CssStyle;
1001 use rustmotion_core::css::Length;
1002 use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
1003 use rustmotion_core::schema::{
1004 AnimationEffect, CharAnimationTiming, EasingType, TextAnimDirection,
1005 };
1006
1007 fn make_text(content: &str, white_space: Option<CssWhiteSpace>) -> Text {
1008 Text {
1009 content: content.into(),
1010 max_width: None,
1011 timing: Default::default(),
1012 style: CssStyle {
1013 font_size: Some(Length::Px(28.0)),
1014 color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
1015 white_space,
1016 ..Default::default()
1017 },
1018 timeline: Vec::new(),
1019 stagger: None,
1020 text_shadow: None,
1021 stroke: None,
1022 text_background: None,
1023 caret: None,
1024 states: Vec::new(),
1025 swap: None,
1026 }
1027 }
1028
1029 fn test_ctx() -> PaintCtx {
1030 PaintCtx {
1031 time: 0.0,
1032 scenario_time: 0.0,
1033 scene_duration: 1.0,
1034 frame_index: 0,
1035 fps: 30,
1036 video_width: 600,
1037 video_height: 200,
1038 stagger_offset: 0.0,
1039 }
1040 }
1041
1042 fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec<u8> {
1045 let snapshot = surface.image_snapshot();
1046 let info = skia_safe::ImageInfo::new(
1047 (width, height),
1048 skia_safe::ColorType::RGBA8888,
1049 skia_safe::AlphaType::Premul,
1050 None,
1051 );
1052 let mut buf = vec![0u8; (width * height * 4) as usize];
1053 let ok = snapshot.read_pixels(
1054 &info,
1055 &mut buf,
1056 (width * 4) as usize,
1057 skia_safe::IPoint::new(0, 0),
1058 skia_safe::image::CachingHint::Disallow,
1059 );
1060 assert!(ok, "pixel read should succeed");
1061 (0..(width * height) as usize)
1062 .map(|i| buf[i * 4 + 3])
1063 .collect()
1064 }
1065
1066 fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool {
1070 for y in y0..y1 {
1071 for x in x0..x1 {
1072 if grid[(y * surface_width + x) as usize] > 0 {
1073 return true;
1074 }
1075 }
1076 }
1077 false
1078 }
1079
1080 #[test]
1081 fn nowrap_paints_a_single_line_past_the_layout_width() {
1082 let text = make_text(
1086 "the quick brown fox jumps over the lazy dog",
1087 Some(CssWhiteSpace::Nowrap),
1088 );
1089 const W: i32 = 600;
1090 const H: i32 = 200;
1091 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1092 let canvas = surface.canvas();
1093 let ctx = test_ctx();
1094 let props = AnimatedProperties::default();
1095 text.paint(canvas, 80.0, None, 0.0, &props, &ctx)
1096 .expect("paint succeeds");
1097 let grid = alpha_grid(&mut surface, W, H);
1098
1099 assert!(
1102 has_ink_in(&grid, W, 300, W, 0, 45),
1103 "nowrap text must paint past its 80px box on line 1 (scanned x∈[300,600), y∈[0,45))"
1104 );
1105
1106 assert!(
1110 !has_ink_in(&grid, W, 0, W, 55, H),
1111 "nowrap text must stay on a single line; found ink on what would be line 2"
1112 );
1113 }
1114
1115 #[test]
1116 fn normal_white_space_wraps_within_the_layout_width() {
1117 let text = make_text("the quick brown fox jumps over the lazy dog", None);
1120 const W: i32 = 600;
1121 const H: i32 = 200;
1122 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1123 let canvas = surface.canvas();
1124 let ctx = test_ctx();
1125 let props = AnimatedProperties::default();
1126 text.paint(canvas, 80.0, None, 0.0, &props, &ctx)
1127 .expect("paint succeeds");
1128 let grid = alpha_grid(&mut surface, W, H);
1129
1130 assert!(
1131 !has_ink_in(&grid, W, 300, W, 0, 45),
1132 "wrapped text must not reach x∈[300,600) on line 1 within an 80px box"
1133 );
1134 assert!(
1135 has_ink_in(&grid, W, 0, W, 55, H),
1136 "wrapped text must spill onto a second line within the box width"
1137 );
1138 }
1139
1140 #[test]
1143 fn rem_font_size_paints_visible_ink() {
1144 let text = Text {
1150 content: "HELLO".into(),
1151 max_width: None,
1152 timing: Default::default(),
1153 style: CssStyle {
1154 font_size: Some(Length::String("2rem".into())),
1155 color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
1156 ..Default::default()
1157 },
1158 timeline: Vec::new(),
1159 stagger: None,
1160 text_shadow: None,
1161 stroke: None,
1162 text_background: None,
1163 caret: None,
1164 states: Vec::new(),
1165 swap: None,
1166 };
1167 const W: i32 = 400;
1168 const H: i32 = 200;
1169 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1170 let canvas = surface.canvas();
1171 let ctx = test_ctx();
1172 let props = AnimatedProperties::default();
1173 text.paint(canvas, 300.0, None, 0.0, &props, &ctx)
1174 .expect("paint succeeds");
1175 let grid = alpha_grid(&mut surface, W, H);
1176
1177 assert!(
1180 has_ink_in(&grid, W, 0, W, 0, 60),
1181 "font-size: 2rem must paint visible ink (32px glyphs), got none"
1182 );
1183 }
1184
1185 #[test]
1186 fn vh_font_size_paints_visible_ink_scaled_to_the_real_viewport() {
1187 let text = Text {
1191 content: "HI".into(),
1192 max_width: None,
1193 timing: Default::default(),
1194 style: CssStyle {
1195 font_size: Some(Length::String("20vh".into())),
1196 color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
1197 ..Default::default()
1198 },
1199 timeline: Vec::new(),
1200 stagger: None,
1201 text_shadow: None,
1202 stroke: None,
1203 text_background: None,
1204 caret: None,
1205 states: Vec::new(),
1206 swap: None,
1207 };
1208 const W: i32 = 400;
1209 const H: i32 = 200;
1210 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1211 let canvas = surface.canvas();
1212 let ctx = test_ctx();
1213 let props = AnimatedProperties::default();
1214 text.paint(canvas, 300.0, None, 0.0, &props, &ctx)
1215 .expect("paint succeeds");
1216 let grid = alpha_grid(&mut surface, W, H);
1217
1218 assert!(
1219 has_ink_in(&grid, W, 0, W, 0, 70),
1220 "font-size: 20vh (40px against a 200px-tall test viewport) must paint visible ink"
1221 );
1222 }
1223
1224 fn soft_pixel_fraction(
1232 grid: &[u8],
1233 surface_width: i32,
1234 x0: i32,
1235 x1: i32,
1236 y0: i32,
1237 y1: i32,
1238 ) -> f32 {
1239 let mut inked = 0u32;
1240 let mut soft = 0u32;
1241 for y in y0..y1 {
1242 for x in x0..x1 {
1243 let a = grid[(y * surface_width + x) as usize];
1244 if a > 0 {
1245 inked += 1;
1246 if a < 250 {
1247 soft += 1;
1248 }
1249 }
1250 }
1251 }
1252 if inked == 0 {
1253 return 0.0;
1254 }
1255 soft as f32 / inked as f32
1256 }
1257
1258 fn props_for(text: &Text) -> AnimatedProperties {
1263 AnimatedProperties {
1264 char_animation: rustmotion_core::engine::animator::extract_effects(
1265 &text.style.animation,
1266 )
1267 .char_animation,
1268 ..Default::default()
1269 }
1270 }
1271
1272 fn inter_font(px: f32) -> Font {
1276 let style = FontStyle::new(
1277 skia_safe::font_style::Weight::NORMAL,
1278 skia_safe::font_style::Width::NORMAL,
1279 skia_safe::font_style::Slant::Upright,
1280 );
1281 let typeface = typeface_with_fallback("Inter", style).expect("typeface resolves");
1282 Font::from_typeface(typeface, px)
1283 }
1284
1285 #[test]
1286 fn char_blur_in_word_is_blurred_mid_reveal_and_sharp_when_settled() {
1287 let mut text = make_text("BLUR", None);
1292 text.style.font_size = Some(Length::Px(100.0));
1293 text.style.white_space = Some(CssWhiteSpace::Nowrap);
1294 text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1295 delay: 0.0,
1296 duration: 0.5,
1297 stagger: 0.03,
1298 granularity: TextAnimGranularity::Word,
1299 easing: EasingType::Linear,
1300 ..Default::default()
1301 })];
1302
1303 const W: i32 = 700;
1304 const H: i32 = 220;
1305 let ctx = test_ctx();
1306 let props = props_for(&text);
1307
1308 let render_at = |t: f64| -> Vec<u8> {
1309 let mut surface =
1310 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1311 {
1312 let canvas = surface.canvas();
1313 text.paint(canvas, W as f32, None, t, &props, &ctx)
1314 .expect("paint succeeds");
1315 }
1316 alpha_grid(&mut surface, W, H)
1317 };
1318
1319 let early = render_at(0.15); let settled = render_at(1.0); assert!(
1323 has_ink_in(&early, W, 0, W, 0, H),
1324 "the word must have started painting by t=0.15"
1325 );
1326
1327 let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H);
1328 let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H);
1329
1330 assert!(
1331 early_soft > settled_soft + 0.15,
1332 "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \
1333 settled fraction ({settled_soft:.3}) — the word should read as blurred while \
1334 animating and sharp at rest"
1335 );
1336 assert!(
1337 settled_soft < 0.25,
1338 "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})"
1339 );
1340 }
1341
1342 #[test]
1343 fn char_blur_in_whitespace_gap_stays_empty_while_words_animate() {
1344 const FONT_PX: f32 = 90.0;
1360 let mut text = make_text("FIRST SECOND", None); text.style.font_size = Some(Length::Px(FONT_PX));
1362 text.style.white_space = Some(CssWhiteSpace::Nowrap);
1363 text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1364 delay: 0.0,
1365 duration: 0.4,
1366 stagger: 0.2,
1367 granularity: TextAnimGranularity::Word,
1368 easing: EasingType::Linear,
1369 blur: Some(18.0),
1370 ..Default::default()
1371 })];
1372
1373 const W: i32 = 1400;
1374 const H: i32 = 180;
1375 let ctx = test_ctx();
1376 let props = props_for(&text);
1377
1378 let font = inter_font(FONT_PX);
1379 let first_w = measure_text_with_fallback("FIRST", &font, &None, 0.0);
1380 let gap_w = measure_text_with_fallback(" ", &font, &None, 0.0);
1381 let margin = (gap_w / 3.0).max(40.0);
1382 let gap_x0 = (first_w + margin) as i32;
1383 let gap_x1 = (first_w + gap_w - margin) as i32;
1384 assert!(
1385 gap_x1 > gap_x0,
1386 "test setup: the space run must measure to a real gap (got [{gap_x0},{gap_x1}))"
1387 );
1388
1389 for &t in &[0.05_f64, 0.35, 1.0] {
1390 let mut surface =
1391 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1392 {
1393 let canvas = surface.canvas();
1394 text.paint(canvas, W as f32, None, t, &props, &ctx)
1395 .expect("paint succeeds");
1396 }
1397 let grid = alpha_grid(&mut surface, W, H);
1398 assert!(
1399 !has_ink_in(&grid, W, gap_x0, gap_x1, 0, H),
1400 "inter-word gap [{gap_x0},{gap_x1}) must stay empty at t={t}"
1401 );
1402 }
1403 }
1404
1405 #[test]
1406 fn char_blur_in_honors_word_delay_and_stagger() {
1407 const FONT_PX: f32 = 90.0;
1411 let mut text = make_text("ONE TWO", None);
1412 text.style.font_size = Some(Length::Px(FONT_PX));
1413 text.style.white_space = Some(CssWhiteSpace::Nowrap);
1414 text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1415 delay: 0.5,
1416 duration: 0.3,
1417 stagger: 0.6,
1418 granularity: TextAnimGranularity::Word,
1419 easing: EasingType::Linear,
1420 blur: Some(16.0),
1421 ..Default::default()
1422 })];
1423
1424 const W: i32 = 900;
1425 const H: i32 = 180;
1426 let ctx = test_ctx();
1427 let props = props_for(&text);
1428 let font = inter_font(FONT_PX);
1429 let word1_end = measure_text_with_fallback("ONE", &font, &None, 0.0) as i32;
1430
1431 let mut before = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1433 {
1434 let canvas = before.canvas();
1435 text.paint(canvas, W as f32, None, 0.1, &props, &ctx)
1436 .expect("paint succeeds");
1437 }
1438 let before_grid = alpha_grid(&mut before, W, H);
1439 assert!(
1440 !has_ink_in(&before_grid, W, 0, W, 0, H),
1441 "nothing should paint before `delay` has elapsed"
1442 );
1443
1444 let mut mid = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1447 {
1448 let canvas = mid.canvas();
1449 text.paint(canvas, W as f32, None, 0.65, &props, &ctx)
1450 .expect("paint succeeds");
1451 }
1452 let mid_grid = alpha_grid(&mut mid, W, H);
1453 assert!(
1454 has_ink_in(&mid_grid, W, 0, word1_end, 0, H),
1455 "word 1 should show ink by t=0.65 (mid-reveal)"
1456 );
1457 assert!(
1462 !has_ink_in(&mid_grid, W, word1_end + 70, W, 0, H),
1463 "word 2 (starts at delay+stagger=1.1s) must still be fully invisible at t=0.65"
1464 );
1465 }
1466
1467 fn autofit_text(content: &str, font_size: f32, white_space: Option<CssWhiteSpace>) -> Text {
1470 let mut t = make_text(content, white_space);
1471 t.style.font_size = Some(Length::Px(font_size));
1472 t.style.text_autofit = Some(true);
1473 t
1474 }
1475
1476 fn max_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
1479 let mut max_x: Option<i32> = None;
1480 for y in 0..height {
1481 for x in (0..surface_width).rev() {
1482 if grid[(y * surface_width + x) as usize] > 0 {
1483 max_x = Some(max_x.map_or(x, |m| m.max(x)));
1484 break;
1485 }
1486 }
1487 }
1488 max_x
1489 }
1490
1491 fn max_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
1494 for y in (0..height).rev() {
1495 for x in 0..surface_width {
1496 if grid[(y * surface_width + x) as usize] > 0 {
1497 return Some(y);
1498 }
1499 }
1500 }
1501 None
1502 }
1503
1504 #[test]
1505 fn measure_and_paint_agree_on_a_shrunk_nowrap_line() {
1506 let text = autofit_text(
1515 "the quick brown fox jumps over the lazy dog",
1516 90.0,
1517 Some(CssWhiteSpace::Nowrap),
1518 );
1519 const BOX_W: f32 = 300.0;
1525 const BOX_H: f32 = 60.0;
1526
1527 let (measured_w, _measured_h) = TextIntrinsic::from_text(&text).measure(
1528 (None, None),
1529 (
1530 AvailableSpace::Definite(BOX_W),
1531 AvailableSpace::Definite(BOX_H),
1532 ),
1533 );
1534 assert!(
1537 measured_w <= BOX_W + 0.5,
1538 "TextIntrinsic itself must report a fit once autofit is on, got {measured_w}"
1539 );
1540
1541 const W: i32 = 900;
1542 const H: i32 = 300;
1543 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1544 {
1545 let canvas = surface.canvas();
1546 let ctx = test_ctx();
1547 let props = AnimatedProperties::default();
1548 text.paint(canvas, BOX_W, Some(BOX_H), 0.0, &props, &ctx)
1549 .expect("paint succeeds");
1550 }
1551 let grid = alpha_grid(&mut surface, W, H);
1552 let ink_right = max_ink_x(&grid, W, H).expect("text must paint some ink");
1553
1554 assert!(
1555 (ink_right as f32) <= measured_w + 3.0,
1556 "painted ink (right edge {ink_right}) must not exceed the box TextIntrinsic reserved \
1557 ({measured_w}) — a wider paint than measure is exactly the class of bug this \
1558 workstream exists to close"
1559 );
1560 assert!(
1561 (ink_right as f32) >= measured_w - 15.0,
1562 "painted ink (right edge {ink_right}) should land close to what TextIntrinsic \
1563 measured ({measured_w}); a big gap would mean the two disagree on the resolved \
1564 font size in the other direction (paint drawing much smaller than reserved)"
1565 );
1566 }
1567
1568 #[test]
1569 fn measure_and_paint_agree_on_a_shrunk_wrapped_paragraph_height() {
1570 let text = autofit_text(
1574 "the quick brown fox jumps over the lazy dog and then keeps going for quite a while longer",
1575 60.0,
1576 None,
1577 );
1578 const BOX_W: f32 = 300.0;
1579 const BOX_H: f32 = 90.0;
1580
1581 let (_measured_w, measured_h) = TextIntrinsic::from_text(&text).measure(
1582 (None, None),
1583 (
1584 AvailableSpace::Definite(BOX_W),
1585 AvailableSpace::Definite(BOX_H),
1586 ),
1587 );
1588 assert!(
1589 measured_h <= BOX_H + 0.5,
1590 "TextIntrinsic itself must report a fit once autofit is on, got {measured_h}"
1591 );
1592
1593 const W: i32 = 500;
1594 const H: i32 = 400;
1595 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1596 {
1597 let canvas = surface.canvas();
1598 let ctx = test_ctx();
1599 let props = AnimatedProperties::default();
1600 text.paint(canvas, BOX_W, Some(BOX_H), 0.0, &props, &ctx)
1601 .expect("paint succeeds");
1602 }
1603 let grid = alpha_grid(&mut surface, W, H);
1604 let ink_bottom = max_ink_y(&grid, W, H).expect("text must paint some ink");
1605
1606 assert!(
1607 (ink_bottom as f32) <= measured_h + 6.0,
1608 "painted ink (bottom edge {ink_bottom}) must not exceed the box TextIntrinsic \
1609 reserved ({measured_h})"
1610 );
1611 }
1612
1613 #[test]
1614 fn autofit_size_is_stable_across_frames_for_fixed_content() {
1615 let text = autofit_text(
1619 "the quick brown fox jumps over the lazy dog",
1620 90.0,
1621 Some(CssWhiteSpace::Nowrap),
1622 );
1623 const W: i32 = 900;
1624 const H: i32 = 300;
1625 let ctx = test_ctx();
1626 let props = AnimatedProperties::default();
1627
1628 let render_at = |t: f64| -> Vec<u8> {
1629 let mut surface =
1630 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1631 {
1632 let canvas = surface.canvas();
1633 text.paint(canvas, 250.0, Some(60.0), t, &props, &ctx)
1634 .expect("paint succeeds");
1635 }
1636 alpha_grid(&mut surface, W, H)
1637 };
1638
1639 let frame_a = render_at(0.0);
1640 let frame_b = render_at(0.9);
1641 assert_eq!(
1642 frame_a, frame_b,
1643 "fixed content in a fixed box must render byte-identically regardless of ctx.time — \
1644 a per-frame drift here is exactly what the temporal-stability requirement forbids"
1645 );
1646 }
1647
1648 #[test]
1649 fn autofit_size_does_not_drift_during_a_typewriter_reveal() {
1650 let text = autofit_text(
1659 "the quick brown fox jumps over the lazy dog",
1660 90.0,
1661 Some(CssWhiteSpace::Nowrap),
1662 );
1663 const W: i32 = 900;
1664 const H: i32 = 300;
1665 let ctx = test_ctx();
1666
1667 let render_at = |progress: f32| -> Vec<u8> {
1668 let mut surface =
1669 skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1670 let props = AnimatedProperties {
1671 visible_chars_progress: progress,
1672 ..Default::default()
1673 };
1674 {
1675 let canvas = surface.canvas();
1676 text.paint(canvas, 250.0, Some(60.0), 0.0, &props, &ctx)
1677 .expect("paint succeeds");
1678 }
1679 alpha_grid(&mut surface, W, H)
1680 };
1681
1682 let early = render_at(0.3);
1683 let full = render_at(1.0);
1684
1685 let early_bottom = max_ink_y(&early, W, H).expect("some ink must paint at 30% reveal");
1686 let full_bottom = max_ink_y(&full, W, H).expect("some ink must paint at full reveal");
1687 assert_eq!(
1688 early_bottom, full_bottom,
1689 "the resolved font size (line height, hence vertical ink footprint) must not change \
1690 as the typewriter reveal progresses: early={early_bottom}, full={full_bottom}"
1691 );
1692
1693 let early_right = max_ink_x(&early, W, H).expect("some ink at 30% reveal");
1697 let full_right = max_ink_x(&full, W, H).expect("some ink at full reveal");
1698 assert!(
1699 early_right < full_right,
1700 "test setup: 30% reveal should show measurably less horizontal ink than the full \
1701 line (early={early_right}, full={full_right})"
1702 );
1703 }
1704
1705 #[test]
1706 fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() {
1707 let text = make_text(
1713 "the quick brown fox jumps over the lazy dog",
1714 Some(CssWhiteSpace::Nowrap),
1715 );
1716 const W: i32 = 900;
1717 const H: i32 = 300;
1718 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1719 let canvas = surface.canvas();
1720 let ctx = test_ctx();
1721 let props = AnimatedProperties::default();
1722 text.paint(canvas, 250.0, Some(60.0), 0.0, &props, &ctx)
1723 .expect("paint succeeds");
1724 let grid = alpha_grid(&mut surface, W, H);
1725 assert!(
1726 has_ink_in(&grid, W, 260, W, 0, 45),
1727 "without text-autofit, nowrap must still bleed past its box exactly as before"
1728 );
1729 }
1730
1731 fn swapping_text(swap: Option<TextSwapConfig>) -> Text {
1734 let mut text = make_text("Saving draft", Some(CssWhiteSpace::Nowrap));
1735 text.style.font_size = Some(Length::Px(48.0));
1736 text.states = vec![TextState {
1737 at: 1.0,
1738 content: "Saved".into(),
1739 }];
1740 text.swap = swap;
1741 text
1742 }
1743
1744 fn render_plain(text: &Text, time: f64) -> Vec<u8> {
1745 let mut surface =
1746 skia_safe::surfaces::raster_n32_premul((CARET_W, CARET_H)).expect("raster surface");
1747 {
1748 let canvas = surface.canvas();
1749 text.paint(
1750 canvas,
1751 CARET_W as f32,
1752 None,
1753 time,
1754 &AnimatedProperties::default(),
1755 &test_ctx(),
1756 )
1757 .expect("paint succeeds");
1758 }
1759 alpha_grid(&mut surface, CARET_W, CARET_H)
1760 }
1761
1762 #[test]
1763 fn states_cut_over_at_their_own_time_without_a_swap_config() {
1764 let text = swapping_text(None);
1767 let before = render_plain(&text, 0.5);
1768 let after = render_plain(&text, 1.5);
1769
1770 let width_of = |g: &[u8]| max_ink_x(g, CARET_W, CARET_H).unwrap_or(0);
1771 assert!(
1772 width_of(&before) > width_of(&after) + 20,
1773 "\"Saving draft\" should be visibly wider than \"Saved\" — the label must actually \
1774 have changed at t=1.0"
1775 );
1776 assert_eq!(
1779 render_plain(&text, 1.01),
1780 render_plain(&text, 1.5),
1781 "without a `swap`, the new label must be fully in place immediately"
1782 );
1783 }
1784
1785 #[test]
1786 fn a_swap_puts_both_labels_on_screen_at_once() {
1787 let text = swapping_text(Some(TextSwapConfig::default()));
1788
1789 let rows_with_ink = |time: f64| -> usize {
1793 let grid = render_plain(&text, time);
1794 (0..CARET_H)
1795 .filter(|&y| (0..CARET_W).any(|x| grid[(y * CARET_W + x) as usize] > 0))
1796 .count()
1797 };
1798
1799 let settled = rows_with_ink(0.5);
1800 let mid = rows_with_ink(1.0 + 0.45 / 2.0);
1801 assert!(
1802 mid > settled,
1803 "mid-swap, the two offset labels should span more rows than one settled label \
1804 (settled={settled}, mid={mid})"
1805 );
1806 }
1807
1808 #[test]
1809 fn a_finished_swap_settles_on_the_incoming_label_alone() {
1810 let swapped = swapping_text(Some(TextSwapConfig::default()));
1811 let cut = swapping_text(None);
1812 assert_eq!(
1816 render_plain(&swapped, 2.0),
1817 render_plain(&cut, 2.0),
1818 "once the swap window has passed, the frame must match a plain cut exactly"
1819 );
1820 }
1821
1822 #[test]
1823 fn the_box_is_measured_for_the_widest_label_not_the_first() {
1824 let mut short_first = make_text("Saved", Some(CssWhiteSpace::Nowrap));
1828 short_first.states = vec![TextState {
1829 at: 1.0,
1830 content: "Saving draft".into(),
1831 }];
1832 let only_short = make_text("Saved", Some(CssWhiteSpace::Nowrap));
1833
1834 let measure = |t: &Text| {
1835 TextIntrinsic::from_text(t)
1836 .measure(
1837 (None, None),
1838 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1839 )
1840 .0
1841 };
1842
1843 assert!(
1844 measure(&short_first) > measure(&only_short) + 10.0,
1845 "the reserved width must cover the longest label the text can show \
1846 (with states={}, without={})",
1847 measure(&short_first),
1848 measure(&only_short)
1849 );
1850 }
1851
1852 const CARET_W: i32 = 900;
1855 const CARET_H: i32 = 160;
1856
1857 fn typewriter_text(caret: Option<CaretConfig>) -> Text {
1858 let mut text = make_text("HELLO WORLD", Some(CssWhiteSpace::Nowrap));
1859 text.style.font_size = Some(Length::Px(64.0));
1860 text.caret = caret;
1861 text
1862 }
1863
1864 fn render_reveal(text: &Text, progress: f32, time: f64) -> Vec<u8> {
1866 let props = AnimatedProperties {
1867 visible_chars_progress: progress,
1868 ..AnimatedProperties::default()
1869 };
1870 let mut surface =
1871 skia_safe::surfaces::raster_n32_premul((CARET_W, CARET_H)).expect("raster surface");
1872 {
1873 let canvas = surface.canvas();
1874 text.paint(canvas, CARET_W as f32, None, time, &props, &test_ctx())
1875 .expect("paint succeeds");
1876 }
1877 alpha_grid(&mut surface, CARET_W, CARET_H)
1878 }
1879
1880 #[test]
1881 fn the_caret_follows_the_reveal_head_instead_of_standing_still() {
1882 let text = typewriter_text(Some(CaretConfig {
1886 blink: 0.0,
1887 ..Default::default()
1888 }));
1889 let plain = typewriter_text(None);
1890
1891 let caret_x = |progress: f32| -> i32 {
1894 let with = render_reveal(&text, progress, 0.0);
1895 let without = render_reveal(&plain, progress, 0.0);
1896 let with_right = max_ink_x(&with, CARET_W, CARET_H).expect("caret paints");
1897 let without_right = max_ink_x(&without, CARET_W, CARET_H).unwrap_or(0);
1898 assert!(
1899 with_right > without_right,
1900 "the caret should extend past the last revealed glyph \
1901 (with={with_right}, without={without_right}) at progress {progress}"
1902 );
1903 with_right
1904 };
1905
1906 let early = caret_x(0.25);
1907 let late = caret_x(0.75);
1908 assert!(
1909 late > early + 40,
1910 "the caret should have travelled with the reveal head (early={early}, late={late})"
1911 );
1912 }
1913
1914 #[test]
1915 fn the_caret_blinks_off_for_half_of_each_period() {
1916 let text = typewriter_text(Some(CaretConfig {
1917 blink: 1.0,
1918 ..Default::default()
1919 }));
1920 let plain = typewriter_text(None);
1921
1922 let right_edge = |grid: &[u8]| max_ink_x(grid, CARET_W, CARET_H).unwrap_or(0);
1923 let baseline = right_edge(&render_reveal(&plain, 0.5, 0.0));
1924
1925 let on = right_edge(&render_reveal(&text, 0.5, 0.1));
1929 let off = right_edge(&render_reveal(&text, 0.5, 0.6));
1930 assert!(on > baseline, "caret should be visible at phase 0.1");
1931 assert_eq!(
1932 off, baseline,
1933 "caret should be blinked out at phase 0.6, leaving only the text's own ink"
1934 );
1935 }
1936
1937 #[test]
1938 fn hide_when_done_removes_the_caret_once_the_reveal_finishes() {
1939 let hiding = typewriter_text(Some(CaretConfig {
1940 blink: 0.0,
1941 hide_when_done: true,
1942 ..Default::default()
1943 }));
1944 let staying = typewriter_text(Some(CaretConfig {
1945 blink: 0.0,
1946 ..Default::default()
1947 }));
1948 let plain = typewriter_text(None);
1949
1950 let right_edge = |t: &Text, progress: f32| {
1951 max_ink_x(&render_reveal(t, progress, 0.0), CARET_W, CARET_H).unwrap_or(0)
1952 };
1953 let text_edge = right_edge(&plain, 1.0);
1954
1955 assert_eq!(
1956 right_edge(&hiding, 1.0),
1957 text_edge,
1958 "with hide_when_done, a finished reveal must leave no caret behind"
1959 );
1960 assert!(
1961 right_edge(&staying, 1.0) > text_edge,
1962 "without hide_when_done, the caret parks at the end of the text"
1963 );
1964 }
1965
1966 #[test]
1967 fn the_caret_is_there_before_the_first_character_is() {
1968 let text = typewriter_text(Some(CaretConfig {
1972 blink: 0.0,
1973 ..Default::default()
1974 }));
1975 let grid = render_reveal(&text, 0.0, 0.0);
1976 assert!(
1977 has_ink_in(&grid, CARET_W, 0, CARET_W, 0, CARET_H),
1978 "the caret must be painting at 0% reveal, before any glyph"
1979 );
1980 }
1981
1982 const TUNING_W: i32 = 520;
1985 const TUNING_H: i32 = 360;
1986
1987 fn tuned_slide_up(timing: CharAnimationTiming) -> Text {
1989 let mut text = make_text("GO", None);
1990 text.style.font_size = Some(Length::Px(90.0));
1991 text.style.white_space = Some(CssWhiteSpace::Nowrap);
1992 text.style.animation = vec![AnimationEffect::CharSlideUp(timing)];
1993 text
1994 }
1995
1996 fn render_alpha(text: &Text, time: f64) -> Vec<u8> {
1997 let mut surface =
1998 skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H)).expect("raster surface");
1999 {
2000 let canvas = surface.canvas();
2001 text.paint(
2002 canvas,
2003 TUNING_W as f32,
2004 None,
2005 time,
2006 &props_for(text),
2007 &test_ctx(),
2008 )
2009 .expect("paint succeeds");
2010 }
2011 alpha_grid(&mut surface, TUNING_W, TUNING_H)
2012 }
2013
2014 fn min_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
2016 (0..height)
2017 .find(|&y| (0..surface_width).any(|x| grid[(y * surface_width + x) as usize] > 0))
2018 }
2019
2020 #[test]
2021 fn direction_down_starts_the_unit_above_its_line_instead_of_below() {
2022 let base = || CharAnimationTiming {
2027 duration: 1.0,
2028 stagger: 0.0,
2029 granularity: TextAnimGranularity::Word,
2030 easing: EasingType::Linear,
2031 distance: Some(0.5),
2032 ..Default::default()
2033 };
2034 let up = tuned_slide_up(CharAnimationTiming {
2035 direction: TextAnimDirection::Up,
2036 ..base()
2037 });
2038 let down = tuned_slide_up(CharAnimationTiming {
2039 direction: TextAnimDirection::Down,
2040 ..base()
2041 });
2042
2043 let up_grid = render_alpha(&up, 0.5);
2044 let down_grid = render_alpha(&down, 0.5);
2045
2046 let up_top = min_ink_y(&up_grid, TUNING_W, TUNING_H).expect("up-travelling word paints");
2047 let down_top =
2048 min_ink_y(&down_grid, TUNING_W, TUNING_H).expect("down-travelling word paints");
2049
2050 assert!(
2051 down_top < up_top - 10,
2052 "at the same instant, a `down` unit should sit clearly higher on the canvas than an \
2053 `up` one (down_top={down_top}, up_top={up_top})"
2054 );
2055 }
2056
2057 #[test]
2058 fn distance_scales_how_far_the_unit_travels() {
2059 let base = || CharAnimationTiming {
2060 duration: 1.0,
2061 stagger: 0.0,
2062 granularity: TextAnimGranularity::Word,
2063 easing: EasingType::Linear,
2064 ..Default::default()
2065 };
2066 let close = tuned_slide_up(CharAnimationTiming {
2067 distance: Some(0.25),
2068 ..base()
2069 });
2070 let far = tuned_slide_up(CharAnimationTiming {
2071 distance: Some(1.0),
2072 ..base()
2073 });
2074
2075 let close_top =
2078 min_ink_y(&render_alpha(&close, 0.5), TUNING_W, TUNING_H).expect("close word paints");
2079 let far_top =
2080 min_ink_y(&render_alpha(&far, 0.5), TUNING_W, TUNING_H).expect("far word paints");
2081
2082 assert!(
2083 far_top > close_top + 10,
2084 "a `distance: 1.0` unit should still be further below its line than a `0.25` one at \
2085 the same instant (far_top={far_top}, close_top={close_top})"
2086 );
2087 }
2088
2089 #[test]
2090 fn settled_units_land_in_the_same_place_whatever_the_direction_and_distance() {
2091 let settled = |timing: CharAnimationTiming| {
2095 let grid = render_alpha(&tuned_slide_up(timing), 5.0);
2096 min_ink_y(&grid, TUNING_W, TUNING_H).expect("settled word paints")
2097 };
2098 let base = || CharAnimationTiming {
2099 duration: 1.0,
2100 stagger: 0.0,
2101 granularity: TextAnimGranularity::Word,
2102 easing: EasingType::Linear,
2103 ..Default::default()
2104 };
2105
2106 let plain = settled(base());
2107 let downward = settled(CharAnimationTiming {
2108 direction: TextAnimDirection::Down,
2109 distance: Some(1.85),
2110 ..base()
2111 });
2112 let sideways = settled(CharAnimationTiming {
2113 direction: TextAnimDirection::Right,
2114 distance: Some(1.85),
2115 ..base()
2116 });
2117
2118 assert_eq!(
2119 plain, downward,
2120 "a settled `down` unit must land on its line"
2121 );
2122 assert_eq!(
2123 plain, sideways,
2124 "a settled `right` unit must land on its line"
2125 );
2126 }
2127
2128 #[test]
2129 fn scale_from_shrinks_the_unit_at_the_start_and_releases_it_by_the_end() {
2130 let timing = CharAnimationTiming {
2131 duration: 1.0,
2132 stagger: 0.0,
2133 granularity: TextAnimGranularity::Word,
2134 easing: EasingType::Linear,
2135 distance: Some(0.0),
2137 scale_from: Some(0.5),
2138 ..Default::default()
2139 };
2140 let text = tuned_slide_up(timing);
2141
2142 let ink_width = |time: f64| -> i32 {
2143 let grid = render_alpha(&text, time);
2144 let left = (0..TUNING_W)
2145 .find(|&x| (0..TUNING_H).any(|y| grid[(y * TUNING_W + x) as usize] > 0));
2146 let right = (0..TUNING_W)
2147 .rev()
2148 .find(|&x| (0..TUNING_H).any(|y| grid[(y * TUNING_W + x) as usize] > 0));
2149 match (left, right) {
2150 (Some(l), Some(r)) => r - l,
2151 _ => 0,
2152 }
2153 };
2154
2155 let early = ink_width(0.35);
2156 let settled = ink_width(5.0);
2157 assert!(early > 0, "the word must be painting by t=0.35");
2158 assert!(
2159 (early as f32) < settled as f32 * 0.9,
2160 "a `scale_from: 0.5` unit should still be visibly narrower than its settled self \
2161 early on (early={early}px, settled={settled}px)"
2162 );
2163 }
2164
2165 #[test]
2166 fn ink_from_starts_at_the_given_colour_and_settles_to_the_texts_own() {
2167 let mut text = make_text("INK", None);
2170 text.style.font_size = Some(Length::Px(90.0));
2171 text.style.white_space = Some(CssWhiteSpace::Nowrap);
2172 text.style.color = Some(rustmotion_core::css::style::Color::String("#FFFFFF".into()));
2173 text.style.animation = vec![AnimationEffect::CharScaleIn(CharAnimationTiming {
2174 duration: 1.0,
2175 stagger: 0.0,
2176 granularity: TextAnimGranularity::Word,
2177 easing: EasingType::Linear,
2178 overshoot: Some(0.0),
2179 ink_from: Some("#FF0000".into()),
2181 ..Default::default()
2182 })];
2183
2184 let mean_green = |time: f64| -> f32 {
2186 let mut surface = skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H))
2187 .expect("raster surface");
2188 {
2189 let canvas = surface.canvas();
2190 text.paint(
2191 canvas,
2192 TUNING_W as f32,
2193 None,
2194 time,
2195 &props_for(&text),
2196 &test_ctx(),
2197 )
2198 .expect("paint succeeds");
2199 }
2200 let snapshot = surface.image_snapshot();
2201 let info = skia_safe::ImageInfo::new(
2202 (TUNING_W, TUNING_H),
2203 skia_safe::ColorType::RGBA8888,
2204 skia_safe::AlphaType::Unpremul,
2205 None,
2206 );
2207 let mut buf = vec![0u8; (TUNING_W * TUNING_H * 4) as usize];
2208 assert!(snapshot.read_pixels(
2209 &info,
2210 &mut buf,
2211 (TUNING_W * 4) as usize,
2212 skia_safe::IPoint::new(0, 0),
2213 skia_safe::image::CachingHint::Disallow,
2214 ));
2215 let (weighted, alpha_sum) =
2222 (0..(TUNING_W * TUNING_H) as usize).fold((0u64, 0u64), |(s, a), i| {
2223 let alpha = buf[i * 4 + 3] as u64;
2224 (s + buf[i * 4 + 1] as u64 * alpha, a + alpha)
2225 });
2226 assert!(alpha_sum > 0, "some inked pixels must exist at t={time}");
2227 weighted as f32 / alpha_sum as f32
2228 };
2229
2230 let early = mean_green(0.1);
2231 let mid = mean_green(0.5);
2232 let settled = mean_green(5.0);
2233
2234 assert!(
2235 early < 60.0,
2236 "at 10% the word should read nearly pure red (mean green {early:.1})"
2237 );
2238 assert!(
2239 mid > early + 40.0 && mid < settled - 40.0,
2240 "at 50% the word should be halfway between its start colour and the text colour \
2241 (early={early:.1}, mid={mid:.1}, settled={settled:.1})"
2242 );
2243 assert!(
2244 settled > 250.0,
2245 "once settled the word must be the text's own white, not a tint of it \
2246 (mean green {settled:.1})"
2247 );
2248 }
2249}