1use skia_safe::{Font, FontStyle as SkFontStyle, Typeface};
9
10use rustmotion_core::css::style::{
11 CssStyle, FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, LineHeight,
12 WhiteSpace, TEXT_AUTOFIT_MIN_FONT_PX,
13};
14use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
15use rustmotion_core::engine::renderer::{
16 emoji_typeface, format_counter_value, measure_text_with_fallback, typeface_with_fallback,
17 wrap_text_with_tracking,
18};
19
20use crate::badge::{Badge, BadgeSize};
21use crate::caption::Caption;
22use crate::counter::Counter;
23use crate::gradient_text::GradientText;
24use crate::kbd::Kbd;
25use crate::text::Text;
26
27use rustmotion_core::css::units::LengthContext;
48
49pub fn font_size_ctx(viewport_width: f32, viewport_height: f32, parent_size: f32) -> LengthContext {
66 LengthContext {
67 viewport_width,
68 viewport_height,
69 parent_size,
70 font_size: 16.0,
71 root_font_size: 16.0,
72 }
73}
74
75pub fn measure_time_font_size_ctx(parent_size: f32) -> LengthContext {
88 font_size_ctx(1920.0, 1080.0, parent_size)
89}
90
91pub struct TextIntrinsic {
98 content: String,
99 font_family: Option<String>,
100 font_size: f32,
101 line_height_resolved: f32,
102 weight: u16,
103 italic: bool,
104 letter_spacing: f32,
105 max_width: Option<f32>,
106 wrap: bool,
107 text_autofit: bool,
114}
115
116impl TextIntrinsic {
117 pub fn from_text(text: &Text) -> Self {
125 let wrap = !matches!(
126 text.style.white_space,
127 Some(WhiteSpace::Nowrap | WhiteSpace::Pre)
128 );
129 let widest = text
135 .all_labels()
136 .max_by_key(|label| label.chars().count())
137 .unwrap_or(&text.content);
138 Self::from_parts_with_wrap(widest, &text.style, text.max_width, wrap)
139 .with_autofit(matches!(text.style.text_autofit, Some(true)))
140 }
141
142 pub fn with_autofit(mut self, on: bool) -> Self {
155 self.text_autofit = on;
156 self
157 }
158
159 pub fn from_parts(content: &str, style: &CssStyle, max_width: Option<f32>) -> Self {
164 let base_ctx = measure_time_font_size_ctx(0.0);
188 let (font_size, letter_spacing, line_height_resolved) =
189 style.typography_px_ctx(&base_ctx, 48.0);
190 Self {
191 content: content.to_string(),
192 font_family: style.font_family.clone(),
193 font_size,
194 line_height_resolved,
195 weight: weight_to_u16(style.font_weight.as_ref()),
196 italic: matches!(style.font_style, Some(CssFontStyle::Italic)),
197 letter_spacing,
198 max_width,
199 wrap: true,
200 text_autofit: false,
201 }
202 }
203
204 pub fn from_parts_with_wrap(
207 content: &str,
208 style: &CssStyle,
209 max_width: Option<f32>,
210 wrap: bool,
211 ) -> Self {
212 let mut t = Self::from_parts(content, style, max_width);
213 t.wrap = wrap;
214 t
215 }
216}
217
218impl IntrinsicMeasure for TextIntrinsic {
219 fn measure(
220 &self,
221 known: (Option<f32>, Option<f32>),
222 available: (AvailableSpace, AvailableSpace),
223 ) -> (f32, f32) {
224 let max_width = if let Some(w) = known.0 {
225 Some(w)
226 } else {
227 let avail_w = match available.0 {
228 AvailableSpace::Definite(w) => Some(w),
229 AvailableSpace::MaxContent => None,
230 AvailableSpace::MinContent => Some(0.0),
231 };
232 match (self.max_width, avail_w) {
233 (Some(a), Some(b)) => Some(a.min(b)),
234 (Some(a), None) => Some(a),
235 (None, Some(b)) => Some(b),
236 (None, None) => None,
237 }
238 };
239
240 let Some(typeface) = self.typeface() else {
241 return (0.0, 0.0);
242 };
243 let wrap_at = if self.wrap { max_width } else { None };
244 let (base_w, base_h) = wrap_and_measure(
245 &self.content,
246 &typeface,
247 self.font_size,
248 wrap_at,
249 self.letter_spacing,
250 self.line_height_resolved,
251 );
252
253 if !self.text_autofit {
254 return (base_w, base_h);
255 }
256
257 let target_height = match known.1 {
267 Some(h) => Some(h),
268 None => match available.1 {
269 AvailableSpace::Definite(h) => Some(h),
270 AvailableSpace::MaxContent => None,
271 AvailableSpace::MinContent => Some(0.0),
272 },
273 };
274
275 let (final_size, final_ls, final_lh) = resolve_text_autofit(
276 &self.content,
277 &typeface,
278 self.font_size,
279 self.letter_spacing,
280 self.line_height_resolved,
281 self.wrap,
282 max_width,
283 target_height,
284 );
285
286 if final_size >= self.font_size {
287 return (base_w, base_h);
288 }
289 let wrap_at = if self.wrap { max_width } else { None };
290 wrap_and_measure(
291 &self.content,
292 &typeface,
293 final_size,
294 wrap_at,
295 final_ls,
296 final_lh,
297 )
298 }
299}
300
301impl TextIntrinsic {
302 fn sk_font_style(&self) -> SkFontStyle {
303 let slant = if self.italic {
304 skia_safe::font_style::Slant::Italic
305 } else {
306 skia_safe::font_style::Slant::Upright
307 };
308 let weight = skia_safe::font_style::Weight::from(self.weight as i32);
309 SkFontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant)
310 }
311
312 fn typeface(&self) -> Option<Typeface> {
313 let family = self.font_family.as_deref().unwrap_or("Inter");
314 typeface_with_fallback(family, self.sk_font_style()).ok()
315 }
316}
317
318fn wrap_and_measure(
325 content: &str,
326 typeface: &Typeface,
327 font_size: f32,
328 wrap_at: Option<f32>,
329 letter_spacing: f32,
330 line_height: f32,
331) -> (f32, f32) {
332 let font = Font::from_typeface(typeface.clone(), font_size);
333 let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
334 let lines = wrap_text_with_tracking(content, &font, &emoji_font, wrap_at, letter_spacing);
339 let mut max_w = 0.0f32;
340 for line in &lines {
341 max_w = max_w.max(measure_text_with_fallback(
342 line,
343 &font,
344 &emoji_font,
345 letter_spacing,
346 ));
347 }
348 let line_count = lines.len().max(1) as f32;
349 (max_w, line_count * line_height)
350}
351
352#[allow(clippy::too_many_arguments)]
387pub fn resolve_text_autofit(
388 content: &str,
389 typeface: &Typeface,
390 requested_font_size: f32,
391 requested_letter_spacing: f32,
392 requested_line_height: f32,
393 wrap: bool,
394 box_width: Option<f32>,
395 declared_height: Option<f32>,
396) -> (f32, f32, f32) {
397 if requested_font_size <= 0.0 || (box_width.is_none() && declared_height.is_none()) {
398 return (
399 requested_font_size,
400 requested_letter_spacing,
401 requested_line_height,
402 );
403 }
404 let wrap_at = if wrap { box_width } else { None };
405 let measure_at = |size: f32| -> (f32, f32) {
406 let ratio = size / requested_font_size;
407 wrap_and_measure(
408 content,
409 typeface,
410 size,
411 wrap_at,
412 requested_letter_spacing * ratio,
413 requested_line_height * ratio,
414 )
415 };
416 let floor = TEXT_AUTOFIT_MIN_FONT_PX.min(requested_font_size);
417 let final_size = shrink_to_fit(
418 requested_font_size,
419 floor,
420 box_width,
421 declared_height,
422 measure_at,
423 );
424 if final_size >= requested_font_size {
425 (
426 requested_font_size,
427 requested_letter_spacing,
428 requested_line_height,
429 )
430 } else {
431 let ratio = final_size / requested_font_size;
432 (
433 final_size,
434 requested_letter_spacing * ratio,
435 requested_line_height * ratio,
436 )
437 }
438}
439
440fn shrink_to_fit(
456 requested_font_size: f32,
457 floor_px: f32,
458 target_width: Option<f32>,
459 target_height: Option<f32>,
460 mut measure_at: impl FnMut(f32) -> (f32, f32),
461) -> f32 {
462 let eps = 0.5;
463 let fits = |w: f32, h: f32| {
464 target_width.is_none_or(|tw| w <= tw + eps) && target_height.is_none_or(|th| h <= th + eps)
465 };
466
467 let (w0, h0) = measure_at(requested_font_size);
468 if fits(w0, h0) {
469 return requested_font_size;
470 }
471
472 let floor_px = floor_px.min(requested_font_size).max(0.1);
473 if floor_px >= requested_font_size {
474 return requested_font_size;
475 }
476
477 let (mut lo, mut hi) = (floor_px, requested_font_size);
478 let (w_floor, h_floor) = measure_at(lo);
479 if !fits(w_floor, h_floor) {
480 return lo;
483 }
484 for _ in 0..16 {
485 let mid = (lo + hi) / 2.0;
486 let (w, h) = measure_at(mid);
487 if fits(w, h) {
488 lo = mid;
489 } else {
490 hi = mid;
491 }
492 }
493 lo
494}
495
496fn weight_to_u16(w: Option<&CssFontWeight>) -> u16 {
497 match w {
498 Some(CssFontWeight::Keyword(FontWeightKw::Bold)) => 700,
499 Some(CssFontWeight::Keyword(FontWeightKw::Bolder)) => 800,
500 Some(CssFontWeight::Keyword(FontWeightKw::Lighter)) => 300,
501 Some(CssFontWeight::Keyword(FontWeightKw::Normal)) | None => 400,
502 Some(CssFontWeight::Number(n)) => (*n).clamp(1, 1000),
503 }
504}
505
506pub struct GradientTextIntrinsic(TextIntrinsic);
510
511impl GradientTextIntrinsic {
512 pub fn from_gradient_text(t: &GradientText) -> Self {
513 use rustmotion_core::css::style::Size as CSize;
515 use rustmotion_core::css::units::LengthPercentage;
516 let max_width = match &t.style.width {
517 Some(CSize::Length(LengthPercentage::Px(v))) => Some(*v),
518 _ => None,
519 };
520 let wrap = !matches!(
524 t.style.white_space,
525 Some(WhiteSpace::Nowrap | WhiteSpace::Pre)
526 );
527 Self(
528 TextIntrinsic::from_parts_with_wrap(&t.content, &t.style, max_width, wrap)
529 .with_autofit(matches!(t.style.text_autofit, Some(true))),
530 )
531 }
532}
533
534impl IntrinsicMeasure for GradientTextIntrinsic {
535 fn measure(
536 &self,
537 known: (Option<f32>, Option<f32>),
538 available: (AvailableSpace, AvailableSpace),
539 ) -> (f32, f32) {
540 self.0.measure(known, available)
541 }
542}
543
544pub struct CaptionIntrinsic(TextIntrinsic);
547
548impl CaptionIntrinsic {
549 pub fn from_caption(c: &Caption) -> Self {
550 let joined = c
551 .words
552 .iter()
553 .map(|w| w.text.as_str())
554 .collect::<Vec<_>>()
555 .join(" ");
556 let wrap = !matches!(
563 c.style.white_space,
564 Some(WhiteSpace::Nowrap | WhiteSpace::Pre)
565 );
566 Self(TextIntrinsic::from_parts_with_wrap(
567 &joined,
568 &c.style,
569 c.max_width,
570 wrap,
571 ))
572 }
573}
574
575impl IntrinsicMeasure for CaptionIntrinsic {
576 fn measure(
577 &self,
578 known: (Option<f32>, Option<f32>),
579 available: (AvailableSpace, AvailableSpace),
580 ) -> (f32, f32) {
581 self.0.measure(known, available)
582 }
583}
584
585pub struct KbdIntrinsic {
589 text: TextIntrinsic,
590 h_padding: f32,
591 v_padding: f32,
592 min_width: f32,
593}
594
595impl KbdIntrinsic {
596 pub fn from_kbd(k: &Kbd) -> Self {
597 let fs = k
598 .style
599 .font_size_px_ctx(&measure_time_font_size_ctx(0.0), k.font_size);
600 let synthetic_style = synthesize_text_style(&k.style, fs, "SF Mono");
601 Self {
602 text: TextIntrinsic::from_parts_with_wrap(&k.key, &synthetic_style, None, false),
603 h_padding: fs * 0.7,
604 v_padding: fs * 0.4,
605 min_width: fs * 1.8,
606 }
607 }
608}
609
610impl IntrinsicMeasure for KbdIntrinsic {
611 fn measure(
612 &self,
613 known: (Option<f32>, Option<f32>),
614 available: (AvailableSpace, AvailableSpace),
615 ) -> (f32, f32) {
616 let (tw, th) = self.text.measure(known, available);
617 let w = (tw + self.h_padding * 2.0).max(self.min_width);
618 let h = th + self.v_padding * 2.0;
619 (w, h)
620 }
621}
622
623pub struct CounterIntrinsic(TextIntrinsic);
626
627impl CounterIntrinsic {
628 pub fn from_counter(c: &Counter) -> Self {
629 let absmax = c.from.abs().max(c.to.abs());
630 let signed = if c.from < 0.0 || c.to < 0.0 {
631 -absmax
632 } else {
633 absmax
634 };
635 let display = format_counter_value(signed, c.decimals, &c.separator, &c.prefix, &c.suffix);
636 Self(TextIntrinsic::from_parts_with_wrap(
638 &display, &c.style, None, false,
639 ))
640 }
641}
642
643impl IntrinsicMeasure for CounterIntrinsic {
644 fn measure(
645 &self,
646 known: (Option<f32>, Option<f32>),
647 available: (AvailableSpace, AvailableSpace),
648 ) -> (f32, f32) {
649 self.0.measure(known, available)
650 }
651}
652
653pub struct NumberWheelIntrinsic(TextIntrinsic);
662
663impl NumberWheelIntrinsic {
664 pub fn from_number_wheel(w: &crate::number_wheel::NumberWheel) -> Self {
665 let widest = (0..10)
666 .map(|d| {
667 let ch = char::from_digit(d, 10).expect("0..10 is a digit");
668 w.value
669 .chars()
670 .map(|c| if c.is_ascii_digit() { ch } else { c })
671 .collect::<String>()
672 })
673 .max_by(|a, b| {
674 let measure = |s: &str| {
675 TextIntrinsic::from_parts_with_wrap(s, &w.style, None, false)
676 .measure(
677 (None, None),
678 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
679 )
680 .0
681 };
682 measure(a)
683 .partial_cmp(&measure(b))
684 .unwrap_or(std::cmp::Ordering::Equal)
685 })
686 .unwrap_or_else(|| w.value.clone());
687 Self(TextIntrinsic::from_parts_with_wrap(
689 &widest, &w.style, None, false,
690 ))
691 }
692}
693
694impl IntrinsicMeasure for NumberWheelIntrinsic {
695 fn measure(
696 &self,
697 known: (Option<f32>, Option<f32>),
698 available: (AvailableSpace, AvailableSpace),
699 ) -> (f32, f32) {
700 self.0.measure(known, available)
701 }
702}
703
704pub struct BadgeIntrinsic {
707 text: TextIntrinsic,
708 h_padding: f32,
709 v_padding: f32,
710 icon_extra: f32,
711 font_size: f32,
712}
713
714impl BadgeIntrinsic {
715 pub fn from_badge(b: &Badge) -> Self {
716 let (default_fs, h_pad, v_pad, icon_size) = badge_size_params(&b.badge_size);
717 let font_size = b
718 .style
719 .font_size_px_ctx(&measure_time_font_size_ctx(0.0), default_fs);
720 let ratio = font_size / default_fs;
721 let h_padding = h_pad * ratio;
722 let v_padding = v_pad * ratio;
723 let icon_extra = if b.icon.is_some() {
724 icon_size * ratio + 6.0 * ratio
725 } else {
726 0.0
727 };
728
729 let synthetic_style = synthesize_text_style(&b.style, font_size, "Inter");
730
731 Self {
732 text: TextIntrinsic::from_parts_with_wrap(&b.text, &synthetic_style, None, false),
733 h_padding,
734 v_padding,
735 icon_extra,
736 font_size,
737 }
738 }
739}
740
741impl IntrinsicMeasure for BadgeIntrinsic {
742 fn measure(
743 &self,
744 known: (Option<f32>, Option<f32>),
745 available: (AvailableSpace, AvailableSpace),
746 ) -> (f32, f32) {
747 let (tw, _th) = self.text.measure(known, available);
748 let w = self.h_padding * 2.0 + tw + self.icon_extra;
749 let h = self.v_padding * 2.0 + self.font_size * 1.3;
750 (w, h)
751 }
752}
753
754fn badge_size_params(s: &BadgeSize) -> (f32, f32, f32, f32) {
755 match s {
757 BadgeSize::Sm => (12.0, 8.0, 4.0, 14.0),
758 BadgeSize::Md => (14.0, 12.0, 6.0, 18.0),
759 BadgeSize::Lg => (18.0, 16.0, 8.0, 22.0),
760 }
761}
762
763fn synthesize_text_style(src: &CssStyle, font_size: f32, default_family: &str) -> CssStyle {
766 use rustmotion_core::css::Length;
767 let family = src
768 .font_family
769 .clone()
770 .unwrap_or_else(|| default_family.to_string());
771 CssStyle {
772 font_size: Some(Length::Px(font_size)),
773 font_family: Some(family),
774 font_weight: src.font_weight.clone(),
775 font_style: src.font_style,
776 letter_spacing: src.letter_spacing.clone(),
777 line_height: src.line_height.clone(),
778 ..CssStyle::default()
779 }
780}
781
782#[allow(dead_code)]
785fn _line_height_unused(_: Option<&LineHeight>) {}
786
787use crate::terminal::{
792 resolve_typeface as resolve_terminal_typeface, Terminal, CHROME_HEIGHT,
793 FONT_SIZE as TERM_FONT_SIZE, LINE_HEIGHT as TERM_LINE_HEIGHT, PADDING as TERM_PADDING,
794};
795
796pub struct TerminalIntrinsic {
806 line_height: f32,
807 n_lines: usize,
808 chrome_height: f32,
809 padding: f32,
810 max_line_width: f32,
812}
813
814impl TerminalIntrinsic {
815 pub fn from_terminal(t: &Terminal) -> Self {
816 let font_size = t
817 .style
818 .font_size_px_ctx(&measure_time_font_size_ctx(0.0), TERM_FONT_SIZE);
819 let line_height = (font_size * TERM_LINE_HEIGHT / TERM_FONT_SIZE).ceil();
820 let chrome_height = if t.show_chrome { CHROME_HEIGHT } else { 0.0 };
821
822 let max_line_width = Self::measure_max_width(t, font_size);
824
825 Self {
826 line_height,
827 n_lines: t.lines.len(),
828 chrome_height,
829 padding: TERM_PADDING,
830 max_line_width,
831 }
832 }
833
834 fn measure_max_width(t: &Terminal, font_size: f32) -> f32 {
835 let Some(typeface) = resolve_terminal_typeface(&t.style) else {
839 return 0.0;
842 };
843 let font = Font::from_typeface(typeface, font_size);
844 let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
845
846 t.lines
847 .iter()
848 .map(|line| {
849 let prefix = match line.line_type {
850 crate::terminal::TerminalLineType::Prompt => "$ ",
851 _ => "",
852 };
853 let full = format!("{}{}", prefix, line.text);
854 measure_text_with_fallback(&full, &font, &emoji_font, 0.0)
855 })
856 .fold(0.0f32, f32::max)
857 }
858}
859
860impl IntrinsicMeasure for TerminalIntrinsic {
861 fn measure(
862 &self,
863 known: (Option<f32>, Option<f32>),
864 _available: (AvailableSpace, AvailableSpace),
865 ) -> (f32, f32) {
866 let w = known.0.unwrap_or(self.max_line_width + self.padding * 2.0);
867 let h = known.1.unwrap_or(
868 self.chrome_height + self.padding * 2.0 + self.n_lines as f32 * self.line_height,
869 );
870 (w, h)
871 }
872}
873
874use crate::table::{
879 Table, DEFAULT_CELL_PADDING, DEFAULT_FONT_SIZE as TABLE_FONT_SIZE, DEFAULT_ROW_HEIGHT_RATIO,
880};
881
882pub struct TableIntrinsic {
890 row_height: f32,
891 row_count: usize, total_width: f32,
893}
894
895impl TableIntrinsic {
896 pub fn from_table(t: &Table) -> Self {
897 let font_size = t
898 .style
899 .font_size_px_ctx(&measure_time_font_size_ctx(0.0), TABLE_FONT_SIZE);
900 let row_height = font_size * DEFAULT_ROW_HEIGHT_RATIO;
901
902 let total_width = Self::compute_width(t, font_size);
903
904 Self {
905 row_height,
906 row_count: t.rows.len(),
907 total_width,
908 }
909 }
910
911 fn compute_width(t: &Table, font_size: f32) -> f32 {
912 if let Some(widths) = &t.column_widths {
914 if !widths.is_empty() {
915 return widths.iter().sum();
916 }
917 }
918
919 let font_style = skia_safe::FontStyle::bold();
921 let family = t.style.font_family.as_deref().unwrap_or("Inter");
922 let Ok(typeface) = typeface_with_fallback(family, font_style) else {
923 let col_count = t.headers.len().max(1) as f32;
925 return col_count * (TABLE_FONT_SIZE * 8.0 + DEFAULT_CELL_PADDING * 2.0);
926 };
927 let font = Font::from_typeface(typeface, font_size);
928 let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
929 let cell_padding = t.cell_padding;
930
931 let col_count = t.headers.len().max(1);
933 let mut col_widths: Vec<f32> = vec![0.0; col_count];
934
935 for (i, header) in t.headers.iter().enumerate() {
936 let w = measure_text_with_fallback(header, &font, &emoji_font, 0.0);
937 col_widths[i] = col_widths[i].max(w + cell_padding * 2.0);
938 }
939 for row in &t.rows {
940 for (i, cell) in row.iter().enumerate() {
941 if i >= col_count {
942 break;
943 }
944 let w = measure_text_with_fallback(cell, &font, &emoji_font, 0.0);
945 col_widths[i] = col_widths[i].max(w + cell_padding * 2.0);
946 }
947 }
948
949 col_widths.iter().sum()
950 }
951}
952
953impl IntrinsicMeasure for TableIntrinsic {
954 fn measure(
955 &self,
956 known: (Option<f32>, Option<f32>),
957 _available: (AvailableSpace, AvailableSpace),
958 ) -> (f32, f32) {
959 let w = known.0.unwrap_or(self.total_width);
960 let h = known
961 .1
962 .unwrap_or((1 + self.row_count) as f32 * self.row_height);
963 (w, h)
964 }
965}
966
967use crate::codeblock::dimensions::compute_code_dimensions;
972use crate::codeblock::highlight::resolve_monospace_font;
973use crate::codeblock::Codeblock;
974use rustmotion_core::css::style::{FontWeight as CssFontWeight2, FontWeightKw as CssFontWeightKw2};
975use rustmotion_core::schema::FontWeight;
976
977pub struct CodeblockIntrinsic {
987 natural_width: f32,
988 natural_height: f32,
989}
990
991impl CodeblockIntrinsic {
992 pub fn from_codeblock(c: &Codeblock) -> Self {
993 let font_family = c.style.font_family_or("JetBrains Mono");
994 let font_size = c
995 .style
996 .font_size_px_ctx(&measure_time_font_size_ctx(0.0), 14.0);
997 let font_weight = match &c.style.font_weight {
998 Some(CssFontWeight2::Keyword(CssFontWeightKw2::Bold | CssFontWeightKw2::Bolder)) => {
999 FontWeight::Bold
1000 }
1001 Some(CssFontWeight2::Number(n)) if *n >= 600 => FontWeight::Bold,
1002 Some(CssFontWeight2::Number(n)) => FontWeight::Weight(*n),
1003 _ => FontWeight::Normal,
1004 };
1005
1006 let Some(font) = resolve_monospace_font(font_family, font_size, font_weight) else {
1007 return Self {
1008 natural_width: 0.0,
1009 natural_height: 0.0,
1010 };
1011 };
1012
1013 let padding = {
1014 let (t, r, b, l) = c.style.padding_px();
1015 if t == 0.0 && r == 0.0 && b == 0.0 && l == 0.0 {
1016 (16.0, 16.0, 16.0, 16.0)
1017 } else {
1018 (t, r, b, l)
1019 }
1020 };
1021
1022 let chrome_height = if c.chrome.as_ref().is_some_and(|ch| ch.enabled) {
1023 36.0
1024 } else {
1025 0.0
1026 };
1027
1028 let dims = compute_code_dimensions(&c.code, &font, font_size, padding, chrome_height, c);
1029
1030 Self {
1031 natural_width: dims.total_width,
1032 natural_height: dims.total_height,
1033 }
1034 }
1035}
1036
1037impl IntrinsicMeasure for CodeblockIntrinsic {
1038 fn measure(
1039 &self,
1040 known: (Option<f32>, Option<f32>),
1041 _available: (AvailableSpace, AvailableSpace),
1042 ) -> (f32, f32) {
1043 let w = known.0.unwrap_or(self.natural_width);
1044 let h = known.1.unwrap_or(self.natural_height);
1045 (w, h)
1046 }
1047}
1048
1049use crate::rich_text::{RichText, RichTextSpan};
1054
1055pub struct RichTextIntrinsic {
1067 spans: Vec<RichTextSpan>,
1068 style: CssStyle,
1069 max_width: Option<f32>,
1070}
1071
1072impl RichTextIntrinsic {
1073 pub fn from_rich_text(rt: &RichText) -> Self {
1074 Self {
1075 spans: rt.spans.clone(),
1076 style: rt.style.clone(),
1077 max_width: rt.max_width,
1078 }
1079 }
1080}
1081
1082impl IntrinsicMeasure for RichTextIntrinsic {
1083 fn measure(
1084 &self,
1085 known: (Option<f32>, Option<f32>),
1086 available: (AvailableSpace, AvailableSpace),
1087 ) -> (f32, f32) {
1088 let max_width = if let Some(w) = known.0 {
1089 Some(w)
1090 } else {
1091 let avail_w = match available.0 {
1092 AvailableSpace::Definite(w) => Some(w),
1093 AvailableSpace::MaxContent => None,
1094 AvailableSpace::MinContent => Some(0.0),
1095 };
1096 match (self.max_width, avail_w) {
1097 (Some(a), Some(b)) => Some(a.min(b)),
1098 (Some(a), None) => Some(a),
1099 (None, Some(b)) => Some(b),
1100 (None, None) => None,
1101 }
1102 };
1103
1104 let layout =
1105 RichText::compute_layout(&self.spans, &self.style, 1920.0, 1080.0, max_width, -1.0);
1106 let line_count = layout.lines.len().max(1) as f32;
1107 (layout.max_width, line_count * layout.line_height)
1108 }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use rustmotion_core::css::style::CssStyle;
1115 use rustmotion_core::css::Length;
1116 use rustmotion_core::engine::box_tree::AvailableSpace;
1117
1118 #[test]
1119 fn measure_returns_positive_size_for_non_empty_text() {
1120 let text = Text {
1121 content: "Hello World".into(),
1122 max_width: None,
1123 timing: Default::default(),
1124 style: CssStyle {
1125 font_size: Some(Length::Px(32.0)),
1126 ..Default::default()
1127 },
1128 timeline: Vec::new(),
1129 stagger: None,
1130 text_shadow: None,
1131 stroke: None,
1132 text_background: None,
1133 caret: None,
1134 states: Vec::new(),
1135 swap: None,
1136 };
1137 let m = TextIntrinsic::from_text(&text);
1138 let (w, h) = m.measure(
1139 (None, None),
1140 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1141 );
1142 assert!(w > 0.0, "width should be > 0, got {}", w);
1143 assert!(
1144 h > 30.0,
1145 "height should be roughly font_size * line_height, got {}",
1146 h
1147 );
1148 }
1149
1150 #[test]
1151 fn wrapping_grows_height_when_max_width_constrained() {
1152 let text = Text {
1153 content: "the quick brown fox jumps over the lazy dog".into(),
1154 max_width: None,
1155 timing: Default::default(),
1156 style: CssStyle {
1157 font_size: Some(Length::Px(20.0)),
1158 ..Default::default()
1159 },
1160 timeline: Vec::new(),
1161 stagger: None,
1162 text_shadow: None,
1163 stroke: None,
1164 text_background: None,
1165 caret: None,
1166 states: Vec::new(),
1167 swap: None,
1168 };
1169 let m = TextIntrinsic::from_text(&text);
1170 let (_w_unwrapped, h_unwrapped) = m.measure(
1171 (None, None),
1172 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1173 );
1174 let (_w_wrapped, h_wrapped) = m.measure(
1175 (None, None),
1176 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1177 );
1178 assert!(
1179 h_wrapped > h_unwrapped,
1180 "wrapped height ({}) should exceed unwrapped ({})",
1181 h_wrapped,
1182 h_unwrapped,
1183 );
1184 }
1185
1186 #[test]
1187 fn empty_text_has_zero_width_but_one_line_height() {
1188 let text = Text {
1189 content: "".into(),
1190 max_width: None,
1191 timing: Default::default(),
1192 style: CssStyle {
1193 font_size: Some(Length::Px(24.0)),
1194 ..Default::default()
1195 },
1196 timeline: Vec::new(),
1197 stagger: None,
1198 text_shadow: None,
1199 stroke: None,
1200 text_background: None,
1201 caret: None,
1202 states: Vec::new(),
1203 swap: None,
1204 };
1205 let m = TextIntrinsic::from_text(&text);
1206 let (w, h) = m.measure(
1207 (None, None),
1208 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1209 );
1210 assert_eq!(w, 0.0);
1211 assert!(h > 0.0);
1212 }
1213
1214 fn nowrap_text(content: &str, white_space: Option<WhiteSpace>) -> Text {
1217 Text {
1218 content: content.into(),
1219 max_width: None,
1220 timing: Default::default(),
1221 style: CssStyle {
1222 font_size: Some(Length::Px(20.0)),
1223 white_space,
1224 ..Default::default()
1225 },
1226 timeline: Vec::new(),
1227 stagger: None,
1228 text_shadow: None,
1229 stroke: None,
1230 text_background: None,
1231 caret: None,
1232 states: Vec::new(),
1233 swap: None,
1234 }
1235 }
1236
1237 #[test]
1238 fn nowrap_ignores_a_constrained_width_and_stays_one_line() {
1239 let text = nowrap_text(
1240 "the quick brown fox jumps over the lazy dog",
1241 Some(WhiteSpace::Nowrap),
1242 );
1243 let m = TextIntrinsic::from_text(&text);
1244 let (w_unconstrained, h_unconstrained) = m.measure(
1245 (None, None),
1246 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1247 );
1248 let (w_constrained, h_constrained) = m.measure(
1249 (None, None),
1250 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1251 );
1252 assert!(
1253 w_constrained > 80.0,
1254 "nowrap must ignore the 80px constraint, got width {}",
1255 w_constrained
1256 );
1257 assert_eq!(
1258 w_constrained, w_unconstrained,
1259 "nowrap width must equal the natural (unconstrained) width regardless of available space"
1260 );
1261 assert_eq!(
1262 h_constrained, h_unconstrained,
1263 "nowrap must always report a single line's height, constrained or not"
1264 );
1265 }
1266
1267 #[test]
1268 fn pre_disables_wrap_exactly_like_nowrap() {
1269 let text = nowrap_text("this string is too long to fit", Some(WhiteSpace::Pre));
1270 let m = TextIntrinsic::from_text(&text);
1271 let (w, _h) = m.measure(
1272 (None, None),
1273 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1274 );
1275 assert!(
1276 w > 80.0,
1277 "white-space: pre must also ignore the width constraint, got {}",
1278 w
1279 );
1280 }
1281
1282 #[test]
1283 fn normal_white_space_still_wraps_at_a_constrained_width() {
1284 let wrapped = nowrap_text(
1287 "the quick brown fox jumps over the lazy dog",
1288 Some(WhiteSpace::Normal),
1289 );
1290 let unset = nowrap_text("the quick brown fox jumps over the lazy dog", None);
1291 for text in [wrapped, unset] {
1292 let m = TextIntrinsic::from_text(&text);
1293 let (w, _h) = m.measure(
1294 (None, None),
1295 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1296 );
1297 assert!(
1298 w <= 80.0 + 0.5,
1299 "white-space: normal (or unset) must still wrap at an 80px constraint, got {}",
1300 w
1301 );
1302 }
1303 }
1304
1305 fn span(text: &str) -> RichTextSpan {
1308 RichTextSpan {
1309 text: text.into(),
1310 color: None,
1311 font_size: None,
1312 font_weight: None,
1313 font_family: None,
1314 font_style: None,
1315 letter_spacing: None,
1316 }
1317 }
1318
1319 #[test]
1320 fn rich_text_intrinsic_is_non_zero_without_explicit_size() {
1321 let spans = vec![span("Hello "), span("world")];
1324 let style = CssStyle {
1325 font_size: Some(Length::Px(32.0)),
1326 ..Default::default()
1327 };
1328 let intrinsic = RichTextIntrinsic {
1329 spans,
1330 style,
1331 max_width: None,
1332 };
1333 let (w, h) = intrinsic.measure(
1334 (None, None),
1335 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1336 );
1337 assert!(w > 0.0, "rich_text natural width must be > 0, got {}", w);
1338 assert!(h > 0.0, "rich_text natural height must be > 0, got {}", h);
1339 }
1340
1341 #[test]
1342 fn rich_text_intrinsic_wraps_a_single_long_span_internally() {
1343 let spans = vec![span(
1346 "the quick brown fox jumps over the lazy dog and keeps going",
1347 )];
1348 let style = CssStyle {
1349 font_size: Some(Length::Px(24.0)),
1350 ..Default::default()
1351 };
1352 let intrinsic = RichTextIntrinsic {
1353 spans,
1354 style,
1355 max_width: None,
1356 };
1357 let (_w_unconstrained, h_unconstrained) = intrinsic.measure(
1358 (None, None),
1359 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1360 );
1361 let (w_constrained, h_constrained) = intrinsic.measure(
1362 (None, None),
1363 (AvailableSpace::Definite(150.0), AvailableSpace::MaxContent),
1364 );
1365 assert!(
1366 w_constrained <= 150.0 + 0.5,
1367 "wrapped width must fit the 150px constraint, got {}",
1368 w_constrained
1369 );
1370 assert!(
1371 h_constrained > h_unconstrained,
1372 "constraining width must add lines (wrap within the single span): {} vs {}",
1373 h_constrained,
1374 h_unconstrained
1375 );
1376 }
1377
1378 #[test]
1379 fn rich_text_intrinsic_matches_compute_layout_used_by_the_painter() {
1380 let spans = vec![span("Total: "), span("42"), span(" items")];
1384 let style = CssStyle::default();
1385 let intrinsic = RichTextIntrinsic {
1386 spans: spans.clone(),
1387 style: style.clone(),
1388 max_width: None,
1389 };
1390 let (w, h) = intrinsic.measure(
1391 (None, None),
1392 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1393 );
1394 let layout = RichText::compute_layout(&spans, &style, 1920.0, 1080.0, None, -1.0);
1395 assert_eq!(w, layout.max_width);
1396 assert_eq!(h, layout.lines.len().max(1) as f32 * layout.line_height);
1397 }
1398
1399 #[test]
1402 fn gradient_text_intrinsic_ignores_constrained_width_when_nowrap() {
1403 let gt = GradientText {
1404 content: "the quick brown fox jumps over the lazy dog".into(),
1405 colors: vec!["#3B82F6".into(), "#8B5CF6".into()],
1406 angle: 90.0,
1407 animate_angle: false,
1408 speed: 0.5,
1409 timing: Default::default(),
1410 style: CssStyle {
1411 font_size: Some(Length::Px(20.0)),
1412 white_space: Some(WhiteSpace::Nowrap),
1413 ..Default::default()
1414 },
1415 timeline: Vec::new(),
1416 stagger: None,
1417 };
1418 let m = GradientTextIntrinsic::from_gradient_text(>);
1419 let (w, h) = m.measure(
1420 (None, None),
1421 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1422 );
1423 assert!(
1424 w > 80.0,
1425 "nowrap gradient_text must ignore the 80px constraint, got {}",
1426 w
1427 );
1428 let (_, h_unconstrained) = m.measure(
1430 (None, None),
1431 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1432 );
1433 assert_eq!(h, h_unconstrained);
1434 }
1435
1436 #[test]
1437 fn gradient_text_intrinsic_wraps_by_default() {
1438 let gt = GradientText {
1439 content: "the quick brown fox jumps over the lazy dog".into(),
1440 colors: vec!["#3B82F6".into(), "#8B5CF6".into()],
1441 angle: 90.0,
1442 animate_angle: false,
1443 speed: 0.5,
1444 timing: Default::default(),
1445 style: CssStyle {
1446 font_size: Some(Length::Px(20.0)),
1447 ..Default::default()
1448 },
1449 timeline: Vec::new(),
1450 stagger: None,
1451 };
1452 let m = GradientTextIntrinsic::from_gradient_text(>);
1453 let (_w, h_unconstrained) = m.measure(
1454 (None, None),
1455 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1456 );
1457 let (w_constrained, h_constrained) = m.measure(
1458 (None, None),
1459 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1460 );
1461 assert!(w_constrained <= 80.0 + 0.5);
1462 assert!(h_constrained > h_unconstrained);
1463 }
1464
1465 #[test]
1466 fn caption_intrinsic_ignores_constrained_width_when_nowrap() {
1467 let caption = Caption {
1468 words: "the quick brown fox jumps over the lazy dog"
1469 .split_whitespace()
1470 .map(|w| rustmotion_core::schema::CaptionWord {
1471 text: w.to_string(),
1472 start: 0.0,
1473 end: 10.0,
1474 })
1475 .collect(),
1476 active_color: "#FFFF00".into(),
1477 mode: Default::default(),
1478 max_width: Some(80.0),
1479 pill_color: None,
1480 style: CssStyle {
1481 font_size: Some(Length::Px(20.0)),
1482 white_space: Some(WhiteSpace::Nowrap),
1483 ..Default::default()
1484 },
1485 timing: Default::default(),
1486 timeline: Vec::new(),
1487 stagger: None,
1488 };
1489 let m = CaptionIntrinsic::from_caption(&caption);
1490 let (w, _h) = m.measure(
1491 (None, None),
1492 (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1493 );
1494 assert!(
1495 w > 80.0,
1496 "nowrap caption intrinsic must ignore the 80px constraint, got {}",
1497 w
1498 );
1499 }
1500
1501 fn text_with_style(content: &str, style: CssStyle) -> Text {
1504 Text {
1505 content: content.into(),
1506 max_width: None,
1507 timing: Default::default(),
1508 style,
1509 timeline: Vec::new(),
1510 stagger: None,
1511 text_shadow: None,
1512 stroke: None,
1513 text_background: None,
1514 caret: None,
1515 states: Vec::new(),
1516 swap: None,
1517 }
1518 }
1519
1520 #[test]
1521 fn line_height_percent_no_longer_collapses_the_box_to_zero_height() {
1522 use rustmotion_core::css::units::LengthPercentage;
1529 let text = text_with_style(
1530 "VISIBLE?",
1531 CssStyle {
1532 font_size: Some(Length::Px(60.0)),
1533 line_height: Some(LineHeight::Length(LengthPercentage::String("150%".into()))),
1534 ..Default::default()
1535 },
1536 );
1537 let m = TextIntrinsic::from_text(&text);
1538 let (_w, h) = m.measure(
1539 (None, None),
1540 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1541 );
1542 assert!(
1543 (h - 90.0).abs() < 0.5,
1544 "line-height: 150% of a 60px font-size must resolve to 90px (own font-size, per \
1545 CSS), got {h}"
1546 );
1547 }
1548
1549 #[test]
1550 fn line_height_em_no_longer_collapses_the_box_to_zero_height() {
1551 use rustmotion_core::css::units::LengthPercentage;
1552 let text = text_with_style(
1553 "VISIBLE?",
1554 CssStyle {
1555 font_size: Some(Length::Px(60.0)),
1556 line_height: Some(LineHeight::Length(LengthPercentage::String("1.5em".into()))),
1557 ..Default::default()
1558 },
1559 );
1560 let m = TextIntrinsic::from_text(&text);
1561 let (_w, h) = m.measure(
1562 (None, None),
1563 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1564 );
1565 assert!(
1566 (h - 90.0).abs() < 0.5,
1567 "line-height: 1.5em of a 60px font-size must resolve to 90px, got {h}"
1568 );
1569 let numeric = text_with_style(
1572 "VISIBLE?",
1573 CssStyle {
1574 font_size: Some(Length::Px(60.0)),
1575 line_height: Some(LineHeight::Number(1.5)),
1576 ..Default::default()
1577 },
1578 );
1579 let (_w, h_numeric) = TextIntrinsic::from_text(&numeric).measure(
1580 (None, None),
1581 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1582 );
1583 assert_eq!(h, h_numeric);
1584 }
1585
1586 #[test]
1587 fn letter_spacing_em_matches_the_equivalent_px_measurement() {
1588 let em_style = CssStyle {
1596 font_size: Some(Length::Px(200.0)),
1597 letter_spacing: Some(Length::String("1.2em".into())),
1598 white_space: Some(WhiteSpace::Nowrap),
1599 ..Default::default()
1600 };
1601 let px_style = CssStyle {
1602 font_size: Some(Length::Px(200.0)),
1603 letter_spacing: Some(Length::Px(240.0)),
1604 white_space: Some(WhiteSpace::Nowrap),
1605 ..Default::default()
1606 };
1607 let w_em = TextIntrinsic::from_text(&text_with_style("TRACKING", em_style))
1608 .measure(
1609 (None, None),
1610 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1611 )
1612 .0;
1613 let w_px = TextIntrinsic::from_text(&text_with_style("TRACKING", px_style))
1614 .measure(
1615 (None, None),
1616 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1617 )
1618 .0;
1619 assert!(
1620 (w_em - w_px).abs() < 1.0,
1621 "letter-spacing: 1.2em (font-size 200) must measure the same as the equivalent \
1622 240px value: em={w_em}, px={w_px}"
1623 );
1624 let w_zero_tracking = TextIntrinsic::from_text(&text_with_style(
1628 "TRACKING",
1629 CssStyle {
1630 font_size: Some(Length::Px(200.0)),
1631 white_space: Some(WhiteSpace::Nowrap),
1632 ..Default::default()
1633 },
1634 ))
1635 .measure(
1636 (None, None),
1637 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1638 )
1639 .0;
1640 assert!(
1641 w_em > w_zero_tracking + 100.0,
1642 "em tracking must measurably widen the line versus zero tracking: em={w_em}, \
1643 zero={w_zero_tracking}"
1644 );
1645 }
1646
1647 #[test]
1650 fn shrink_to_fit_is_a_noop_when_content_already_fits() {
1651 let calls = std::cell::RefCell::new(Vec::new());
1652 let size = shrink_to_fit(48.0, 12.0, Some(200.0), Some(100.0), |s| {
1653 calls.borrow_mut().push(s);
1654 (150.0, 80.0)
1655 });
1656 assert_eq!(size, 48.0);
1657 assert_eq!(
1658 *calls.borrow(),
1659 vec![48.0],
1660 "must measure only once (at the requested size) when it already fits"
1661 );
1662 }
1663
1664 #[test]
1665 fn shrink_to_fit_is_a_noop_when_nothing_to_fit_against() {
1666 let size = shrink_to_fit(48.0, 12.0, None, None, |_| (99999.0, 99999.0));
1669 assert_eq!(size, 48.0);
1670 }
1671
1672 #[test]
1673 fn shrink_to_fit_finds_a_size_that_fits_the_width_target() {
1674 let target = 100.0;
1677 let size = shrink_to_fit(120.0, 5.0, Some(target), None, |s| (s * 2.0, 10.0));
1678 assert!(size < 120.0, "must have shrunk, got {size}");
1679 assert!(size * 2.0 <= target + 0.5, "resolved size must fit: {size}");
1680 assert!(
1683 (size + 1.0) * 2.0 > target + 0.5,
1684 "resolved size should be close to the fitting boundary, got {size}"
1685 );
1686 }
1687
1688 #[test]
1689 fn shrink_to_fit_respects_both_axes_jointly() {
1690 let size = shrink_to_fit(100.0, 5.0, Some(1000.0), Some(20.0), |s| (s, s * 2.0));
1693 assert!(size * 2.0 <= 20.5, "must respect the height target: {size}");
1694 assert!(
1695 (size + 0.5) * 2.0 > 20.5,
1696 "should converge close to the height boundary, got {size}"
1697 );
1698 }
1699
1700 #[test]
1701 fn shrink_to_fit_never_returns_below_the_floor() {
1702 let size = shrink_to_fit(120.0, 20.0, Some(10.0), None, |s| (s * 5.0, 10.0));
1705 assert_eq!(size, 20.0, "must stop exactly at the floor, not lower");
1706 }
1707
1708 #[test]
1709 fn shrink_to_fit_is_deterministic_across_repeated_calls() {
1710 let run = || shrink_to_fit(90.0, 10.0, Some(137.0), Some(64.0), |s| (s * 1.7, s * 0.9));
1715 let a = run();
1716 let b = run();
1717 assert_eq!(a, b);
1718 }
1719
1720 fn inter_typeface() -> Typeface {
1723 typeface_with_fallback("Inter", SkFontStyle::normal()).expect("Inter resolves in tests")
1724 }
1725
1726 #[test]
1727 fn resolve_text_autofit_shrinks_to_fit_a_width_target() {
1728 let typeface = inter_typeface();
1729 let content = "A very long headline that will not fit in this box";
1730 let requested = 80.0;
1731 let box_width = 300.0;
1732 let (fs, ls, lh) = resolve_text_autofit(
1733 content,
1734 &typeface,
1735 requested,
1736 0.0,
1737 requested * 1.3,
1738 false, Some(box_width),
1740 None,
1741 );
1742 assert!(fs < requested, "must shrink, got {fs}");
1743 assert!(
1744 fs >= TEXT_AUTOFIT_MIN_FONT_PX - 0.01,
1745 "must not shrink past the calibrated floor, got {fs}"
1746 );
1747 let (w, _) = wrap_and_measure(content, &typeface, fs, None, ls, lh);
1750 assert!(
1751 w <= box_width + 0.5,
1752 "resolved size must actually fit: w={w}, target={box_width}"
1753 );
1754 }
1755
1756 #[test]
1757 fn resolve_text_autofit_is_a_noop_when_it_already_fits() {
1758 let typeface = inter_typeface();
1759 let (fs, ls, lh) = resolve_text_autofit(
1760 "hi",
1761 &typeface,
1762 24.0,
1763 1.0,
1764 30.0,
1765 true,
1766 Some(1000.0),
1767 Some(1000.0),
1768 );
1769 assert_eq!(fs, 24.0);
1770 assert_eq!(ls, 1.0);
1771 assert_eq!(lh, 30.0);
1772 }
1773
1774 #[test]
1775 fn resolve_text_autofit_never_goes_below_the_calibrated_floor() {
1776 let typeface = inter_typeface();
1777 let (fs, _, _) = resolve_text_autofit(
1780 "This sentence is far too long for a ten pixel wide box",
1781 &typeface,
1782 80.0,
1783 0.0,
1784 104.0,
1785 true,
1786 Some(10.0),
1787 Some(10.0),
1788 );
1789 assert!(
1790 (fs - TEXT_AUTOFIT_MIN_FONT_PX).abs() < 0.01,
1791 "expected exactly the floor ({TEXT_AUTOFIT_MIN_FONT_PX}), got {fs}"
1792 );
1793 }
1794
1795 #[test]
1796 fn resolve_text_autofit_rescales_letter_spacing_and_line_height_proportionally() {
1797 let typeface = inter_typeface();
1798 let (fs, ls, lh) = resolve_text_autofit(
1799 "SHRINK ME PLEASE, THIS LINE IS QUITE LONG",
1800 &typeface,
1801 100.0,
1802 5.0,
1803 130.0,
1804 false,
1805 Some(150.0),
1806 None,
1807 );
1808 assert!(fs < 100.0, "sanity: must have shrunk, got {fs}");
1809 let ratio = fs / 100.0;
1810 assert!((ls - 5.0 * ratio).abs() < 1e-3);
1811 assert!((lh - 130.0 * ratio).abs() < 1e-3);
1812 }
1813
1814 fn autofit_text(content: &str, font_size: f32) -> Text {
1817 Text {
1818 content: content.into(),
1819 max_width: None,
1820 timing: Default::default(),
1821 style: CssStyle {
1822 font_size: Some(Length::Px(font_size)),
1823 text_autofit: Some(true),
1824 white_space: Some(WhiteSpace::Nowrap),
1825 ..Default::default()
1826 },
1827 timeline: Vec::new(),
1828 stagger: None,
1829 text_shadow: None,
1830 stroke: None,
1831 text_background: None,
1832 caret: None,
1833 states: Vec::new(),
1834 swap: None,
1835 }
1836 }
1837
1838 #[test]
1839 fn text_intrinsic_shrinks_when_autofit_is_on_and_the_box_is_too_narrow() {
1840 let text = autofit_text("the quick brown fox jumps over the lazy dog", 60.0);
1841 let m = TextIntrinsic::from_text(&text);
1842 let (w_unconstrained, _) = m.measure(
1843 (None, None),
1844 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1845 );
1846 let target = w_unconstrained / 2.0;
1855 let (w_constrained, _) = m.measure(
1856 (None, None),
1857 (AvailableSpace::Definite(target), AvailableSpace::MaxContent),
1858 );
1859 assert!(
1860 w_constrained <= target + 0.5,
1861 "autofit must shrink the nowrap line to fit {target}px, got {w_constrained}"
1862 );
1863 assert!(
1864 w_constrained < w_unconstrained,
1865 "must have actually shrunk from the natural width ({w_unconstrained}), got {w_constrained}"
1866 );
1867 }
1868
1869 #[test]
1870 fn text_intrinsic_ignores_autofit_target_when_the_flag_is_off() {
1871 let mut text = autofit_text("the quick brown fox jumps over the lazy dog", 60.0);
1872 text.style.text_autofit = None;
1873 let m = TextIntrinsic::from_text(&text);
1874 let (w, _) = m.measure(
1875 (None, None),
1876 (AvailableSpace::Definite(200.0), AvailableSpace::MaxContent),
1877 );
1878 assert!(
1879 w > 200.0,
1880 "without text-autofit, nowrap must still bleed past the box exactly as before, got {w}"
1881 );
1882 }
1883
1884 #[test]
1885 fn text_intrinsic_autofit_still_overflows_when_even_the_floor_does_not_fit() {
1886 let text = autofit_text(
1887 "This is an extremely long sentence that will not fit no matter how much the font shrinks",
1888 80.0,
1889 );
1890 let m = TextIntrinsic::from_text(&text);
1891 let (w, _) = m.measure(
1892 (None, None),
1893 (AvailableSpace::Definite(5.0), AvailableSpace::MaxContent),
1894 );
1895 assert!(
1896 w > 5.0,
1897 "must not silently report a fit that never actually happened, got {w}"
1898 );
1899 }
1900
1901 #[test]
1902 fn caption_intrinsic_never_autofits_even_if_style_declares_it() {
1903 let caption = Caption {
1909 words: "the quick brown fox jumps over the lazy dog"
1910 .split_whitespace()
1911 .map(|w| rustmotion_core::schema::CaptionWord {
1912 text: w.to_string(),
1913 start: 0.0,
1914 end: 10.0,
1915 })
1916 .collect(),
1917 active_color: "#FFFF00".into(),
1918 mode: Default::default(),
1919 max_width: None,
1920 pill_color: None,
1921 style: CssStyle {
1922 font_size: Some(Length::Px(60.0)),
1923 text_autofit: Some(true),
1924 white_space: Some(WhiteSpace::Nowrap),
1925 ..Default::default()
1926 },
1927 timing: Default::default(),
1928 timeline: Vec::new(),
1929 stagger: None,
1930 };
1931 let m = CaptionIntrinsic::from_caption(&caption);
1932 let (w_unconstrained, _) = m.measure(
1933 (None, None),
1934 (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1935 );
1936 let (w_constrained, _) = m.measure(
1937 (None, None),
1938 (AvailableSpace::Definite(200.0), AvailableSpace::MaxContent),
1939 );
1940 assert_eq!(
1941 w_constrained, w_unconstrained,
1942 "caption must ignore text-autofit entirely (nowrap bleeds exactly as before)"
1943 );
1944 }
1945}