Hold what a widget answers with a rule, and its box with a widget

Bryan's call, given the measurements in `76aaf06`: `SizeRule::{Min, Max,
Clamp}` holds the length a widget answers and never touches the box it draws
in, and `MaxSize` is the box version.

The split is the difference between a rule and a widget here. A box is
whoever asked's to decide, and the retained machinery hands a widget one by
paths that never ask it anything -- a parent re-placing a child, a subtree
repositioned after its parent's box moved. A rule that read the box was
therefore decided again by whichever path arrived last, which is what the
oracle was refusing. A widget has no such trouble: it is drawn again whenever
its own box changes, so `MaxSize` asks `longer_than` where the answer can be
kept, and `region_len` pins the box lengths its drawing holds for.

What that costs is nothing the app wanted: `a_capped_scroll_takes_its_
viewport_from_the_cap` puts 400 px of content under `.max_height(100)` and
gets a 100 px viewport with 300 to scroll, which is what `MaxSize` gave on the
app's pin, and `.max_width`/`.max_height` are that widget rather than a rule.
A cap narrows the offer and not a declared length, so a child that declares
500 px still draws 500 and the cap holds what `MaxSize` itself answers; a
child that asked for a share takes the box the cap allows and the share passes
up, since whoever divides one is `MaxSize`'s parent.

`.min_width`/`.min_height` stay a rule: answering at least so much is a claim
about the length, and a row honours it without anyone narrowing anything.

Bounds in the generated trees are pixels for now, with the reason written
where the next tree is grown: a fraction in a bound is resolved against the
rel base the widget was asked with, and `place_at` hands a parent a retained
answer without checking that it still holds for the rel base this place
gives. Seeds 4 and 196 at depth 5 are where that showed. The hole is older
than bounds -- an `Exact` rule that is a fraction can reach it too -- and
closing it is a check at the re-place site rather than anything about bounds.
A fraction through `MaxSize` is fine and tested, since the widget compares
against its own box.

Format, clippy with and without layout-diagnostics, and the suite (142 + 19 +
13 + 4) are clean. All three seed scans pass: 400 at depth 5 (62s), 1,000 at
depth 6 (162s), 2,000 at depth 4 (299s). The cold dump is 34,986 boxes and
moves wholesale against `2dba90b`, which is the generator growing rules it
did not grow before rather than a layout change; it is the new baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-20 15:13:46 -04:00
1 parent 76aaf06c0b
commit de1eb7e406
7 files changed
+183 -141

No files matched your search

+13 -27
View File
@@ -1,9 +1,9 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter};
use crate::{
Axis, Bound, Bounds, Declared, Holds, LayoutHolds, LayoutLen, Len, PlaceDesc, Px, PxVec2,
RegionAlign, Rel, RenderedText, RetainedPrimitive, Size, StrongWidget, TextAttrs, TextBuffer,
TextureHandle, UiRegion, UiRenderState, UiRsc, UiVec2, Weight, WidgetId, Widgets,
Axis, Bounds, Declared, Holds, LayoutHolds, LayoutLen, Len, PlaceDesc, Px, PxVec2, RegionAlign,
Rel, RenderedText, RetainedPrimitive, Size, StrongWidget, TextAttrs, TextBuffer, TextureHandle,
UiRegion, UiRenderState, UiRsc, UiVec2, Weight, WidgetId, Widgets,
render::{
GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveInst, PrimitiveKind,
TexturePrimitive,
@@ -272,13 +272,7 @@ impl<'a> Painter<'a> {
let states_rel_base = Axis::BOTH
.iter()
.any(|&axis| matches!(place[axis].rel_base, RelBase::Len(_)));
// A bound is decided against the box the widget is given, so a box
// decided here is a question rather than a move: putting the drawing
// in it would keep a decision made about the box it was measured in.
let bounded = Axis::BOTH
.iter()
.any(|&axis| self.rsc.widgets().size_rules(id.id())[axis].bound() != Bound::ANY);
if states_rel_base || bounded || !self.children.contains(&id.id()) {
if states_rel_base || !self.children.contains(&id.id()) {
return self.widget_at(id, place);
}
let at = self.placing();
@@ -732,7 +726,7 @@ impl Placing {
let align = widgets.alignment(id);
let rules = widgets.size_rules(id);
let mut holds = LayoutHolds::ANY;
let mut declared = widgets.declared_lens(id);
let declared = widgets.declared_lens(id);
let mut bounds = Bounds::ANY;
for axis in Axis::BOTH {
let base = place.base(axis, self.rel_base);
@@ -747,22 +741,14 @@ impl Placing {
if let Some(len) = share {
place[axis] = len.as_desc().fills();
}
// A bound the box falls outside is what the widget's length is
// instead, which is a declaration: the box comes to the bound,
// and its own answer is held to the same bound where it drew
// past that. Asked of the box it would otherwise have -- what it
// declares of the place, or what the place gives it.
let bound = rules[axis].bound();
bounds[axis] = bound.within_len(base);
let offered = declared[axis].map_or_else(
|| place.of(self.region, align)[axis].len(),
|len| len.within_len(base),
);
let (outside, kept) = bounds[axis].outside(offered, window[axis]);
holds[axis].window = holds[axis].window.and(kept);
if let Some(outside) = outside {
declared[axis] = Some(bound.at(outside));
}
// A bound holds what the widget answers, not the box it is asked
// in: the box it is given is whoever asked's to decide, and a
// rule that read it would be decided again by every path that
// hands the widget a box -- including the ones that never ask it
// anything. Resolved here because only the ask knows the rel base
// a fraction in it is of. `MaxSize` is the box version, and it is
// a widget because a widget is drawn again when its box changes.
bounds[axis] = rules[axis].bound().within_len(base);
}
let (rel_base, region) =
place.rel_base_and_region(self.region, self.rel_base, declared, align);