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 complete there; see "Built-in alignment" below for the retained-layout
details. No PR reviews were present when checked on 2026-09-15. 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 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 `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 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 `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; buried under three containers that are each a fraction of their parent;
swept over 2,100 box widths and exact at every one. 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 - **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 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 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 -- it. Found three times before the class was fixed rather than the instances --
`Stack::size(Child(i))`, `Scroll`'s orthogonal axis, and `Pad`. `Stack::size(Child(i))`, `Scroll`'s orthogonal axis, and `Pad`.
`Painter::widget_aligned(child, region, align)` is the override: an `Painter::widget_decided(child, region, [bool; 2])` is the override as of
`Option<RegionAlign>` carried in `DrawInfo` and resolved once in `draw_inner`, `5b78002`, and it is per axis and not an alignment: it says the parent chose
so the root resolves like anything else. `Pad`, `Stack` and `Scroll` pass the this box from the child's own answer along those axes, so the answer is not
near edge for children whose size they report. `ActiveData` keeps both the placed inside it again. It replaced `widget_aligned(child, region, NEAR)`,
resolved alignment, so a local redraw asks the question its parent asked, and which forced the near edge on *both* axes and, being an alignment, still let
the widget's own, which is what a change is compared against -- an override `placed_box` scale a fractional answer against the box -- fine while every
means the answer is the parent's to give again. 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 ### 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 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 is the unsound side of it. **Run the generated cases in debug**, since the
assertion is compiled out of every release run. 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 - 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 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. 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 - Marking every widget dirty converges warm onto cold, which is what says
the retained path and not the arithmetic is at fault. the retained path and not the arithmetic is at fault.
**What makes the fix awkward** is coordinate frames, and it is worth knowing **The fix is to stop recomputing the offer at all** (agreed with Bryan,
before starting. `ActiveData::offer` is in the parent *widget's* coordinates 2026-09-16). `ActiveData::offer` is in the parent *widget's* coordinates and
and `ActiveData::region` is in the parent *node's*, so "was this node placed `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 where it was offered" is not a comparison either field can answer alone --
shapes look plausible: 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 - Thread the placed box's pixel size down beside `offered_px` -- one
part of its placed box -- an inverse composition, with a rounding of its expression per level, `local.size().to_px(parent_px)` -- and keep both on
own. `ActiveData`. Cold layout computes them the same way, so warm and cold
- Let `offered_region` say it cannot answer, and have `redraw` escalate to agree by construction rather than by allowance.
the parent the way `parent_must_place` does. Correct and small, but it - `at_offer` becomes `px == offered_px`. Equal lengths at a different
costs region nodes their point for partial repaint unless the "placed position no longer escalate: the widget draws at `region`, which is the
where offered" case is detected, which is the frame problem again. `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 ## 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 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 this agrees with itself frame to frame, not that it matches the CPU to the
last bit" -- and then lands each edge with 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 **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)` 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 gaps are exactly ten pixels on both sides of the truncation change. So the