1use skia_safe::font_style::Weight;
2use skia_safe::{Font, FontStyle as SkFontStyle, Typeface};
3
4use rustmotion_core::css::style::{
5 CssStyle, FontStyle as CssFontStyle, LineHeight, TextAlign as CssTextAlign, WhiteSpace,
6 TEXT_AUTOFIT_MIN_FONT_PX,
7};
8use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
9use rustmotion_core::engine::deps::{TextMetrics, TextMetricsProvider};
10use rustmotion_core::engine::renderer::{
11 compute_glyph_metrics, css_font_weight, emoji_typeface, format_counter_value,
12 measure_text_with_fallback, subpixel_font, typeface_with_fallback, wrap_text_with_tracking,
13 GlyphMetric,
14};
15
16use crate::badge::{Badge, BadgeSize};
17use crate::caption::Caption;
18use crate::counter::Counter;
19use crate::gradient_text::GradientText;
20use crate::kbd::Kbd;
21use crate::text::Text;
22
23use rustmotion_core::css::units::LengthContext;
24
25pub fn font_size_ctx(viewport_width: f32, viewport_height: f32, parent_size: f32) -> LengthContext {
26 LengthContext {
27 viewport_width,
28 viewport_height,
29 parent_size,
30 font_size: 16.0,
31 root_font_size: 16.0,
32 }
33}
34
35pub fn measure_time_font_size_ctx(parent_size: f32) -> LengthContext {
36 font_size_ctx(1920.0, 1080.0, parent_size)
37}
38
39pub struct TextIntrinsic {
40 content: String,
41 font_family: Option<String>,
42 font_size: f32,
43 line_height_resolved: f32,
44 weight: Weight,
45 italic: bool,
46 letter_spacing: f32,
47 max_width: Option<f32>,
48 wrap: bool,
49 text_align: CssTextAlign,
50 text_autofit: bool,
51}
52
53impl TextIntrinsic {
54 pub fn from_text(text: &Text) -> Self {
55 let wrap = !matches!(
56 text.style.white_space,
57 Some(WhiteSpace::Nowrap | WhiteSpace::Pre)
58 );
59 let widest = text
60 .all_labels()
61 .max_by_key(|label| label.chars().count())
62 .unwrap_or(&text.content);
63 Self::from_parts_with_wrap(widest, &text.style, text.max_width, wrap)
64 .with_autofit(matches!(text.style.text_autofit, Some(true)))
65 }
66
67 pub fn with_autofit(mut self, on: bool) -> Self {
68 self.text_autofit = on;
69 self
70 }
71
72 pub fn from_parts(content: &str, style: &CssStyle, max_width: Option<f32>) -> Self {
73 let base_ctx = measure_time_font_size_ctx(0.0);
74 let (font_size, letter_spacing, line_height_resolved) =
75 style.typography_px_ctx(&base_ctx, 48.0);
76 let text_align = match style.text_align {
77 Some(CssTextAlign::Center) => CssTextAlign::Center,
78 Some(CssTextAlign::Right | CssTextAlign::End) => CssTextAlign::Right,
79 _ => CssTextAlign::Left,
80 };
81 Self {
82 content: content.to_string(),
83 font_family: style.font_family.clone(),
84 font_size,
85 line_height_resolved,
86 weight: css_font_weight(style.font_weight.as_ref()),
87 italic: matches!(style.font_style, Some(CssFontStyle::Italic)),
88 letter_spacing,
89 max_width,
90 wrap: true,
91 text_align,
92 text_autofit: false,
93 }
94 }
95
96 pub fn from_parts_with_wrap(
97 content: &str,
98 style: &CssStyle,
99 max_width: Option<f32>,
100 wrap: bool,
101 ) -> Self {
102 let mut t = Self::from_parts(content, style, max_width);
103 t.wrap = wrap;
104 t
105 }
106}
107
108impl IntrinsicMeasure for TextIntrinsic {
109 fn measure(
110 &self,
111 known: (Option<f32>, Option<f32>),
112 available: (AvailableSpace, AvailableSpace),
113 ) -> (f32, f32) {
114 let max_width = if let Some(w) = known.0 {
115 Some(w)
116 } else {
117 let avail_w = match available.0 {
118 AvailableSpace::Definite(w) => Some(w),
119 AvailableSpace::MaxContent => None,
120 AvailableSpace::MinContent => Some(0.0),
121 };
122 match (self.max_width, avail_w) {
123 (Some(a), Some(b)) => Some(a.min(b)),
124 (Some(a), None) => Some(a),
125 (None, Some(b)) => Some(b),
126 (None, None) => None,
127 }
128 };
129
130 let Some(typeface) = self.typeface() else {
131 return (0.0, 0.0);
132 };
133 let wrap_at = if self.wrap { max_width } else { None };
134 let (base_w, base_h) = wrap_and_measure(
135 &self.content,
136 &typeface,
137 self.font_size,
138 wrap_at,
139 self.letter_spacing,
140 self.line_height_resolved,
141 );
142
143 if !self.text_autofit {
144 return (base_w, base_h);
145 }
146
147 let target_height = match known.1 {
148 Some(h) => Some(h),
149 None => match available.1 {
150 AvailableSpace::Definite(h) => Some(h),
151 AvailableSpace::MaxContent => None,
152 AvailableSpace::MinContent => Some(0.0),
153 },
154 };
155
156 let (final_size, final_ls, final_lh) = resolve_text_autofit(
157 &self.content,
158 &typeface,
159 self.font_size,
160 self.letter_spacing,
161 self.line_height_resolved,
162 self.wrap,
163 max_width,
164 target_height,
165 );
166
167 if final_size >= self.font_size {
168 return (base_w, base_h);
169 }
170 let wrap_at = if self.wrap { max_width } else { None };
171 wrap_and_measure(
172 &self.content,
173 &typeface,
174 final_size,
175 wrap_at,
176 final_ls,
177 final_lh,
178 )
179 }
180}
181
182impl TextIntrinsic {
183 fn sk_font_style(&self) -> SkFontStyle {
184 let slant = if self.italic {
185 skia_safe::font_style::Slant::Italic
186 } else {
187 skia_safe::font_style::Slant::Upright
188 };
189 SkFontStyle::new(self.weight, skia_safe::font_style::Width::NORMAL, slant)
190 }
191
192 fn typeface(&self) -> Option<Typeface> {
193 let family = self.font_family.as_deref().unwrap_or("Inter");
194 typeface_with_fallback(family, self.sk_font_style()).ok()
195 }
196
197 pub fn text_metrics(&self, content_box_width: f32) -> Option<TextMetrics> {
198 let typeface = self.typeface()?;
199 let font = subpixel_font(typeface, self.font_size);
200 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, self.font_size));
201
202 let wrap_at = if self.wrap {
203 Some(
204 self.max_width
205 .map(|m| m.min(content_box_width))
206 .unwrap_or(content_box_width),
207 )
208 } else {
209 None
210 };
211 let lines = wrap_text_with_tracking(
212 &self.content,
213 &font,
214 &emoji_font,
215 wrap_at,
216 self.letter_spacing,
217 );
218
219 let mut text_width = 0.0f32;
220 let mut glyphs: Vec<GlyphMetric> = Vec::new();
221 for line in &lines {
222 let advance = measure_text_with_fallback(line, &font, &emoji_font, self.letter_spacing);
223 text_width = text_width.max(advance);
224 let line_x = match self.text_align {
225 CssTextAlign::Center => (content_box_width - advance) / 2.0,
226 CssTextAlign::Right => content_box_width - advance,
227 _ => 0.0,
228 };
229 let line_glyphs = compute_glyph_metrics(line, &font, &emoji_font, self.letter_spacing);
230 glyphs.extend(line_glyphs.into_iter().map(|g| GlyphMetric {
231 x: g.x + line_x,
232 width: g.width,
233 }));
234 }
235
236 let (_, metrics) = font.metrics();
237 let ascender = -metrics.ascent;
238 let descender = metrics.descent;
239 let baseline = (self.line_height_resolved + ascender - descender) / 2.0;
240
241 Some(TextMetrics {
242 text_width,
243 cap_height: metrics.cap_height,
244 ascender,
245 baseline,
246 glyphs,
247 })
248 }
249}
250
251pub struct ComponentTextMetrics;
252
253impl TextMetricsProvider for ComponentTextMetrics {
254 fn text_metrics(
255 &self,
256 payload: &(dyn std::any::Any + Send + Sync),
257 content_box_width: f32,
258 ) -> Option<TextMetrics> {
259 if let Some(t) = payload.downcast_ref::<Text>() {
260 return TextIntrinsic::from_text(t).text_metrics(content_box_width);
261 }
262 if let Some(g) = payload.downcast_ref::<GradientText>() {
263 return GradientTextIntrinsic::from_gradient_text(g)
264 .0
265 .text_metrics(content_box_width);
266 }
267 None
268 }
269}
270
271fn wrap_and_measure(
272 content: &str,
273 typeface: &Typeface,
274 font_size: f32,
275 wrap_at: Option<f32>,
276 letter_spacing: f32,
277 line_height: f32,
278) -> (f32, f32) {
279 let font = subpixel_font(typeface.clone(), font_size);
280 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
281 let lines = wrap_text_with_tracking(content, &font, &emoji_font, wrap_at, letter_spacing);
282 let mut max_w = 0.0f32;
283 for line in &lines {
284 max_w = max_w.max(measure_text_with_fallback(
285 line,
286 &font,
287 &emoji_font,
288 letter_spacing,
289 ));
290 }
291 let line_count = lines.len().max(1) as f32;
292 (max_w, line_count * line_height)
293}
294
295#[allow(clippy::too_many_arguments)]
296pub fn resolve_text_autofit(
297 content: &str,
298 typeface: &Typeface,
299 requested_font_size: f32,
300 requested_letter_spacing: f32,
301 requested_line_height: f32,
302 wrap: bool,
303 box_width: Option<f32>,
304 declared_height: Option<f32>,
305) -> (f32, f32, f32) {
306 if requested_font_size <= 0.0 || (box_width.is_none() && declared_height.is_none()) {
307 return (
308 requested_font_size,
309 requested_letter_spacing,
310 requested_line_height,
311 );
312 }
313 let wrap_at = if wrap { box_width } else { None };
314 let measure_at = |size: f32| -> (f32, f32) {
315 let ratio = size / requested_font_size;
316 wrap_and_measure(
317 content,
318 typeface,
319 size,
320 wrap_at,
321 requested_letter_spacing * ratio,
322 requested_line_height * ratio,
323 )
324 };
325 let floor = TEXT_AUTOFIT_MIN_FONT_PX.min(requested_font_size);
326 let final_size = shrink_to_fit(
327 requested_font_size,
328 floor,
329 box_width,
330 declared_height,
331 measure_at,
332 );
333 if final_size >= requested_font_size {
334 (
335 requested_font_size,
336 requested_letter_spacing,
337 requested_line_height,
338 )
339 } else {
340 let ratio = final_size / requested_font_size;
341 (
342 final_size,
343 requested_letter_spacing * ratio,
344 requested_line_height * ratio,
345 )
346 }
347}
348
349fn shrink_to_fit(
350 requested_font_size: f32,
351 floor_px: f32,
352 target_width: Option<f32>,
353 target_height: Option<f32>,
354 mut measure_at: impl FnMut(f32) -> (f32, f32),
355) -> f32 {
356 let eps = 0.5;
357 let fits = |w: f32, h: f32| {
358 target_width.is_none_or(|tw| w <= tw + eps) && target_height.is_none_or(|th| h <= th + eps)
359 };
360
361 let (w0, h0) = measure_at(requested_font_size);
362 if fits(w0, h0) {
363 return requested_font_size;
364 }
365
366 let floor_px = floor_px.min(requested_font_size).max(0.1);
367 if floor_px >= requested_font_size {
368 return requested_font_size;
369 }
370
371 let (mut lo, mut hi) = (floor_px, requested_font_size);
372 let (w_floor, h_floor) = measure_at(lo);
373 if !fits(w_floor, h_floor) {
374 return lo;
375 }
376 for _ in 0..16 {
377 let mid = (lo + hi) / 2.0;
378 let (w, h) = measure_at(mid);
379 if fits(w, h) {
380 lo = mid;
381 } else {
382 hi = mid;
383 }
384 }
385 lo
386}
387
388pub struct GradientTextIntrinsic(TextIntrinsic);
389
390impl GradientTextIntrinsic {
391 pub fn from_gradient_text(t: &GradientText) -> Self {
392 use rustmotion_core::css::style::Size as CSize;
393 use rustmotion_core::css::units::LengthPercentage;
394 let max_width = match &t.style.width {
395 Some(CSize::Length(LengthPercentage::Px(v))) => Some(*v),
396 _ => None,
397 };
398 let wrap = !matches!(
399 t.style.white_space,
400 Some(WhiteSpace::Nowrap | WhiteSpace::Pre)
401 );
402 Self(
403 TextIntrinsic::from_parts_with_wrap(&t.content, &t.style, max_width, wrap)
404 .with_autofit(matches!(t.style.text_autofit, Some(true))),
405 )
406 }
407}
408
409impl IntrinsicMeasure for GradientTextIntrinsic {
410 fn measure(
411 &self,
412 known: (Option<f32>, Option<f32>),
413 available: (AvailableSpace, AvailableSpace),
414 ) -> (f32, f32) {
415 self.0.measure(known, available)
416 }
417}
418
419pub struct CaptionIntrinsic(TextIntrinsic);
420
421impl CaptionIntrinsic {
422 pub fn from_caption(c: &Caption) -> Self {
423 let joined = c
424 .words
425 .iter()
426 .map(|w| w.text.as_str())
427 .collect::<Vec<_>>()
428 .join(" ");
429 let wrap = !matches!(
430 c.style.white_space,
431 Some(WhiteSpace::Nowrap | WhiteSpace::Pre)
432 );
433 Self(TextIntrinsic::from_parts_with_wrap(
434 &joined,
435 &c.style,
436 c.max_width,
437 wrap,
438 ))
439 }
440}
441
442impl IntrinsicMeasure for CaptionIntrinsic {
443 fn measure(
444 &self,
445 known: (Option<f32>, Option<f32>),
446 available: (AvailableSpace, AvailableSpace),
447 ) -> (f32, f32) {
448 self.0.measure(known, available)
449 }
450}
451
452pub struct KbdIntrinsic {
453 text: TextIntrinsic,
454 h_padding: f32,
455 v_padding: f32,
456 min_width: f32,
457}
458
459impl KbdIntrinsic {
460 pub fn from_kbd(k: &Kbd) -> Self {
461 let fs = k
462 .style
463 .font_size_px_ctx(&measure_time_font_size_ctx(0.0), k.font_size);
464 let synthetic_style = synthesize_text_style(&k.style, fs, "SF Mono");
465 Self {
466 text: TextIntrinsic::from_parts_with_wrap(&k.key, &synthetic_style, None, false),
467 h_padding: fs * 0.7,
468 v_padding: fs * 0.4,
469 min_width: fs * 1.8,
470 }
471 }
472}
473
474impl IntrinsicMeasure for KbdIntrinsic {
475 fn measure(
476 &self,
477 known: (Option<f32>, Option<f32>),
478 available: (AvailableSpace, AvailableSpace),
479 ) -> (f32, f32) {
480 let (tw, th) = self.text.measure(known, available);
481 let w = (tw + self.h_padding * 2.0).max(self.min_width);
482 let h = th + self.v_padding * 2.0;
483 (w, h)
484 }
485}
486
487pub struct CounterIntrinsic(TextIntrinsic);
488
489impl CounterIntrinsic {
490 pub fn from_counter(c: &Counter) -> Self {
491 let absmax = c.from.abs().max(c.to.abs());
492 let signed = if c.from < 0.0 || c.to < 0.0 {
493 -absmax
494 } else {
495 absmax
496 };
497 let display = format_counter_value(signed, c.decimals, &c.separator, &c.prefix, &c.suffix);
498 Self(TextIntrinsic::from_parts_with_wrap(
499 &display, &c.style, None, false,
500 ))
501 }
502}
503
504impl IntrinsicMeasure for CounterIntrinsic {
505 fn measure(
506 &self,
507 known: (Option<f32>, Option<f32>),
508 available: (AvailableSpace, AvailableSpace),
509 ) -> (f32, f32) {
510 self.0.measure(known, available)
511 }
512}
513
514pub struct NumberWheelIntrinsic(TextIntrinsic);
515
516impl NumberWheelIntrinsic {
517 pub fn from_number_wheel(w: &crate::number_wheel::NumberWheel) -> Self {
518 let widest = (0..10)
519 .map(|d| {
520 let ch = char::from_digit(d, 10).expect("0..10 is a digit");
521 w.value
522 .chars()
523 .map(|c| if c.is_ascii_digit() { ch } else { c })
524 .collect::<String>()
525 })
526 .max_by(|a, b| {
527 let measure = |s: &str| {
528 TextIntrinsic::from_parts_with_wrap(s, &w.style, None, false)
529 .measure(
530 (None, None),
531 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
532 )
533 .0
534 };
535 measure(a)
536 .partial_cmp(&measure(b))
537 .unwrap_or(std::cmp::Ordering::Equal)
538 })
539 .unwrap_or_else(|| w.value.clone());
540 Self(TextIntrinsic::from_parts_with_wrap(
541 &widest, &w.style, None, false,
542 ))
543 }
544}
545
546impl IntrinsicMeasure for NumberWheelIntrinsic {
547 fn measure(
548 &self,
549 known: (Option<f32>, Option<f32>),
550 available: (AvailableSpace, AvailableSpace),
551 ) -> (f32, f32) {
552 self.0.measure(known, available)
553 }
554}
555
556pub struct BadgeIntrinsic {
557 text: TextIntrinsic,
558 h_padding: f32,
559 v_padding: f32,
560 icon_extra: f32,
561 font_size: f32,
562}
563
564impl BadgeIntrinsic {
565 pub fn from_badge(b: &Badge) -> Self {
566 let (default_fs, h_pad, v_pad, icon_size) = badge_size_params(&b.badge_size);
567 let font_size = b
568 .style
569 .font_size_px_ctx(&measure_time_font_size_ctx(0.0), default_fs);
570 let ratio = font_size / default_fs;
571 let h_padding = h_pad * ratio;
572 let v_padding = v_pad * ratio;
573 let icon_extra = if b.icon.is_some() {
574 icon_size * ratio + 6.0 * ratio
575 } else {
576 0.0
577 };
578
579 let synthetic_style = synthesize_text_style(&b.style, font_size, "Inter");
580
581 Self {
582 text: TextIntrinsic::from_parts_with_wrap(&b.text, &synthetic_style, None, false),
583 h_padding,
584 v_padding,
585 icon_extra,
586 font_size,
587 }
588 }
589}
590
591impl IntrinsicMeasure for BadgeIntrinsic {
592 fn measure(
593 &self,
594 known: (Option<f32>, Option<f32>),
595 available: (AvailableSpace, AvailableSpace),
596 ) -> (f32, f32) {
597 let (tw, _th) = self.text.measure(known, available);
598 let w = self.h_padding * 2.0 + tw + self.icon_extra;
599 let h = self.v_padding * 2.0 + self.font_size * 1.3;
600 (w, h)
601 }
602}
603
604fn badge_size_params(s: &BadgeSize) -> (f32, f32, f32, f32) {
605 match s {
606 BadgeSize::Sm => (12.0, 8.0, 4.0, 14.0),
607 BadgeSize::Md => (14.0, 12.0, 6.0, 18.0),
608 BadgeSize::Lg => (18.0, 16.0, 8.0, 22.0),
609 }
610}
611
612fn synthesize_text_style(src: &CssStyle, font_size: f32, default_family: &str) -> CssStyle {
613 use rustmotion_core::css::Length;
614 let family = src
615 .font_family
616 .clone()
617 .unwrap_or_else(|| default_family.to_string());
618 CssStyle {
619 font_size: Some(Length::Px(font_size)),
620 font_family: Some(family),
621 font_weight: src.font_weight.clone(),
622 font_style: src.font_style,
623 letter_spacing: src.letter_spacing.clone(),
624 line_height: src.line_height.clone(),
625 ..CssStyle::default()
626 }
627}
628
629#[allow(dead_code)]
630fn _line_height_unused(_: Option<&LineHeight>) {}
631
632use crate::table::{Table, DEFAULT_FONT_SIZE as TABLE_FONT_SIZE, DEFAULT_ROW_HEIGHT_RATIO};
633
634pub struct TableIntrinsic {
635 row_height: f32,
636 row_count: usize,
637 total_width: f32,
638}
639
640impl TableIntrinsic {
641 pub fn from_table(t: &Table) -> Self {
642 let font_size = t
643 .style
644 .font_size_px_ctx(&measure_time_font_size_ctx(0.0), TABLE_FONT_SIZE);
645 let row_height = font_size * DEFAULT_ROW_HEIGHT_RATIO;
646
647 let total_width = Self::compute_width(t, font_size);
648
649 Self {
650 row_height,
651 row_count: t.rows.len(),
652 total_width,
653 }
654 }
655
656 fn compute_width(t: &Table, font_size: f32) -> f32 {
657 if let Some(widths) = &t.column_widths {
658 if !widths.is_empty() {
659 return widths.iter().sum();
660 }
661 }
662 t.natural_column_widths(font_size).iter().sum()
663 }
664}
665
666impl IntrinsicMeasure for TableIntrinsic {
667 fn measure(
668 &self,
669 known: (Option<f32>, Option<f32>),
670 _available: (AvailableSpace, AvailableSpace),
671 ) -> (f32, f32) {
672 let w = known.0.unwrap_or(self.total_width);
673 let h = known
674 .1
675 .unwrap_or((1 + self.row_count) as f32 * self.row_height);
676 (w, h)
677 }
678}
679
680use crate::rich_text::{RichText, RichTextSpan};
681
682pub struct RichTextIntrinsic {
683 spans: Vec<RichTextSpan>,
684 style: CssStyle,
685 max_width: Option<f32>,
686}
687
688impl RichTextIntrinsic {
689 pub fn from_rich_text(rt: &RichText) -> Self {
690 Self {
691 spans: rt.spans.clone(),
692 style: rt.style.clone(),
693 max_width: rt.max_width,
694 }
695 }
696}
697
698impl IntrinsicMeasure for RichTextIntrinsic {
699 fn measure(
700 &self,
701 known: (Option<f32>, Option<f32>),
702 available: (AvailableSpace, AvailableSpace),
703 ) -> (f32, f32) {
704 let max_width = if let Some(w) = known.0 {
705 Some(w)
706 } else {
707 let avail_w = match available.0 {
708 AvailableSpace::Definite(w) => Some(w),
709 AvailableSpace::MaxContent => None,
710 AvailableSpace::MinContent => Some(0.0),
711 };
712 match (self.max_width, avail_w) {
713 (Some(a), Some(b)) => Some(a.min(b)),
714 (Some(a), None) => Some(a),
715 (None, Some(b)) => Some(b),
716 (None, None) => None,
717 }
718 };
719
720 let layout =
721 RichText::compute_layout(&self.spans, &self.style, 1920.0, 1080.0, max_width, -1.0);
722 let line_count = layout.lines.len().max(1) as f32;
723 (layout.max_width, line_count * layout.line_height)
724 }
725}
726
727use rustmotion_core::engine::animator::ResolvedCharAnimation;
728use rustmotion_core::engine::renderer::{draw_text_with_fallback, paint_from_hex};
729use rustmotion_core::schema::{CharAnimPreset, RotateOrigin, TextAlign, TextAnimGranularity};
730use skia_safe::{Canvas, Paint};
731
732pub fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 {
733 cfg.unit_progress(idx, time)
734}
735
736pub fn reflow_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 {
737 cfg.reflow_progress(idx, time)
738}
739
740fn ink_paint(cfg: &ResolvedCharAnimation, paint: &Paint, t: f32) -> Option<Paint> {
741 let from = cfg.ink_from.as_deref()?;
742 let start = paint_from_hex(from).color();
743 let end = paint.color();
744 let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t.clamp(0.0, 1.0)) as u8;
745 let mut p = paint.clone();
746 p.set_color(skia_safe::Color::from_argb(
747 end.a(),
748 lerp(start.r(), end.r()),
749 lerp(start.g(), end.g()),
750 lerp(start.b(), end.b()),
751 ));
752 Some(p)
753}
754
755#[allow(clippy::too_many_arguments)]
756pub fn apply_text_anim_preset(
757 canvas: &Canvas,
758 text: &str,
759 font: &Font,
760 emoji_font: &Option<Font>,
761 paint: &Paint,
762 cursor_x: f32,
763 line_y: f32,
764 unit_width: f32,
765 letter_spacing: f32,
766 cfg: &ResolvedCharAnimation,
767 t: f32,
768 time: f64,
769 unit_idx: usize,
770 font_size: f32,
771) {
772 let preset = &cfg.preset;
773 let overshoot = cfg.overshoot;
774 let blur_radius = cfg.blur;
775 let center_x = cursor_x + unit_width / 2.0;
776 let center_y = line_y;
777
778 let inked = ink_paint(cfg, paint, t);
779 let paint = inked.as_ref().unwrap_or(paint);
780 if let Some(from) = cfg.scale_from {
781 if !matches!(preset, CharAnimPreset::ScaleIn | CharAnimPreset::Bounce) {
782 let s = from + (1.0 - from) * t.clamp(0.0, 1.0);
783 canvas.translate((center_x, center_y));
784 canvas.scale((s, s));
785 canvas.translate((-center_x, -center_y));
786 }
787 }
788 if let Some(from_deg) = cfg.rotate_from {
789 if !matches!(preset, CharAnimPreset::RotateIn) {
790 let angle = from_deg * (1.0 - t.clamp(0.0, 1.0));
791 let (px, py) = match cfg.rotate_origin {
792 RotateOrigin::Center => (center_x, center_y),
793 RotateOrigin::Edge => (cursor_x, center_y),
794 };
795 canvas.translate((px, py));
796 canvas.rotate(angle, None);
797 canvas.translate((-px, -py));
798 }
799 }
800 let scale_jitter_delta = cfg.scale_jitter_delta(unit_idx);
801 if scale_jitter_delta.abs() > 1e-6 {
802 let js = 1.0 + scale_jitter_delta * (1.0 - t.clamp(0.0, 1.0));
803 canvas.translate((center_x, center_y));
804 canvas.scale((js, js));
805 canvas.translate((-center_x, -center_y));
806 }
807 let baseline_jitter_delta = cfg.baseline_jitter_delta(unit_idx);
808 if baseline_jitter_delta.abs() > 1e-6 {
809 let jy = baseline_jitter_delta * font_size * (1.0 - t.clamp(0.0, 1.0));
810 canvas.translate((0.0, jy));
811 }
812
813 match preset {
814 CharAnimPreset::ScaleIn => {
815 let scale = if overshoot > 0.001 {
816 if t < 0.7 {
817 let p = t / 0.7;
818 p * (1.0 + overshoot)
819 } else {
820 let p = (t - 0.7) / 0.3;
821 (1.0 + overshoot) - overshoot * p
822 }
823 } else {
824 t
825 };
826 if scale < 0.001 {
827 return;
828 }
829 canvas.translate((center_x, center_y));
830 canvas.scale((scale, scale));
831 canvas.translate((-center_x, -center_y));
832 draw_text_with_fallback(
833 canvas,
834 text,
835 font,
836 emoji_font,
837 letter_spacing,
838 cursor_x,
839 line_y,
840 paint,
841 );
842 }
843 CharAnimPreset::FadeIn => {
844 let mut p = paint.clone();
845 p.set_alpha_f(t * paint.alpha_f());
846 draw_text_with_fallback(
847 canvas,
848 text,
849 font,
850 emoji_font,
851 letter_spacing,
852 cursor_x,
853 line_y,
854 &p,
855 );
856 }
857 CharAnimPreset::Wave => {
858 let wave_offset =
859 (time as f32 * 4.0 + unit_idx as f32 * 0.5).sin() * 8.0 * (1.0 - t * 0.5);
860 let mut p = paint.clone();
861 p.set_alpha_f(t.min(1.0) * paint.alpha_f());
862 draw_text_with_fallback(
863 canvas,
864 text,
865 font,
866 emoji_font,
867 letter_spacing,
868 cursor_x,
869 line_y + wave_offset,
870 &p,
871 );
872 }
873 CharAnimPreset::Bounce => {
874 let peak = 1.0 + overshoot.max(0.3);
875 let scale = if t < 0.5 {
876 t * 2.0 * peak
877 } else {
878 peak - (peak - 1.0) * ((t - 0.5) * 2.0)
879 };
880 let scale = scale.max(0.001);
881 canvas.translate((center_x, center_y));
882 canvas.scale((scale, scale));
883 canvas.translate((-center_x, -center_y));
884 draw_text_with_fallback(
885 canvas,
886 text,
887 font,
888 emoji_font,
889 letter_spacing,
890 cursor_x,
891 line_y,
892 paint,
893 );
894 }
895 CharAnimPreset::RotateIn => {
896 let angle = (1.0 - t) * -90.0;
897 let mut p = paint.clone();
898 p.set_alpha_f(t * paint.alpha_f());
899 canvas.translate((center_x, center_y));
900 canvas.rotate(angle, None);
901 canvas.translate((-center_x, -center_y));
902 draw_text_with_fallback(
903 canvas,
904 text,
905 font,
906 emoji_font,
907 letter_spacing,
908 cursor_x,
909 line_y,
910 &p,
911 );
912 }
913 CharAnimPreset::SlideUp => {
914 let travel = (1.0 - t) * font_size * 0.8 * cfg.distance;
915 let (dx, dy) = cfg.direction.offset(travel);
916 let mut p = paint.clone();
917 p.set_alpha_f(t * paint.alpha_f());
918 draw_text_with_fallback(
919 canvas,
920 text,
921 font,
922 emoji_font,
923 letter_spacing,
924 cursor_x + dx,
925 line_y + dy,
926 &p,
927 );
928 }
929 CharAnimPreset::BlurIn => {
930 let tt = t.clamp(0.0, 1.0);
931 let travel = (1.0 - tt) * font_size * 0.12 * cfg.distance;
932 let (dx, dy) = cfg.direction.offset(travel);
933 let sigma = ((1.0 - tt) * blur_radius).max(0.0);
934 let mut p = paint.clone();
935 p.set_alpha_f(tt * paint.alpha_f());
936 if sigma > 0.05 {
937 if let Some(filter) = skia_safe::image_filters::blur(
938 (sigma, sigma),
939 skia_safe::TileMode::Clamp,
940 None,
941 None,
942 ) {
943 p.set_image_filter(filter);
944 }
945 }
946 draw_text_with_fallback(
947 canvas,
948 text,
949 font,
950 emoji_font,
951 letter_spacing,
952 cursor_x + dx,
953 line_y + dy,
954 &p,
955 );
956 }
957 }
958}
959
960#[allow(clippy::too_many_arguments)]
961struct ReflowWordToken {
962 text: String,
963 is_space: bool,
964 full_width: f32,
965}
966
967fn tokenize_reflow_words(
968 line: &str,
969 font: &Font,
970 emoji_font: &Option<Font>,
971 letter_spacing: f32,
972) -> Vec<ReflowWordToken> {
973 let mut toks = Vec::new();
974 let mut chars = line.chars().peekable();
975 while chars.peek().is_some() {
976 let mut spaces = String::new();
977 while let Some(&c) = chars.peek() {
978 if c.is_whitespace() {
979 spaces.push(c);
980 chars.next();
981 } else {
982 break;
983 }
984 }
985 if !spaces.is_empty() {
986 let full_width = measure_text_with_fallback(&spaces, font, emoji_font, letter_spacing);
987 toks.push(ReflowWordToken {
988 text: spaces,
989 is_space: true,
990 full_width,
991 });
992 }
993 let mut word = String::new();
994 while let Some(&c) = chars.peek() {
995 if c.is_whitespace() {
996 break;
997 }
998 word.push(c);
999 chars.next();
1000 }
1001 if !word.is_empty() {
1002 let full_width = measure_text_with_fallback(&word, font, emoji_font, letter_spacing);
1003 toks.push(ReflowWordToken {
1004 text: word,
1005 is_space: false,
1006 full_width,
1007 });
1008 }
1009 }
1010 toks
1011}
1012
1013fn reflowed_word_width(
1014 tok: &ReflowWordToken,
1015 idx: usize,
1016 char_anim: &ResolvedCharAnimation,
1017 time: f64,
1018) -> f32 {
1019 if tok.is_space {
1020 tok.full_width
1021 } else {
1022 tok.full_width * reflow_progress(char_anim, idx, time)
1023 }
1024}
1025
1026#[allow(clippy::too_many_arguments)]
1027fn paint_reflowing_word_line(
1028 canvas: &Canvas,
1029 font: &Font,
1030 emoji_font: &Option<Font>,
1031 paint: &Paint,
1032 letter_spacing: f32,
1033 align: &TextAlign,
1034 align_width: f32,
1035 line_y: f32,
1036 line: &str,
1037 char_anim: &ResolvedCharAnimation,
1038 time: f64,
1039 base_idx: usize,
1040) -> usize {
1041 let toks = tokenize_reflow_words(line, font, emoji_font, letter_spacing);
1042
1043 let mut word_counter = 0usize;
1044 let effective_width: f32 = toks
1045 .iter()
1046 .map(|tok| {
1047 if tok.is_space {
1048 tok.full_width
1049 } else {
1050 let idx = base_idx + word_counter;
1051 word_counter += 1;
1052 reflowed_word_width(tok, idx, char_anim, time)
1053 }
1054 })
1055 .sum();
1056
1057 let line_x = match align {
1058 TextAlign::Left => 0.0,
1059 TextAlign::Center => (align_width - effective_width) / 2.0,
1060 TextAlign::Right => align_width - effective_width,
1061 };
1062
1063 let mut cursor_x = line_x;
1064 word_counter = 0;
1065 for tok in &toks {
1066 if tok.is_space {
1067 draw_text_with_fallback(
1068 canvas, &tok.text, font, emoji_font, 0.0, cursor_x, line_y, paint,
1069 );
1070 cursor_x += tok.full_width;
1071 continue;
1072 }
1073 let idx = base_idx + word_counter;
1074 word_counter += 1;
1075 let t = unit_progress(char_anim, idx, time);
1076 let draw_width = reflowed_word_width(tok, idx, char_anim, time);
1077
1078 canvas.save();
1079 apply_text_anim_preset(
1080 canvas,
1081 &tok.text,
1082 font,
1083 emoji_font,
1084 paint,
1085 cursor_x,
1086 line_y,
1087 draw_width,
1088 letter_spacing,
1089 char_anim,
1090 t,
1091 time,
1092 idx,
1093 font.size(),
1094 );
1095 canvas.restore();
1096
1097 cursor_x += draw_width;
1098 }
1099 base_idx + word_counter
1100}
1101
1102#[allow(clippy::too_many_arguments)]
1103fn paint_reflowing_char_line(
1104 canvas: &Canvas,
1105 font: &Font,
1106 emoji_font: &Option<Font>,
1107 paint: &Paint,
1108 letter_spacing: f32,
1109 align: &TextAlign,
1110 align_width: f32,
1111 line_y: f32,
1112 line: &str,
1113 char_anim: &ResolvedCharAnimation,
1114 time: f64,
1115 base_idx: usize,
1116) -> usize {
1117 let chars_list: Vec<char> = line.chars().collect();
1118 let widths: Vec<f32> = chars_list
1119 .iter()
1120 .map(|ch| {
1121 let (w, _) = font.measure_str(ch.to_string(), None);
1122 w + letter_spacing
1123 })
1124 .collect();
1125
1126 let effective_width: f32 = widths
1127 .iter()
1128 .enumerate()
1129 .map(|(i, w)| w * reflow_progress(char_anim, base_idx + i, time))
1130 .sum();
1131
1132 let line_x = match align {
1133 TextAlign::Left => 0.0,
1134 TextAlign::Center => (align_width - effective_width) / 2.0,
1135 TextAlign::Right => align_width - effective_width,
1136 };
1137
1138 let mut cursor_x = line_x;
1139 for (i, ch) in chars_list.iter().enumerate() {
1140 let idx = base_idx + i;
1141 let ch_str = ch.to_string();
1142 let t = unit_progress(char_anim, idx, time);
1143 let draw_width = widths[i] * reflow_progress(char_anim, idx, time);
1144
1145 canvas.save();
1146 apply_text_anim_preset(
1147 canvas,
1148 &ch_str,
1149 font,
1150 emoji_font,
1151 paint,
1152 cursor_x,
1153 line_y,
1154 draw_width,
1155 0.0,
1156 char_anim,
1157 t,
1158 time,
1159 idx,
1160 font.size(),
1161 );
1162 canvas.restore();
1163
1164 cursor_x += draw_width;
1165 }
1166 base_idx + chars_list.len()
1167}
1168
1169pub fn render_char_animation(
1170 canvas: &Canvas,
1171 font: &Font,
1172 emoji_font: &Option<Font>,
1173 paint: &Paint,
1174 letter_spacing: f32,
1175 align: TextAlign,
1176 align_width: f32,
1177 line_height_val: f32,
1178 baseline_offset: f32,
1179 lines: &[String],
1180 char_anim: &ResolvedCharAnimation,
1181 time: f64,
1182) {
1183 let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word);
1184 let mut global_unit_idx = 0usize;
1185
1186 for (line_idx, line) in lines.iter().enumerate() {
1187 if line.is_empty() {
1188 continue;
1189 }
1190
1191 let line_y = line_idx as f32 * line_height_val + baseline_offset;
1192
1193 if char_anim.reflow {
1194 global_unit_idx = if is_word_mode {
1195 paint_reflowing_word_line(
1196 canvas,
1197 font,
1198 emoji_font,
1199 paint,
1200 letter_spacing,
1201 &align,
1202 align_width,
1203 line_y,
1204 line,
1205 char_anim,
1206 time,
1207 global_unit_idx,
1208 )
1209 } else {
1210 paint_reflowing_char_line(
1211 canvas,
1212 font,
1213 emoji_font,
1214 paint,
1215 letter_spacing,
1216 &align,
1217 align_width,
1218 line_y,
1219 line,
1220 char_anim,
1221 time,
1222 global_unit_idx,
1223 )
1224 };
1225 continue;
1226 }
1227
1228 let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing);
1229 let line_x = match align {
1230 TextAlign::Left => 0.0,
1231 TextAlign::Center => (align_width - advance_width) / 2.0,
1232 TextAlign::Right => align_width - advance_width,
1233 };
1234
1235 if is_word_mode {
1236 let mut cursor_x = line_x;
1237 let mut chars = line.chars().peekable();
1238
1239 while chars.peek().is_some() {
1240 let mut spaces = String::new();
1241 while let Some(&c) = chars.peek() {
1242 if c.is_whitespace() {
1243 spaces.push(c);
1244 chars.next();
1245 } else {
1246 break;
1247 }
1248 }
1249 if !spaces.is_empty() {
1250 let space_w =
1251 measure_text_with_fallback(&spaces, font, emoji_font, letter_spacing);
1252 draw_text_with_fallback(
1253 canvas, &spaces, font, emoji_font, 0.0, cursor_x, line_y, paint,
1254 );
1255 cursor_x += space_w;
1256 }
1257
1258 let mut word = String::new();
1259 while let Some(&c) = chars.peek() {
1260 if c.is_whitespace() {
1261 break;
1262 }
1263 word.push(c);
1264 chars.next();
1265 }
1266 if word.is_empty() {
1267 continue;
1268 }
1269
1270 let word_width =
1271 measure_text_with_fallback(&word, font, emoji_font, letter_spacing);
1272
1273 let t = unit_progress(char_anim, global_unit_idx, time);
1274
1275 canvas.save();
1276 apply_text_anim_preset(
1277 canvas,
1278 &word,
1279 font,
1280 emoji_font,
1281 paint,
1282 cursor_x,
1283 line_y,
1284 word_width,
1285 letter_spacing,
1286 char_anim,
1287 t,
1288 time,
1289 global_unit_idx,
1290 font.size(),
1291 );
1292 canvas.restore();
1293
1294 cursor_x += word_width;
1295 global_unit_idx += 1;
1296 }
1297 } else {
1298 let mut cursor_x = line_x;
1299 for ch in line.chars() {
1300 let ch_str = ch.to_string();
1301 let (ch_width, _) = font.measure_str(&ch_str, None);
1302 let ch_width = ch_width + letter_spacing;
1303
1304 let t = unit_progress(char_anim, global_unit_idx, time);
1305
1306 canvas.save();
1307 apply_text_anim_preset(
1308 canvas,
1309 &ch_str,
1310 font,
1311 emoji_font,
1312 paint,
1313 cursor_x,
1314 line_y,
1315 ch_width,
1316 0.0,
1317 char_anim,
1318 t,
1319 time,
1320 global_unit_idx,
1321 font.size(),
1322 );
1323 canvas.restore();
1324
1325 cursor_x += ch_width;
1326 global_unit_idx += 1;
1327 }
1328 }
1329 }
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334 use super::*;
1335 use rustmotion_core::css::style::CssStyle;
1336 use rustmotion_core::css::Length;
1337 use rustmotion_core::engine::box_tree::AvailableSpace;
1338
1339 #[test]
1340 fn measure_returns_positive_size_for_non_empty_text() {
1341 let text = Text {
1342 content: "Hello World".into(),
1343 max_width: None,
1344 timing: Default::default(),
1345 style: CssStyle {
1346 font_size: Some(Length::Px(32.0)),
1347 ..Default::default()
1348 },
1349 timeline: Vec::new(),
1350 stagger: None,
1351 text_shadow: None,
1352 stroke: None,
1353 text_background: None,
1354 caret: None,
1355 states: Vec::new(),
1356 swap: None,
1357 morph: None,
1358 };
1359 let m = TextIntrinsic::from_text(&text);
1360 let (w, h) = m.measure(
1361 (None, None),
1362 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1363 );
1364 assert!(w > 0.0, "width should be > 0, got {}", w);
1365 assert!(
1366 h > 30.0,
1367 "height should be roughly font_size * line_height, got {}",
1368 h
1369 );
1370 }
1371
1372 #[test]
1373 fn wrapping_grows_height_when_max_width_constrained() {
1374 let text = Text {
1375 content: "the quick brown fox jumps over the lazy dog".into(),
1376 max_width: None,
1377 timing: Default::default(),
1378 style: CssStyle {
1379 font_size: Some(Length::Px(20.0)),
1380 ..Default::default()
1381 },
1382 timeline: Vec::new(),
1383 stagger: None,
1384 text_shadow: None,
1385 stroke: None,
1386 text_background: None,
1387 caret: None,
1388 states: Vec::new(),
1389 swap: None,
1390 morph: None,
1391 };
1392 let m = TextIntrinsic::from_text(&text);
1393 let (_w_unwrapped, h_unwrapped) = m.measure(
1394 (None, None),
1395 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1396 );
1397 let (_w_wrapped, h_wrapped) = m.measure(
1398 (None, None),
1399 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1400 );
1401 assert!(
1402 h_wrapped > h_unwrapped,
1403 "wrapped height ({}) should exceed unwrapped ({})",
1404 h_wrapped,
1405 h_unwrapped,
1406 );
1407 }
1408
1409 #[test]
1410 fn empty_text_has_zero_width_but_one_line_height() {
1411 let text = Text {
1412 content: "".into(),
1413 max_width: None,
1414 timing: Default::default(),
1415 style: CssStyle {
1416 font_size: Some(Length::Px(24.0)),
1417 ..Default::default()
1418 },
1419 timeline: Vec::new(),
1420 stagger: None,
1421 text_shadow: None,
1422 stroke: None,
1423 text_background: None,
1424 caret: None,
1425 states: Vec::new(),
1426 swap: None,
1427 morph: None,
1428 };
1429 let m = TextIntrinsic::from_text(&text);
1430 let (w, h) = m.measure(
1431 (None, None),
1432 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1433 );
1434 assert_eq!(w, 0.0);
1435 assert!(h > 0.0);
1436 }
1437
1438 fn nowrap_text(content: &str, white_space: Option<WhiteSpace>) -> Text {
1439 Text {
1440 content: content.into(),
1441 max_width: None,
1442 timing: Default::default(),
1443 style: CssStyle {
1444 font_size: Some(Length::Px(20.0)),
1445 white_space,
1446 ..Default::default()
1447 },
1448 timeline: Vec::new(),
1449 stagger: None,
1450 text_shadow: None,
1451 stroke: None,
1452 text_background: None,
1453 caret: None,
1454 states: Vec::new(),
1455 swap: None,
1456 morph: None,
1457 }
1458 }
1459
1460 #[test]
1461 fn nowrap_ignores_a_constrained_width_and_stays_one_line() {
1462 let text = nowrap_text(
1463 "the quick brown fox jumps over the lazy dog",
1464 Some(WhiteSpace::Nowrap),
1465 );
1466 let m = TextIntrinsic::from_text(&text);
1467 let (w_unconstrained, h_unconstrained) = m.measure(
1468 (None, None),
1469 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1470 );
1471 let (w_constrained, h_constrained) = m.measure(
1472 (None, None),
1473 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1474 );
1475 assert!(
1476 w_constrained > 80.0,
1477 "nowrap must ignore the 80px constraint, got width {}",
1478 w_constrained
1479 );
1480 assert_eq!(
1481 w_constrained, w_unconstrained,
1482 "nowrap width must equal the natural (unconstrained) width regardless of available space"
1483 );
1484 assert_eq!(
1485 h_constrained, h_unconstrained,
1486 "nowrap must always report a single line's height, constrained or not"
1487 );
1488 }
1489
1490 #[test]
1491 fn pre_disables_wrap_exactly_like_nowrap() {
1492 let text = nowrap_text("this string is too long to fit", Some(WhiteSpace::Pre));
1493 let m = TextIntrinsic::from_text(&text);
1494 let (w, _h) = m.measure(
1495 (None, None),
1496 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1497 );
1498 assert!(
1499 w > 80.0,
1500 "white-space: pre must also ignore the width constraint, got {}",
1501 w
1502 );
1503 }
1504
1505 #[test]
1506 fn normal_white_space_still_wraps_at_a_constrained_width() {
1507 let wrapped = nowrap_text(
1508 "the quick brown fox jumps over the lazy dog",
1509 Some(WhiteSpace::Normal),
1510 );
1511 let unset = nowrap_text("the quick brown fox jumps over the lazy dog", None);
1512 for text in [wrapped, unset] {
1513 let m = TextIntrinsic::from_text(&text);
1514 let (w, _h) = m.measure(
1515 (None, None),
1516 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1517 );
1518 assert!(
1519 w <= 80.0 + 0.5,
1520 "white-space: normal (or unset) must still wrap at an 80px constraint, got {}",
1521 w
1522 );
1523 }
1524 }
1525
1526 fn span(text: &str) -> RichTextSpan {
1527 RichTextSpan {
1528 text: text.into(),
1529 color: None,
1530 font_size: None,
1531 font_weight: None,
1532 font_family: None,
1533 font_style: None,
1534 letter_spacing: None,
1535 background: None,
1536 padding: None,
1537 border_radius: None,
1538 rotation: None,
1539 }
1540 }
1541
1542 #[test]
1543 fn rich_text_intrinsic_is_non_zero_without_explicit_size() {
1544 let spans = vec![span("Hello "), span("world")];
1545 let style = CssStyle {
1546 font_size: Some(Length::Px(32.0)),
1547 ..Default::default()
1548 };
1549 let intrinsic = RichTextIntrinsic {
1550 spans,
1551 style,
1552 max_width: None,
1553 };
1554 let (w, h) = intrinsic.measure(
1555 (None, None),
1556 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1557 );
1558 assert!(w > 0.0, "rich_text natural width must be > 0, got {}", w);
1559 assert!(h > 0.0, "rich_text natural height must be > 0, got {}", h);
1560 }
1561
1562 #[test]
1563 fn rich_text_intrinsic_wraps_a_single_long_span_internally() {
1564 let spans = vec![span(
1565 "the quick brown fox jumps over the lazy dog and keeps going",
1566 )];
1567 let style = CssStyle {
1568 font_size: Some(Length::Px(24.0)),
1569 ..Default::default()
1570 };
1571 let intrinsic = RichTextIntrinsic {
1572 spans,
1573 style,
1574 max_width: None,
1575 };
1576 let (_w_unconstrained, h_unconstrained) = intrinsic.measure(
1577 (None, None),
1578 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1579 );
1580 let (w_constrained, h_constrained) = intrinsic.measure(
1581 (None, None),
1582 (AvailableSpace::Definite(150.0), AvailableSpace::MaxContent),
1583 );
1584 assert!(
1585 w_constrained <= 150.0 + 0.5,
1586 "wrapped width must fit the 150px constraint, got {}",
1587 w_constrained
1588 );
1589 assert!(
1590 h_constrained > h_unconstrained,
1591 "constraining width must add lines (wrap within the single span): {} vs {}",
1592 h_constrained,
1593 h_unconstrained
1594 );
1595 }
1596
1597 #[test]
1598 fn rich_text_intrinsic_matches_compute_layout_used_by_the_painter() {
1599 let spans = vec![span("Total: "), span("42"), span(" items")];
1600 let style = CssStyle::default();
1601 let intrinsic = RichTextIntrinsic {
1602 spans: spans.clone(),
1603 style: style.clone(),
1604 max_width: None,
1605 };
1606 let (w, h) = intrinsic.measure(
1607 (None, None),
1608 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1609 );
1610 let layout = RichText::compute_layout(&spans, &style, 1920.0, 1080.0, None, -1.0);
1611 assert_eq!(w, layout.max_width);
1612 assert_eq!(h, layout.lines.len().max(1) as f32 * layout.line_height);
1613 }
1614
1615 #[test]
1616 fn gradient_text_intrinsic_ignores_constrained_width_when_nowrap() {
1617 let gt = GradientText {
1618 content: "the quick brown fox jumps over the lazy dog".into(),
1619 colors: vec!["#3B82F6".into(), "#8B5CF6".into()],
1620 stops: None,
1621 angle: 90.0,
1622 animate_angle: false,
1623 speed: 0.5,
1624 timing: Default::default(),
1625 style: CssStyle {
1626 font_size: Some(Length::Px(20.0)),
1627 white_space: Some(WhiteSpace::Nowrap),
1628 ..Default::default()
1629 },
1630 timeline: Vec::new(),
1631 stagger: None,
1632 };
1633 let m = GradientTextIntrinsic::from_gradient_text(>);
1634 let (w, h) = m.measure(
1635 (None, None),
1636 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1637 );
1638 assert!(
1639 w > 80.0,
1640 "nowrap gradient_text must ignore the 80px constraint, got {}",
1641 w
1642 );
1643 let (_, h_unconstrained) = m.measure(
1644 (None, None),
1645 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1646 );
1647 assert_eq!(h, h_unconstrained);
1648 }
1649
1650 #[test]
1651 fn gradient_text_intrinsic_wraps_by_default() {
1652 let gt = GradientText {
1653 content: "the quick brown fox jumps over the lazy dog".into(),
1654 colors: vec!["#3B82F6".into(), "#8B5CF6".into()],
1655 stops: None,
1656 angle: 90.0,
1657 animate_angle: false,
1658 speed: 0.5,
1659 timing: Default::default(),
1660 style: CssStyle {
1661 font_size: Some(Length::Px(20.0)),
1662 ..Default::default()
1663 },
1664 timeline: Vec::new(),
1665 stagger: None,
1666 };
1667 let m = GradientTextIntrinsic::from_gradient_text(>);
1668 let (_w, h_unconstrained) = m.measure(
1669 (None, None),
1670 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1671 );
1672 let (w_constrained, h_constrained) = m.measure(
1673 (None, None),
1674 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1675 );
1676 assert!(w_constrained <= 80.0 + 0.5);
1677 assert!(h_constrained > h_unconstrained);
1678 }
1679
1680 #[test]
1681 fn caption_intrinsic_ignores_constrained_width_when_nowrap() {
1682 let caption = Caption {
1683 words: "the quick brown fox jumps over the lazy dog"
1684 .split_whitespace()
1685 .map(|w| rustmotion_core::schema::CaptionWord {
1686 text: w.to_string(),
1687 start: 0.0,
1688 end: 10.0,
1689 })
1690 .collect(),
1691 active_color: "#FFFF00".into(),
1692 mode: Default::default(),
1693 max_width: Some(80.0),
1694 pill_color: None,
1695 style: CssStyle {
1696 font_size: Some(Length::Px(20.0)),
1697 white_space: Some(WhiteSpace::Nowrap),
1698 ..Default::default()
1699 },
1700 timing: Default::default(),
1701 timeline: Vec::new(),
1702 stagger: None,
1703 };
1704 let m = CaptionIntrinsic::from_caption(&caption);
1705 let (w, _h) = m.measure(
1706 (None, None),
1707 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1708 );
1709 assert!(
1710 w > 80.0,
1711 "nowrap caption intrinsic must ignore the 80px constraint, got {}",
1712 w
1713 );
1714 }
1715
1716 fn text_with_style(content: &str, style: CssStyle) -> Text {
1717 Text {
1718 content: content.into(),
1719 max_width: None,
1720 timing: Default::default(),
1721 style,
1722 timeline: Vec::new(),
1723 stagger: None,
1724 text_shadow: None,
1725 stroke: None,
1726 text_background: None,
1727 caret: None,
1728 states: Vec::new(),
1729 swap: None,
1730 morph: None,
1731 }
1732 }
1733
1734 #[test]
1735 fn line_height_percent_no_longer_collapses_the_box_to_zero_height() {
1736 use rustmotion_core::css::units::LengthPercentage;
1737 let text = text_with_style(
1738 "VISIBLE?",
1739 CssStyle {
1740 font_size: Some(Length::Px(60.0)),
1741 line_height: Some(LineHeight::Length(LengthPercentage::String("150%".into()))),
1742 ..Default::default()
1743 },
1744 );
1745 let m = TextIntrinsic::from_text(&text);
1746 let (_w, h) = m.measure(
1747 (None, None),
1748 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1749 );
1750 assert!(
1751 (h - 90.0).abs() < 0.5,
1752 "line-height: 150% of a 60px font-size must resolve to 90px (own font-size, per \
1753 CSS), got {h}"
1754 );
1755 }
1756
1757 #[test]
1758 fn line_height_em_no_longer_collapses_the_box_to_zero_height() {
1759 use rustmotion_core::css::units::LengthPercentage;
1760 let text = text_with_style(
1761 "VISIBLE?",
1762 CssStyle {
1763 font_size: Some(Length::Px(60.0)),
1764 line_height: Some(LineHeight::Length(LengthPercentage::String("1.5em".into()))),
1765 ..Default::default()
1766 },
1767 );
1768 let m = TextIntrinsic::from_text(&text);
1769 let (_w, h) = m.measure(
1770 (None, None),
1771 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1772 );
1773 assert!(
1774 (h - 90.0).abs() < 0.5,
1775 "line-height: 1.5em of a 60px font-size must resolve to 90px, got {h}"
1776 );
1777 let numeric = text_with_style(
1778 "VISIBLE?",
1779 CssStyle {
1780 font_size: Some(Length::Px(60.0)),
1781 line_height: Some(LineHeight::Number(1.5)),
1782 ..Default::default()
1783 },
1784 );
1785 let (_w, h_numeric) = TextIntrinsic::from_text(&numeric).measure(
1786 (None, None),
1787 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1788 );
1789 assert_eq!(h, h_numeric);
1790 }
1791
1792 #[test]
1793 fn letter_spacing_em_matches_the_equivalent_px_measurement() {
1794 let em_style = CssStyle {
1795 font_size: Some(Length::Px(200.0)),
1796 letter_spacing: Some(Length::String("1.2em".into())),
1797 white_space: Some(WhiteSpace::Nowrap),
1798 ..Default::default()
1799 };
1800 let px_style = CssStyle {
1801 font_size: Some(Length::Px(200.0)),
1802 letter_spacing: Some(Length::Px(240.0)),
1803 white_space: Some(WhiteSpace::Nowrap),
1804 ..Default::default()
1805 };
1806 let w_em = TextIntrinsic::from_text(&text_with_style("TRACKING", em_style))
1807 .measure(
1808 (None, None),
1809 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1810 )
1811 .0;
1812 let w_px = TextIntrinsic::from_text(&text_with_style("TRACKING", px_style))
1813 .measure(
1814 (None, None),
1815 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1816 )
1817 .0;
1818 assert!(
1819 (w_em - w_px).abs() < 1.0,
1820 "letter-spacing: 1.2em (font-size 200) must measure the same as the equivalent \
1821 240px value: em={w_em}, px={w_px}"
1822 );
1823 let w_zero_tracking = TextIntrinsic::from_text(&text_with_style(
1824 "TRACKING",
1825 CssStyle {
1826 font_size: Some(Length::Px(200.0)),
1827 white_space: Some(WhiteSpace::Nowrap),
1828 ..Default::default()
1829 },
1830 ))
1831 .measure(
1832 (None, None),
1833 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1834 )
1835 .0;
1836 assert!(
1837 w_em > w_zero_tracking + 100.0,
1838 "em tracking must measurably widen the line versus zero tracking: em={w_em}, \
1839 zero={w_zero_tracking}"
1840 );
1841 }
1842
1843 #[test]
1844 fn shrink_to_fit_is_a_noop_when_content_already_fits() {
1845 let calls = std::cell::RefCell::new(Vec::new());
1846 let size = shrink_to_fit(48.0, 12.0, Some(200.0), Some(100.0), |s| {
1847 calls.borrow_mut().push(s);
1848 (150.0, 80.0)
1849 });
1850 assert_eq!(size, 48.0);
1851 assert_eq!(
1852 *calls.borrow(),
1853 vec![48.0],
1854 "must measure only once (at the requested size) when it already fits"
1855 );
1856 }
1857
1858 #[test]
1859 fn shrink_to_fit_is_a_noop_when_nothing_to_fit_against() {
1860 let size = shrink_to_fit(48.0, 12.0, None, None, |_| (99999.0, 99999.0));
1861 assert_eq!(size, 48.0);
1862 }
1863
1864 #[test]
1865 fn shrink_to_fit_finds_a_size_that_fits_the_width_target() {
1866 let target = 100.0;
1867 let size = shrink_to_fit(120.0, 5.0, Some(target), None, |s| (s * 2.0, 10.0));
1868 assert!(size < 120.0, "must have shrunk, got {size}");
1869 assert!(size * 2.0 <= target + 0.5, "resolved size must fit: {size}");
1870 assert!(
1871 (size + 1.0) * 2.0 > target + 0.5,
1872 "resolved size should be close to the fitting boundary, got {size}"
1873 );
1874 }
1875
1876 #[test]
1877 fn shrink_to_fit_respects_both_axes_jointly() {
1878 let size = shrink_to_fit(100.0, 5.0, Some(1000.0), Some(20.0), |s| (s, s * 2.0));
1879 assert!(size * 2.0 <= 20.5, "must respect the height target: {size}");
1880 assert!(
1881 (size + 0.5) * 2.0 > 20.5,
1882 "should converge close to the height boundary, got {size}"
1883 );
1884 }
1885
1886 #[test]
1887 fn shrink_to_fit_never_returns_below_the_floor() {
1888 let size = shrink_to_fit(120.0, 20.0, Some(10.0), None, |s| (s * 5.0, 10.0));
1889 assert_eq!(size, 20.0, "must stop exactly at the floor, not lower");
1890 }
1891
1892 #[test]
1893 fn shrink_to_fit_is_deterministic_across_repeated_calls() {
1894 let run = || shrink_to_fit(90.0, 10.0, Some(137.0), Some(64.0), |s| (s * 1.7, s * 0.9));
1895 let a = run();
1896 let b = run();
1897 assert_eq!(a, b);
1898 }
1899
1900 fn inter_typeface() -> Typeface {
1901 typeface_with_fallback("Inter", SkFontStyle::normal()).expect("Inter resolves in tests")
1902 }
1903
1904 #[test]
1905 fn resolve_text_autofit_shrinks_to_fit_a_width_target() {
1906 let typeface = inter_typeface();
1907 let content = "A very long headline that will not fit in this box";
1908 let requested = 80.0;
1909 let box_width = 300.0;
1910 let (fs, ls, lh) = resolve_text_autofit(
1911 content,
1912 &typeface,
1913 requested,
1914 0.0,
1915 requested * 1.3,
1916 false,
1917 Some(box_width),
1918 None,
1919 );
1920 assert!(fs < requested, "must shrink, got {fs}");
1921 assert!(
1922 fs >= TEXT_AUTOFIT_MIN_FONT_PX - 0.01,
1923 "must not shrink past the calibrated floor, got {fs}"
1924 );
1925 let (w, _) = wrap_and_measure(content, &typeface, fs, None, ls, lh);
1926 assert!(
1927 w <= box_width + 0.5,
1928 "resolved size must actually fit: w={w}, target={box_width}"
1929 );
1930 }
1931
1932 #[test]
1933 fn resolve_text_autofit_is_a_noop_when_it_already_fits() {
1934 let typeface = inter_typeface();
1935 let (fs, ls, lh) = resolve_text_autofit(
1936 "hi",
1937 &typeface,
1938 24.0,
1939 1.0,
1940 30.0,
1941 true,
1942 Some(1000.0),
1943 Some(1000.0),
1944 );
1945 assert_eq!(fs, 24.0);
1946 assert_eq!(ls, 1.0);
1947 assert_eq!(lh, 30.0);
1948 }
1949
1950 #[test]
1951 fn resolve_text_autofit_never_goes_below_the_calibrated_floor() {
1952 let typeface = inter_typeface();
1953 let (fs, _, _) = resolve_text_autofit(
1954 "This sentence is far too long for a ten pixel wide box",
1955 &typeface,
1956 80.0,
1957 0.0,
1958 104.0,
1959 true,
1960 Some(10.0),
1961 Some(10.0),
1962 );
1963 assert!(
1964 (fs - TEXT_AUTOFIT_MIN_FONT_PX).abs() < 0.01,
1965 "expected exactly the floor ({TEXT_AUTOFIT_MIN_FONT_PX}), got {fs}"
1966 );
1967 }
1968
1969 #[test]
1970 fn resolve_text_autofit_rescales_letter_spacing_and_line_height_proportionally() {
1971 let typeface = inter_typeface();
1972 let (fs, ls, lh) = resolve_text_autofit(
1973 "SHRINK ME PLEASE, THIS LINE IS QUITE LONG",
1974 &typeface,
1975 100.0,
1976 5.0,
1977 130.0,
1978 false,
1979 Some(150.0),
1980 None,
1981 );
1982 assert!(fs < 100.0, "sanity: must have shrunk, got {fs}");
1983 let ratio = fs / 100.0;
1984 assert!((ls - 5.0 * ratio).abs() < 1e-3);
1985 assert!((lh - 130.0 * ratio).abs() < 1e-3);
1986 }
1987
1988 fn autofit_text(content: &str, font_size: f32) -> Text {
1989 Text {
1990 content: content.into(),
1991 max_width: None,
1992 timing: Default::default(),
1993 style: CssStyle {
1994 font_size: Some(Length::Px(font_size)),
1995 text_autofit: Some(true),
1996 white_space: Some(WhiteSpace::Nowrap),
1997 ..Default::default()
1998 },
1999 timeline: Vec::new(),
2000 stagger: None,
2001 text_shadow: None,
2002 stroke: None,
2003 text_background: None,
2004 caret: None,
2005 states: Vec::new(),
2006 swap: None,
2007 morph: None,
2008 }
2009 }
2010
2011 #[test]
2012 fn text_intrinsic_shrinks_when_autofit_is_on_and_the_box_is_too_narrow() {
2013 let text = autofit_text("the quick brown fox jumps over the lazy dog", 60.0);
2014 let m = TextIntrinsic::from_text(&text);
2015 let (w_unconstrained, _) = m.measure(
2016 (None, None),
2017 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
2018 );
2019 let target = w_unconstrained / 2.0;
2020 let (w_constrained, _) = m.measure(
2021 (None, None),
2022 (AvailableSpace::Definite(target), AvailableSpace::MaxContent),
2023 );
2024 assert!(
2025 w_constrained <= target + 0.5,
2026 "autofit must shrink the nowrap line to fit {target}px, got {w_constrained}"
2027 );
2028 assert!(
2029 w_constrained < w_unconstrained,
2030 "must have actually shrunk from the natural width ({w_unconstrained}), got {w_constrained}"
2031 );
2032 }
2033
2034 #[test]
2035 fn text_intrinsic_ignores_autofit_target_when_the_flag_is_off() {
2036 let mut text = autofit_text("the quick brown fox jumps over the lazy dog", 60.0);
2037 text.style.text_autofit = None;
2038 let m = TextIntrinsic::from_text(&text);
2039 let (w, _) = m.measure(
2040 (None, None),
2041 (AvailableSpace::Definite(200.0), AvailableSpace::MaxContent),
2042 );
2043 assert!(
2044 w > 200.0,
2045 "without text-autofit, nowrap must still bleed past the box exactly as before, got {w}"
2046 );
2047 }
2048
2049 #[test]
2050 fn text_intrinsic_autofit_still_overflows_when_even_the_floor_does_not_fit() {
2051 let text = autofit_text(
2052 "This is an extremely long sentence that will not fit no matter how much the font shrinks",
2053 80.0,
2054 );
2055 let m = TextIntrinsic::from_text(&text);
2056 let (w, _) = m.measure(
2057 (None, None),
2058 (AvailableSpace::Definite(5.0), AvailableSpace::MaxContent),
2059 );
2060 assert!(
2061 w > 5.0,
2062 "must not silently report a fit that never actually happened, got {w}"
2063 );
2064 }
2065
2066 #[test]
2067 fn caption_intrinsic_never_autofits_even_if_style_declares_it() {
2068 let caption = Caption {
2069 words: "the quick brown fox jumps over the lazy dog"
2070 .split_whitespace()
2071 .map(|w| rustmotion_core::schema::CaptionWord {
2072 text: w.to_string(),
2073 start: 0.0,
2074 end: 10.0,
2075 })
2076 .collect(),
2077 active_color: "#FFFF00".into(),
2078 mode: Default::default(),
2079 max_width: None,
2080 pill_color: None,
2081 style: CssStyle {
2082 font_size: Some(Length::Px(60.0)),
2083 text_autofit: Some(true),
2084 white_space: Some(WhiteSpace::Nowrap),
2085 ..Default::default()
2086 },
2087 timing: Default::default(),
2088 timeline: Vec::new(),
2089 stagger: None,
2090 };
2091 let m = CaptionIntrinsic::from_caption(&caption);
2092 let (w_unconstrained, _) = m.measure(
2093 (None, None),
2094 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
2095 );
2096 let (w_constrained, _) = m.measure(
2097 (None, None),
2098 (AvailableSpace::Definite(200.0), AvailableSpace::MaxContent),
2099 );
2100 assert_eq!(
2101 w_constrained, w_unconstrained,
2102 "caption must ignore text-autofit entirely (nowrap bleeds exactly as before)"
2103 );
2104 }
2105
2106 fn plain_text(content: &str, font_size: f32) -> Text {
2107 Text {
2108 content: content.into(),
2109 max_width: None,
2110 timing: Default::default(),
2111 style: CssStyle {
2112 font_size: Some(Length::Px(font_size)),
2113 ..Default::default()
2114 },
2115 timeline: Vec::new(),
2116 stagger: None,
2117 text_shadow: None,
2118 stroke: None,
2119 text_background: None,
2120 caret: None,
2121 states: Vec::new(),
2122 swap: None,
2123 morph: None,
2124 }
2125 }
2126
2127 #[test]
2128 fn text_metrics_reports_glyph_count_matching_content_for_one_line() {
2129 let text = plain_text("Sentence", 32.0);
2130 let metrics = TextIntrinsic::from_text(&text)
2131 .text_metrics(500.0)
2132 .expect("host must have a fallback typeface");
2133 assert_eq!(metrics.glyphs.len(), "Sentence".chars().count());
2134 assert!(metrics.text_width > 0.0);
2135 assert!(metrics.cap_height > 0.0);
2136 assert!(metrics.ascender > 0.0);
2137 }
2138
2139 #[test]
2140 fn text_metrics_last_glyph_sits_before_where_a_detached_char_would_go() {
2141 let text = plain_text("Sentence", 32.0);
2142 let metrics = TextIntrinsic::from_text(&text).text_metrics(500.0).unwrap();
2143 let last = *metrics.glyphs.last().unwrap();
2144 let detached_question_mark_x = last.x + last.width;
2145 assert!(detached_question_mark_x > last.x);
2146 assert!(detached_question_mark_x <= metrics.text_width + 0.5);
2147 }
2148
2149 #[test]
2150 fn text_metrics_width_matches_measure_when_unwrapped() {
2151 let text = plain_text("no wrap needed", 24.0);
2152 let intrinsic = TextIntrinsic::from_text(&text);
2153 let (measured_w, _measured_h) = intrinsic.measure(
2154 (None, None),
2155 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
2156 );
2157 let metrics = intrinsic.text_metrics(10_000.0).unwrap();
2158 assert!(
2159 (metrics.text_width - measured_w).abs() < 0.5,
2160 "text_metrics width {} should match measure() width {}",
2161 metrics.text_width,
2162 measured_w
2163 );
2164 }
2165
2166 #[test]
2167 fn text_metrics_centers_glyphs_when_text_align_is_center() {
2168 let mut text = plain_text("Hi", 32.0);
2169 text.style.text_align = Some(rustmotion_core::css::style::TextAlign::Center);
2170 let intrinsic = TextIntrinsic::from_text(&text);
2171 let centered = intrinsic.text_metrics(400.0).unwrap();
2172 let left = plain_text("Hi", 32.0);
2173 let left_metrics = TextIntrinsic::from_text(&left).text_metrics(400.0).unwrap();
2174 assert!(
2175 centered.glyphs[0].x > left_metrics.glyphs[0].x,
2176 "centered first glyph ({}) should start further right than left-aligned ({})",
2177 centered.glyphs[0].x,
2178 left_metrics.glyphs[0].x
2179 );
2180 }
2181
2182 #[test]
2183 fn component_text_metrics_downcasts_text() {
2184 let text = plain_text("Hello", 28.0);
2185 let provider = ComponentTextMetrics;
2186 let payload: &(dyn std::any::Any + Send + Sync) = &text;
2187 let metrics = provider
2188 .text_metrics(payload, 500.0)
2189 .expect("Text must resolve through ComponentTextMetrics");
2190 assert_eq!(metrics.glyphs.len(), "Hello".chars().count());
2191 }
2192
2193 #[test]
2194 fn component_text_metrics_downcasts_gradient_text() {
2195 let gt = GradientText {
2196 content: "Gradient".into(),
2197 colors: vec!["#3B82F6".into(), "#8B5CF6".into()],
2198 stops: None,
2199 angle: 90.0,
2200 animate_angle: false,
2201 speed: 0.5,
2202 timing: Default::default(),
2203 style: CssStyle {
2204 font_size: Some(Length::Px(28.0)),
2205 ..Default::default()
2206 },
2207 timeline: Vec::new(),
2208 stagger: None,
2209 };
2210 let provider = ComponentTextMetrics;
2211 let payload: &(dyn std::any::Any + Send + Sync) = >
2212 let metrics = provider
2213 .text_metrics(payload, 500.0)
2214 .expect("GradientText must resolve through ComponentTextMetrics");
2215 assert_eq!(metrics.glyphs.len(), "Gradient".chars().count());
2216 }
2217
2218 #[test]
2219 fn component_text_metrics_returns_none_for_a_non_text_component() {
2220 let payload: &(dyn std::any::Any + Send + Sync) = &42i32;
2221 assert!(ComponentTextMetrics.text_metrics(payload, 500.0).is_none());
2222 }
2223}