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

+77 -91
View File
@@ -1005,112 +1005,98 @@ fn a_region_node_root_is_a_region_node() {
assert_eq!(h.region(&probe).unwrap().size().x, Px::from_int(900));
}
/// A cap is the shorter of itself and the box the widget would have had, and
/// a floor the longer of itself and that box. Asked at the root, under a
/// parent that divides nothing, and in a span, since the box comes of one ask
/// wherever the widget is.
/// A bound is a rule about what a widget answers: it holds the length that
/// reaches whoever asked and leaves the box alone. Here the content is 400
/// wide in a 250 window, so a cap cuts what the row reports and a floor
/// raises it, while the rects inside stay where the 250 box put them.
#[test]
fn a_bound_decides_the_box_against_the_one_offered() {
let width = |rule: SizeRule, asked: Asked| {
let mut h = Harness::new((400, 200));
let probe = rect(Color::RED).add(&mut h.rsc);
h.rsc.widgets_mut().set_size_rule(probe, Axis::X, rule);
match asked {
Asked::Root => h.set_root(probe),
Asked::Wrapped => h.set_root(probe.wrapper()),
Asked::InASpan => h.set_root((probe,).span(Dir::RIGHT)),
}
h.region(&probe).unwrap().size().x
};
for (rule, want) in [
// Shorter than the 400 box, so the cap decides it.
(SizeRule::Max(Len::px(300.0)), 300),
// Longer than it, so the box stands.
(SizeRule::Max(Len::px(500.0)), 400),
// Longer than the box, so the floor decides it and it overflows.
(SizeRule::Min(Len::px(500.0)), 500),
(SizeRule::Min(Len::px(300.0)), 400),
// Both at once are one rule, and the cap is the shorter here.
fn a_bound_holds_what_a_widget_answers() {
let row = |rule: SizeRule| {
let mut h = Harness::new((250, 200));
let left = rect(Color::RED).width(200).add(&mut h.rsc);
let right = rect(Color::BLUE).width(200).add(&mut h.rsc);
let row = (left, right).span(Dir::RIGHT).add(&mut h.rsc);
h.rsc.widgets_mut().set_size_rule(row, Axis::X, rule);
h.set_root(row);
(
SizeRule::Clamp {
min: Len::px(100.0),
max: Len::px(300.0),
},
300,
),
] {
for asked in Asked::ALL {
assert_eq!(width(rule, asked), Px::from_int(want), "asked {asked:?}");
}
}
h.region(&row).unwrap().size().x,
h.region(&left).unwrap().size().x,
)
};
let (capped, left) = row(SizeRule::Max(Len::px(300.0)));
assert_eq!(capped, Px::from_int(300), "the cap, not the 400 drawn");
assert_eq!(left, Px::from_int(200), "the box the children were given");
let (floored, _) = row(SizeRule::Min(Len::px(600.0)));
assert_eq!(floored, Px::from_int(600), "the floor, not the 400 drawn");
let (free, _) = row(SizeRule::Free);
assert_eq!(free, Px::from_int(400), "what it drew");
}
/// A floor and a cap set one after the other are one rule, which is what lets
/// a caller say both without knowing about the third variant.
/// A cap on the box is `MaxSize`, which asks its child in the shorter of the
/// cap and its own box. That is the box a text wraps at and a scroll takes
/// its viewport from, so it cannot be had by holding the answer.
#[test]
fn a_floor_and_a_cap_set_apart_make_one_rule() {
fn a_cap_widget_asks_its_child_in_the_shorter_box() {
let mut h = Harness::new((400, 200));
let probe = rect(Color::RED)
.min_width(100)
.max_width(300)
.add(&mut h.rsc);
// A fraction of its box, so it says what box it was asked in.
let fills = rect(Color::RED).width(rel(1.0)).add(&mut h.rsc);
let capped = fills.max_width(300).add(&mut h.rsc);
h.set_root(capped);
assert_eq!(h.region(&fills).unwrap().size().x, Px::from_int(300));
assert_eq!(
h.rsc.widgets().size_rules(probe)[Axis::X],
SizeRule::Clamp {
min: Len::px(100.0),
max: Len::px(300.0),
}
h.region(&capped).unwrap().size().x,
Px::from_int(300),
"as long as its child used"
);
h.set_root(probe);
assert_eq!(h.region(&probe).unwrap().size().x, Px::from_int(300));
// A child that asked for a share takes the box the cap allows, and the
// share itself passes up: whoever divides one is this widget's parent.
let mut h = Harness::new((400, 200));
let share = rect(Color::RED).add(&mut h.rsc);
let capped = share.max_width(300).add(&mut h.rsc);
h.set_root(capped);
assert_eq!(h.region(&share).unwrap().size().x, Px::from_int(300));
assert_eq!(h.region(&capped).unwrap().size().x, Px::from_int(400));
}
/// Which of the cap and the box is shorter is a question in pixels, so the
/// box is decided again wherever the answer can change -- at the root as much
/// as under a parent, since nothing above the root will ask again for it.
/// Which of the cap and the box is shorter is a question in pixels, so it is
/// asked again wherever the answer can change -- and the widget asking it is
/// drawn again whenever its own box is, which is what keeps the two in step.
#[test]
fn a_bound_is_decided_again_on_either_side_of_the_crossing() {
for wrapped in [false, true] {
let mut h = Harness::new((400, 200));
let probe = rect(Color::RED).add(&mut h.rsc);
h.rsc.widgets_mut().set_max_len(probe, Axis::X, 300.into());
h.rsc.widgets_mut().set_min_len(probe, Axis::X, 200.into());
match wrapped {
true => h.set_root(probe.wrapper()),
false => h.set_root(probe),
}
let width = |h: &Harness| h.region(&probe).unwrap().size().x;
assert_eq!(width(&h), Px::from_int(300), "wrapped: {wrapped}");
h.resize((250, 200));
h.frame();
assert_eq!(width(&h), Px::from_int(250), "wrapped: {wrapped}");
h.resize((100, 200));
h.frame();
assert_eq!(width(&h), Px::from_int(200), "wrapped: {wrapped}");
h.resize((400, 200));
h.frame();
assert_eq!(width(&h), Px::from_int(300), "wrapped: {wrapped}");
}
}
/// A fraction in a bound is a fraction of the same box a declared length
/// would be: the rel base the widget was asked with, and not the box the
/// bound itself decided.
#[test]
fn a_bound_is_a_fraction_of_the_box_the_widget_was_asked_in() {
fn a_cap_widget_is_decided_again_on_either_side_of_the_crossing() {
let mut h = Harness::new((400, 200));
let probe = rect(Color::RED).add(&mut h.rsc);
h.rsc
.widgets_mut()
.set_max_len(probe, Axis::X, Len::rel(0.5));
h.set_root(probe.pad(Padding::uniform(50)));
h.set_root(probe.max_width(300));
let width = |h: &Harness| h.region(&probe).unwrap().size().x;
assert_eq!(width(&h), Px::from_int(300));
// Half of the 300 left by the padding, not half of the window and not
// half of itself.
h.resize((250, 200));
h.frame();
assert_eq!(
width(&h),
Px::from_int(250),
"its box, which is under the cap"
);
h.resize((400, 200));
h.frame();
assert_eq!(width(&h), Px::from_int(300));
}
/// A fraction in a cap is a fraction of the box the widget capping it was
/// given, which is the box a declared length of its own would be a fraction
/// of -- not of the window, and not of what the cap itself decided.
#[test]
fn a_cap_is_a_fraction_of_the_box_it_was_given() {
let mut h = Harness::new((400, 200));
let probe = rect(Color::RED).add(&mut h.rsc);
h.set_root(probe.max_width(Len::rel(0.5)).pad(Padding::uniform(50)));
// Half of the 300 left by the padding, not half of the window.
assert_eq!(h.region(&probe).unwrap().size().x, Px::from_int(150));
}
+2 -2
View File
@@ -171,8 +171,8 @@ fn a_capped_scroll_takes_its_viewport_from_the_cap() {
let top = rect(Color::RED).height(200).add(&mut h.rsc);
let bottom = rect(Color::BLUE).height(200).add(&mut h.rsc);
let scroll = (top, bottom).span(Dir::DOWN).scrollable().add(&mut h.rsc);
h.rsc.widgets_mut().set_max_len(scroll, Axis::Y, 100.into());
h.set_root(scroll);
let capped = scroll.max_height(100).add(&mut h.rsc);
h.set_root(capped);
h.move_to((200, 50));
// 400 of content in a viewport of 100, so 300 to scroll and the end