From 9a26862bce8e05b91f44b6cb7aef05ace76ae517 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Wed, 16 Sep 2026 23:02:39 -0400 Subject: [PATCH] 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 --- docs/HANDOFF.md | 117 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 97 insertions(+), 20 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index ca9f028..90d2a4c 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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` 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