Skip to content

Commit 14b7766

Browse files
authored
feat(text): let text declare that it must fit its box (#170)
Closes the second Critical gap from the re-scored Remotion differential, and the one the original audit called the most profitable reliability lever here. Until now, a text that overflowed its box was reported and nothing more. `apply_fixes` deliberately refuses `ContentOverflowsBox`, and its comment says why: growing the box, shrinking the font and shortening the copy are all legitimate, and picking one is not the validator's call. That reasoning holds only while the engine has no way to shrink text at all. Given one, the arbitration disappears — the author declares the intent, and a whole class of generation failure stops existing. `style.text-autofit` makes `text` and `gradient_text` reduce their font size until they fit the resolved width and, where taffy defines one, the content box height. `white-space: nowrap` still decides *whether* the text wraps; autofit decides *at what size* — they compose rather than compete. `auto_scroll` never interacts: codeblock and terminal do not read the field, by construction rather than by convention. Three hazards drove the design: - **Measure and paint must agree.** This repository spent a whole chantier repairing divergences where `TextIntrinsic` measured one thing and the painter drew another, blinding the geometry pass to real overflow. One pure `resolve_text_autofit` is called with identical arguments from both sides, so the agreement is structural rather than coincidental — and the tests assert it rather than merely checking the render looks right. - **The size must not drift.** Paint runs per frame. The resolution is fed the complete content, never the typewriter-truncated view, so a reveal cannot make the size oscillate mid-read. Asserted by rendering the same content at two instants and comparing pixels byte for byte. - **Shrinking needs a floor**, or a visible defect is traded for a discreet one. The floor reuses `MIN_LEGIBLE_FONT_RATIO`, already calibrated by visual inspection, relocated into `rustmotion-core` so both sides share the one constant instead of inventing a second. `rustmotion info` now reports each text's natural size, through the same measurer the engine and the validator use. The floor is pinned to a 1080-tall reference, because `IntrinsicMeasure` cannot see the frame height and using the real one on the paint side alone would reintroduce exactly the divergence above. On a taller canvas that floor therefore sits below the legibility threshold, and a declared 120px shrinking to ~13px on a 2160-tall frame would have passed the legibility check in silence — the failure mode this feature exists to remove, not relocate. `check_legibility` now warns in precisely that case, naming both numbers, and stays quiet at 1080 where the two agree. The precise fix is a canvas-relative floor on both sides; that needs the frame height plumbed into `TextIntrinsic` and is tracked separately.
1 parent a0fd570 commit 14b7766

7 files changed

Lines changed: 1737 additions & 109 deletions

File tree

crates/rustmotion-cli/src/commands/geometry.rs

Lines changed: 270 additions & 18 deletions
Large diffs are not rendered by default.

crates/rustmotion-cli/src/commands/info.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
use rustmotion::components::intrinsic::{GradientTextIntrinsic, TextIntrinsic};
12
use rustmotion::components::{ChildComponent, Component};
3+
use rustmotion::core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
24
use rustmotion::engine::animator::spring_rest_time;
35
use rustmotion::engine::render::deserialize_children;
46
use rustmotion::error::Result;
@@ -66,9 +68,132 @@ pub fn cmd_info(input: &PathBuf) -> Result<()> {
6668
}
6769
}
6870

71+
let text_sizes = collect_text_measurements(&scenario);
72+
if !text_sizes.is_empty() {
73+
println!("Text sizes:");
74+
for report in &text_sizes {
75+
println!(" {}", report.describe());
76+
}
77+
}
78+
6979
Ok(())
7080
}
7181

