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