Record the frame rule, the decided axes, and the root entry's removal

Iris 5b78002 on #18: a child's answer comes back in the asker's frame, the
near-edge override is per-axis decided flags, and the window is no longer
a move entry. Also records the threading plan that closes the region-node
offer defect, and why widening the grid would not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Fable 5.1 committed 2026-09-16 23:02:39 -04:00
1 parent 82646e8408
commit 9a26862bce
1 file changed
+97 -20
+97 -20
View File
@@ -12,6 +12,12 @@ head is **`5f16617`**, eighty-two commits, pushed. Built-in alignment is
complete there; see "Built-in alignment" below for the retained-layout
details. No PR reviews were present when checked on 2026-09-15.
**`5b78002` on top of it** (2026-09-16) does two things Bryan asked for after
the frame and root questions below. A child's answer now comes back to the
widget that asked in *that widget's* frame, and the window is no longer a
move entry -- see "A report is a fraction of the box it was given" and the
root item under "Fixed point". `Holds::through` is untouched by either.
The current head completes LAYOUT.md §2's position chain and the requested
`leftover` behavior. A child whose length is only `leftover` is not drawn when
nothing is left. A child that also asks for pixels or a relative fraction keeps
@@ -551,6 +557,24 @@ across it.
`a_length_in_pixels_is_that_many_pixels_however_it_is_nested`, over a row
buried under three containers that are each a fraction of their parent;
swept over 2,100 box widths and exact at every one.
- **A report is a fraction of the box the widget was given, and comes back
to the asker in the asker's frame** (Bryan, 2026-09-16: `rel` has nothing
to do with the remainder; it is the proportion of the space the widget
was given). `placed_box` always read it that way. `Span` did not: it added
a drawn child's `rel` straight into a cursor that counts fractions of the
row, which is right only while the offer has the row's whole extent --
and a `Span` offers what is left after the cursor, so it broke once a
relative child preceded another. `Pad` summed padding onto a fraction of
its inset the same way and reported its box plus the padding for a child
that filled the inset. `DrawResult::size`/`len` and `known_len` now
compose the answer through the offer's length before handing it back
(`in_parent_frame`), so a container reads lengths of its own box and the
box `Span` hands back afterwards is the one `placed_box` chose. A
declared axis is excepted: the parent resolved the rule in its own box
and the rule is what the report says there. A hint is used as a
declaration and so is in the parent's frame too; `hints_agree` compares
it with the raw report, which holds because every hint is pixels or
`leftover`. A hint with a fraction in it would need one frame chosen.
- **A length given as a share is not**, and cannot be: children asking for
the same fraction come out one or two steps apart, 0.001 to 0.002 px. A
position is the quantity that gets rounded, so the row fills exactly and no
@@ -641,13 +665,31 @@ 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.
`Painter::widget_decided(child, region, [bool; 2])` is the override as of
`5b78002`, and it is per axis and not an alignment: it says the parent chose
this box from the child's own answer along those axes, so the answer is not
placed inside it again. It replaced `widget_aligned(child, region, NEAR)`,
which forced the near edge on *both* axes and, being an alignment, still let
`placed_box` scale a fractional answer against the box -- fine while every
overriding container handed a box its child had reported in pixels, and
wrong the moment `Span` had to use it. `Span` now decides the row axis alone
when it places a child at the length it reported, since across the row the
child sits where its alignment says; `Scroll` decides both, and `Stack` both
for its sizing child. `DrawInfo::decided` and `ActiveData::decided` carry it,
so a local redraw asks the question its parent asked; alignment is always the
widget's own property now, and `ActiveData::align` is gone.
**A fractional answer taken of the parent's second box shrinks twice.**
Found by the frame fix's own test: a nested span reporting `rel(0.5)` was
placed by its parent at a quarter of the row, correctly, and then
`placed_box` took half of *that* box because the parent's second ask was not
an override. A report of "half of what you give me" has no fixed point but
zero, so the framework asks exactly twice -- once at the offer, once in the
box chosen from the answer -- and the second box is final on the axes the
parent decided. This is also why a root that reports a fraction of the
window is placed inside it by its own alignment and then scaled to fit,
which the two new tests in `tests/cases/layout.rs` avoid by ruling the root
to the window; that is old behaviour and was not changed.
### Alignment as a property changed two examples, unnoticed
@@ -784,6 +826,16 @@ pixels and `Holds`. Decided with Bryan on 2026-09-15.
and shrinking this allowance is the test of whether that worked. Too wide
is the unsound side of it. **Run the generated cases in debug**, since the
assertion is compiled out of every release run.
- **The window is not a move entry** (`5b78002`). It was one, holding the
output in pixels, so composing through it zeroed every `rel` below and the
shader's `rel * window.dim` and the CPU's final multiply by the output
were both dead. Now a chain bottoms out in `MoveIdx::NONE`, the window is
applied where a fraction becomes pixels -- `to_px(output_size)` on the
CPU, the uniform in the shader -- and a resize rewrites no retained entry
and re-uploads nothing but the uniform. Instances were never re-uploaded
on a resize either way; they are `(rel, px)` in their node's frame and the
shader walks the chain per vertex. The cost of a resize is whatever
`Holds` redraws.
- A pointer, a wheel notch, a shaped glyph advance and a window size arrive
as floats and are put on the grid where they arrive. `Vec2` stays what the
GPU and the platform speak; `PxVec2` is what layout decides in.
@@ -833,19 +885,42 @@ Traced, at a 900x1200 window, with `reorder` rotating both spans:
- Marking every widget dirty converges warm onto cold, which is what says
the retained path and not the arithmetic is at fault.
**What makes the fix awkward** is coordinate frames, and it is worth knowing
before starting. `ActiveData::offer` is in the parent *widget's* coordinates
and `ActiveData::region` is in the parent *node's*, so "was this node placed
where it was offered" is not a comparison either field can answer alone. Two
shapes look plausible:
**The fix is to stop recomputing the offer at all** (agreed with Bryan,
2026-09-16). `ActiveData::offer` is in the parent *widget's* coordinates and
`ActiveData::region` is in the parent *node's*, so "was this node placed
where it was offered" is not a comparison either field can answer alone --
and neither answer is needed. By the retained contract a drawing and its
answer depend on the box's pixel *lengths* only; checked against every
widget, the pixel reads are `px_len` and `px_size` and nothing reads a
position. The offer's symbolic position was only ever an input to its pixel
size, and that the parent already knew when it asked. So:
- Compose offers through a region node by expressing the node's offer as a
part of its placed box -- an inverse composition, with a rounding of its
own.
- Let `offered_region` say it cannot answer, and have `redraw` escalate to
the parent the way `parent_must_place` does. Correct and small, but it
costs region nodes their point for partial repaint unless the "placed
where offered" case is detected, which is the frame problem again.
- Thread the placed box's pixel size down beside `offered_px` -- one
expression per level, `local.size().to_px(parent_px)` -- and keep both on
`ActiveData`. Cold layout computes them the same way, so warm and cold
agree by construction rather than by allowance.
- `at_offer` becomes `px == offered_px`. Equal lengths at a different
position no longer escalate: the widget draws at `region`, which is the
`aea878d` fix without the second draw. A region node whose lengths differ
still draws twice, and the first box only needs the right length.
- `redraw` reads the stored `offered_px`. `offered_region`, `px_of`,
`px_region`, `Moves::compose`/`size_of`, `slot_wide` and `wide.rs` go; a
narrow `resolve` stays for `window_region`, which is hit testing and not a
layout decision.
- `remap_subtree`'s `Translate` keeps lengths, so stored sizes stay valid
at no cost on the hot path; `Scale` is the resize path and recomputes
them down the subtree from the same expression.
- `Holds::through` becomes the exact preimage of the truncating map
`px + floor(rel * B)`: two integer divisions, no `ROUTES`, no `way_in`,
and the whole-of-a-box case is the identity. The allowance existed only
because the box arrived by a second route.
Symbolic regions then exist for the GPU and for remaps alone, and their
rounding is cosmetic below a step. That is the separation the fixed-point
work kept reaching for. **Do not widen the grid instead**: every failure in
this document is one value reached by two expressions, sitting exactly on a
boundary because the boundary was defined by the same value coming back the
other way, and no precision shrinks a residue that is the whole distance.
## The leftover boundary
@@ -1043,7 +1118,9 @@ Queued from this work, in order:
chain in floats -- with the comment saying so, "what has to hold is that
this agrees with itself frame to frame, not that it matches the CPU to the
last bit" -- and then lands each edge with
`snap_floor(rel * dim) + snap_floor(px)`, which is a whole pixel.
`snap_floor(rel * dim + px)`, which is a whole pixel. (It floored the two
parts apart until `5b78002`, which was harmless only while the root entry
zeroed every `rel`.)
**Measured: every box edge in the `tabs` render is a hard edge**, no
partially covered column anywhere along the band, and the two `pad(10)`
gaps are exactly ten pixels on both sides of the truncation change. So the