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:
iris-aiandClaude Opus 5 committed 2026-09-20 14:34:34 -04:00
1 parent 0d0326769c
commit 76aaf06c0b
14 files changed
+604 -73

No files matched your search

+78 -29
View File
@@ -1,8 +1,8 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind};
use crate::{
ActiveData, Answer, Axis, Declared, DrawLayers, IdLike, LayoutHolds, LayoutLen, Len, MaskIdx,
MoveIdx, Moves, Painter, PixelRegion, PlaceDesc, PxVec2, Rel, Size, StrongWidget, UiRegion,
ActiveData, Answer, Axis, Bounds, Declared, DrawLayers, IdLike, LayoutHolds, LayoutLen, Len,
MaskIdx, MoveIdx, Moves, Painter, PixelRegion, PlaceDesc, PxVec2, Size, StrongWidget, UiRegion,
UiRsc, UiSpan, UiVec2, Weight, WidgetId, Widgets,
ui::painter::Ask,
util::{HashMap, Vec2},
@@ -30,6 +30,10 @@ pub(super) struct DrawInfo {
/// the answer somewhere else.
pub placed: PlaceDesc,
pub asked: PlaceDesc,
/// What the ask made of the widget's own rules. See [`Ask::declared`]
/// and [`Ask::bounds`].
pub declared: Declared,
pub bounds: Bounds,
/// What the ask that gave it those two holds for. See [`Ask::holds`].
pub ask_holds: LayoutHolds,
/// Whether the parent already asked about this widget in this draw.
@@ -158,6 +162,8 @@ impl UiRenderState {
region: ask.region,
placed: ask.place,
asked: PlaceDesc::WHOLE,
declared: ask.declared,
bounds: ask.bounds,
ask_holds: ask.holds,
re_asked: false,
}
@@ -236,7 +242,7 @@ impl UiRenderState {
);
}
let align = rsc.widgets().alignment(id);
let declared = rsc.widgets().declared_lens(id);
let declared = info.declared;
// Nothing this widget measured can be dirty while it draws: layout is
// one bottom-up walk, so anything deeper has settled or deferred to
// its own parent, and a deferred one leaves that parent marked.
@@ -392,9 +398,9 @@ impl UiRenderState {
// A rule wins on the axis it names, and the draw answers the rest.
// Applied here so it is one place rather than every widget that could
// carry one, and so the widget under a rule never learns of it. The
// rel base is the answer where the rule gave a length outright: it was
// resolved into the rel base when the child was asked, and resolving it
// again here would take the fraction of a fraction.
// rel base is the answer wherever the ask declared a length: it was
// resolved into the rel base when the widget was asked, and resolving
// it again here would take the fraction of a fraction.
let rules = rsc.widgets().size_rules(id);
let ruled = |axis: Axis, reported: LayoutLen| match rules[axis].exact() {
None => reported,
@@ -405,10 +411,37 @@ impl UiRenderState {
},
Some(len) => len.within_len(info.rel_base[axis]),
};
let size = Size {
let mut size = Size {
x: ruled(Axis::X, size.x),
y: ruled(Axis::Y, size.y),
};
// A bound is a promise about the length as well as about the box: a
// widget that drew past the box it was given -- a text too tall for
// it, an image at its own size under a cap -- is still held to what
// its rule allows.
//
// Held here rather than taken from the box, even where the bound
// decided that box. What a widget answers is its own, and a bound
// that replaced the answer would make a share into a fixed length
// the moment a box was long enough -- which is a length the span
// dividing that box decided from this answer, so the two would
// choose each other. A share is left alone here for the same reason:
// it is a length only to whoever divides one, and the box that
// divider gives is a box this widget is asked in, where the bound is
// applied to it.
let mut bounded = LayoutHolds::ANY;
for axis in Axis::BOTH {
let answer = size[axis];
if answer.leftover != Weight::ZERO {
continue;
}
let (outside, kept) =
info.bounds[axis].outside(answer.without_leftover(), window[axis]);
bounded[axis].window = kept;
if let Some(outside) = outside {
size[axis] = info.bounds[axis].at(outside).into();
}
}
// 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.
@@ -439,11 +472,12 @@ impl UiRenderState {
// A rule that is a fraction of the rel base is answered with the
// rel base's own length, so the answer is that rel base's and not just
// that many pixels of this window -- the same pin a widget that read
// its rel base took for its drawing.
let mut own_holds = own;
// its rel base took for its drawing. A bound counts: which side of it
// the box fell was decided against this rel base, and the same box of
// a different one can fall on the other.
let mut own_holds = own.and(bounded);
for axis in Axis::BOTH {
let fraction = rules[axis].exact().is_some_and(|len| len.rel != Rel::ZERO);
if fraction {
if rules[axis].has_fraction() {
own_holds[axis].rel_base = Some(info.rel_base[axis]);
}
}
@@ -475,6 +509,8 @@ impl UiRenderState {
region: UiRegion::FULL,
placed: PlaceDesc::WHOLE,
asked: PlaceDesc::WHOLE,
declared: Declared::NONE,
bounds: Bounds::ANY,
ask_holds: LayoutHolds::ANY,
re_asked: false,
},
@@ -503,7 +539,8 @@ impl UiRenderState {
primitives,
mask_region,
children,
declared: rsc.widgets().declared_lens(id),
declared: info.declared,
bounds: info.bounds,
own_align: rsc.widgets().alignment(id),
move_idx,
parent_move: info.parent_move,
@@ -673,6 +710,9 @@ impl UiRenderState {
let active = self.active.get_mut(&id).unwrap();
active.rel_base = info.rel_base;
active.placed = info.placed;
// What the ask made of its rules, which a re-place decides again.
active.declared = info.declared;
active.bounds = info.bounds;
#[cfg(feature = "layout-diagnostics")]
{
let (counter, outcome) = match (moved, is_region_node) {
@@ -720,6 +760,8 @@ impl UiRenderState {
region,
placed: place,
asked: active.asked,
declared: active.declared,
bounds: active.bounds,
// Placing decides no box: this is the one the ask already gave.
ask_holds: LayoutHolds::ANY,
re_asked: active.re_asked,
@@ -874,6 +916,7 @@ impl UiRenderState {
children: Vec::new(),
move_idx: info.parent_move,
declared: Declared::NONE,
bounds: Bounds::ANY,
own_align: rsc.widgets().alignment(id),
parent_move: info.parent_move,
mask: info.mask,
@@ -1058,12 +1101,22 @@ impl UiRenderState {
let Some(active) = self.active.get(&id) else {
return true;
};
// Its parent resolved its declared lengths into its box and decided
// whether to draw it at all, so a change to either is the parent's
// to draw -- with the mark left on, so the parent draws it rather
// than keeping it. So is a widget the parent asked twice: its
// layout rests on an answer this widget cannot give again alone.
let declared_changed = rsc.widgets().declared_lens(id) != active.declared;
// Asked where its parent asked it, which is what says whether the
// question is still this widget's own: its parent resolved its
// declared lengths into its box -- a bound of its own that the box
// falls outside is one of them -- and decided whether to draw it at
// all, so a change to either is the parent's to draw, with the mark
// left on so the parent draws it rather than keeping it. So is a
// widget the parent asked twice: its layout rests on an answer this
// widget cannot give again alone. The root's parent is the window,
// which no draw made and no answer can move.
let at = match active.parent {
Some(parent) => self.placing_of(parent, self.active[&parent].region),
None => Placing::WINDOW,
};
let ask = at.ask(rsc.widgets(), self.output_size, id, active.asked);
let active = &self.active[&id];
let declared_changed = ask.declared != active.declared;
let alignment_changed = rsc.widgets().alignment(id) != active.own_align;
if let Some(parent) = active.parent
&& (declared_changed
@@ -1083,17 +1136,11 @@ impl UiRenderState {
return true;
}
let (was_answer, was_holds, was_place) = (active.answer, active.holds, active.placed);
// The question its parent asked, asked again: the same place of the
// box the parent was asked in, which is the box the parent's own
// draw ran in and what its children's parts are of. Where the
// parent's answer put its own drawing is not a question anybody
// asked, and nothing is asked in it here either. The root's parent is
// the window, which no draw made and no answer can move.
let at = match active.parent {
Some(parent) => self.placing_of(parent, self.active[&parent].region),
None => Placing::WINDOW,
};
let ask = at.ask(rsc.widgets(), self.output_size, id, active.asked);
// The place the ask above came to: the same place of the box the
// parent was asked in, which is the box the parent's own draw ran in
// and what its children's parts are of. Where the parent's answer put
// its own drawing is not a question anybody asked, and nothing is
// asked in it here either.
let (rel_base, region) = (ask.rel_base, ask.region);
let info = DrawInfo {
layer: active.layer,
@@ -1106,6 +1153,8 @@ impl UiRenderState {
region,
placed: ask.place,
asked: active.asked,
declared: ask.declared,
bounds: ask.bounds,
ask_holds: ask.holds,
re_asked: false,
};