Record why placement cannot be applied after a draw

Three attempts at "draw the widget, then move its drawing to where its
alignment says" failed, and the notes are worth more than the attempts: the
move is a change of coordinate frame, and no split of the stored state
carries it. Moving a widget's region with its drawing has a later local
redraw ask a differently rounded question, which crosses the one boundary
`Span` needs exact. Leaving the region alone lets the placed box accumulate,
because `try_reuse` returns a clean subtree's size without walking into it.
Both are measured.

Also recorded: that a container reporting a child's size while handing it a
bigger box places that content twice, which is what the align override is
for; that a widget must not report more than it occupies, which is why
`Scroll` now fills both axes; and Bryan's decision that alignment is one
fraction per axis rather than four directions, with the middle as the
default because the edges assume a direction.

The alignment work is in the working tree at `8220a78` and not committed.
Its one red case, and the four things queued behind it, are listed.
This commit is contained in:
iris-ai committed 2026-09-15 21:26:04 -04:00
1 parent cf7b33da39
commit 085fc97334
1 file changed
+125 -39
+125 -39
View File
@@ -8,7 +8,9 @@ log.
Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18
`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`; its local
head is **`0283c9d`**, forty-eight commits, pushed to the fork. No PR reviews
head is **`8220a78`**, forty-nine commits, pushed to the fork. Built-in
alignment is **in the working tree at that checkout and not committed** -- see
"Built-in alignment, in flight" below before touching it. No PR reviews
were present when checked on 2026-09-15; the verification summary is posted on
the PR.
@@ -70,15 +72,17 @@ rewrite:
`core/src/ui/holds.rs`, the retained tests, and the generated cold-layout oracle
pin those rules. Seeds 10 and 86 are now in the ordinary generated set.
## Verification at `0283c9d`
## Verification at `8220a78`
- `cargo fmt --all --check`
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`
- `cargo test --workspace --all-features`: 85 passed, 10 ignored
- `cargo test --release --test generated -- --ignored
a_long_run_of_seeds_agrees --exact`: 100 seeds passed in 64.77 s
- The `tabs` reference at 900x1200 remained byte-identical to `f437495`: zero
differing pixels out of 1,080,000 after the region-node rewrite.
a_long_run_of_seeds_agrees --exact`: 100 seeds passed in 67.6 s
- `minimal`, `text` and `view` render byte-identical at 1920x1200 across
`8220a78`. `tabs` differs only in the widget count it prints about itself,
which is two wrapper types smaller -- so it is no longer a byte-identical
reference and the generated oracle is the check that matters.
- At preceding head `29c7881`, reference renders against
`/home/bob/repos/iris-main-cmp` at `ca2b4b2` covered:
`tabs`, `view`, `minimal`, and `text` at 1920x1200; `tabs` cold at
@@ -172,6 +176,99 @@ tighter figures.
being paid for. The generated oracle compares within 0.05 and `unsettled.rs`
runs too few frames to reach it, which is why `tests/drift.rs` exists.
## Built-in alignment, in flight
Uncommitted in `/home/bob/repos/iris-pr18` on top of `8220a78`: 87 tests, fmt
and clippy clean, the 100-seed generated oracle green, and the shrinker's
`resize` case green at 300 seeds. The shrinker's **`repaint` case is red**, for
the reason in "Placement cannot be applied after the fact" below. Its `reorder`
case is red at `8220a78` too, so that one is **pre-existing and unrelated**.
What is in it: `align` is a widget property beside `region_node` and the size
rule, `Aligned` is deleted, `.align()`/`.center()` set the property, and the
fuzzer covers size rules, alignment and region nodes and changes all three at
runtime. Region nodes had **no generated coverage at all** before that.
### Alignment is two fractions, not four directions
Decided 2026-09-15 (Bryan). A widget's alignment is one `f32` per axis, so a
quarter of the way along an axis is expressible. `AxisAlign`'s three cases
become named constants over that number, which is what every expression
already uses: the layout math only ever reads `AxisAlign::rel()`, so nothing
downstream changes shape. The in-flight tree still has the enum.
The default is **the middle on both axes**, because the two edges are the ones
that assume a direction -- which edge is the near one depends on the writing
system and on which way a container runs. "Near edge" throughout this document
means the *start of the box in the box's own orientation*, which for a
reversed span is its visually far end, not "top left".
### Placement cannot be applied after the fact
**Do not re-attempt "draw the widget, then move its drawing to where its
alignment says".** Three attempts failed, and the reason is structural: the
move is a change of coordinate frame, and no consistent split of the stored
state carries it.
- Move `ActiveData::region` with the drawing, and a later local redraw asks a
differently rounded question. Measured: `(0,0)..(0,305.936)` re-expressed as
`(0.5,-81.5)..(0.5,224.436)` reads its length back as `305.93604`, which
crosses `Span`'s leftover/no-leftover boundary -- the one that must be exact
-- and draws a child a cold layout leaves undrawn.
- Leave `region` alone, and `placed` accumulates without bound, because
`try_reuse` returns a clean subtree's size **without walking into it**: only
the top widget's `placed` is recomputed while an ancestor's shift carries the
whole subtree. Measured 7,048,813 where a cold layout says 456.
The replacement, not yet written: alignment applies in the two places that
already exist and are exact. Where the size is known before drawing, from a
rule, `declared_box` hands the child its aligned box directly -- one draw, no
move. Where the size is only known after drawing, the widget is **re-asked in
its placed box**, with its alignment forced to the near edge on the second ask
so it terminates; that ask goes through `try_reuse`, which moves by
recomposing, which `tests/drift.rs` pins as exact. That deletes
`ActiveData::placed` and `shift_subtree`. The cost is a second ask for a
measured widget that is not near-aligned, which is exactly what `Aligned` cost
before this work.
### Placement compounds, which is what the align override is for
A container that reports a child's size while handing that child a **bigger
box** gets its content placed twice: once by the child, once by the box around
it. Found three times before the class was fixed rather than the instances --
`Stack::size(Child(i))`, `Scroll`'s orthogonal axis, and `Pad`.
`Painter::widget_aligned(child, region, align)` is the override: an
`Option<RegionAlign>` carried in `DrawInfo` and resolved once in `draw_inner`,
so the root resolves like anything else. `Pad`, `Stack` and `Scroll` pass the
near edge for children whose size they report. `ActiveData` keeps both the
resolved alignment, so a local redraw asks the question its parent asked, and
the widget's own, which is what a change is compared against -- an override
means the answer is the parent's to give again.
### A widget occupies its box, and must not report more than it draws
`Scroll` reports `Size::LEFTOVER` on **both** axes: it clips its content to its
box, so it can neither take less of one nor honestly ask for more. The
content's length is what it scrolls through, not what it is. Reporting the
content length instead made the framework place a 400-long drawing in a
200-long box, and placement by recomposition **scales** in that case, because a
part stored at fraction 2 of its box stays at fraction 2 of a box twice as
long. Reporting the content's *cross* length had the box around it place
content already placed.
Where content shorter than the viewport sits is now `Scroll`'s own alignment.
Its `Holds` widening -- "content of a fixed length that fits sits at the start
of any box it fits in" -- is therefore gated on near alignment: anchored
anywhere else it is a part of the room left over, so it moves with every
length the box takes and the drawing holds for that length alone.
`Stack` gives every child the box its sizing child defines, through the new
`Painter::box_of`, for the same reason.
A `debug_assert` that a reported non-leftover size does not exceed the box it
drew in would have caught both immediately, and is still worth adding.
## Rigs and reproduction
Ordinary framework verification:
@@ -231,44 +328,33 @@ independently movable-subtree use case; do not restore a separate
child-placement API. `docs/LAYOUT.md` §2 is stale: it still describes
`Painter::place`, which `71c9c39` replaced.
**Built-in alignment and size is next, and goes on #18 rather than after it**
(Bryan, 2026-09-15: #18 is unreviewed and already large enough that most lines
get read anyway). Ownership is agreed:
- Declared size and alignment become **widget properties on the same mechanism
as `region_node`**, read by `Painter::widget_at`. `SetSize`, `MaxSize` and
`Aligned` are deleted, and `.width()`, `.sized()`, `.max_width()`, `.align()`
and `.center()` stop building widgets. `Painter` then owns measure-then-place,
which `Span`, `Aligned` and `Scroll` each implement separately today.
- A **declared length wins per axis**; the `Size` returned by `draw` answers
only the axes with no declaration. The rule becomes an enum per axis --
`Exact`, `Min`, `Max`, `Clamp` -- so a clamp is resolved where the box is
decided instead of by a wrapper. `MaxSize::draw` currently calls
`px_size()`, which pins its interval to one exact box on both axes and
redraws its whole subtree on any resize; the parent resolving the clamp
reads px itself and can declare the interval over which the answer holds.
- The clamp boundary is a **hard layout decision, not a tolerance**, the same
shape as `Span`'s leftover split. Its `Holds` range must be exact and split
at the crossover; generated seed 16 is what a tolerant endpoint costs.
- Two things to settle while implementing. `max_width` today does not
constrain anything -- `MaxSize` draws its child in its full box and only
caps the `Size` it reports upward -- so resolving the rule in the box is a
behaviour change. And `declared_len` derives from `size_hint`, so one
channel carries both "my size, for a span to divide around" and "resolve my
box to this length"; those separate.
Why it comes before anything else that touches layout: a widget's `region`
becomes **its reported size placed by its alignment inside the offer**, rather
than the offer itself. A span's measure-then-place then moves a child between
boxes of the same length, so it is a translation by construction, and the
measuring draw disappears wherever the size is knowable without one. That
changes what `.background()` sits behind, so the reference renders stop being
byte-identical to `ca2b4b2` and the generated cold-layout oracle becomes the
verification.
**Built-in alignment and size goes on #18 rather than after it** (Bryan,
2026-09-15: #18 is unreviewed and already large enough that most lines get read
anyway). The size half landed as `8220a78`; the alignment half is in the
working tree and described below.
Do not restore `OnResize::Translate`; retained translation is now expressed by
the same `Holds` contract and box chain.
Queued from this work, in order:
- The shrinker's `repaint` case, via the replacement placement design above.
- Delete `OrthoSize`. It is redundant now that size is built in: a span should
read its own rule on the orthogonal axis and, where that is fixed, skip
reading its children's orthogonal sizes entirely, which is what `Full` does
today. That also removes the span's own instance of the compounding above.
- `Scroll` should take a direction rather than one axis: vertical, horizontal,
or both. Reporting `LEFTOVER` on both axes is already the right shape for it.
- Restore `max_width`/`max_height` as `SizeRule::{Min, Max, Clamp}`. `8220a78`
deleted `MaxSize` **and its builders**, so that is public API owed back. A
clamp resolved where the box is decided also fixes `MaxSize`'s reading of
`px_size`, which pinned its interval to one exact box on both axes and
redrew its whole subtree on any resize. The clamp boundary is a hard layout
decision, not a tolerance: its `Holds` range must be exact and split at the
crossover, the way generated seed 16 taught for `Span`.
- The shrinker's `reorder` case, which is red at `8220a78` and was not
introduced by any of this.
Other queued work, in dependency order:
- `UiRenderState` behind `Rc<RefCell<_>>`.