82+
/// "Quelle largeur/hauteur fait ce texte, à cette taille, dans cette
83+
/// police" (text-autofit workstream, lot text-autofit) exposed the same way
84+
/// `rustmotion info` already exposes spring settle times (see
85+
/// `SpringReport` above) rather than as a bespoke, separate command:
86+
/// `rustmotion info` walks the scenario and reports; this adds one more
87+
/// thing it reports.
88+
///
89+
/// The measurement is the natural (unconstrained) size at the declared
90+
/// `font-size`/family — via `TextIntrinsic`/`GradientTextIntrinsic`, the
91+
/// exact same Skia-backed measurer the layout engine and the geometry
92+
/// validator use, so this is never a second, independently-drifting
93+
/// estimate of what the same text measures elsewhere.
94+
struct TextMeasurement {
95+
label: String,
96+
kind: &'static str,
97+
preview: String,
98+
font_size: f32,
99+
natural_width: f32,
100+
natural_height: f32,
101+
autofit: bool,
102+
}
103+
104+
impl TextMeasurement {
105+
fn describe(&self) -> String {
106+
let autofit_note = if self.autofit {
107+
" (text-autofit: true — shrinks further if its box is smaller than this)"
108+
} else {
109+
""
110+
};
111+
format!(
112+
"{}: {} \"{}\" @ {:.0}px → natural {:.0}×{:.0}px{}",
113+
self.label,
114+
self.kind,
115+
self.preview,
116+
self.font_size,
117+
self.natural_width,
118+
self.natural_height,
119+
autofit_note,
120+
)
121+
}
122+
}
123+
124+
fn collect_text_measurements(scenario: &ResolvedScenario) -> Vec<TextMeasurement> {
125+
let mut out = Vec::new();
126+
for (vi, view) in scenario.views.iter().enumerate() {
127+
for (si, scene) in view.scenes.iter().enumerate() {
128+
let children = deserialize_children(scene);
129+
let path = format!("view {} / scene {}", vi + 1, si + 1);
130+
collect_text_measurements_in_children(&children, &path, &mut out);
131+
}
132+
}
133+
out
134+
}
135+
136+
fn collect_text_measurements_in_children(
137+
children: &[ChildComponent],
138+
path: &str,
139+
out: &mut Vec<TextMeasurement>,
140+
) {
141+
let natural = (AvailableSpace::MaxContent, AvailableSpace::MaxContent);
142+
for (i, child) in children.iter().enumerate() {
143+
let p = format!("{path} / layer {}", i + 1);
144+
match &child.component {
145+
Component::Text(t) => {
146+
let (w, h) = TextIntrinsic::from_text(t).measure((None, None), natural);
147+
out.push(TextMeasurement {
148+
label: p.clone(),
149+
kind: "text",
150+
preview: preview(&t.content),
151+
font_size: t.style.font_size_px_or(48.0),
152+
natural_width: w,
153+
natural_height: h,
154+
autofit: matches!(t.style.text_autofit, Some(true)),
155+
});
156+
}
157+
Component::GradientText(t) => {
158+
let (w, h) =
159+
GradientTextIntrinsic::from_gradient_text(t).measure((None, None), natural);
160+
out.push(TextMeasurement {
161+
label: p.clone(),
162+
kind: "gradient_text",
163+
preview: preview(&t.content),
164+
font_size: t.style.font_size_px_or(48.0),
165+
natural_width: w,
166+
natural_height: h,
167+
autofit: matches!(t.style.text_autofit, Some(true)),
168+
});
169+
}
170+
_ => {}
171+
}
172+
match &child.component {
173+
Component::Card(c) => collect_text_measurements_in_children(&c.children, &p, out),
174+
Component::Flex(c) => collect_text_measurements_in_children(&c.children, &p, out),
175+
Component::Grid(c) => collect_text_measurements_in_children(&c.children, &p, out),
176+
Component::Positioned(c) => collect_text_measurements_in_children(&c.children, &p, out),
177+
Component::Container(c) => collect_text_measurements_in_children(&c.children, &p, out),
178+
_ => {}
179+
}
180+
}
181+
}
182+
183+
/// Truncate a long content string for a single-line report — the full
184+
/// content is already visible in the source scenario file; this is a label,
185+
/// not a transcript.
186+
fn preview(content: &str) -> String {
187+
const MAX_CHARS: usize = 40;
188+
let char_count = content.chars().count();
189+
if char_count <= MAX_CHARS {
190+
content.to_string()
191+
} else {
192+
let truncated: String = content.chars().take(MAX_CHARS).collect();
193+
format!("{truncated}…")
194+
}
195+
}
196+
72197
/// Where a `SpringConfig` was found, and the settle time computed for it —
73198
/// the "measure du repos" issue #167 lot E asks `rustmotion info` to
74199
/// surface, so an author can size the enclosing animation's `duration`

0 commit comments

Comments
 (0)