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:
1 parent
cf7b33da39
commit
085fc97334
1 file changed
+125
-39
+125
-39
@@ -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<_>>`.
|
||||
|
||||
Reference in new issue
Block a user