Skip to main content

rustmotion_components/
intrinsic.rs

1//! Intrinsic measurers for components whose box size depends on content.
2//!
3//! Uses the same Skia metrics that the painter uses, so the box reserved by
4//! taffy matches the pixels actually drawn — measure-vs-paint mismatches
5//! would otherwise cause text to wrap onto an extra line at paint time and
6//! overflow into the next sibling.
7
8use 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
27// ─── Shared `font-size` context resolution (deployment of `font_size_px_ctx`
28// / `typography_px_ctx`, css/style.rs, across every component that still
29// resolved `font-size` with the context-free `font_size_px_or`) ───────────
30//
31// `font_size_px_or`/`.px()` cannot resolve `%`/`em`/`rem`/`vw`/`vh` — for a
32// `Some(Length::String(_))` that parses as one of those units, `.px()` warns
33// and returns `0.0`, and since the field itself is `Some`, the `_or`
34// fallback default never kicks in either. A `text` with `"font-size":
35// "2rem"` therefore measured *and* painted at 0px: `validate` passed (only
36// warnings), but the rendered frame had no visible text (paint_pass's
37// `height <= 0.0` guard skips the node once the intrinsic measures it at
38// zero).
39//
40// `rustmotion_core::css::style::CssStyle::font_size_px_ctx` (and
41// `typography_px_ctx`, which resolves `font-size`, `letter-spacing`, and
42// `line-height` together, honouring CSS's two different `em` bases) already
43// exist and are tested — nothing in the engine called them. These two
44// helpers build the `LengthContext` every call site below feeds them,
45// so the context-building logic lives in exactly one place instead of being
46// copied into ~15 components.
47use rustmotion_core::css::units::LengthContext;
48
49/// `LengthContext` for resolving `font-size` (and, through
50/// [`CssStyle::typography_px_ctx`], `letter-spacing`/`line-height` derived
51/// from it) against a real, per-frame viewport. Use from `Painter::
52/// paint_content` and friends, which have a real `PaintCtx` (`video_width`/
53/// `video_height`) on hand.
54///
55/// `rem`/`vw`/`vh` resolve correctly through this. `em`/`%` on `font-size`
56/// itself do not: per CSS they're relative to the *parent's* computed
57/// font-size, but `cascade.rs` inherits `font-size` down the tree as a raw,
58/// unresolved `Length`, not a resolved px value (see the module note above
59/// `CssStyle::font_size_px_ctx`) — no caller in this workstream's scope can
60/// supply the real cascaded value. `font_size: 16.0` here is the CSS root
61/// default used as the best available stand-in; it makes `em`/`%` on
62/// `font-size` *resolve* (no longer silently drop to 0px) without making
63/// them *correct* against an actual parent font-size. Fixing that fully
64/// needs a `cascade.rs` change, out of scope here.
65pub 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
75/// Same as [`font_size_ctx`], for the `Intrinsic` measurers in this module:
76/// they run at `box_builder`/`geometry` construction time, before layout, so
77/// there is no real per-frame viewport to hand (see the pre-existing note on
78/// `TextIntrinsic::from_parts`, which has the same limitation for
79/// `letter-spacing`/`line-height`). Falls back to the engine-wide default
80/// 1920×1080 (same as `LengthContext::default()`) so `rem` — which does not
81/// depend on the viewport at all — still resolves exactly, and `vw`/`vh` get
82/// a reasonable non-zero approximation instead of silently dropping to 0.
83/// This can diverge from what `Painter::paint_content` resolves via
84/// [`font_size_ctx`] for `vw`/`vh` specifically, on videos that aren't
85/// 1920×1080 — closing that fully needs the real `VideoConfig` threaded
86/// through `box_builder.rs`/`geometry.rs`, both outside this workstream.
87pub fn measure_time_font_size_ctx(parent_size: f32) -> LengthContext {
88    font_size_ctx(1920.0, 1080.0, parent_size)
89}
90
91/// Skia-backed intrinsic measurer for [`Text`] (audit #10: despite the name
92/// this module's doc header suggests, this uses `skia_safe::Font::
93/// measure_str` via `engine::renderer::text`'s fallback-aware helpers — the
94/// same primitives `Text::paint` draws with — not `engine::text::cosmic`,
95/// which has no callers on the real render path at all; see that module's
96/// doc comment).
97pub 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    /// `style.text-autofit == Some(true)`, but only ever set by
108    /// [`Self::from_text`] / [`GradientTextIntrinsic::from_gradient_text`] —
109    /// see [`Self::with_autofit`]'s doc comment for why `from_parts`/
110    /// `from_parts_with_wrap` (shared by `Caption`/`Kbd`/`Badge`/`Counter`,
111    /// none of whose painters read `text-autofit`) must never set this from
112    /// `style` directly.
113    text_autofit: bool,
114}
115
116impl TextIntrinsic {
117    /// M1: `white-space: nowrap|pre` disables wrapping — the geometry
118    /// validator's `unwrappable_text_overflow`/`ContentOverflowsBox` checks
119    /// (crates/rustmotion/src/cli/commands/geometry.rs) already branch on
120    /// exactly this pair of variants and re-measure via this same
121    /// `TextIntrinsic`, so the wrap decision here must match theirs exactly
122    /// or the validator's assumption about what the renderer produces is
123    /// false.
124    pub fn from_text(text: &Text) -> Self {
125        let wrap = !matches!(
126            text.style.white_space,
127            Some(WhiteSpace::Nowrap | WhiteSpace::Pre)
128        );
129        // Measure the *longest* label the text can ever show, not just the
130        // first: a box sized for "Saved" would be overrun the moment a
131        // `states` entry swapped in "Saving draft…", and the geometry
132        // validator — which measures through here — would have signed off on
133        // the overflow.
134        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    /// Opt this instance into `text-autofit`. Deliberately a separate,
143    /// explicit step rather than something `from_parts`/`from_parts_with_wrap`
144    /// read off `style` themselves: those two constructors are shared by
145    /// every atomic/synthetic-style caller in this file (`Caption`, `Kbd`,
146    /// `Badge`, `Counter`) whose *painters* have no idea `text-autofit`
147    /// exists — if the flag leaked in through the shared style, `measure()`
148    /// would shrink the reserved box for one of those while the painter
149    /// went on drawing at the full requested size, which is exactly the
150    /// measure-vs-paint divergence this feature exists to prevent, not
151    /// reintroduce elsewhere. Only [`Self::from_text`] and
152    /// [`GradientTextIntrinsic::from_gradient_text`] call this, matching the
153    /// two painters (`Text`, `GradientText`) that actually implement it.
154    pub fn with_autofit(mut self, on: bool) -> Self {
155        self.text_autofit = on;
156        self
157    }
158
159    /// Generic constructor shared by [`GradientText`]/[`Caption`] intrinsics,
160    /// whose painters don't (yet) implement `white-space: nowrap` — kept
161    /// wrap:true unconditionally so their measured size still matches what
162    /// those painters actually draw.
163    pub fn from_parts(content: &str, style: &CssStyle, max_width: Option<f32>) -> Self {
164        // No *real* `LengthContext` (real viewport, real parent width) is
165        // reachable here without changing this constructor's signature —
166        // its only callers are `box_builder.rs` and
167        // `rustmotion/src/cli/commands/geometry.rs`, both outside this
168        // workstream's scope (box_builder.rs is a sibling's live file this
169        // wave; the geometry validator re-measures via this exact type and
170        // must keep agreeing with it byte-for-byte, so changing what it
171        // needs to pass in is not a call to make unilaterally here).
172        // `measure_time_font_size_ctx` falls back to the engine-wide default
173        // viewport (1920×1080) for this reason — see its doc comment.
174        //
175        // `font-size` itself, and `letter-spacing`/`line-height`'s `em`/`%`
176        // (relative to this element's *own*, just-resolved font-size — CSS
177        // spec, also documented on `CssStyle::letter_spacing_px_ctx`/
178        // `line_height_for_ctx`) are resolved together by
179        // `typography_px_ctx`, which re-derives the right context between
180        // the two steps. `Text`/`Caption`'s painters resolve the same three
181        // properties with the real `PaintCtx`'s viewport (lot B, wave S), so
182        // `rem` (viewport-independent) always agrees between measure and
183        // paint; `vw`/`vh` can diverge on videos that aren't 1920×1080 —
184        // closing that fully needs the real `VideoConfig` plumbed through
185        // `box_builder.rs`/`geometry.rs`, still out of scope for the reasons
186        // above.
187        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    /// Build with an explicit `wrap` override (used by atomic components like
205    /// counter, kbd, badge that never wrap).
206    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        // Height target: mirrors the `known`/`available` merge above for
258        // width — taffy hands a leaf its own `known`/`available` height
259        // already padding/border-subtracted (content-box space) whenever
260        // the node's own box resolves to a *definite* height, exactly the
261        // same protocol it uses for width. No separate hand-rolled read of
262        // `style.height` here: reusing this signal is what guarantees this
263        // agrees with `Text::paint`'s `content_height` (from the *same*
264        // taffy-resolved `BoxLayout::content_box()`, post-layout) — see
265        // `CssStyle::text_autofit`'s doc comment.
266        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
318/// Wrap `content` at `font_size` (with `letter_spacing`/`line_height`
319/// already resolved for that size) and return its `(max_line_width,
320/// total_height)` — the single wrap+measure routine `TextIntrinsic::measure`
321/// calls for both its base (requested-size) and, when `text-autofit` shrinks
322/// it, its final (resolved-size) measurement, so the two never drift apart
323/// from hand-duplicated logic.
324fn 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    // Tracking-aware wrap (issue #125 §1): matches the real `letter_spacing`
335    // used to measure each line's width just below, so the box this
336    // measurer reserves and what the painter (also tracking-aware) actually
337    // paints agree on line count.
338    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/// `text-autofit`'s shared shrink resolution — the single computation
353/// `TextIntrinsic::measure` and `Text`/`GradientText`'s painters all call
354/// with identical inputs, so the resolved size can never disagree between
355/// the box taffy reserves and the pixels actually painted into it (see
356/// `CssStyle::text_autofit`'s doc comment — this exact class of bug is what
357/// this workstream exists to close, not reopen).
358///
359/// Pure and stateless: the same `(content, typeface, requested_font_size,
360/// requested_letter_spacing, requested_line_height, wrap, box_width,
361/// declared_height)` always produces the same `(font_size, letter_spacing,
362/// line_height)`. That purity is *why* calling this fresh every paint call
363/// (frame) is stable rather than something that needs caching — see the two
364/// call sites' comments for what does and does not change frame to frame.
365/// The one input this deliberately never sees is the paint-time typewriter
366/// reveal (`AnimatedProperties::visible_chars_progress`): both call sites
367/// pass the full, untruncated content, so a reveal-in-progress can't make
368/// the resolved size drift as more characters become visible.
369///
370/// `letter_spacing`/`line_height` are rescaled proportionally with the
371/// chosen font size (`requested * chosen/requested`) rather than
372/// re-resolved from the original CSS declaration at each candidate size.
373/// This matches CSS exactly for the common declarations (a unitless
374/// `line-height` number, `%`/`em` line-height, or the engine's `1.3×`
375/// default all scale linearly with font-size by definition) and is a
376/// deliberate approximation for the rare case of an absolute
377/// (`px`/`rem`/`vw`/`vh`) `line-height`/`letter-spacing`, which CSS says
378/// should stay fixed regardless of font-size — getting that exactly right
379/// needs threading the full `CssStyle` (not just its already-resolved
380/// scalars) through both call sites, out of scope here.
381///
382/// `box_width`/`declared_height`: `None` means nothing to fit against on
383/// that axis (an unconstrained box cannot overflow); returns
384/// `requested_font_size` unchanged, without measuring anything, when both
385/// are `None`.
386#[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
440/// Binary-search the largest font size in `[floor_px, requested_font_size]`
441/// whose `measure_at(size)` fits within `(target_width, target_height)`
442/// (either bound `None` = no constraint on that axis). Assumes `measure_at`
443/// is monotonically non-increasing as `size` shrinks — true for real text:
444/// smaller glyphs measure narrower, and a fixed pixel wrap width can only
445/// need the same or fewer lines as glyphs get smaller. 16 halvings of the
446/// search range give sub-0.01px precision for any realistic font size — this
447/// is a visual convenience, not a geometry-critical value, so that precision
448/// is far more than needed.
449///
450/// Never returns below `floor_px`: if the content doesn't fit there either,
451/// `floor_px` is returned anyway — illegible-but-smallest beats an even
452/// larger overflow — and the caller's own overflow signal (the geometry
453/// validator's `ContentOverflowsBox`) is left to fire. This function never
454/// silences that; it only tries to make it unnecessary.
455fn 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        // Doesn't fit even at the floor — stop there and let the caller's
481        // own overflow check fire; see this function's doc comment.
482        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
506/// Cosmic-text–backed intrinsic measurer for [`GradientText`] — same content
507/// model as [`Text`] (a single string + style); the gradient is purely a
508/// paint-time concern and doesn't change box dimensions.
509pub struct GradientTextIntrinsic(TextIntrinsic);
510
511impl GradientTextIntrinsic {
512    pub fn from_gradient_text(t: &GradientText) -> Self {
513        // max_width comes from CSS style.width if set as a fixed pixel value
514        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        // M1 follow-up (issue #109 review): gradient_text now word-wraps
521        // like `text` (see `gradient_text.rs::paint`) — mirror the same
522        // white-space: nowrap|pre rule here so measure and paint agree.
523        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
544/// Intrinsic measurer for [`Caption`]. Concatenates the words with single
545/// spaces and measures the result like a regular text run.
546pub 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        // M1 follow-up: `Highlight`/`Karaoke`/`KaraokePop` word-wrap all
557        // words (see `caption.rs::paint`); `white-space: nowrap|pre` now
558        // forces them onto one line there too — mirror it here. (`WordByWord`
559        // /`WordPop` show one word at a time; wrapping is moot for those,
560        // same as the existing kbd/counter/badge "atomic, never wraps"
561        // components, so this doesn't need a mode-specific branch.)
562        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
585/// Intrinsic measurer for [`Kbd`] — measures the key text plus the legacy
586/// keyboard-cap padding (h ≈ font_size × 0.7, v ≈ font_size × 0.4) and
587/// enforces a min-width of `font_size × 1.8`.
588pub 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
623/// Intrinsic measurer for [`Counter`] — reserves space for the largest absolute
624/// value the counter will display so layout never reflows during animation.
625pub 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        // Counter is atomic: it never wraps.
637        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
653/// Intrinsic measurer for [`crate::number_wheel::NumberWheel`].
654///
655/// Every digit column is as wide as the *widest* digit, because that is how
656/// the painter lays the reels out — otherwise a figure that lands on `111`
657/// would reserve a narrow box and then overflow it while a `0` rolls past.
658/// Measuring the value once per possible digit and keeping the largest gives
659/// exactly the painter's own `max over digits` per column, separators
660/// included, without duplicating its layout arithmetic here.
661pub 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        // A wheel is atomic: it never wraps.
688        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
704/// Intrinsic measurer for [`Badge`] — measures the label text plus icon, gap,
705/// and the size-derived horizontal/vertical padding.
706pub 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    // (font_size, h_padding, v_padding, icon_size) — matches badge.rs::params
756    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
763/// Build a CssStyle for text measurement carrying just the typography fields
764/// from `src`, with a forced `font-size` and `font-family` fallback.
765fn 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// Compatibility shim: keep an unused fn so old callers that referenced
783// `LineHeight::Number` style helpers compile cleanly.
784#[allow(dead_code)]
785fn _line_height_unused(_: Option<&LineHeight>) {}
786
787// ─────────────────────────────────────────────────────────────────────────────
788// Terminal intrinsic measurer
789// ─────────────────────────────────────────────────────────────────────────────
790
791use 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
796/// Intrinsic measurer for [`Terminal`].
797///
798/// Natural size formula (matches the painter exactly):
799/// - `line_height = ceil(font_size × TERM_LINE_HEIGHT / TERM_FONT_SIZE)`
800/// - `height = chrome_height + 2 × TERM_PADDING + n_lines × line_height`
801/// - `width` = widest line text (prefix + content) + 2 × TERM_PADDING
802///
803/// If the Skia font fails to load, returns (0, 0) so layout falls back to
804/// whatever container constraints supply.
805pub struct TerminalIntrinsic {
806    line_height: f32,
807    n_lines: usize,
808    chrome_height: f32,
809    padding: f32,
810    /// Maximum measured text width across all lines (including prefix).
811    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        // Measure each line (prefix + text) with the same Skia font the painter uses.
823        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        // Same resolver the painter calls — see `terminal::resolve_typeface`.
836        // Measuring with one face and painting with another is how text ends up
837        // overflowing a box the geometry pass has already approved.
838        let Some(typeface) = resolve_terminal_typeface(&t.style) else {
839            // Font unavailable (CI without fonts); return 0 — the layout will
840            // be width-unconstrained and the container drives the size.
841            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
874// ─────────────────────────────────────────────────────────────────────────────
875// Table intrinsic measurer
876// ─────────────────────────────────────────────────────────────────────────────
877
878use crate::table::{
879    Table, DEFAULT_CELL_PADDING, DEFAULT_FONT_SIZE as TABLE_FONT_SIZE, DEFAULT_ROW_HEIGHT_RATIO,
880};
881
882/// Intrinsic measurer for [`Table`].
883///
884/// Natural size formula (matches the painter exactly):
885/// - `row_height = font_size × DEFAULT_ROW_HEIGHT_RATIO`
886/// - `height = (1 + row_count) × row_height`  (header + data rows)
887/// - `width`: if `column_widths` are provided, their sum; otherwise each
888///   column gets `max(header_text_width + 2 × cell_padding, min_col_width)`.
889pub struct TableIntrinsic {
890    row_height: f32,
891    row_count: usize, // data rows only; header adds 1
892    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        // Explicit column widths provided → sum them.
913        if let Some(widths) = &t.column_widths {
914            if !widths.is_empty() {
915                return widths.iter().sum();
916            }
917        }
918
919        // Measure each header with the bold font; add 2× cell_padding per column.
920        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            // Font unavailable: fall back to col_count × a reasonable minimum.
924            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        // Also consider data cell widths to size columns appropriately.
932        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
967// ─────────────────────────────────────────────────────────────────────────────
968// Codeblock intrinsic measurer
969// ─────────────────────────────────────────────────────────────────────────────
970
971use 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
977/// Intrinsic measurer for [`Codeblock`].
978///
979/// Reuses `compute_code_dimensions` (same function as the painter) to derive:
980/// - `width  = max_line_width + gutter_width + pad_left + pad_right`
981/// - `height = line_count × line_height + pad_top + pad_bottom + chrome_height`
982///
983/// Computed once at construction from the initial `code` string. If a state
984/// transition widens the content at paint time, `auto_scroll` handles vertical
985/// overflow without needing the intrinsic to re-run.
986pub 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
1049// ─────────────────────────────────────────────────────────────────────────────
1050// RichText intrinsic measurer
1051// ─────────────────────────────────────────────────────────────────────────────
1052
1053use crate::rich_text::{RichText, RichTextSpan};
1054
1055/// Intrinsic measurer for [`RichText`].
1056///
1057/// M2: previously absent from `component_intrinsic` entirely, so a
1058/// `rich_text` with no explicit `width`/`height` laid out 0×0 and rendered
1059/// nothing. Reuses `RichText::compute_layout` — the exact same word-wrapped
1060/// line-breaking algorithm the painter uses — so the box taffy reserves
1061/// always matches what gets painted (same measure/paint-parity rationale as
1062/// [`TextIntrinsic`]).
1063///
1064/// Always measures the full (untruncated) content — a `visible_chars`
1065/// typewriter animation must not reflow layout as it plays.
1066pub 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    // ─── M1: white-space: nowrap/pre ────────────────────────────────────────
1215
1216    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        // Regression guard: making `nowrap`/`pre` real must not touch the
1285        // default (`normal`/unset) wrapping path.
1286        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    // ─── M2: rich_text intrinsic ─────────────────────────────────────────────
1306
1307    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        // M2's core defect: rich_text had no `component_intrinsic` entry at
1322        // all, so it measured 0×0 unless the author guessed a width/height.
1323        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        // M2's second ask: a long single span must wrap like any other text,
1344        // not just break at span boundaries (there is only one span here).
1345        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        // Measure/paint parity: the intrinsic must reuse the exact same
1381        // layout algorithm the painter does, or taffy could reserve a box
1382        // that doesn't match what gets drawn.
1383        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    // ─── M1 follow-up: gradient_text / caption honor white-space too ───────
1400
1401    #[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(&gt);
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        // Single line: height should be one line, not several.
1429        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(&gt);
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    // ─── #2 / #5: em/% typography resolve against own font-size, not 0 ────
1502
1503    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        // #2 reproduction: `line-height: "150%"` went through the
1523        // context-free `line_height_for`, which cannot resolve `%` and
1524        // silently fell back to 0 — the intrinsic then reported a
1525        // `line_count * 0.0 = 0` height, so `paint_pass.rs`'s `if height <=
1526        // 0.0 { return }` guard skipped painting the node (and its
1527        // subtree) entirely, even though `validate` reported success.
1528        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        // Sanity: matches the already-correct unitless-number form exactly,
1570        // proving em and the bare-number multiplier agree.
1571        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        // #5 reproduction: `letter-spacing: "1.2em"` at font-size 200
1589        // (=240px) went through the context-free `letter_spacing_px`, which
1590        // returns 0 for `em` — the intrinsic reserved a box as if tracking
1591        // were 0 while `Text::paint` (which already uses the `_ctx`
1592        // resolver) painted with the real 240px tracking, so `validate`'s
1593        // `unwrappable_text_overflow`/viewport checks (which re-measure via
1594        // this same intrinsic) never saw the real, wider painted width.
1595        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        // And it must differ from the old (broken) zero-tracking width —
1625        // otherwise this test would pass vacuously even if em still
1626        // resolved to 0.
1627        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    // ─── text-autofit: `shrink_to_fit` (pure binary search, no Skia) ───────
1648
1649    #[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        // Both axes unconstrained: no target to shrink for, regardless of
1667        // what `measure_at` reports.
1668        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        // Fake linear model (width = size * 2), matching how real glyph
1675        // widths scale roughly linearly with font size.
1676        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        // And it's close to the true boundary, not grossly under-shrunk: one
1681        // more px would no longer fit.
1682        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        // Width alone would allow a much bigger size than height alone —
1691        // the chosen size must satisfy the tighter of the two.
1692        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        // Content that never fits even at the floor: must stop exactly
1703        // there, not silence the overflow by continuing to shrink.
1704        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        // Same inputs, same deterministic binary search → same output every
1711        // time. This is the purity property the temporal-stability argument
1712        // (see `resolve_text_autofit`'s doc comment) rests on: nothing here
1713        // depends on when or how many times it's called.
1714        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    // ─── text-autofit: `resolve_text_autofit` (real Skia fonts) ────────────
1721
1722    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, // nowrap: single line
1739            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        // Prove the resolved size actually fits when wrapped/measured the
1748        // same way the caller will — not just that a smaller number came out.
1749        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        // Absurdly small box: even the floor doesn't fit, but the function
1778        // must still stop exactly at the floor.
1779        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    // ─── text-autofit: `TextIntrinsic` end to end ──────────────────────────
1815
1816    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        // A box at half the natural width needs roughly a ~50% size
1847        // reduction — comfortably above the legibility floor for a 60px
1848        // request, so this exercises the "shrinks and fits" path distinctly
1849        // from `..._still_overflows_when_even_the_floor_does_not_fit` below
1850        // (which drives it all the way to the floor on purpose). Derived
1851        // from the actual measured natural width rather than a hardcoded
1852        // px guess, so it isn't sensitive to exactly which glyph widths
1853        // this font ships.
1854        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        // Regression guard for the leak this feature must not reintroduce:
1904        // `Caption`'s painter has no idea `text-autofit` exists (only
1905        // `Text`/`GradientText`'s do), so its intrinsic must never shrink
1906        // because of it, even if the field is present in `style` — see
1907        // `TextIntrinsic::with_autofit`'s doc comment.
1908        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}