Add SizeRule::{Min, Max, Clamp}, which the oracle refuses
`MaxSize` on the app's pin narrows the box it asks its child in and cuts the
answer to the cap; nothing on this branch does either, so the capability is
missing rather than merely unported. This is that capability as a rule beside
the widget, the way `Exact` already is: `Min(Len)`, `Max(Len)` and
`Clamp { min, max }`, resolved against the rel base a declared length is a
fraction of, and never carrying `leftover` -- a cap containing a share admits
several self-sizing fixed points (`docs/LAYOUT.md`, failed hypotheses).
Where it stands: every hand-written test passes, including the capability the
app actually used -- `a_capped_scroll_takes_its_viewport_from_the_cap` puts
400 px of content under a 100 px cap and gets a 100 px viewport with 300 to
scroll, which is what `MaxSize` gave. The 400-seed depth-5 scan does not
pass, and the reason is a design question rather than a slip, so this sits on
its own branch instead of in #19.
What the scan finds: a bound is the first rule whose effect depends on the
box its parent gives it, and the retained machinery hands a widget a box by
paths that never ask it again -- `place_in` from a re-placing parent, and
`reposition` after a parent's box moved. A decision made when the box was one
length therefore survives into a box of another, so warm and cold disagree
about a tree they agree on structurally. Four readings were measured over 400
seeds at depth 5:
- deciding at every ask and keeping it: seeds 291, 1, 120, 178, 64 differ.
- the same, re-decided at `place_in` too: seeds 1, 362, 188, 254, 156 differ,
because that path's box is the one the answer chose rather than the one the
widget was asked in.
- skipping a place its parent decided outright, which is the rule the share
follows: worse -- the same widget then gets two decisions by two paths.
- the bound as an answer rule only, leaving the box alone: seeds 4 and 196,
and those are the closest to passing by a wide margin.
The share is the one existing rule of this kind and it is stable because
`place_at` re-asks a child whose rel base it narrows, and because its
decision is baked into the retained place as a `Sized` length. Neither
protection generalises: a bound that binds is a length of the rel base, and
`Sized` cannot say "this slot, narrowed" for a `Within` place.
Also here, because a bound needed them: `Len::longer_than` and
`Bound::outside` share one comparison with the span; a rule that is a
fraction now pins its rel base whether the fraction is a length or a bound,
which was a real gap for `Exact` too; `widget_trait!` passes attributes
through, so the methods it defines can carry doc comments (none could);
`From<N> for Len`, so a bound reads `max_width(300)`; and `random.rs` grows
all three variants, with `describe` printing them so a failure can be written
out by hand.
Format, clippy with and without layout-diagnostics, and the suite (142 + 19 +
13 + 4) are clean. The fast ten-seed oracle passes; the long scans do not.
Neutering the bounds in the generator while leaving its draws in place puts
the same shapes back to green, so the divergence is the bounds and not the
new trees.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d0326769c
commit
76aaf06c0b
14 files changed
+604
-73
No files matched your search
+133
-7
@@ -296,11 +296,11 @@ impl Asked {
|
||||
fn a_share_is_a_minimum_wherever_nothing_divides_it() {
|
||||
for (rule, want) in [
|
||||
(LayoutLen::LEFTOVER, 400),
|
||||
(LayoutLen::px(50) + LayoutLen::LEFTOVER, 400),
|
||||
(LayoutLen::px(500) + LayoutLen::LEFTOVER, 500),
|
||||
(LayoutLen::px(50.0) + LayoutLen::LEFTOVER, 400),
|
||||
(LayoutLen::px(500.0) + LayoutLen::LEFTOVER, 500),
|
||||
(LayoutLen::rel(0.5) + LayoutLen::LEFTOVER, 400),
|
||||
(LayoutLen::rel(2.0) + LayoutLen::LEFTOVER, 800),
|
||||
(LayoutLen::px(500), 500),
|
||||
(LayoutLen::px(500.0), 500),
|
||||
] {
|
||||
let want = Px::from_int(want);
|
||||
for asked in Asked::ALL {
|
||||
@@ -323,7 +323,7 @@ fn a_share_past_the_box_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.set_len(probe, Axis::X, LayoutLen::px(500) + LayoutLen::LEFTOVER);
|
||||
h.set_len(probe, Axis::X, LayoutLen::px(500.0) + LayoutLen::LEFTOVER);
|
||||
match wrapped {
|
||||
true => h.set_root(probe.wrapper()),
|
||||
false => h.set_root(probe),
|
||||
@@ -339,11 +339,11 @@ fn a_share_past_the_box_is_decided_again_on_either_side_of_the_crossing() {
|
||||
h.frame();
|
||||
assert_eq!(width(&h), Px::from_int(500), "wrapped: {wrapped}");
|
||||
|
||||
h.set_len(probe, Axis::X, LayoutLen::px(50) + LayoutLen::LEFTOVER);
|
||||
h.set_len(probe, Axis::X, LayoutLen::px(50.0) + LayoutLen::LEFTOVER);
|
||||
h.frame();
|
||||
assert_eq!(width(&h), Px::from_int(400), "wrapped: {wrapped}");
|
||||
|
||||
h.set_len(probe, Axis::X, LayoutLen::px(500) + LayoutLen::LEFTOVER);
|
||||
h.set_len(probe, Axis::X, LayoutLen::px(500.0) + LayoutLen::LEFTOVER);
|
||||
h.frame();
|
||||
assert_eq!(width(&h), Px::from_int(500), "wrapped: {wrapped}");
|
||||
}
|
||||
@@ -756,7 +756,7 @@ fn only_a_pure_leftover_child_disappears_when_nothing_is_left() {
|
||||
let mut h = Harness::new((100, 20));
|
||||
let fixed = rect(Color::RED).width(100).add(&mut h.rsc);
|
||||
let mixed = rect(Color::BLUE)
|
||||
.width(LayoutLen::px(20) + LayoutLen::LEFTOVER)
|
||||
.width(LayoutLen::px(20.0) + LayoutLen::LEFTOVER)
|
||||
.add(&mut h.rsc);
|
||||
h.set_root((fixed, mixed).span(Dir::RIGHT));
|
||||
|
||||
@@ -1004,3 +1004,129 @@ fn a_region_node_root_is_a_region_node() {
|
||||
h.frame();
|
||||
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.
|
||||
#[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.
|
||||
(
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn a_floor_and_a_cap_set_apart_make_one_rule() {
|
||||
let mut h = Harness::new((400, 200));
|
||||
let probe = rect(Color::RED)
|
||||
.min_width(100)
|
||||
.max_width(300)
|
||||
.add(&mut h.rsc);
|
||||
assert_eq!(
|
||||
h.rsc.widgets().size_rules(probe)[Axis::X],
|
||||
SizeRule::Clamp {
|
||||
min: Len::px(100.0),
|
||||
max: Len::px(300.0),
|
||||
}
|
||||
);
|
||||
|
||||
h.set_root(probe);
|
||||
assert_eq!(h.region(&probe).unwrap().size().x, Px::from_int(300));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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() {
|
||||
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)));
|
||||
|
||||
// Half of the 300 left by the padding, not half of the window and not
|
||||
// half of itself.
|
||||
assert_eq!(h.region(&probe).unwrap().size().x, Px::from_int(150));
|
||||
}
|
||||
|
||||
/// A cap is a promise about the length as well as the box: a widget whose
|
||||
/// content is longer than the box it was given reports what it drew, and the
|
||||
/// cap holds that down even though it never decided the box.
|
||||
#[test]
|
||||
fn a_cap_holds_an_answer_that_overflowed_its_box() {
|
||||
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_max_len(row, Axis::X, 300.into());
|
||||
h.set_root(row);
|
||||
|
||||
// The box is the 250 window, which the cap of 300 leaves alone, and the
|
||||
// row draws 400 of it. Its answer is the cap, and the window centres it.
|
||||
assert_corners!(h, row, (-25, 0), (275, 200));
|
||||
}
|
||||
@@ -161,3 +161,26 @@ fn content_that_fits_is_placed_in_the_viewport_and_not_in_the_window() {
|
||||
assert_corners!(h, scroll, (0, 100), (400, 400));
|
||||
assert_corners!(h, inner, (0, 225), (400, 275));
|
||||
}
|
||||
|
||||
/// A cap narrows the box the widget is asked in, which is what a scroll
|
||||
/// measures its viewport from: the content scrolls within the cap rather than
|
||||
/// within the room the cap was cut from.
|
||||
#[test]
|
||||
fn a_capped_scroll_takes_its_viewport_from_the_cap() {
|
||||
let mut h = Harness::new((400, 200));
|
||||
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);
|
||||
h.move_to((200, 50));
|
||||
|
||||
// 400 of content in a viewport of 100, so 300 to scroll and the end
|
||||
// showing: the top is 300 above the box, which the window centres.
|
||||
assert_eq!(h.region(&scroll).unwrap().size().y, Px::from_int(100));
|
||||
assert_corners!(h, top, (0, -250), (400, -50));
|
||||
|
||||
h.scroll((0, 1));
|
||||
h.frame();
|
||||
assert_corners!(h, top, (0, -200), (400, 0));
|
||||
}
|
||||
Reference in new issue
Block a user