From 5ed9e874a31bd12edd4e14a2213fb76d02d954ad Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Tue, 15 Sep 2026 23:48:19 -0400 Subject: [PATCH] Keep a span's leftover decision off the box its parent hands back The shrinker's `reorder` case had two red seeds at depth 5, and neither was about reordering. A span asks whether anything is left over by comparing its box in pixels with what its fixed and relative children fill. Where the parent sized that box from this span's own answer those are the same number, and the box returns through the chain a few bits off, so 0.00003 px decided it: warm rounded under and left a leftover-only child undrawn, cold rounded over and drew it at zero length. Both are stable, and the pixels are the same either way, which is why nothing but the oracle could see it. The room to divide is `len * fixed - total.px`, and under `HOLDS_EPSILON_PX` of it is now none. That moves the boundary off the length boxes land on rather than making the comparison tolerant: the validity range is still split at the boundary exactly, as generated seed 16 requires, and what it gives up is a share of under a twentieth of a pixel. The same margin answers the `fixed == 0` arm, where the only room is what negative pixels leave. `tests/unsettled.rs` gets the six-widget tree, shrunk from 266. It needs the span above the one that divides: without a box composed through it both trees round the same way and the boundary is never crossed. Checked: fmt, clippy, 89 tests, 100 generated seeds agreeing in 68.5 s, and all five shrinker cases at 1000 seeds of depth 6 (159,024 widgets each). `tabs`, `view`, `minimal`, `text` and `random` render byte-identical at 1920x1200 against the same worktree without the change. Co-Authored-By: Claude Opus 5 --- src/widget/position/span.rs | 33 +++++++++----- tests/unsettled.rs | 91 ++++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs index 873650f..7d61233 100644 --- a/src/widget/position/span.rs +++ b/src/widget/position/span.rs @@ -44,28 +44,39 @@ impl Widget for Span { let total = lens.iter().fold(Len::px(gap), |sum, len| sum + *len); // Whether anything is left over is a question in pixels: `rel(0.5)` - // beside 300 px is full at 600 and overfull at 400. The answer is - // the same on either side of the length the fixed parts alone fill. + // beside 300 px is full at 600 and overfull at 400. The room to divide + // is `len * fixed - total.px`, and under `HOLDS_EPSILON_PX` of it is + // none -- because the length where the room runs out is exactly the + // box a parent sizing itself from this answer hands back, and that box + // returns through the chain a few bits either way. Without the margin + // 0.00003 px of rounding decides whether a leftover-only child is + // drawn at all. The validity range is split at the moved boundary, and + // exact there, since no box lands on it any more. let fixed = 1.0 - total.rel; let mut shares = false; if total.leftover > 0.0 { let current = painter.px_len(axis); let holds = if fixed > 0.0 { - let full = total.px / fixed; - shares = current > full; + // The box length at which the room reaches the margin. + let enough = (total.px + HOLDS_EPSILON_PX) / fixed; + shares = current > enough; match shares { - true => Holds::exact(full.next_up()..=f32::INFINITY), - false => Holds::exact(f32::NEG_INFINITY..=full), + true => Holds::exact(enough.next_up()..=f32::INFINITY), + false => Holds::exact(f32::NEG_INFINITY..=enough), } } else if fixed < 0.0 { - let full = total.px / fixed; - shares = current < full; + // The relative parts grow faster than the box does, so here + // a shorter box is the one that leaves room. + let enough = (total.px + HOLDS_EPSILON_PX) / fixed; + shares = current < enough; match shares { - true => Holds::exact(f32::NEG_INFINITY..=full.next_down()), - false => Holds::exact(full..=f32::INFINITY), + true => Holds::exact(f32::NEG_INFINITY..=enough.next_down()), + false => Holds::exact(enough..=f32::INFINITY), } } else { - shares = total.px < 0.0; + // The relative parts take exactly the box, whatever it is, so + // the only room is what negative pixels leave. + shares = total.px < -HOLDS_EPSILON_PX; Holds::ANY }; painter.holds(axis, holds); diff --git a/tests/unsettled.rs b/tests/unsettled.rs index 046c644..bc28049 100644 --- a/tests/unsettled.rs +++ b/tests/unsettled.rs @@ -3,7 +3,9 @@ //! frame that had not settled: a wrapping text shaped at a width it was //! measured in rather than the one it was given. The rest are a widget //! measured again in a box its own answer had decided, where the old answer -//! is a fixed point whatever the content now says. +//! is a fixed point whatever the content now says. The last is neither: one +//! box length, composed two ways, landing either side of the boundary that +//! decided whether a child was drawn at all. use iris::harness::Harness; use iris::prelude::*; @@ -311,3 +313,90 @@ fn a_scrolls_retained_answer_is_the_one_a_cold_layout_asks_for() { assert_eq!(warm.region(&scroll), cold.region(&cold_scroll)); } + +/// Six widgets, shrunk from 266. `measured`'s box is exactly the height of its +/// one fixed child, which is the box a parent sizing itself from that answer +/// hands back -- so whether its leftover-only child was drawn at all came down +/// to the 0.00003 px the composed length differs by, one way warm and the +/// other cold. +fn plant_boundary(h: &mut Harness, swapped: bool) -> (Vec, [WeakWidget; 2]) { + let filler = rect(Color::RED).add(&mut h.rsc); + let plain = wtext("one line, overflowing whatever it is given") + .size(16) + .wrap(false) + .add(&mut h.rsc); + let mut pair: Vec = + vec![filler.add_strong(&mut h.rsc), plain.add_strong(&mut h.rsc)]; + if swapped { + pair.rotate_left(1); + } + let measured = Span { + children: pair, + dir: Dir::DOWN, + gap: 0.0, + ortho: OrthoSize::Children, + } + .add(&mut h.rsc); + // Takes the whole box on its own, so the span above has nothing left to + // divide and `measured` is given exactly the text's height. + let whole = rect(Color::RED).add(&mut h.rsc); + h.rsc + .widgets_mut() + .set_size_rules(whole, None, Some(Len::rel(1.0))); + let mut inner_children: Vec = vec![ + measured.add_strong(&mut h.rsc), + whole.add_strong(&mut h.rsc), + ]; + if swapped { + inner_children.rotate_left(1); + } + let inner = Span { + children: inner_children, + dir: Dir::DOWN, + gap: 0.0, + ortho: OrthoSize::Children, + } + .add(&mut h.rsc); + h.rsc + .widgets_mut() + .set_size_rules(inner, None, Some(Len::px(198.0))); + // One more span above it: without a box composed through it, both trees + // round the same way and the boundary is never crossed. + let outer = (inner,).span(Dir::DOWN).add(&mut h.rsc); + h.set_root(outer); + ( + vec![ + filler.id(), + plain.id(), + measured.id(), + whole.id(), + inner.id(), + outer.id(), + ], + [measured, inner], + ) +} + +#[test] +fn a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over() { + let mut warm = Harness::new((640, 900)); + let (ids, spans) = plant_boundary(&mut warm, false); + warm.frame(); + for span in spans { + warm.rsc[span].children.rotate_left(1); + } + warm.frame(); + + let mut cold = Harness::new((640, 900)); + let (cold_ids, _) = plant_boundary(&mut cold, true); + cold.frame(); + + let mut wrong = Vec::new(); + for (i, (&w, &c)) in ids.iter().zip(&cold_ids).enumerate() { + let (got, want) = (warm.region(&w), cold.region(&c)); + if got != want { + wrong.push(format!("widget {i}: warm {got:?} cold {want:?}")); + } + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); +}