Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions crates/rustmotion/src/cli/commands/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ fn walk(
check_unwrappable_text(
&child.component,
&child_path,
&raw_bbox,
layout,
viewport,
vi,
si,
Expand Down Expand Up @@ -791,10 +791,32 @@ fn measurer_and_nowrap(component: &Component) -> Option<(Box<dyn IntrinsicMeasur
}
}

/// natural (unwrapped) width vs the node's own CONTENT box, not its
/// border box. `LegacyPaintDispatcher::dispatch` hands every non-codeblock
/// painter (`Text`/`GradientText`/`Caption` included) a synthetic
/// `BoxLayout` built from `layout.content_box()`, translated to the
/// content-box origin — so the painter wraps and draws inside the content
/// box, not the raw taffy layout box this walker reads. Comparing against
/// the border box (as this used to) under-reports by exactly
/// `padding.left + padding.right + border.left + border.right`, mirroring
/// the same fix `check_content_overflows_box` already applies for the
/// wrapped case.
///
/// Measured via the same cosmic-text–backed intrinsic the layout engine
/// uses. Width is bounded by the node's own resolved content-box width
/// (not `MaxContent`) so a `text-autofit: true` node can shrink to fit it —
/// see `measurer_and_nowrap`'s `TextIntrinsic`/`GradientTextIntrinsic` arms
/// and `CssStyle::text_autofit`'s doc comment. For a non-autofit node this
/// changes nothing: `TextIntrinsic::measure` only reads the width
/// constraint at all when `text_autofit` is on (see its early return), and
/// `nowrap` already forces a single unwrapped line here regardless of what
/// width is offered — so `natural_w` below is "natural" in the non-autofit
/// case exactly as before, and "shrunk to fit, if that's enough" when the
/// author declared it.
fn check_unwrappable_text(
component: &Component,
path: &str,
bbox: &BBox,
layout: &BoxLayout,
viewport: (u32, u32),
vi: usize,
si: usize,
Expand All @@ -806,22 +828,12 @@ fn check_unwrappable_text(
if !nowrap {
return;
}
// Measure via the same cosmic-text–backed intrinsic the layout engine
// uses. Width is bounded by the node's own resolved `bbox.w` (not
// `MaxContent`) so a `text-autofit: true` node can shrink to fit it —
// see `measurer_and_nowrap`'s `TextIntrinsic`/`GradientTextIntrinsic`
// arms and `CssStyle::text_autofit`'s doc comment. For a non-autofit
// node this changes nothing: `TextIntrinsic::measure` only reads the
// width constraint at all when `text_autofit` is on (see its early
// return), and `nowrap` already forces a single unwrapped line here
// regardless of what width is offered — so `natural_w` below is
// "natural" in the non-autofit case exactly as before, and "shrunk to
// fit, if that's enough" when the author declared it.
let (cx, cy, cw, ch) = layout.content_box();
let (natural_w, _) = intrinsic.measure(
(None, None),
(AvailableSpace::Definite(bbox.w), AvailableSpace::MaxContent),
(AvailableSpace::Definite(cw), AvailableSpace::MaxContent),
);
if natural_w > bbox.w + 0.5 {
if natural_w > cw + 0.5 {
let kind = component_kind(component);
out.push(GeometryViolation {
view_index: vi,
Expand All @@ -830,11 +842,16 @@ fn check_unwrappable_text(
component: kind.to_string(),
axis: Axis::X,
kind: ViolationKind::UnwrappableTextOverflow,
bbox: *bbox,
bbox: BBox {
x: cx,
y: cy,
w: cw,
h: ch,
},
viewport,
hint: format!(
"{kind} natural width is {natural_w:.0}px but only {:.0}px available — remove style.white-space: nowrap (or set it to normal) so it can wrap, or reduce style.font-size",
bbox.w
cw
),
});
}
Expand Down
51 changes: 51 additions & 0 deletions crates/rustmotion/tests/audit_ws_b.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,54 @@ fn fix_leaves_relative_asset_paths_untouched() {
"the actual violation --fix targeted must still be fixed: {fixed}"
);
}

// ─── unwrappable_text_overflow must measure the CONTENT box ────────

/// A nowrap text's own painter draws inside its CONTENT box
/// (`LegacyPaintDispatcher` hands it `layout.content_box()`, not the raw
/// layout box, for every component except `codeblock`) — so the geometry
/// check must compare the natural line width against the content box too.
/// Content box width here is 2000 - 1900 = 100px (950px of padding on each
/// side); the border box is 2000px. Any real natural width for this
/// string/font-size sits comfortably in between, so the violation fires if
/// and only if the content box is used.
#[test]
fn unwrappable_text_overflow_is_measured_against_the_content_box() {
let scenario = ScratchFile::new("rm31-scenario");
let report = ScratchFile::new("rm31-report");
let json = r##"{
"video": { "width": 2400, "height": 1080 },
"scenes": [{
"duration": 1.0,
"children": [{
"type": "text",
"content": "Hello World Example",
"position": "absolute",
"x": 50, "y": 50,
"style": {
"width": "2000px", "height": "300px",
"padding": { "top": "20px", "right": "950px", "bottom": "20px", "left": "950px" },
"white-space": "nowrap",
"font-size": "48px",
"color": "#ffffff"
}
}]
}]
}"##;
std::fs::write(&scenario.0, json).expect("write scenario");

let output = run_validate(&scenario.0, Some(&report.0), false, false);
let report_json = read_report(&report.0);
assert!(
!output.status.success(),
"the 100px content box (2000px border box minus 1900px of padding) is too narrow \
for this nowrap line; report={report_json}"
);
let violation = find_kind(&report_json, "unwrappable_text_overflow")
.expect("expected an unwrappable_text_overflow violation");
let width = violation["bbox"]["w"].as_f64().expect("bbox.w is a number");
assert!(
(width - 100.0).abs() < 1.0,
"violation bbox should be the 100px CONTENT box, not the 2000px border box: {report_json}"
);
}
Loading