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>
`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>
The root had a layout path of its own: `root_layout` read its declared
lengths against the window, while every other widget's box came of
`Painter::widget_at`, where a rule of the widget's own -- a share with pixels
or a fraction beside it -- is compared against the offer and can take the box
past it. So a share on the root was the window whatever it asked for, which
`docs/LAYOUT_LOG.md` recorded as a gap rather than fixing, and any later rule
that reads the offer would have had to be written twice.
There is one box nobody drew, and that is the whole of what the root is
asked in. `Placing::WINDOW` says it -- the full output, fractions of the full
output, no move entry and no mask -- and `Placing::ask` is then the one place
a box is decided, called by the painter, by a local redraw, and by the root's
first draw. The root's own path is what is left of it: a widget with no
parent keeps different bookkeeping, not a different layout.
Measured in a 400 px window, a probe under each of three parents, which now
agree on every row where two of them agreed before:
rule as root wrapped in a span
leftover(1) 400 400 400
px(50) + leftover(1) 400 400 400
px(500) + leftover(1) 500 500 500 (was 400 as root)
rel(0.5) + leftover(1) 400 400 400
rel(2.0) + leftover(1) 800 800 800 (was 400 as root)
px(500) 500 500 500
rel(0.5) 200 200 200
The comparison is kept on the widget asked about rather than on the asker,
which is what makes the root need nothing of its own: a window range means
the same thing at either end of an ask, `in_parent` passes one up unchanged,
and the asker ends up holding it through the child's drawing exactly as it
did when `longer_than` narrowed the asker directly. The root has no asker, so
its own record is the only place that range can live -- and `resize` already
checks that record, so a share crossing its length is caught with no new
code. `a_share_past_the_box_is_decided_again_on_either_side_of_the_crossing`
now runs at the root too: 500 at a 400 window, 900 at 900, 500 again at 400.
Two things this changes beyond the share. `DrawInfo::asked` is now the place
the parent offered rather than the place the ask came to, so a local redraw
re-decides the rule instead of re-reading the decision -- the two were the
same until a rule could move the box. And the root's `is_region_node` is
read, where the old path passed `false`: a region-node root now gets its
entry, whose translation is the identity, pinned by
`a_region_node_root_is_a_region_node`.
Format, clippy with and without layout-diagnostics, and the suite (136 + 19 +
13 + 4) are clean. The cold dump over 400 depth-5 trees is byte-identical to
`2dba90b` across all 34,571 boxes, and the three seed scans pass: 400 at
depth 5 (61s), 1,000 at depth 6 (155s), 2,000 at depth 4 (291s).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Image` is the only widget in the repository whose size hint is a length in
pixels -- everything else hints a share, or nothing -- so it is the only one
that exercises a rule beside a hint, a box a widget knows before it is drawn,
and the answer the commit before this one changed. The generated trees had
none, which is why nothing there could reach that case.
`Kind::Image` is a fifth leaf, drawn one time in five, and it steps to a plain
rect when the shrinker reduces it: a picture measures nothing either, but its
length is its own, so the leaf that takes whatever it is given is the simpler
one. The picture is a 64x64 checkerboard of purple and black in 8 px cells,
committed at `src/assets/checkerboard.png` beside the generator that draws it
-- the way `examples/tabs` keeps its own -- and included rather than opened, so
that growing a tree does not depend on a working directory and one seed is one
tree whatever anything else does.
One upload per tree, however many images it grows: a `TextureHandle` is a
counted reference, so the first image in a tree uploads the checkerboard and
every one after it clones the handle. Measured: seed 1 at depth 4 grows 13
images and holds 1 texture, seed 6 grows none and holds none, and
`a_tree_of_images_uploads_one_texture` asserts it. `Image::new` is what a
caller holding a handle needs, since `image` uploads what it is given.
A seed names a tree only while the generator draws the same things in the same
order, so every seed now grows a different tree. The seed list in
`generated.rs` says so: 20 and 86 no longer grow the trees whose defects they
once caught, and both of those live on as shrunk fixtures in `unsettled.rs`,
which are trees rather than numbers. The seeds those fixtures name are
similarly historical, and their file says that too.
Format, clippy with and without layout-diagnostics, and the suite (135 + 19 +
13 + 4) are clean. The cold dump is a new baseline of 34,571 boxes over the 400
depth-5 trees, since the trees themselves changed; all three seed scans pass
over the new ones -- 400 at depth 5 in 62.79s, 1,000 at depth 6 in 160.20s,
2,000 at depth 4 in 299.58s -- which is what actually checks that images lay
out warm the way they do cold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A share under a parent that divides nothing is still a share: the pixels and
fraction beside it are taken first, the share fills whatever the box has left,
and where those are already longer than the box they overflow it exactly as
they would without the share. So the length is `max(box, px + rel*box)`, a
minimum the share imposes rather than an addition to what was asked for
(Bryan, 2026-09-20, generalising the same `max` he gave for `Scroll`'s content
length two days earlier).
A span does that. Measured at `77ed7a2`, a probe recording the box it is asked
in, in a 400 px window, under `.wrapper()` against a one-child span:
rule nothing divides a span divides
leftover(1) 400 400
px(50) + leftover(1) 400 400
px(500) + leftover(1) 400 500
rel(0.5) + leftover(1) 400 400
px(500), no leftover 500 500
One row disagreed, and the same length without the share overflows fine
(drawn -50..450, its alignment centring it), so what swallowed the overflow was
the share. `LayoutLen::declared` refuses to answer for anything carrying
leftover weight, so the non-dividing path never learned the fixed part and fell
back to the offer.
Said as the place the parent gives rather than as a declaration, because that
is what the retained record already keeps: where the fixed part is the longer,
`widget_at` hands the child `fixed.as_desc().fills()` -- a box of that length,
placed by the child's alignment, its own rel base -- which is what a declared
length already comes to, and `active.placed` stores it, so a recomposed subtree
reads the same box without resolving anything again. A place that is already
the child's placement is skipped: a parent that divides has given the share
whatever it was owed, and re-placing a span's slot moved its child.
Which of two lengths is longer is a question in pixels, so it is one operation
with the crossing kept as a window range, and both callers now share it.
`Painter::longer_than` is that operation -- the span's room for the shares it
divides, and a share past the box it was given -- and it narrows this widget's
range where the span replaced it, since a comparison the framework makes on an
arbitrary parent's behalf is one more reason its drawing holds, not the only
one. A `SizeRule::Min` of `rel(1.0)` is the same operation again, which is what
this is (Bryan, 2026-09-20); when that lands it belongs on this path.
`a_share_is_a_minimum_wherever_nothing_divides_it` walks the table above and
holds the two parents to the same length; the crossing case is checked from
both sides, by a window that crosses it and by the rule itself crossing while
the window holds still. Both fail at `77ed7a2` with 400 where 500 is wanted. A
change of rule needs nothing to escalate it: the reported size is the rule
resolved, so the answer changes and the parent refuses its own drawing --
verified by writing the escalation, finding the tests pass without it, and
dropping it.
Format, clippy with and without layout-diagnostics, and the suite (134 + 19 +
13 + 4) are clean. The cold dump over 400 depth-5 trees is byte-identical to
`77ed7a2` across all 34,488 boxes, since no generated tree carries a share with
pixels beside it -- which the next commit changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A ninth sweep, over the part no earlier round named -- the widget vocabulary and
the builder methods, `Widgets`, the examples, the `util` additions and the
manifests -- and once more over `77ed7a2`, the eighth sweep's own commit and so
itself unreviewed.
A hint overrode a rule. `declared_lens` asked `rules[axis].declared()` first and
fell through to the widget's own `size_hint` whenever that answered `None` --
which it does for a share, since a share is not a declaration. So a widget
carrying `width(leftover(1))` and hinting a pixel length of its own was handed a
box of the hint, against the rule and against the comment inside the function:
"a hint still narrows the box where no rule does". `Painter::size_hint` spells
the same rule-else-hint step three hundred lines up and gets it right, with the
reason written on it; both read `Widgets::exact_len` now, and `declared_lens` is
the part of its answer that needs nobody to divide it. `Image` is the only
widget here whose hint is a declared length, and neither the tests nor the
generator builds one, so nothing in this repository could reach the difference
-- which is why the dump is unchanged and why the test builds a widget of its
own. It records the box it was asked in: 400 with the rule and 50 without, and
50 either way before this.
Marking a widget for redraw had no name. Twenty-one sites under `tests/` said it
as `widgets_mut().get_dyn_mut(id);` with the widget thrown away, five with a
`let _ =` in front, one with a comment explaining what the line was for, and one
wrapped in a local function called `mark`. `Widgets::mark_for_redraw` says it.
`revision_cost.rs` keeps the long spelling and now says why in place: it is
deliberately in the API subset an old worktree also has.
`assert_same_regions` could not see the defect the eighth sweep had just fixed.
It zips the warm and cold id lists, so a list naming one widget twice -- which
is what `width`, `sized` and `align` giving back their own argument produces --
compares fewer boxes than it lists and says nothing about it. It now rejects a
repeated id and two lists of different lengths, which also checks the nine
fixtures that round left alone: all eighteen cases pass.
Bare pairs where the framework has named ones. `random.rs`'s `Lens` and `Aligns`
were `[Option<LayoutLen>; 2]` and `[Option<AxisAlign>; 2]`, read as `[0]`/`[1]`
and zipped against a hand-written `[Axis::X, Axis::Y]`. They are `SizeRules` and
`Align`; `Align` took the `Index<Axis>` every other per-axis pair on this branch
has, and `RegionAlign::from` does the "an axis left out is centred" step two
rigs were spelling per axis. The three sites that wrote the axis pair out say
`Axis::BOTH`, which is what the rest of the layout code says.
`BothAxis<T>`, `AxisT`, `XAxis` and `YAxis` -- 45 lines with a const trait, two
marker types and three accessors -- have no user anywhere in the workspace. They
are the mechanism `impl_axis_index!` replaced, in the file this branch took
`Vec2::axis`/`axis_mut` out of. Deleted, which is a drive-by in a block the
branch was already rewriting; drop it if the scope matters more.
Smaller things, each in its own place: `Wrapper` arrived beside core's
`WidgetWrapper`, one word for a widget that wraps a child and for a dynamic
borrow guard, so the alias is gone and its two uses name `DynBorrower` -- which
is what they are. `Wrapper::new`, `Wrapper::empty` and its `Default` were three
names for one value, two of them unused. `Arena::get_mut` was the only
`pub(crate)` among `pub` siblings on a public type. `Selector` rounded the
pointer onto the pixel grid to do arithmetic on two values already there, losing
the precision the platform gave it for nothing; the step between the regions is
taken on the grid instead. And the two `debug` profile settings carry their
reason where the next reader looks rather than only in the commit that made
them, one of which was about renaming `rest`.
Format, clippy with and without layout-diagnostics, and the suite (132 + 19 + 13
+ 4) are clean. The cold dump over 400 depth-5 trees is byte-identical to
`77ed7a2` across all 34,488 boxes, and all three seed scans pass: 400 at depth 5
in 63.27s, 1,000 at depth 6 in 160.45s, 2,000 at depth 4 in 302.52s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An eighth sweep, over the part no earlier round named: the 6,300 lines of
tests, and once more over the seventh sweep's own commit, which was itself
unreviewed.
Four of the shrunk fuzz fixtures name one widget two or three times. `width`,
`sized` and `align` set a rule on the widget they are given and return its own
id -- only `pad` and `wrapper` make a new one -- so `let sized =
wrapped.width(76).add(..)` and the `let aligned = sized` beside it are three
names for one text. Each name then went into the list of ids the case compares
warm against cold, so a case that says it checks six boxes checks four, and
three doc comments quote that inflated count as the size of the tree the
shrinker reduced to. Measured: `plant` and `plant_fixed` list 6 and hold 4,
`plant_pair` lists 4 and holds 3, `plant_scrolled` lists 8 and holds 7. The
aliases are gone and the counts say what the fixtures build; each rebuilt
fixture was diffed against the old one, and both the widget slots and every
region are identical, for both settings of `swapped`.
`assert_same_regions` sits at the top of `unsettled.rs` and six tests call it.
Seven more spell its body out instead, byte for byte. They call it now, and it
is `#[track_caller]` so the panic names the case.
`tests/gpu/mod.rs` holds the adapter probe and the surface configuration that
`draw_cost` and `chain_cost` had a copy of each -- `config` identical, and the
probe identical but for the feature it asks for. The leak's justification lived
in one file with the other referring to it; it now sits on the thing it is
about. Shared through `#[path]`, the way `scenario/mod.rs` already is.
The mask a widget is clipped by was resolved in three places, two of them a
byte-identical closure. `mask_bounds` takes the slot rather than the widget,
because the third site deliberately reads the slot it saved before the frame:
that a redraw keeps the slot is what it is checking.
`Layered::_revision` was a field nothing reads, incremented to mark the widget
dirty. Two tests in the same file already do that with
`get_dyn_mut`, which is what the underscore was hiding.
`plan.rs` claimed every simplification is strictly smaller, and asserted `<=`.
Measured: 53 of one tree's 101 simplifications keep the widget count, since a
dropped alignment and a simpler leaf both do. The assertion is right and the
claim was not; the comment now gives the argument that does hold.
`generated.rs` said "Seven that have never failed" and "the nine the others
check" of a ten-seed array. The `should_panic` scroll test ended in an
`h.frame()` that cannot run, since `set_root` lays out and is where the panic
comes from. Two `drop(tree)` at the end of their own scope did nothing.
Format, clippy with and without layout-diagnostics, and the suite (131 + 19 +
13 + 4) are clean. The cold dump over 400 depth-5 trees is byte-identical to
f8aa0c5 across all 34,490 boxes. No library code changed, so the seed scans
have nothing to find. Both GPU rigs were rebuilt and run: chain cost +470% at
depth 64, draw cost ~4.4 us per layer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A seventh sweep, over the parts no earlier round named: the tree generator
and the scenario harness, `Fixed`, the headless rig, and once more over the
commit the sixth sweep left, which was itself unreviewed.
`Scroll`'s content box is `answer_px.max(container_len)`, so a scroll whose
content fits has nothing to scroll through and `update_amt` has already put
`amt` at zero. The test choosing between the viewport and a scrolled span
asked `amt != ZERO || content_len != container_len`, where the first
disjunct can never decide it -- the same defect `b7b8d09` removed from the
line above, one operand over. A `debug_assert` of the implication held
across the whole suite, including every scrolling test.
`Fixed::to_scale` and its private `shift_round` arrived on this branch with
no caller and never got one; the only thing that called either was the test
written for them.
`Len::align` wrote `Len` arithmetic out a component at a time, around an
`at.px` that is always zero, where `Len::scale` and the `Add`/`Sub` beside
it say the whole rule in two lines. `LayoutLen::without_leftover` took
`self` where the `apply_leftover` its own doc calls the opposite reading of
the same value takes `&self`.
`run-headless.sh --resize` changed the output's mode but not `out_w`/`out_h`,
which is the extent `replay-touch` scales a recording against -- so
`--resize` with `--replay` put every sample of the gesture somewhere else
and still finished like a run that worked. Both come from one function now.
The generator's plan/build split stranded a comment: "a row takes the height
it is given" describes the size rule `build` derives from `dir`, and it was
left above the `gap` draw, which is the one line it is not about and which
does consume randomness.
Format, clippy with and without layout-diagnostics, and the suite (131 + 19
+ 13 + 4) are clean. The cold dump over 400 depth-5 trees is byte-identical
to b7b8d09 across all 34,492 boxes, and all three seed scans pass: 400 at
depth 5 in 62.75s, 1,000 at depth 6 in 162.37s, 2,000 at depth 4 in 305.25s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from a sweep over the WGSL prelude and the position widgets,
scoped against upstream/main at ca2b4b2.
`module_source` already builds each shader's preamble from iris_core's own
constants, so the move-chain work's second copy of `MOVE_NONE` and
`CHAIN_LIMIT` -- under "keep in step with iris_core::CHAIN_LIMIT" -- asked a
reader by hand for what the mechanism beside it exists to do. Both are
injected now, with `MASK_NONE` beside them replacing a bare literal, and the
shader declares none of them.
`Scroll`'s `content_len` is never less than its box, so `slack` and the
`anchor` computed from it were always zero whatever the alignment: the
framework centres short content by placing the answer in the whole box, and
the comment credited arithmetic that could not have done it. The same belief
guarded the fits-in-the-box contract with `align == NEG`, so at the default
alignment -- the middle -- every box change redrew the scroll, measured as 1
widget against 0 at TOP_LEFT. `align` now has no reader at all.
`UiSpan::translated` and `UiRegion::translated` are reachable only from each
other and from nothing else.
Format, clippy with and without layout-diagnostics, and the 131-test suite
are clean. The cold dump over 400 depth-5 trees is byte-identical to
1096c31, and all three seed scans pass: 400 at depth 5 in 69.07s, 1,000 at
depth 6 in 169.29s, 2,000 at depth 4 in 300.75s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Painter::text_data` had no caller. It was left in the previous round because
it is the only way a widget inside `draw` can reach `TextData`, and the app's
pending integration might have wanted it; nothing in iris is kept for the
app's sake, since the app is to be largely rewritten against this API rather
than ported call by call (Bryan, 2026-09-20).
A method taking `self` can only be called on a value, so anywhere the caller
holds a reference it has to dereference to ask -- which costs the caller
whether or not the type is `Copy` (Bryan, 2026-09-20, correcting the opposite
change made in 7502176).
So every method that answers a question about a value takes `&self`:
`Holds`'s four, `AxisHolds` and `LayoutHolds`'s three each, `LayoutLen`'s
`is_px`, `is_only_leftover`, `declared` and `fills`, and `Size::within_box`.
The two callers passing `LayoutLen::declared` as a function value say the
closure instead.
Builders that return a changed copy, and methods on a handle that is meant to
be given up, still take `self`.
`Axis::pair` and `RegionAlign::NEAR` arrived on this branch with no caller
and never got one. `Holds::contains` took `&self` where its five siblings on
the same `Copy` pair of pixels take `self`.
The comment beside a span's cross-axis accumulator said a scalable child
"makes Children scalable too"; `Children` names nothing here, and what it
makes scalable is the span.
`AxisHolds` is four contracts, and `diag::outside` counted three: a refusal
because this window is outside the range the drawing was made for bumped
"reuse outside: a rel base". The two are different questions -- a window
range is pixels, a rel base pin is a window-unit length that an unchanged
window can still change -- so the rig answered "why did that redraw?" with
the wrong one for every resize.
`Surface::configure` panics while a texture the surface handed out is still
alive, which wgpu says at both `configure` and `get_current_texture`. The
`Suboptimal` arm configured with the texture it was about to draw with in
hand, so the first suboptimal frame -- a resize or a display change on some
drivers -- takes the app down instead of rebuilding the swapchain.
The texture is good for this frame, so it is drawn with and presented, and
the rebuild happens once `present` has consumed it.
`redraw` keeps the narrower of an old and a fresh contract so that widening
and narrowing back do not churn the parent that reads it. The drawing's half
asked whether the old range still covers this window and box before keeping
it; the answer's half did not, so a widget whose answer contract widened in
a frame that also resized the window kept a range the new window is outside.
The parent's next ask then refuses that answer and draws the whole subtree
again -- throwing away the drawing the widget had just made. Cost, not
geometry: the size kept is the size just reported.
`a_contract_this_window_is_outside_is_not_kept` draws the leaf twice before
the change and once after.
`Case::SizeResize` was in `ALL` and in none of the `case!` invocations, so
a size change followed by a resize -- the order the enum's own comment
argues is not the same test as the other one -- was only ever checked by
the ignored long run. The tests and the list of which cases have one come
from one macro invocation now, and a case missing from it fails a test
rather than going quiet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of the eight rejections in `try_reuse` were invisible or half-visible
to the diagnostics: a changed inherited mask counted nothing and traced
nothing, an undrawn record traced without counting, and a changed
region-node choice counted without tracing. The mask one is the rejection
this branch's repair was about, so "why did that redraw?" was exactly the
question the rig could not answer.
Adding a counter meant editing a variant list and a name list at the same
index, which renames every total after a slip and says nothing. The two
lists are one declaration now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A local redraw keeps the narrower guarantee its parent holds when the new
drawing covers it, so widening and narrowing back do not churn the parent.
It checked that guarantee against `placement`, where the answer put the
drawing, rather than `region`, the box the drawing was made in and the box
both contracts are about. The two differ on every axis a widget reported
less than it was offered, so any such widget escalated to its parent every
time its contract widened -- which is the churn the retention exists to
avoid. `resize` and `try_reuse` both already ask about `region`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ActiveData::size_deps` was written on every draw, cleared on every undraw,
and read nowhere: a `Vec<WidgetId>` per active widget for a list only the
`Painter`'s own copy is used from, in `draw_at`, before the record is built.
What it looked like it was for -- reaching a widget whose size was read --
is already done there, by recording whoever asked about a child it did not
draw.
`SizeRule::apply` had no caller and would have been wrong if it found one:
it answers with the rule's own length, where `draw_at` resolves a fraction
against the rel base first. One rule, applied in one place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PlaceSpan` and `RelBase` were private to `place.rs`, so `painter.rs`
reached them through six `pub(crate)` accessors -- `stated_rel_base`,
`narrows_rel_base`, `within_span`, `is_sized`, `does_fill`,
`with_rel_base` -- and `in_parent` re-derived a three-case enum from
five yes/no answers. The two enums are `pub` now and `ui/mod.rs`
re-exports `place` by name rather than by glob, the way it already did
for `painter`, so nothing new leaves the crate and there is no
visibility qualifier to get right. All six accessors are deleted:
`in_parent` matches `(at.span, declared)`, `resolve_rel_base` matches
`(rel_base, span)` and assigns, and the other three read the field.
`!at.is_sized()` was dead. `PlaceSpan::Sized` is built in exactly one
place, `Len::as_desc`, which sets `RelBase::Len(self)` in the same
literal, so `stated_rel_base().is_none()` already excluded it. Deleting
`with_rel_base` removes the only writer that could have separated the
two, so a named length now carries its own base by construction rather
than by habit -- which is what the comment on `RelBase` says.
The four `pub(crate)` methods left in `painter.rs` are inherent methods
on types the crate does export, so hiding the path is not available to
them; they are `pub(super)`, which is the module tree that calls them.
fmt, workspace clippy under `-D warnings` with and without
`layout-diagnostics`, and the workspace tests under both are clean. The
cold dump over 400 depth-5 trees is byte-identical: 34,492 boxes.
`DrawInfo::px` was the child's rel base in pixels, resolved at all four
construction sites on every draw and read only by three diagnostics --
each of which called it the box: `diag::draw_request`'s `pixel_size`,
printed by `trace_unsettled` as "draw in"; and two `debug_assert`
messages saying "clips to" and "drew in". A rel base and a box differ
wherever a parent hands down part of its own, which is every child of a
span, so all three said something that was not true.
The field is gone and each site reads `region.to_px(window)`, which is
the box it claimed to be printing and costs nothing outside a failing
assert. The trace field is `region_px`. `Placing::window` existed only
to resolve that value and follows it out.
The 23-line counter block inside `try_reuse`'s "outside its range"
branch is `diag::outside`, beside the other diagnostics, so the decision
reads as its six checks.
fmt, workspace clippy under `-D warnings` with and without
`layout-diagnostics`, and the workspace tests under both are clean. The
cold dump over 400 depth-5 trees is byte-identical: 34,492 boxes. The
trace now prints "draw in 189.00x176.00" beside a region 189 by 176.
`along` said nothing about what it did. It is `Span::slot` now: the
stretch of the row between two distances from where the span starts
laying out, as a span of its own box, with the mirror for a negative
direction in one place. `far` is `row`, which is what the comment above
it already called it, and `shares` is `has_room` beside the
`any_leftover` it was folded into. `reached` now guards on the leftover
weight it divides by rather than on the numerator that happened to be
zero with it.
The pairs layout returns are named rather than positional: `Answer`
{size, holds} and `Drawn` {answer, drawing_holds} replace
`(Size, LayoutHolds)` and a three-tuple with two `LayoutHolds` in it,
which was the one shape the cold dump exists to catch. `try_reuse`
answers `bool` rather than `Option<()>`, and the four hand-written
copies of `move_idx != parent_move` are `ActiveData::is_region_node`.
`AXES` was declared in three modules; it is `Axis::BOTH`. `rel_min`,
`rel_max` and the unused `select_len` are gone -- `ZERO` and `FULL`
already said those. Three doc comments sat on `impl` blocks instead of
the single method inside them. `reposition` and `redepth` walked their
children by index, looking the parent up again per child; both take the
list and put it back. `Scroll`'s `fixed` and `fixed_len` are
`answer_px` and `answer_is_px`, which says which one is the length.
fmt, workspace clippy under `-D warnings` with and without
`layout-diagnostics`, and the workspace tests are clean. The cold dump
over 400 depth-5 trees is byte-identical to `6c84b6f`: 34,492 boxes,
no seed moved.
`start` looked like a third accumulator beside `fixed` and `taken`, carried
across iterations and assigned at three points. It was never independent:
every assignment was `shared(fixed, taken)`, so it was those two read
together. Reading it at each end of a slot instead drops the variable, drops
two of the three calls per child, and leaves the gap added after the last
child deriving nothing -- which was the thing that read as a bug, and is not
one because no end is taken from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cursor` added `px` and `rel` by hand where the placing loop below now says
`fixed += len.without_leftover()` -- the same sum, one of them named. And
`let along = total` shadowed the closure that makes a span along the row,
two meanings for one word in one function; the local said nothing `total`
did not.
A scroll's draw writes `amt` and `snap_end`, so a second draw at another
viewport reads what the first wrote. Warm still matches cold because
re-clamping is idempotent, but nothing said so and nothing checked it: the
seed scans build scrolls and never scroll them. The test scrolls four
distances, one past the end, and widens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`per_axis` on `PlaceDesc` and `Declared` builds a pair by asking for each
axis. `from_axis` beside it already names the three-argument form, so the
plural is the one that takes a function, and both follow Rust's convention
for a constructor rather than an invented word.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PlaceDescAxis::axis(axis)` shared its name with `PlaceDesc`'s extraction,
which is now `Index<Axis>` and reads `place[axis]`. The two go opposite
directions, so they get different words: `on_axis` pairs with the
`from_axis` it is the shorthand for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`declared_lens` filtered `leftover` out of both its sources and every
consumer then re-dropped it, so the rule lived in two filters and a comment.
A declaration is a `Len`: `LayoutLen::declared` states the rule once and both
sources go through it, and `Declared` replaces the bare two-element array on
`ActiveData` and in four signatures.
The two sources stay one value deliberately. A rule decides the child's box;
a hint only promises what it will report -- but `size_hint` is by contract an
exact answer with no painter context, and `hints_agree` fails a widget that
draws something else, so narrowing the box to a hint cannot change what is
drawn. Every consumer asks about the length, never which said it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four rounds over the same idea: an expression that needed a comment to say
what it computed wanted to be a named operation.
The placement description is built by chaining off the value that says it.
`UiSpan::within_desc`/`shifted_desc` and `Len::as_desc` replace the
`PlaceDescAxis::` constructors, `PlaceDescAxis::axis` lifts one axis into a
pair with the whole box across it, and `PlaceDesc::per_axis` covers the case
where the two axes differ. `beside` is dropped: `from_axis` already said it.
Seven module-level functions become methods on the value each took first --
`Widgets::declared_lens`, `LayoutLen::fills`, `PlaceDesc::placement` and
`::rel_base_and_region`, `Size::within_box`, `UiRegion::at_origin` and
`::as_translation`.
`UiSpan::place` is the aligned-placement rule, which was written out three
times; `LayoutLen::without_leftover` is the sibling `apply_leftover` never
had, at six sites; `is_px` and `is_only_leftover` name field comparisons the
surrounding comments had to translate; `Holds::covers` was interval
containment spelled out by hand. A span's `shared` loses the two arguments
that did not vary across its loop.
`LayoutHolds` was four two-element arrays where every other pair here is a
struct of two per-axis values, so nothing it did could be written once.
It becomes `AxisHolds` on `x` and `y`, and `and`, `covers` and `contains`
lose their loops.
Every pair gets `Index<Axis>`/`IndexMut<Axis>` through one macro, and the
eighteen `axis`/`axis_mut` methods go. `const_index` keeps the accessors
usable in const context.
Cold layout is unchanged: `layout_dump` over 400 depth-5 trees is identical
to 58ce74d byte for byte, across all 34,492 boxes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Place` was a product written as a sum -- a `Part` and a fill flag -- and
`Part` named three operations the geometry already had, under words that did
not match them. `Of` was `UiSpan::within`, `From` was `UiSpan::shift`, and
`Sized` was `placement`'s own body with the length given rather than
reported. Both enums are gone.
`PlaceDescAxis` says one axis, named after the operation it performs:
`within`, `shifted`, `sized`, and `WHOLE`. What is optional is a builder --
`fills` and `rel_base` -- so a caller writes only what it decided, and the
rel base it does not write follows the constructor: a span composed into the
caller's box narrows it, a span along a cursor does not, a decided length is
it. That was the one rule a caller could get wrong with nothing failing.
`PlaceDesc` says both axes with named fields, so `axis`, `axis_mut` and
`from_axis` work the way they do on every other pair here, and the joint
work -- resolving a region, reading the fill flags -- is written once rather
than per axis. `widget_at` and `place_at` take `impl Into<PlaceDesc>`, so a
wrapper passes a `UiRegion` and says nothing else. `widget_within` and
`ActiveData::narrow_rel_base` are deleted; `asked` and `placed` carry the
rel base their ask stated.
Cold layout is byte-identical to `84dad21`.
An argument that is usually `None` goes last. So `widget_at` and `place_at`
take the place first, and `narrow_rel_base` after it.
`Place` and `Part` also now say which of the two boxes they decide, since
that is the question a caller has to answer to pick between them: `Part` is
the child's region, said as a part of the caller's own, and `Within`/`Fill`
is what becomes of the placement in it.
`narrow` said what the argument did to a value it never named, so a reader
had to go and find out which value. It is `narrow_rel_base`, and the
resolved one stays the bare `rel_base` -- which is also the only one in
`Painter`, `Placing` and `LayoutHolds`, where there is nothing to tell it
apart from.
It takes `impl Into<Option<[Option<Len>; 2]>>`, so a container that does not
narrow anything writes `None` rather than `[None; 2]`, and `Span` builds the
one case that does with a `then` instead of a mutable array.
Cold layout is byte-identical to `84dad21`.
`frame` named a length, not a rectangle, which was the one word in the
layout vocabulary that lied about its own shape. It is `rel_base`: what a
fraction a widget declares or reports is a fraction of.
Three API changes with it, all for containers that do one simple thing:
- `widget_within(id, region)` returns, taking a box in the widget's own
coordinates and deriving the child's rel base from it. `Offset` and `Pad`
are one call each again. `Offset` also stops reading `region_len`, which
pinned its drawing to a box length it does not care about.
- `place_at` takes the rel base, returns the answer, and asks the child
where there is no answer to re-express. Which of the two happens is the
painter's to work out, so `Span`'s second pass is one call and its
`drawn_across` bookkeeping is gone.
- `Part::All` is a `Part::WHOLE` constant rather than a variant, since it
was exactly `Of(UiSpan::FULL)` and bought a separate arm in two matches.
Measured at 0.07% of instructions retired against 0.04% run-to-run noise.
Cold layout is byte-identical to `84dad21` over 400 depth-5 trees.
The split box was named `region` and `placement` on 2026-09-17; `frame`
came back as a length and survived, `extent` did not. It stayed as the
name for both halves, distinguished only by prose: `draw_at` bound the
caller's `part` to a parameter called `extent`, and `ActiveData` held two
`UiRegion`s that `draw_at` wrote `part: extent` from.
The box a parent asks a widget in is now the region, and where its
drawing ends up is its placement. `Painter`'s four holds accumulators
become the one `LayoutHolds` they were assembled into, which also drops
the name mapping between them.
The cold dump of 400 depth-5 trees is byte-identical across the change.
A span measuring a child in the room kept its whole `Size`, of which the
along axis is already in `lens` and only the across one is read again when
the drawing is placed. Keep that length alone, which also retires the
rebinding of the match's result and the one in the placing loop. The
placement comment already says what becomes of a drawing made in the room,
so the measuring pass no longer says it a second time.
`size_hint` resolves a child's hint against the asking widget's frame and
pins that frame, so a later draw cannot reuse a resolution made against a
different one. It asked the *resolved* hint whether it still had a fraction,
which is false whenever the frame is itself pixels -- a slot of a row, or the
box a stack's sizing child decided -- and the pin was dropped there. Ask the
declared hint, which is what made this draw depend on the frame, and what
`ruled` in `render_state` already asks for a rule.
No generated tree distinguishes the two: the fuzzer grows no `rel` rules, and
a frame that changes almost always changes a box the other pins catch. Kept
for the reason the `frame_len` pin beside it is kept -- "these two
invalidations always coincide" is an assumption nothing states.
A share child was drawn in the measuring room and again in its slot, and
one record holding two questions made every local change under it defer
to the span. Where a rule or a hint gives the length along the span, the
first ask answers nothing the rule does not, so the child is asked once,
in its slot; the widgets that always report the whole of their box now
say so. A hint with a fraction resolves against the frame and pins it.
The dump rig prints every cold layout so a change to it shows in a diff.
Lengths became lengths of the window when the frame did, and `Part::From`'s
own documentation still described its spans as frame lengths -- which is
what the scroll above read them as.
A scroll that has not been scrolled and whose content fits asked for its
content box as `Part::From(UiSpan::FULL)`. A `Part::From` span is in window
lengths, so `rel(1.0)` in one is the whole window rather than the whole box,
and the content landed in a window-tall box anchored at the viewport's
start -- 50 px low for a 300 px viewport in a 400 px window.
Saying the whole of the box as `Part::All` is the one expression that cannot
mean anything else, and it is also the place the child was already asked in,
so the placement becomes a no-op.
The offer names are from the protocol before this one, where a widget was
drawn twice and the record had to say which drawing was the question. It
is asked once now, so offer_part is the part it was asked in, offer_place
the place it was asked at, and place where its drawing was put: part,
asked and placed. LayoutHolds::frame is a range on the window since the
frame became a length of one, and the frame's own entry is the frame_len
pin beside it, so it is window; Painter::frame_own goes with it.
answers_at had one caller and said less than the line that replaces it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Branch reads a measurement in pixels and draws a different subtree either
side of a threshold, and it left that read as a pin on the window, so
every one of them redrew on every resize: at depth 8 that was seed 1's
resize going from 40 widget draws to 131 and seed 13's from nothing to
828. It now states the range it actually branched on, the way Span states
the one that decides whether its shares have room. A fixture that redraws
everything on a resize cannot tell a change that reuses well from one
that does not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A widget that resolves a window length in pixels depends on that window
wherever the length is a fraction of it, and nothing was recording that:
Painter::to_px replaces window_px_len and pins the window it read, while
a length that is only pixels is that many pixels in any window and pins
nothing. Span still states the range it actually branched on, which
replaces the pin with something wider.
Scroll is where it showed: its content's answer is a window length now,
so a viewport whose own box does not change with the window -- 40 px of
a branch's box -- kept an end-snapped offset from the window before.
Seed 942 at depth 6 under resize, pinned as
unsettled::resizing_under_a_short_scroll_snaps_its_window_tall_content_again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There is one coordinate unit, the window. Every box in the tree is a
region in window units and a widget's frame is a length in the same
units, which is only what fractions resolve against, so the box need not
be the frame and padding can take from both without either becoming the
other. A region node's entry is a translation -- a rel 1 region anchored
where its box starts -- rather than a box, so nothing composes a frame
back up a chain and a node that moves is one entry write.
Padding is then an inset of both: its pixels come off the frame, so
rel(1.0) under it fills the padded widget rather than overflowing it,
and off the box, so what is drawn sits inside. A length a container
decides for a child's frame is a length of the window like everything
else here -- a row's slot, padding's frame less its pixels, or the box a
stack's sizing child decided, which arrives as Part::Sized -- because a
slot of a row is not a fraction of anything the row can name, the same
reason a node entry is a translation. A declaration is a fraction of
whichever of those reached it, and is the only one that also places the
box.
Frame validity is a pin beside the box's, not a range: a range of window
pixels cannot say which frame an answer is a fraction of, since two
frames are different lengths at the same window size. A widget pins its
frame by reading it or by being answered with it under a fractional
rule, and the pin composes up wherever a length of this frame is what
reached the child.
Also here, because the diagnosis needed them: the shrinker reports the
shrunk tree's own divergence with each level's frame, ask, box and size
warm against cold, and there is a size-resize case -- a change and then a
resize, the order that shows an answer kept as a fraction of the wrong
length, which every other case compares at the window it was made at.
Three defects the reports found, each pinned: a rule changed over two
pads relocated the column under them instead of dividing it again (seed
59, depth 5, resize-size), a share inside padding had the padding taken
off twice, and a root resolved its own rule twice.
fmt and clippy clean with and without layout-diagnostics, 121 suite, 20
core, 11 generated, the 400-seed depth-5 shrinker over all sixteen cases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A span asks a share child twice in one draw: in the room, whose answer
its slots rest on, and in the decided slot, whose cross-axis answer it
reads. The record keeps only the second question, so a local redraw that
found that answer unchanged never told the row that the first had --
seed 946 at depth 6, where emptying a fixed-height column turns it from
a share into a fixed width as wide as the row. A widget its parent asked
more than once in one draw now defers to that parent, like one whose
declared length changed. Pinned as
unsettled::emptying_a_column_the_row_asked_twice_asks_the_row_again.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A widget draws in the box it is asked in and its answer is placed inside
that box by re-expressing the drawing; nothing is drawn again in a box an
answer chose. The offer machinery, whose job was to tell a measuring draw
from a placing one, goes with the placing draw. A span measures each child
from its cursor and moves fixed children to their slots with place_at; a
share child is asked once more in its decided slot with its frame narrowed
to it. A stack asks non-sizing children in the box its sizing child
decided. A scroll asks its content once and moves it to the scrolled
offset. A local redraw asks the retained question again and puts the
answer back where the parent placed it.
A symbolic length a child pinned composes through Part::Of exactly where
the part is the whole box less pixels, and pins the parent's own length
otherwise; dropping it let a pad reuse a drawing across a narrowed frame
of the same pixel length (shrinker seeds 60, 248 and 384 at depth 5).
Suite 114/114 including the two decided-box pins, fast oracle 11/11,
shrinker 400 seeds at depth 5 over all fifteen cases.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>