From 95fb4f962ceb21a8d9cace4ac9e94786af1d3743 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Wed, 16 Sep 2026 03:48:23 -0400 Subject: [PATCH] Hold a clipping widget to its box, and check that it is `Scroll` reports `LEFTOVER` on both axes because it clips its content to its box: it can neither take less of one nor honestly ask for more. `Masked` is the other widget that clips and was passing its inner's size up, so a mask over something taller than its box asked to be placed at the length it had just cut off. It reports its box now, for the same reason. The `debug_assert` the handoff has been asking for is the one that would have caught both, narrowed to what is actually true: a widget that set a mask this draw has to report inside the box it drew in. Reported as "does not exceed the box" it fires on ordinary overflow instead -- measured, a hundred fuzzer trees produce thousands of them, every one a text too tall for the box it was offered, which is what a text is meant to say. `tests/cases/scroll.rs` has a clipping widget that reports its content, to show the assertion catches it. Checked: fmt, clippy, 104 tests, all five shrinker cases at 300 seeds, 100 generated seeds, five examples byte-identical at 1920x1200. Co-Authored-By: Claude Opus 5 --- core/src/ui/render_state.rs | 19 +++++++++++++++++++ src/widget/mask.rs | 8 +++++++- tests/cases/scroll.rs | 23 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index c21c321..d599ee9 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -366,6 +366,16 @@ impl UiRenderState { x: rules.x.apply(size.x), y: rules.y.apply(size.y), }; + // A widget that clipped its contents to its box drew nothing outside + // it, so reporting more than the box asks to be placed at a length it + // does not occupy -- and its parent would place the part it cut off. + // Overflowing is otherwise ordinary: a text too tall for the box it + // was offered reports the height it needs. + debug_assert!( + mask == info.mask || AXES.into_iter().all(|axis| within_box(size, px, axis)), + "'{}' ({id:?}) clips to {px:?} and reports {size}", + rsc.widgets().label(id), + ); let holds = [own[0].and(under[0]), own[1].and(under[1])]; debug_assert!( holds[0].contains(px.x) && holds[1].contains(px.y), @@ -1011,6 +1021,15 @@ impl UiRenderState { } } +/// Whether what a widget reports along `axis` is inside the box it drew in. +/// A share is a length only to whoever divides one, so it is not a claim +/// about this box and cannot exceed it. +fn within_box(size: Size, px: PxVec2, axis: Axis) -> bool { + let len = size.axis(axis); + let box_len = px.axis(axis); + len.leftover != Weight::ZERO || box_len.mul(len.rel) + len.px <= box_len +} + /// The same box is the same number of steps, both of these being lengths on /// the grid rather than floats to be compared for nearness. fn same_px(a: PxVec2, b: PxVec2) -> bool { diff --git a/src/widget/mask.rs b/src/widget/mask.rs index 2eee5de..45fd608 100644 --- a/src/widget/mask.rs +++ b/src/widget/mask.rs @@ -7,6 +7,12 @@ pub struct Masked { impl Widget for Masked { fn draw(&mut self, painter: &mut Painter) -> Size { painter.set_mask(painter.region()); - painter.widget(&self.inner).size() + painter.widget(&self.inner); + // What it occupies is its box, on both axes, for the reason `Scroll` + // reports the same: it clips what is inside to that box, so it can + // neither take less of one nor honestly ask for more. Passing the + // inner size up instead asks to be placed at a length it does not + // draw, and the framework would place the drawing it clipped away. + Size::LEFTOVER } } diff --git a/tests/cases/scroll.rs b/tests/cases/scroll.rs index 32c5e8d..be4ad06 100644 --- a/tests/cases/scroll.rs +++ b/tests/cases/scroll.rs @@ -59,3 +59,26 @@ fn a_wheel_scrolls_the_content_and_stops_at_its_end() { h.frame(); assert_corners!(h, top, (0, 0), (400, 200)); } + +/// A widget that clips to its box may not report more than the box: its +/// parent would place the part it cut off, and the framework would put a +/// drawing longer than its box somewhere. `Masked` is the second of these +/// after `Scroll`, and the assertion in `draw_at` is what says so. +#[test] +#[should_panic = "clips to"] +fn a_clipping_widget_reporting_more_than_its_box_is_caught() { + struct Clipper(StrongWidget); + + impl Widget for Clipper { + fn draw(&mut self, painter: &mut Painter) -> Size { + painter.set_mask(painter.region()); + painter.widget(&self.0).size() + } + } + + let mut h = Harness::new((100, 100)); + let tall = rect(Color::RED).height(400).add_strong(&mut h.rsc); + let clipper = Clipper(tall).add(&mut h.rsc); + h.set_root(clipper); + h.frame(); +}