61 KiB
Handoff
Where the work in flight stands for a session picking it up cold. Keep current invariants, measurements, and failed hypotheses here; this is not a decisions log. Pruned on 2026-09-17: the chronicle of closed defects, superseded verification lists and cross-fixture tables went, the rules and the lessons stayed.
Where things stand
Canonical Iris main is ca2b4b2 (#17, the headless rig). #18
split/18-position-chain is open in /home/bob/repos/iris-pr18, head
e44dea3, pushed. It holds LAYOUT.md §2's position chain, leftover,
the Holds retained-layout contract, region nodes, built-in alignment and
size rules, fixed-point layout, a box in pixels threaded down the draw, and
a report read as a fraction of the containing widget. No PR review was
present when checked on 2026-09-15.
Widen one fuzzer axis at a time, and record which. Seeds 1121 and
1839 at depth 4 failed on ea6dbae and on every commit before it,
and nothing in the routine verification reached them: the fast oracle takes
ten seeds, the shrinker 400 at depth 5 and the long oracle 1000 at depth 6,
so a defect past seed 400 at depth 4 had nowhere to show. Both are fixed by
4bd8607. The scan that found them, worth running again after any change
to layout:
// tests/scan.rs, deleted once it had done its job
over_seeds((1..=2000).collect(), |seed| {
let grown = plan(seed, 4, &Edits::default());
for &case in ALL.iter() {
if let Some(how) = diverges(&grown, case, seed) {
println!("HIT seed {seed} case {} {how}", case.name());
}
}
});
261 s for 2000 seeds at depth 4 over all fifteen cases, and clean at
4bd8607. Rng::new is seed | 1, so an even seed and the odd one above
it are one tree: 1120 and 1121 are the same counterexample, as are 1838 and
1839.
Two ideas outrank everything else in this document (Bryan, 2026-09-17).
First, a changed tree lays out exactly as if it had been drawn that way from
the start; that is what the retained machinery is for and what the oracle
and shrinker check. Second, widgets are predictable: px is that many
pixels, rel(0.5) is half of the containing widget's area however many
siblings there are and wherever it sits among them, and leftover is a
share of the room left once every sibling's px and rel are resolved. The
invariants listed further down were accumulated by agents chasing single
failures; any of them may be simplified or deleted if those two ideas still
hold.
Review of 2026-09-17
A fresh read of core/src/fixed.rs, orientation/, ui/holds.rs,
ui/painter.rs, ui/render_state.rs and the position widgets, outside of
doing work on them, with each finding checked by a scratch test.
Verdict. The concepts are sound and stay. Fixed point on a 1/1024
grid is the right base for a layout that decides "same box or not" by
equality. Threading the pixel box down the draw, with Holds::through the
exact preimage of that one multiply, is the strongest idea in the code:
layout has one route to every length and the reuse test is its exact
inverse. Offer, given and placed is the ordinary measure-then-arrange model.
What needed work was the bookkeeping around the second ask, one boundary in
Span computed by an expression other than the drawing it guards, and a
rel that meant two things. The two-step residual is structural and no
grid width fixes it; where it becomes visible is the shader's snap.
Decided by Bryan
relis a fraction of the containing widget's whole area. In a span,rel(0.5)is half the span whatever else is in it and wherever it sits. It is never a fraction of what was left after earlier children. This supersedes the 2026-09-16 reading that a report is a fraction of the box the widget was given, wherever that box is a remainder rather than the child's whole area.drawstays the only layout method onWidget(Bryan, 2026-09-17, reversing the same day's acceptance of a measure/draw split). Ease of writing a widget is half the reason. The real one is that a second method holding the same layout drifts from the first, which a span makes extremely easy, and the shared logic then gets pulled into helpers both call that still have to be applied carefully in each. Where the framework needs a widget's layout twice it runs the same body again with the painter in a different state, or hands it more through the painter.- A widget's frame does not change between the ask that measures and the ask that places. Fractions are of the frame; the extent reaches the widget through the painter. See "Frame and extent" below.
A report is a fraction of the containing widget (landed, ffd79f3)
Superseded in part. Bryan refined the rule twice on 2026-09-17: a
fraction means whatever the parent says it means, and it need not even
mean the same thing for two siblings -- "Main point is truly that rel is
decided by parent". The span conclusion below is unchanged and the reason
for it stands (a span's offer differs per child and the row does not), but
reports_of is the wrong shape for saying it; see the parked padding
branch.
rel(0.5) is half the span whatever else is in it and wherever the child
sits. A report used to come back composed through the box it was offered,
and a span offers each child the room from its cursor, so a nested span
taking half of what it was given took a quarter of a row whose first half
was spoken for -- where the same half written as a rule on the child took
half the row.
The offer is still the remainder, because a text has to wrap at the width
actually there. What separated from it is the base a report's fractions are
of, which the ask now carries as reports_of:
pub fn widget_at<'s, W: ?Sized>(
&'s mut self,
id: &'s StrongWidget<W>,
region: UiRegion,
reports_of: UiVec2,
decided: [bool; 2],
) -> DrawResult<'s, 'a, W>
It is region.size() wherever the box offered is the child's whole area --
Pad's inset, a Stack child, Scroll's content -- and Span passes
UiVec2::FULL_SIZE along its row. widget_decided is gone; widget_at
says both things about an ask rather than one of them.
A span can now overflow itself without bound, which is the consequence
Bryan's rule asks for: two children reporting half each take the whole row
and a third starts past the end. Under the old reading total.rel could
not exceed one, so Span's fixed <= 0 branches were only reachable
through declared fractions; they are ordinary now, and with them boxes of
negative length passed down to children.
An answer is not an answer while anything under it is dirty (0e0d4af, superseded by a0693ac)
Superseded: dirty_size_under is deleted. Once a resize goes through
the settling walk the ordering makes the whole category unreachable rather
than checked, which is Bryan's steer and the better answer. The defect and
its reasoning are kept below because they say what the ordering is buying.
draw_inner took an answer from try_reuse, which checks only whether the
widget itself is marked, where retained_answer beside it also refused one
while anything the widget read a size from was dirty. A widget whose drawing
happened to be reusable therefore handed back the answer it gave before that
descendant changed, and nothing puts it right: the comparison that tells a
reader its child's answer moved is in redraw, and a widget settled inside
its parent's own draw never goes through it. The placing ask redraws the
subtree, the descendant's mark is cleared there, and the parent keeps a
number the tree no longer agrees with.
So dirty_size_under was not the optimization its comment claimed: it was
what made an answer an answer, until the walk made the state it guarded
against impossible to be in.
Found at seed 564, depth 6, shuffle-every-other, reachable only once a
span could overflow itself. No hand-built tree ever reproduced it, and the
seeds at depth 4 above fail for some other reason.
A text is handed back a box its own line fits in (landed, 4bd8607)
A wrapping text reported the width it used through Px::from_f32, which
takes the nearest step and is under the line the shaper measured half the
time. A parent that sizes itself from that report -- a stack taking a span's
width, the span taking its widest child's -- then hands the text back a box
its own longest line does not fit in, and a greedy break there is one line
longer. Warm kept the break it had; cold made the narrower one.
Two tolerances were holding that together and both are gone:
TextBuffer::shapeanswered any width withinBREAK_EPSILON_PX = 0.05of the longest line from the break in hand. Fifty steps of the grid, and a structural decision taken on a hair's breadth -- the thing theSpanboundary invariant below already forbids. It iswant >= layout.width()now, exactly.- The
Holdsrange the text declares started at the nearest step to its longest line, so it admitted boxes that line does not fit in. It starts atPx::ceil_from_f32of it now.
Neither was the fix. The fix is the report: Size::from_px( PxVec2::ceil_from_f32(tex.size)), the step at or above what was measured,
so the box that comes back fits. With it in place either tolerance could
have stayed and the case passes; both are wrong on their own terms, so both
went. Fixed::ceil_from_f32 is new and is the only rounding on the grid
that is not to the nearest step.
The general shape, and the third time this branch has hit it: a value that comes back as a box has to be rounded away from the measurement, not to the nearest step. Rounding to nearest is right for a value being carried; it is wrong for a bound.
A frame settles strictly bottom-up (landed, a92c6ac)
The queue was already deepest-first, but a widget that could not settle
where it was called redraw on its parent from inside itself, which drew a
shallow widget while dirty widgets deeper in other subtrees were still
pending. A parent drawing over a subtree that has not settled reads answers
about to move, and the one that settles does so inside the parent's draw --
where its mark comes off and nothing compares what it now answers.
A widget that cannot settle defers instead: it marks its parent, stays
marked, and waits in deferred until the walk reaches the parent's depth,
which cannot happen before everything deeper has settled.
loop {
let next = rsc.widgets().needs_redraw.iter().copied()
.filter(|id| !self.deferred.contains(id))
.max_by_key(|&id| self.depth(id));
let Some(id) = next else { break };
if !self.redraw(id, rsc) {
self.deferred.insert(id);
}
}
Bryan's, 2026-09-17, and the right answer where 0e0d4af was a check:
"then that entire category of issue can't even occur".
The walk is sound on its own, and the second entry point is closed
(a0693ac). By induction on depth: when a widget at depth d draws fresh,
every dirty widget deeper has been popped, so each is settled or deferred,
and a deferred one has marked its parent. A clean child asked by that draw
therefore has a clean subtree, because anything dirty under it would have a
dirty parent, and so on up to the child itself.
The entry point that was left was update drawing the root for a resize
before the walk ran, top-down over a tree with dirty widgets still in it.
It is closed by marking the root instead, so layout is one walk a frame and
dirty_size_under is gone at both call sites. The root is marked only
where the new output falls outside what its answer holds for: that range
is the intersection of everything under it, so admitting the new output
says the whole tree stands, and nothing above the root moved. Marking it
unconditionally cost the root its own Holds -- a leaf root that scales
with its box was drawn again on every resize, which two tests caught.
The rig says the cost is scheduling only: on resize, one queue pop, one
local redraw and one depth read appear and one failed reuse attempt goes,
and every other counter on cold, repaint, many, size, scroll and
resize is identical.
A subtree that changes hands is recorded on both sides (landed, e44dea3)
A subtree can be reused whole under a different parent -- same box, same layer, same region node, clean -- and nothing in the drawing says it moved. Two things read who its parent is, and both were wrong after one of these.
- The old parent still listed it, and a parent's next draw undraws whatever is missing from that list. Two spans under one root, with the root swapping which of them it holds, drew the subtree under the new span and then erased it when the old one drew.
- Its depth was the one it had under the old parent, which is what the settling walk orders by, so a change made under it afterwards settled at the wrong point in the frame.
Both are written where draw_inner already records what the ask decided:
active.parent is replaced and the old parent's children repaired, and
try_reuse re-walks the subtree's depths -- only where the top of it
moved, which is what makes that free in the ordinary case. Pinned by
retained::a_subtree_that_changed_parents_is_not_undrawn_by_the_one_it_left
and ..._settles_at_the_depth_it_moved_to; each fails without one half.
The fuzzer never re-parents (reshuffle only trades children between a
span and its own spares), which is why nothing generated reached either.
A span's leftover boundary is its own inverse (landed, 53b00c6)
The decision used a rounded division where the room the children get is a
floored multiply, so the boundary and the drawing it guarded were two
expressions for one length. room is that length as a Len, room.to_px
is the multiply, and Holds::through is its exact preimage:
let room = Len::rel_max() - Len::from_parts(total.rel, total.px);
let mut shares = false;
if total.leftover > Weight::ZERO {
shares = room.to_px(painter.px_len(axis)) > Px::ZERO;
let holds = match shares {
true => Holds::from(Px::STEP..=Px::MAX),
false => Holds::from(Px::MIN..=Px::ZERO),
};
painter.holds(axis, holds.through(room));
}
The three branches were the sign of 1 - rel, which through reads
already. Forty lines became twelve and one div left layout. The general
rule stands and is now demonstrated: derive a boundary through the
inverse of the expression that draws, never by a second expression for the
same length.
Two branches parked, both real, neither ready
Superseded on 2026-09-17: the two branches are one defect, and it is in
the protocol rather than in either widget. See "Frame and extent" below.
Do not chase seed 1091's three steps or the Inset's 47.5 px; both are the
placing ask resolving a fraction in the answer box. What each branch got
right is kept: the Inset/Outset pair and its tests, and the stack test
asserting that half stays half. Their painter changes go.
wip/stack-fraction-twice. A stack sized by a child that reports a
fraction applies that fraction twice: its parent places the stack at the
reported length, and box_of(size) then takes the same fraction of that
box. Half a row becomes a quarter. Pixels are idempotent under a second
application, so only a share ever shrank -- and warm and cold shrink alike,
so no oracle can see it. Bryan: "I believe this exact thing has come up
multiple times for some reason."
The fix is that every child gets the whole box, plus widget_at not
resolving a rule into a box already chosen from it. Painter::box_of is
deleted rather than guarded: a first attempt made it answer the whole box on
a decided axis, which fails because place can reuse the stack's
measuring drawing by remapping it, so Stack::draw never re-runs. A
drawing has to be a function of its box alone -- if decided is in it, a
reused drawing is wrong.
What stops it landing: seed 1091 at depth 4, shuffle-swap-for-three,
disagrees by three steps where the oracle tolerates two (warm 1053.9971
against cold 1054). box_of was also making placement exact, by handing a
child a box of exactly the length it asked for, and the whole box puts a
rounding back at each nesting level. Find that composition; do not widen
AGREE_STEPS.
wip/padding-outset-and-inset. Padding goes outside what it pads and
never insets the child (Bryan, 2026-09-17): otherwise a child's rel and
leftover would mean the inner box while its px meant the outer one.
Padding::region moves the child's box in rather than shrinking it, a new
Inset widget with .inset() is the old behaviour, and Pad is to be
renamed Outset with .outset() to match. in_parent_frame's
composition and the reports_of argument go with it -- a report comes up
raw and the parent says what it is a fraction of, which Inset does for
itself.
What stops it landing: a child declaring rel(0.5) under an Inset comes
out 47.5 px wide of the 190 inside rather than 95, and the second halving is
unaccounted for. The Pad half is green on its own; three tests moved to
.inset() because they were using padding as scaffolding rather than
testing it.
Span's leftover boundary is a third expression for the room
The decision uses a rounded division, total.px.div(fixed), while the room
the children get is a floored multiply, so the two disagree at the boundary.
Measured with 300 px, rel(2/3) and a leftover child:
| row width | leftover child | its threaded length |
|---|---|---|
| 900.000 | undrawn | |
| 900.001 | drawn | 0 steps |
| 900.002 | drawn | 0 steps |
Harmless at two steps, but the three-branch block collapses into the inverse
that already exists. room is computed a few lines below the block as
Len::rel_max() - Len::from_parts(total.rel, total.px), and its to_px is
exactly the threaded length the leftover children share:
let room = Len::rel_max() - Len::from_parts(total.rel, total.px);
let mut shares = false;
if total.leftover > Weight::ZERO {
shares = room.to_px(painter.px_len(axis)) > Px::ZERO;
let holds = match shares {
true => Holds::from(Px::STEP..=Px::MAX),
false => Holds::from(Px::MIN..=Px::ZERO),
};
painter.holds(axis, holds.through(room));
}
through already handles a negative fraction and a zero one, so the
fixed < 0 and fixed == 0 branches go with it. The general lesson: mul
floors while div, div_int and ratio round to nearest, so a boundary
derived with a division guards a drawing made with a multiply. Derive
boundaries through through, or make the grid floor everywhere.
Where the residual comes from, and the snap
The e44dea3 baseline's two-step allowance came from inverse remapping and
alignment composed by different routes. The frame/extent continuation below
retains local widget frames as well as local primitive coordinates, recomposes
in the same order as a cold draw, and removes inverse remapping. Its oracle
requires exact pixel-region equality. That experiment is not yet the pinned
framework; the baseline's snap decision below still applies there.
Where it does matter is snap_floor in prelude.wgsl, which adds half a
layout step before flooring: that absorbs float error and not a layout
step, and truncation makes "one step under an integer" the common residue.
A third of 900 px is 299.999 on the grid and lands at 299 on screen, which
is the pixel 08c9d5a moved tabs's arcs by. Rounding to the nearest
pixel absorbs both the truncation and the two-step residual everywhere
except within two steps of a half pixel, where layout never lands on
purpose, and keeps integer widths for equal fractional parts:
fn snap_floor(v: vec2<f32>) -> vec2<f32> {
return floor(v + 0.5);
}
Approved by Bryan on 2026-09-17, together with rounding on the CPU; see
item 5 under "Next" for why both, and what each does not fix. The check is
the reference render set plus the oracle; expect tabs to move its arcs
back.
Smaller items
- The comment on the
local == UiRegion::FULLshortcut inwidget_atsays composing throughFULL"is not quite the identity in f32". On the grid it is exact; the shortcut is performance only now. - An undrawn
leftoverchild still contributes its gap, so a vanished child leaves a double gap. - Nested spans pass
leftoverweight up, so three leftover children in one inner span beside one in another get three quarters to one quarter. No other layout system does that, and the doc's old example of two and two did not distinguish it from per-span division. Confirm it is wanted. Fixed::divby zero answersMIN/MAXwhileratioanswersZERO; both are caller bugs underdebug_assert, but the fallbacks differ.
Frame and extent: retained prototype
The original proposal was implemented by Claude as 5fcace1 on
wip/region-and-placement in /home/bob/repos/iris-pr18. It kept the fraction
reference stable but failed five layout cases and three draw-count cases.
The first correction is efb416b, exact recomposition is 2ed5503, and the
current performance continuation is c44bd19, pushed to origin's
wip/region-and-placement, in the isolated checkout
/home/bob/repos/iris-layout-experiment; it is not on PR #18. The original
checkout is unchanged. Keep the experiment's build directory separate: Cargo
can accept a different worktree's artifacts as fresh when a target directory
is shared. The comparison checkout's source timestamps can precede its build.
The distinction stays. Painter::region is the frame, the parent's chosen
reference for fractions. placement is the extent in that frame. A sizing
report must not replace the reference against which that report was obtained.
px_len reads the extent's pixel length; region_px_len reads the frame's.
Text wraps at the available extent, not the whole fraction reference.
The offer includes availability. A span can keep the same reference frame
while offering a text less room after an earlier sibling. measure_len therefore
accepts the placement as well as the frame. The first measurement's
offer_placement survives later placing evaluations, and local redraw asks
that original question before restoring the assigned slot. Frame-length
equality alone no longer identifies the measurement question.
An answer and a drawing each retain their dependencies. LayoutHolds
keeps frame ranges, extent ranges, and an optional raw placement snapshot.
px_len narrows one extent axis without making position or the other axis a
dependency; holds widens that extent range without erasing a frame read.
Holds::through still inverts one fixed mapping exactly. A measured answer
and the final drawing can only substitute for one another when those
independent inputs satisfy the retained contract. The old fallback checked
only frame ranges, so text placed at a fixed pixel width answered a narrower
window's measurement with its previous line breaks.
Geometry retains its reference. DrawRegion::{Frame, Extent} records local
primitive and mask coordinates before composition. Ordinary primitive()
follows the extent; explicit UiRegion arguments remain frame-relative.
Text glyphs use extent-relative origins. Painter::widget() records a child
that inherits placement, so a pass-through wrapper and its valid descendants
can be repositioned without rerunning their draw bodies. placement() is
still a conservative raw read for arbitrary widget computations.
No measurement is different from a measured zero. The first wider scan
found seeds 560 (shuffle-swap-for-three) and 1690 (shuffle-add-three) at
depth 4. A previously undrawn pure share had only supplied a hint; its
placeholder zero answer became reusable once it was drawn during placement.
ActiveData::answer is now optional. Both seeds pass after that correction,
and adding_text_to_a_reverse_row_keeps_its_shared_height reproduces the
zero-height failure with a small tree.
Exact recomposition and reuse. The continuation replaces given_len with
given_region: each widget retains its original frame in its parent's
coordinates. Moving a retained subtree replays those same local compositions,
and stops at region nodes. RegionRemap, AxisRemap, and inverse division
are gone. Fixed-pixel frames can now change size without forcing otherwise
valid descendants to draw again. This adds 16 bytes per active widget; local
primitive coordinates were already retained by efb416b.
The shared oracle now compares pixel regions with ordinary equality; there
is no AGREE_STEPS allowance. Text publishes the range of its retained line
breaks rather than narrowing that range to every later
requested width. A layout with only explicit line breaks or no breaks holds
at arbitrarily wider widths. New tests compare actual primitive and mask
geometry and assert reuse for fixed-frame resizing and widening unwrapped
text (including explicit newlines and empty content).
Measure the offer without placing an intermediate answer. Painter::measure_len
replaces the hint/cache query and its duplicated drawing fallback. It still runs
Widget::draw when needed, but leaves that drawing in the offer until the parent
assigns the child's slot. Placing the child's own answer first was wasted work:
the parent immediately replaced that placement. The regression counts three
rather than four evaluations for a numeric-size leaf in a span, checks its actual
primitive bounds, and repeats after resize. There is no second layout body on
Widget. Holds::ANY.through(...) now returns ANY directly: an unrestricted
range needs no inverse division, including for a negative fraction.
A chosen slot does not cancel a child's alignment. Bryan caught this in the
tabs image: a fixed 100 px square under a flexible wrapper sat at the slot's
near edge. ask_box now aligns a declared frame inside the chosen slot, just as
it does inside an unassigned offer. A regression asserts the actual coordinates
before and after resize. Warm/cold equality could not find this because both
were wrong. The corrected 900 px image still has 88 px between blue and red and
78 px between red and orange: the enclosing .pad(10) adds 10 px before the
red slot. Red is centered in that slot, 5 px right of the visible black gap's
center. Do not describe those two centers as the same thing or compensate with
an arbitrary offset. The parked padding API change is still separate.
Ordinary verification passes: formatting, workspace clippy with
layout-diagnostics, workspace tests (101 suite tests and 21 core tests), and
the fast generated cases. The corrected tabs resize from 1920x1200 to
900x1200 matches a cold image with zero differing pixels. The release oracle
passes 2000 seeds at depth 4 and 1000 at depth 6; the shrinker passes 400 at
depth 5, and the debug oracle passes 120 at depth 4 with assertions enabled.
Every scan runs all fifteen scenarios with exact equality. The measurement
regression fails with four draws instead of three when the optimization is
disabled.
Still not ready to replace PR #18. The prototype does substantially more
nested layout work than e44dea3, even after retaining extent-relative child
frames (seed 1, depth 8):
| phase | widget draws at e44dea3 | current | primitive writes at e44dea3 | current |
|---|---|---|---|---|
| cold | 369 | 463 | 9635 | 9179 |
| repaint | 1 | 1 | 1 | 1 |
| many | 157 | 263.1 | 4721 | 5873 |
| size | 16 | 240 | 106 | 5200 |
| scroll | 2 | 2 | 0 | 0 |
| resize | 13 | 278 | 0 | 5730 |
These are instrumented work counts, not speedups (50 frames per phase except
cold, which is one; 578 generated widgets, 191 finally active). The
measurement-only change at 7601aa2 removed 7 widget draws and 525
primitive writes per size frame, and 8 draws and 525 writes per resize frame,
relative to 2ed5503. The extent-relative continuation below skips additional
container evaluations but does not reduce primitive writes.
Measure cycles as well as instructions (Bryan, 2026-09-17). The following
are whole-process medians from seven alternating before/after runs of direct,
uninstrumented release test executables under perf stat -e cycles:u,instructions:u. The baseline is 39f7b08: 2ed5503 with the same
centering fix, so both sides draw the corrected layout. CPU-heavy scans were stopped
during measurement. Counts include fixture setup; size, resize and many run
5000 frames, repaint and scroll run 1,000,000. These are VM CPU measurements,
not phone frame times.
| phase | cycles before → after (billions) | instructions before → after (billions) |
|---|---|---|
| size | 12.8008 → 11.0344 (-13.8%) | 34.3660 → 29.9450 (-12.9%) |
| resize | 14.9368 → 13.0376 (-12.7%) | 39.8487 → 35.0032 (-12.2%) |
| many | 14.8875 → 12.9579 (-13.0%) | 39.6021 → 34.7499 (-12.3%) |
| repaint | 2.7416 → 2.6626 (-2.9%) | 6.0266 → 6.0197 (-0.1%) |
| scroll | 9.7848 → 8.4829 (-13.3%) | 21.7203 → 20.9527 (-3.5%) |
The size/resize/many cycle samples have nonoverlapping before/after ranges, with each range less than 1.3% of its median. Additional depth-6 fixtures, 10,000 frames and seven alternating pairs each: seed 3 improves cycles by 7.0% for size and 8.4% for resize; seed 13 improves resize by 9.2%, while its size samples overlap and establish no cycle improvement.
Glyph emission now composes its origin once per text draw. Glyph offsets and bitmap sizes are pixel lengths, so composition adds them unchanged. The fresh draw resolves the shared frame/extent origin once, then adds each glyph's offset and size. Retained local geometry is unchanged; a regression compares fresh drawing with retained recomposition for fractional origins, negative pixel offsets, changing frames and extents, and region nodes. This adds no retained state and changes no layout decisions.
Against 7601aa2, nine alternating pairs of direct, uninstrumented release
executables under perf stat -e cycles:u,instructions:u, seed 1, depth 8,
2000 frames per process, including setup:
| phase | cycles before → after (billions) | instructions before → after (billions) |
|---|---|---|
| size | 4.5164 → 4.0909 (-9.4%) | 12.1709 → 10.3993 (-14.6%) |
| resize | 5.3025 → 4.8044 (-9.4%) | 14.2150 → 12.1999 (-14.2%) |
| many | 5.2743 → 4.8071 (-8.9%) | 14.0989 → 12.0868 (-14.3%) |
All nine samples per side were retained; before/after cycle ranges do not
overlap. No scans or builds ran during measurement. All instrumented work
counters match 7601aa2 on all six phases (50 frames, cold once), including
the primitive counts in the table above: this reduces the cost of
emitting the repeated glyphs, not the repeated layout evaluations themselves.
Additional depth-6 fixtures, nine alternating pairs and 4000 frames each: seed 3's size updates improve cycles by 5.0% and instructions by 8.0%; seed 13's resizes improve cycles by 8.5% and instructions by 13.6%, both with nonoverlapping cycle ranges. Seed 3's resize and seed 13's size ranges overlap. On seed 1/depth 8, cold (one frame/process) and scroll (300,000 frames) also have overlapping cycle ranges. Repaint (1,000,000 frames) measures 1.1% more cycles, with nonoverlapping ranges and just 0.02% more instructions: this slice has a small measured repaint cost alongside the text-heavy gains. All nine samples per side were retained in these checks too.
The verification runs above pass again at this head. All five reference
renders (view, minimal,
random, tabs, text) match 7601aa2 pixel for pixel at 1920x1200; tabs
resized to 900x1200 matches a cold render there. The GPU probe reported Venus
on the RX 7900 XT in this session.
Two trials were removed: combining frame recomposition with extent movement
saved under 0.8% of instructions and no cycles; special-casing equal endpoint
fractions in UiSpan::within cost 11% cycles and 7% instructions despite still
inlining. Keep that hot general composition small.
Child frames can now follow the extent without evaluating their container.
widget_within accepts the same DrawRegion::{Frame, Extent} reference as
primitives and masks. Pad retains its pixel inset in extent coordinates;
Stack inherits placement for its sizing child and retains extent-relative
frames for its other children. Moving the extent replays those requests through
ask_box, preserving child alignment and region nodes. This extends the existing
inherited-child walk rather than adding a separate layout body or a second cache.
It stores a region-bearing relation per extent-following child, so retained
child records are larger than the old widget-id-only list.
Two dependencies stay conservative. Fractional endpoints compose before pixel
evaluation, so their bounds cannot in general be pulled back through the extent
length alone; those frames still depend on raw placement. Reading a child's
fractional report also depends on placement because composing that report into
the parent's frame can change its value without changing the child's answer.
Pixel-inset frame bounds use the existing exact Holds::through.
Keep the measured slot when repositioning. A first trial recomputed it from
active.size, which is the final draw's report. Text measured narrow and placed
wider may report a different height at the latter width; the slot is still the
one chosen from measurement. Seed 20/depth 4 caught this. The retained slot is
now preserved on axes not explicitly assigned by the parent. A small synthetic
width-dependent widget pins the same failure without fonts.
Against a7307d9, seed 1/depth 8 skips 21 draws cold, 10.9 per many frame,
7 per size frame and 14 per resize frame. Repaint, scroll, and all primitive-write
counts are unchanged. Nine alternating pairs of direct uninstrumented release
executables under perf stat -e cycles:u,instructions:u, including setup:
| phase | median cycles before → after (billions) | median instructions before → after (billions) |
|---|---|---|
| size (2000 frames) | 4.0819 → 4.0714 | 10.4107 → 10.4017 |
| resize (2000 frames) | 4.8108 → 4.7237 | 12.1854 → 11.9252 |
| many (2000 frames) | 4.7918 → 4.7787 | 12.0925 → 12.0567 |
| repaint (1,000,000 frames) | 2.6701 → 2.6851 | 6.0175 → 6.0195 |
| scroll (300,000 frames) | 2.6296 → 2.6273 | 6.5025 → 6.5817 |
Only resize establishes a cycle improvement: 1.8%, with nonoverlapping sample ranges, and 2.1% fewer instructions. Every other phase's cycle ranges overlap; scroll uses 1.2% more instructions. All samples were retained and no scans or builds ran during measurement. This is a small reduction in work, not closure of the main gap.
Verification: workspace formatting, clippy with layout-diagnostics, tests
(104 suite tests, 21 core tests), exact release scans of 2000 seeds/depth 4 and
1000/depth 6, the shrinker over 400 trees/depth 5, and a debug scan of 120
seeds/depth 4 with assertions enabled.
The new no-redraw test checks nested padding/stacks, fixed child
alignment, primitives, masks, and region nodes; restoring the old container
calls makes it fail with three draws instead of two. Additional tests cover
fractional reports and numeric dependencies. All five reference renders match
a7307d9 pixel for pixel at 1920x1200, and the resized 900x1200 tabs matches a
cold render there. The GPU probe reports Venus on the RX 7900 XT.
The remaining gap has not been established as an unavoidable correctness
cost. Before the extent-relative change, a temporary failure trace of one
frame on seed 1, depth 8 counted 39/108 failed reuse attempts in size, 65/151 in resize, and 45/109 in many
where only the raw placement snapshot differed: both numeric frame and extent
ranges still held. Containers read that snapshot to construct child positions,
so discarding the check would be wrong. Padding and stacks now retain their
extent-relative child frames, but Span still constructs its mixed-reference
slots from a raw placement read: along the row, child fractions refer to the
frame while cursor positions and leftover room refer to the extent. Retaining
those expressions without changing their fixed-point evaluation order is the
next protocol question. There is also necessary width-dependent work: the hot text at widget 199 visits 68 px and 89.53613 px
and reports different heights (316.80078 and 228.80078). It is evaluated
repeatedly at those widths, so that fact alone does not justify all the repeats.
Keeping measurement validity separate from final drawing validity and deferring
to an already dirty parent were previously tried without improving draw counts.
Measuring first in local redraw and then restoring its slot saved only one draw
in many and added another request on simple repaints; that trial was removed.
Retaining local coordinates still costs memory per primitive. Optimize under
the exact oracle before adopting the prototype; the Inset/Outset changes
remain parked.
How layout is decided
Fixed point
Decided with Bryan on 2026-09-15. Layout decides on a grid rather than in floats.
Fixed<SHIFT>is ani32counting1 / 2^SHIFT. Adding and subtracting are exact;muldrops to the step below (Bryan, 2026-09-16: truncation is preferable);div,div_intandratioround to nearest;to_scaletakes the nearest step. Two routes to one place that land on one number are the same place, so everything downstream compares for equality.Pxis1/1024px,Relis1/2^24of a box,Weightis1/65536of a share.PX_SHIFTandREL_SHIFTare the only statement of the first two; the shader's copy is prepended from them byrender::module_source.Pxwas1/64first, where one rounding's residue was 0.016 px and enough to move a box. Range is +/-2.1M px and conversion tof32is exact to 16,384 px.- A weight is not a fraction: a list divides its room by the total of its
weights, and
Rel::ratioturns two weights into a share on the finer grid. - Arithmetic wraps (
4febabf, Bryan: a coordinate past the range will not draw reasonably anyway, so wrap and break clearly). Saturating cost a twelfth of layout's instructions.MINandMAXstand in for an unbounded end and are only ever compared against;from_f32is the one operation that clamps, andHoldskeeps a saturatingnarrow. - A pointer, a wheel notch, a shaped glyph advance and a window size arrive
as floats and go on the grid where they arrive.
Vec2is what the GPU and the platform speak;PxVec2is what layout decides in. - Do not widen the grid to chase a residue. Every failure this branch saw was one value reached by two expressions, sitting on a boundary defined by the same value coming back the other way. No precision shrinks a residue that is the whole distance.
A box in pixels is one multiply from its parent's
ActiveData keeps a widget's box as lengths of its parent's box --
given_len, and offer_len for the box it was first asked about --
DrawInfo carries the pixel lengths themselves (px, offered_px), and a
draw threads them down one Len::to_px at a time: the box its parent gave
it, then the part of that box its own answer placed its drawing in, which
placed_lens states once for both placed_box and the walk.
Painter::px_size and px_len read that value, and
UiRenderState::asked_px takes the same steps back up the parent chain
when a local redraw starts part-way down the tree. Neither chain has a
coordinate frame in it, so a region node cannot break either, and warm and
cold reach every length by the same expression.
Holds::throughis the exact preimage ofpx + floor(rel * box):floor(rel * B) >= lo - pxisrel * B >= (lo - px) << RELandfloor(rel * B) <= hi - pxisrel * B < (hi - px + 1) << REL, twodiv_towards once the sign ofrelhas said which bound is which. The answer is an interval even for a single length, because a floor is not invertible. The range has to contain the box a drawing was made in (theHoldsassertion indraw_at, debug only) and must not contain a box the drawing does not hold for (the oracle); being the preimage makes those one statement rather than a trade-off.- Symbolic regions are for the GPU, hit testing and remaps alone.
Moves::resolveis the only walk left and it is the vertex shader's. Nothing layout decides is composed back up the move chain. pxis not stored onActiveData, deliberately. A resize every widget'sHoldsadmits redraws nothing, so a stored pixel length would be stale on every widget in the tree with nothing to say so.asked_pxwalks up only where a widget is already being redrawn; the mean chain is 2.8 levels.- The window is not a move entry (
5b78002). A chain bottoms out inMoveIdx::NONE; the window is applied where a fraction becomes pixels,to_px(output_size)on the CPU and the uniform in the shader. A resize rewrites no retained entry and re-uploads nothing but the uniform; its cost is whateverHoldsredraws. - Failed hypothesis, kept as the shape of the mistake: an offer composed
back up the chain fell back to
FULLunder a region node and was resolved against that node's placed box, so everything under aScrollwas re-asked at the content's width and confirmed its own answer. Pinned byunsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered. The old chain with an allowance inthroughpassed that case and the old chain with the exactthroughfailed it; both halves had to land at once.
What the fuzzers tolerate
The frame/extent continuation removes AGREE_STEPS: warm and cold pixel
regions must compare exactly. This is distinct from the equal-share test:
when a row's grid-step count is not divisible by the number of children,
individual share widths can differ while every rerun of that layout must
still agree exactly. The PR #18 baseline still allows two position steps;
its earlier failure at one step (resize-size, seeds 384 and 162 at depth 5)
is a useful regression target for the experimental recomposition.
Retained-layout invariants
Holdsis the interval of box lengths for which a widget's drawing and reported size stay valid. ReadingPainter::px_lenorpx_sizenarrows it to the length read;Painter::holdswidens it. Parent validity is the intersection of what its children induce. The contract is trusted: a widget declaring a wrong range is a defective widget, and Iris adds no defensive work to recover from one.- A retained drawing can be reused only when its
Holdscontains the new pixel box on both axes, its parent node is unchanged, its region-node choice matches the retained structure, it is on the layer it is asked for, and the widget is clean. A valid ordinary subtree moves without redrawing by recursive remap; a region node moves by one entry. - A retained drawing belongs to the layer it was made on. A container
that measures a child by drawing it measures on the layer that child will
draw on --
Painter::child_layer_at-- or it pays two draws a frame. - The first box a parent asks about is the offer; a later box chosen from the answer is the final box, not another answer. A dirty widget is re-asked in the box its parent gave it, and only where that box is as long as the offer; anything else is its parent's question, with the mark left on. Lengths and not whole boxes: what a drawing depends on is its lengths, so the same lengths elsewhere is the same question.
- An answer is reusable only where both its measurement and the drawing in
its final placed box remain valid; the drawing's
Holdsis translated back throughplaced_lensand intersected with the answer's. Painter::widget_decided(child, region, [bool; 2])says the parent chose this box from the child's own answer along those axes, so the answer is not placed inside it again. A report of "half of what you give me" has no fixed point but zero, so the framework asks exactly twice: at the offer, and in the box chosen from the answer, final on the decided axes.Spandecides the row axis,Scrollboth,Stackboth for its sizing child.Padoverrides nothing: its inset is exactly the inner where the box is its answer, and the slack is the inner's to sit in otherwise.- Placement cannot be applied after the fact. Three attempts at "draw
the widget, then move its drawing to where its alignment says" failed,
because the move is a change of frame and no split of the stored state
carries it: moving
ActiveData::regionwith the drawing made a later local redraw ask a differently rounded question, and leaving it madeplacedaccumulate without bound becausetry_reusereturns a clean subtree's size without walking into it. Alignment is applied where the size is known --declared_boxfor a rule, the placing second ask otherwise. - A widget that clips to its box reports its box:
ScrollandMaskedreportLEFTOVERon both axes, and adebug_assertholds any widget that set a mask this draw to it. Overflowing is otherwise ordinary, which is why the assertion is narrowed to mask-setters. Where content shorter than aScroll's viewport sits is the scroll's own alignment, and its "fits at the start of any box" widening is gated on near alignment. - A widget's own mask is not the one it inherited.
ActiveDatakeeps both; they differ exactly where the widget calledset_mask, which says whose mask a move rewrites, and a local redraw is handed the inherited one. Pinned byretained::a_masked_widget_redrawn_on_its_own_sets_its_mask_again. - A span is as long across itself as its longest fixed child, unless a rule
gives that length outright (
Painter::has_exact_size), in which case it does not read its children there at all. Any relative orleftoverchild makes it reportleftover. Do not choose between a fixed and a relative child in pixels at the span's current width: that admits multiple self-sizing fixed points, and generated seed 13 settled differently warm and cold under it. The same circularity is what a cap containingleftoverwould put intoSizeRule::Max. Span's leftover/no-leftover split is a strict layout decision, not a rounding tolerance: itsHoldsrange must use the same exact boundary as drawing. A tolerant endpoint retained zero-height children in seed 16; a boundary moved off where boxes land was needed in floats and is not on the grid. A structural decision may not be taken on a hair's breadth that two routes can disagree about. Pinned byunsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over.- A pixel comparison is equality. A length given in pixels is that many
pixels wherever it ends up, structurally:
Len::withinadds a part's own pixels rather than scaling them. Pinned bya_length_in_pixels_is_that_many_pixels_however_it_is_nested. A length given as a share is not: equal shares come out one or two steps apart because positions, not lengths, are what gets rounded, so the row fills and no two children leave a seam (equal_shares_differ_by_at_most_two_steps_and_fill_the_row). - A move that keeps a box's length is a translation, and exact. A box
that changed length re-expresses each part as a fraction of the new one,
which rounds. This inverts the float-era rule;
tests/cases/drift.rspins that the grid does not drift either way. Scrollmust return the answer from the first box it asked about, whether retained or fresh; returning the final placed answer advanced one fixed-point iteration (seed 86). Content that fills the viewport unscrolled is handed back as it came, because the same box written as its own length in pixels does not round alike.- An asked-but-undrawn size dependency names the widget that asked as its
parent (seed 10).
Painterrecords size-dependency edges only when a parent reads a child's size or hint; an undrawn measured child stays recorded so a later change reaches whoever decided not to draw it. - Dirty widgets settle deepest-first, and that ordering is what makes an
answer trustworthy;
dirty_size_underis to be deleted once a resize goes through the same walk (see "A frame settles strictly bottom-up"). - Declared non-
leftoverlengths are resolved by the parent where the widget is drawn, so a declared-length change redraws the parent. A rule wins on the axis it names and the widget under it never learns of it. A cap may not containleftover: a cap must read the report, so rule and report are one equation, and a share puts the row's division into it -- the multiple-fixed-point failure again. A cap is pixels and a fraction, which is whatLenis. - Text shaping is retained separately from line breaking; a greedy break
holds from its longest produced line through the width it was made at,
reported through
Painter::holds. - Region nodes: a node holds a whole
UiRegionin its parent node's coordinates,FULLis the identity, widgets opt in with.region_node()orWidgets::set_region_node, and changing it redraws the subtree once..scrollable()sets it once; rawScroll::newdoes not. A removed node's move entry stays alive until every descendant has migrated.SpanandAlignadd no nodes. - Alignment is one
f32per axis (Bryan, 2026-09-15), default the middle on both because the edges assume a direction. One widget keeps one length per axis; a second length needs a second widget,Wrappervia.wrapper()(Bryan, 2026-09-16,d21a215).
Verification at the current head
At e44dea3:
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace: green, 92 suite tests, 19 core unit tests, 11 generated cases. Only the long runs and the profiling rigs are ignored; no known defect is.- The release oracle at 100 seeds in 14.2 s, and 120 seeds in debug in
59 s -- the debug run exercises the
Holdsassertion indraw_at. - All fifteen shrinker cases at 400 seeds of depth 5 in 57 s, the oracle at 1000 seeds of depth 6 in 143 s, and 2000 seeds at depth 4 over all fifteen cases in 260 s. The last is not routine and should be: it is the only run that has ever found anything past seed 400.
view,minimal,random,tabsandtextbyte-identical at 1920x1200 against25e456e, as istabsunder the recorded replay.randomlive-resized from 1920x1200 to 1280x800 is byte-identical to a cold 1280x800 render. Rendered through Venus on the host's RX 7900 XT, confirmed againstvulkaninfo --summaryin the same session -- an llvmpipe fallback makes the same PNG and nothing in it says so.- All rig counters identical on
cold,repaint,many,sizeandscrollacross25e456e.resizegains one queue pop, one local redraw and one depth read and loses one failed reuse attempt, which is the root going through the walk; drawn widgets, widget draws, draw requests and primitive writes do not move.
Everything above is verification of what was changed, not a claim that the branch is correct.
A claim about a render holds for the commit it was checked at and no
further. tabs changed twice across d3b0ebf with nobody looking; take
the oracle as the reference and the five renders as a spot check.
Run the long two before believing a rounding change, and run the ordinary suite in debug:
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
SHRINK_CASE=all SHRINK_SEEDS=400 SHRINK_DEPTH=5 \
cargo test --release --test shrink -- --ignored --nocapture
IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 \
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
Depth is what finds things, but so is breadth: every late defect before 2026-09-17 surfaced at depth 5 or 6, and the two open ones were found by running 2000 seeds at depth 4, which nothing routine does. Widen one axis at a time and record which.
Performance
Threading a box in pixels down the draw is free on cold layout and 9-13%
off the retained paths (2026-09-17). Instructions:u, medians of 21 runs of
binaries built in one worktree, seed 1 at depth 8, against 5b78002:
| phase | before | after | |
|---|---|---|---|
cold, 200 frames |
313.1M | 312.9M | -0.04% |
resize |
408.1M | 405.6M | -0.61% |
many |
1,924M | 1,756M | -8.75% |
scroll |
357.3M | 323.4M | -9.49% |
repaint |
363.3M | 315.4M | -13.18% |
cold and resize compare directly: all twenty-five work counters are
identical. The other three do less work: repaint goes from 23 draw
requests and 13 widget draws to 1 and 1, because redraw composes nothing
and a widget whose box moved without changing length settles itself instead
of escalating.
How to measure here
- Check the work counters before comparing two commits' times. The rig
prints drawn widgets, widget draws and primitive writes; a comparison is
only worth reading when they match.
random.rs'sBranchpicks a subtree by a measured pixel length, so the fixture's shape moves with the thing measured;Edits::fixed_branchespins it for timing and the oracle keeps measured branches on purpose. A 3x this section once reported was that artifact. perf statin this VM returns garbage readings for bothinstructions:uandcycles:u, roughly a quarter of the time, off by a factor of five to fifteen. Take medians of nine or more and report how many readings a filter kept. Cycles spread 1-3% between sets of one unchanged binary and 6.7% in the worst; instruction counts hold to 0.02% within a binary and move 0.5% across a rebuild, so build the baseline beside the thing measured and quote a delta.ex_div_busyheld to 0.1%.- What moves cycles is whether
UiSpan::withininlines. It is the hottest line in layout;nmshows it as a symbol when it does not. Shrinking its body until the inliner takes it won;#[inline]on the body it had lost 1.5% cycles. Shrink it, do not annotate it. Holds::throughdivides twice per call and accounts for essentially all of a run'si64divisions: 21.3M cycles of a 500-framemany, 2.8%. The float head divided twice there too.
Tried and rejected, with numbers
- A float reciprocal for
AxisRemap::apply_scalar's division: +6% cycles.Holds::through's division has not been tried. - Branchless
shift_round: +6.7% cycles alone, and worse again with the short-circuits removed. Size, not the branch, is what keepswithinout of line. - Removing the per-child hash lookup in
remap_subtree: 0.0%. - Short-circuiting
apply_scalarwhere the fraction is nought or one: +17%. - Short-circuits guarding a saturating multiply stopped paying once the multiply wrapped. Re-price a short-circuit before keeping it.
- Rust does not contract
a + b * c; the float head never had an FMA to compare the grid's multiply against.
Wrapping (4febabf) was -8.6% instructions and -6.6% cycles. Truncating
(08c9d5a, with Fixed::scaled's zero test and within's is_full tests
removed as one commit, since they are worth 61M instructions apart and 115M
together) costs a share a thousandth of a pixel of its row, makes a flipped
span sit a step from its mirror, and moved an antialiased edge in tabs by
one pixel. See "Where the residual comes from" for what that last one is.
Rigs and reproduction
Ordinary framework verification:
cd /home/bob/repos/iris-pr18
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
The ordinary tests are modules of one tests/suite.rs target; pick a module
with cargo test --test suite layout::. profile.test uses
debug = "line-tables-only", which halved the test-target rebuild.
tests/generated.rs compares a warm incremental tree with a cold tree of
the same state; IRIS_GENERATED_SEED, IRIS_GENERATED_SEEDS and
IRIS_GENERATED_DEPTH select what it covers. tests/shrink.rs reduces a
failing tree over the same fifteen cases and the same trees --
iris::random::plan(seed, depth, &edits) and build(rsc, &plan), so a
failing seed reduces directly and the oracle prints the command:
SHRINK_SEED=18 SHRINK_DEPTH=6 SHRINK_CASE=repaint-some \
cargo test --release --test shrink -- --ignored --nocapture
The cases live in tests/scenario/mod.rs, included by both targets by
#[path]; a case only one rig knows is how the two drifted apart once. Turn
what the shrinker finds into a test of its own rather than leaving a seed as
the record. Both fuzzers take a thread per core but one. A git bisect
once named a commit that could not be the cause; read the tree rather than
the bisect when that happens.
tests/layout_diagnostics.rs is the retained CPU rig: IRIS_PHASE selects
cold, many, repaint, size, scroll or resize, the
layout-diagnostics feature gives the explanatory counters, and an
uninstrumented release binary under perf gives totals. Dump the counters
with
IRIS_SEED=1 IRIS_DEPTH=8 IRIS_FRAMES=500 IRIS_PHASE=many \
<instrumented binary> --ignored --nocapture \
| grep -E '^ +[a-z].*[0-9.]+$' | grep -v ' ms$' | sort
and diff two runs; identical output is what says a change is free.
The float head is checked out at /home/bob/repos/iris-float-cmp, at
5ed9e87 with Edits::fixed_branches applied uncommitted. Its counters do
not match the grid's and will not, so a comparison against it is a bound
rather than a measurement.
The headless reference set runs one process at a time because the rig reuses one compositor; comparison worktrees need separate target directories.
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/tabs.png
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \
--resize 900x1200@60Hz --shot /tmp/resized.png
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \
--replay /tmp/tabs.touch --shot /tmp/replay.png
The replay used for the reference check:
0 down 1728 24
80 up 1728 24
400 down 1836 1116
480 up 1836 1116
800 down 1836 1116
880 up 1836 1116
Next
In order, from the review above and Bryan's steer (2026-09-17):
A resize marks the root and goes through the walk.Landed asa0693acande44dea3; see the two sections above. The re-parenting half turned out to be two defects rather than the predicted one, and neither was thedepth()assertion.- Frame and extent, as written above. This is the fundamental change
and comes before anything built on the placing ask. It lands the two
parked branches' tests (the stack test unchanged,
Inset/Outsetwith the rename and the.pad()audit), flipsa_span_reads_a_child_report_as_a_fraction_of_the_row, and deletesbox_of,reports_of's composition inin_parent_frame, and thethrough(lens)translation indraw_inner. Run the long fuzzers and the render set once for it. - Write
ActiveData::answerin one place. - Keep the
DrawInfoonActiveData; delete the copied fields and the reconstruction inredraw. - Round on the CPU and snap to the nearest pixel in the shader, as
one change with one verification. Bryan approved the snap on 2026-09-17
(rendering may change wherever it brings the screen closer to what the
user's code says: three equal sections of 1000 px need one of them
rounded up), and to CPU rounding the same day. The reason for that one
is different: a
Relis off by at most2^-25of its box, so with round-to-nearest every product whose true value is a whole number of steps is exact for boxes under about 8,000 px, where truncation leaves half of them one step short and layout then decides "does not fit" on a container the user meant to fit exactly. Use the branchless round-half-up form,(a * b + (1 << (BY - 1))) >> BY, not the sign-branchingshift_round; re-deriveHolds::throughforround(its two shifted bounds move by half aRelstep); check withnmthatUiSpan::withinstill inlines; expect a couple of percent of instructions and re-run the long fuzzers and the render set once for both. - The smaller items: the stale
f32comment, the gap of an undrawn child, confirm nestedleftoverweights, one zero-divisor fallback. Add to them: a span that overflows itself hands a child a box of negative length, which is ordinary now rather than a corner, and nothing states what a widget may assume about one. LazySpan, the next LAYOUT.md §2 item. Region nodes cover the movable subtree case; do not restore a separate child-placement API.SizeRule::{Min, Max, Clamp}, restoring themax_width/max_heightbuilders8220a78deleted. The clamp boundary is a hard layout decision with an exactHoldssplit at the crossover, both sides inPx. Still awaiting Bryan: whether aMaxnarrows the box the child draws in, or only what the parent reports for it.Scrolltaking a direction rather than one axis.
docs/LAYOUT.md §4, §5 and the density section are stale: they name
Painter::place, SetSize, desired_width, apply_rest, Len::dp,
Aligned and MaxSize, none of which exist. Do not restore
OnResize::Translate or OrthoSize.
Other queued work, in dependency order: UiRenderState behind
Rc<RefCell<_>>; density-independent pixels; input restructuring (pointer
capture, drag slop and axis, cancellation, mask-aware hit testing,
timestamps); retained paints, selection, overlays and shared runtime state;
generic desktop/Android hosts and reusable example/APK tooling;
application-owned fonts and replaceable glyph-atlas buckets; positioned
text overflow and cluster-safe ellipsis.
The archive is a reference, not a patch: it predates returned Size, the
current box chain and the current length types. Recreate changes on current
types and keep app/session concepts out of Iris.