Commit Graph
390 Commits
Author SHA1 Message Date
iris-aiandClaude Opus 5 cb1bba4682 Work a move out once for the subtree, not once for each part
`RegionRemap` re-derived the same things for every scalar of every part of a
moving subtree: the extent it divides by, whether the box only moved, whether
it spans the whole of its parent's, and the two ends of each `lerp`. All of
them are the same for the whole walk, because the walk is one box moving into
one other box. They are worked out once in `RegionRemap::new` now, as an
`AxisRemap` per axis that is either a translation or a scale.

Identical arithmetic in the same order, so the answers are unchanged: 500
frames of the `many` phase went from 1,886,328,855 instructions to
1,815,666,327, 3.8% fewer, and `tabs`, `text` and `random` are byte-identical
at 1920x1200.

Cycles moved 0.8%, which is the finding worth keeping: the surrounding
arithmetic was never the cost. The `i64` division is, and it is still there.

Checked: fmt, clippy, 105 tests, all five shrinker cases at 300 seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:46:23 -04:00
iris-aiandClaude Opus 5 490918b789 Ask whether a rule gives the length, not whether there is one
`Painter::ruled` answered "is there a rule beside me on this axis", which is
the same question as "is my report moot" only while `Exact` is the only rule
there is. `Min`, `Max` and `Clamp` are queued, and under one of those the
answer is still the widget's to give and a span across itself still has to
read its children -- so the name would have been true and the meaning wrong,
which is the worst way for a predicate to age.

It is `has_exact_size` now, over `SizeRule::exact` rather than `known`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:40:51 -04:00
iris-aiandClaude Opus 5 a8898aaa54 Give a length with no share in it its own type again
`UiScalar` was `Len` without the `leftover` weight, which is the separation
canonical `main` already had as `Len` beside `LayoutLen` and this branch
collapsed. It is needed back for the queued clamp: a cap may not contain a
share, because a cap has to read the report a rule otherwise makes moot, and
a share puts the container's division into the same equation -- two
self-consistent assignments, which is the multiple-fixed-point failure
generated seed 13 punished for orthogonal sizing. `min(report, cap)` is not
a `LayoutLen` either: it is a sum of parts, and the smaller of two of them
is not one.

So `UiScalar` is `Len`, what was `Len` is `LayoutLen`, and the two say in
their docs which is which: a `Len` is pixels plus a fraction of a box -- a
position being the length from the box's start, which is why a span is two
of them -- and a `LayoutLen` is a `Len` plus a claim only a container
dividing its room can answer. `From<Len> for LayoutLen` is the one-way step
between them.

Names only; the shader's `UiScalar` is renamed with them. Checked: fmt,
clippy, 105 tests, and `tabs`, `minimal`, `view`, `text` and `random`
byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:40:15 -04:00
iris-aiandClaude Opus 5 4f5e27cba9 Do not divide by one to remap a box that spans its parent
A retained part is re-expressed as a fraction of its new box by dividing by
the old box's extent, and that extent is one whenever the box spans the whole
of its parent's -- which is the common shape. An integer division is the most
expensive thing in `apply_scalar` and it ran twice per span.

`perf stat -e instructions:u` over 500 frames: `many` 1,938,264,572 to
1,886,265,821, `scroll` 452,517,906 to 444,792,314.

Tried first and reverted: short-circuiting a fraction of nought or one, at
either end of the box. That is not the common case, and the two comparisons
cost 17% more than the divisions they were meant to save.

Checked: fmt, clippy, 105 tests, three shrinker cases at 300 seeds, 100
generated seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:13:51 -04:00
iris-aiandClaude Opus 5 11c55bcef9 Put a glyph's offset on the grid where it is placed
A placed glyph's offset is whole pixels by construction -- a floored pen
position plus the entry's integer bearing -- and `Painter::glyphs` was
converting it, and the entry's width and height, from `f32` on every frame
that drew the glyph. It is a `PxVec2` now, converted once when the text is
placed, and the size is two integer shifts.

Measured with `perf stat -e instructions:u`, since the difference is smaller
than this machine's clock: the `many` phase went from 2,013,099,594
instructions to 1,938,264,572 over 500 frames, 3.7% less. `scroll` and
`repaint` are unchanged to within noise, which is right -- they do not redraw
glyphs.

Checked: fmt, clippy, 105 tests, the reorder fuzzer at 300 seeds, and `tabs`,
`text` and `random` byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:11:07 -04:00
iris-aiandClaude Opus 5 f11f5f4825 Divide twice in a range's inverse, not four times
Which end of the answer each bound comes from is known from the sign of the
fraction before dividing; taking the min and max of four divisions asked the
question twice. A division is the most expensive thing in that function and
it runs per child per axis.

`many` 0.283 ms a frame to 0.278. Small, and strictly less work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:06:46 -04:00
iris-aiandClaude Opus 5 97cc8b32ed Measure a child on the layer it draws on, not twice on two
Fixed point cost 3x in layout: `many` went from 0.179 ms a frame to 0.544,
and `scroll` from 0.011 to 0.030. The counters said why -- eight more "placed
by redrawing" a frame -- and the reason was mine rather than the grid's. A
retained drawing belongs to the layer it was made on, which `4e28f10` started
enforcing, and `Stack` measures the child that sizes it by drawing it on its
own layer and then draws it again on the child layer. So every stacked child
redrew twice a frame, forever.

`Painter::child_layer_at` addresses a child's layer rather than walking to
it, and `Stack` measures on the layer that child ends up on. The second ask
is then a reuse. Its glyphs are written once rather than once under the
background and once over it.

Measured on the same fixture: `scroll` 0.031 ms to 0.020, `many` 0.570 to
0.283, and the scroll phase's counters are back to what they were before
fixed point -- 4 widget draws and 12 draw requests a frame, exactly. What is
left above that baseline is not this.

`ReuseOutcome` could not say "another layer" or "the region-node choice
changed"; both returned without a counter, which is why the first look at
this said nothing. They have counters now.

Checked: fmt, clippy, 105 tests, five shrinker cases at 300 seeds, 100
generated seeds, and the examples byte-identical but for 36 pixels of
`random` at one level -- edges that were being drawn twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:04:31 -04:00
iris-aiandClaude Opus 5 95fb4f962c Hold a clipping widget to its box, and check that it is
`Scroll` reports `LEFTOVER` on both axes because it clips its content to its
box: it can neither take less of one nor honestly ask for more. `Masked` is
the other widget that clips and was passing its inner's size up, so a mask
over something taller than its box asked to be placed at the length it had
just cut off. It reports its box now, for the same reason.

The `debug_assert` the handoff has been asking for is the one that would have
caught both, narrowed to what is actually true: a widget that set a mask this
draw has to report inside the box it drew in. Reported as "does not exceed
the box" it fires on ordinary overflow instead -- measured, a hundred fuzzer
trees produce thousands of them, every one a text too tall for the box it was
offered, which is what a text is meant to say.

`tests/cases/scroll.rs` has a clipping widget that reports its content, to
show the assertion catches it.

Checked: fmt, clippy, 104 tests, all five shrinker cases at 300 seeds, 100
generated seeds, five examples byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:48:23 -04:00
iris-aiandClaude Opus 5 bdab55824f Take two roundings out of where a box comes from
Traced what was left of the warm-against-cold difference after fixed point.
It is not accumulation and not one place: it is the same box reached two
ways, and each way rounds where the other does not.

`Scroll` was writing a box it had been given back out as its own length in
pixels. That is the same box in another form, and centring a part in `rel 1`
lands a step from centring it in `px 900`, because halving a difference is
not halving each part of it. Content that fills the viewport and has not been
scrolled is now handed back as it came, which makes the shrinker's `repaint`
and `resize-repaint` cases agree exactly rather than within a step.

`Span` placed each child a step from where the last one ended, so the
rounding of every share was carried along the row. A position is now the
fixed parts before it -- a sum, exact -- plus one rounded share of the room.
Measured: two hundred equal shares of a 1000 px row ended at 999.999 and now
end at 1000, and `tests/cases/layout.rs` pins it at 2, 3, 7, 64 and 200.

What is left is a step per level of nesting between the two ways, which is
what the fuzzers now allow: four of the five shrinker cases pass at one step
and the fifth is five spans deep. Closing it needs one way of asking where a
box is, which is a bigger change than this.

Checked: fmt, clippy, 103 tests, all five shrinker cases at 300 seeds, 100
generated seeds, five examples byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:42:09 -04:00
iris-aiandClaude Opus 5 9d8415d65f Delete OrthoSize, and run the seeds in parallel
A span is as long across itself as its longest child, unless a rule beside it
already says how long it is -- and then reading the children answers nothing
and only makes its size depend on theirs. `OrthoSize::Full` was that second
case written twice, once as an enum on the span and once as the rule that
actually decides; `Painter::ruled` lets the span ask which it is in. The
widget under a rule still does not learn what the rule says, only that its
answer for that axis is not wanted.

The fuzzers grow, lay out and drop a tree within one seed, so the seeds share
nothing and take a thread each, one short of every core. Measured here: the
generated oracle's hundred seeds went from 68 s to 10 s, and a shrinker case
at 300 seeds from 18 s to 3.5 s. A seed that fails still shrinks and panics
on its own thread, and `std::thread::scope` carries that out.

The shrinker now allows the two steps the oracle already did -- the deeper
trees these grow reach a second composition, and a step is a thousandth of a
pixel.

Checked: fmt, clippy, 102 tests, all five shrinker cases at 300 seeds, 100
generated seeds, and five examples byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:18:49 -04:00
iris-aiandClaude Opus 5 cb955f1023 Link the ordinary tests once, and keep their debug info to line tables
Eleven `tests/*.rs` were eleven binaries, each linking the whole graph --
`wgpu` and all -- to run a handful of cases. They are modules of one target
now, under `tests/cases/`, and `cargo test --test suite layout::` still picks
one out. The fuzzers and the `*_cost` measurements stay their own targets:
they are run on their own and want to be selectable without building the
rest.

`profile.test` takes `debug = "line-tables-only"`, which is what a backtrace
here actually reads; the type and variable information was the bulk of what
the linker was writing.

Measured on this machine, rebuilding `iris`'s test targets after a change to
the crate: 14.3 s before, 9.8 s with one target, 7.7 s with both. `target/`
went from 45 GB to 13 GB. The suite still passes 102 tests, and the binary
still carries `.debug_line`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:00:43 -04:00
iris-aiandClaude Opus 5 39e4ca20e6 Decide layout on the grid end to end, and delete the tolerance
`Px` and `PxVec2` reach the last places a pixel was a float: the window, the
box a widget reads, the box it is compared against, and `PixelRegion`. A
pointer, a wheel notch and a shaped glyph advance still arrive as floats,
and each is put on the grid where it arrives.

`Holds` is an interval of `Px`. `HOLDS_EPSILON_PX` is gone with the
`exact`/tolerant split it existed for: `at` is the length a widget read, an
open end is the next step along, and `same_px` is equality. `Span`'s margin
from `5ed9e87` goes too -- the box a parent hands back and the sum of what
its children asked for are counts of the same step, so the boundary decides
the same way from either side.

Three things had to be true for that, and were not:

`Holds::through` inverts `px + rel * box`, which rounds -- so a part of a
given length came from a range of boxes, and inverting the length alone gave
a point that need not contain the box the part was drawn in. It now maps the
half step either side, and one more for a length composed down the chain
against the same length measured against the window.

`RegionRemap` translates when a box only moved, rather than dividing to find
each part's fraction and multiplying to place it again. Two roundings landed
a step from where growing the tree that way does; a move is exact on a grid,
which is the whole reason `tests/drift.rs` was written.

A pixel is `1/1024` rather than `1/64`. At `1/64` the residue of a length
reached two ways was one step, and one step was 0.016 px -- enough to move
a box. `PX_SHIFT` and `REL_SHIFT` are the only statement of the grid now,
and the shader's copy is prepended from them rather than written twice.

Checked: fmt, clippy, 102 tests, 100 generated seeds in 75 s, all five
shrinker cases at 300 seeds, and `tabs`, `view`, `minimal`, `text` and
`random` byte-identical at 1920x1200.

What the fuzzers ask for is now a step, not a twentieth of a pixel: the
shrinker's five cases agree within one (`resize` exactly), and the oracle's
two-operation cases within two. The residue is a single rounding either way
-- it scales with the grid rather than accumulating, which is why it is a
thousandth of a pixel now. Closing it means one way of asking how long a box
is, rather than a chain composed down and a length measured against the
window; that is a bigger change than this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 02:56:48 -04:00
iris-aiandClaude Opus 5 bd6de71a55 Put lengths, padding, gaps and alignment on the grid too
`Len` is `Px` beside `Rel` beside `Weight`, so the seam `4e28f10` left in
`Span` -- a float length added to a fixed-point cursor -- is gone, and the
sum a span compares against its box is exact.

`Weight` is its own scale, `Fixed<16>`, because a share of what is left over
is not a fraction of anything: a list divides its room by the total of them,
so the range has to hold a whole list's worth while the precision only has to
tell two weights apart. `Rel::ratio` turns two weights into a share on the
finer grid, which is what a span needs and what dividing them on their own
grid would round away.

`AxisAlign` holds a `Rel` rather than a float, which is what the layout was
reading out of it anyway. `Padding` and `Span::gap` hold `Px`, converted
where they are built instead of on every frame. `RegionAlign::rel` is gone;
its one caller wanted a position, and now builds one.

`Fixed` gains `from_num` for a number as it is written in source, `mul_int`
for a length repeated a whole number of times, and `ratio`.

Checked: fmt, clippy, 101 tests, 100 generated seeds in 86 s, all five
shrinker cases at 300 seeds, and all five examples byte-identical at
1920x1200 against `4e28f10`.

With the fuzzer comparing for equality rather than within 0.05 px, four of
the five cases now pass 100 seeds -- `resize-repaint` joins the other three.
`reorder` still fails one seed by one step, so the last of it is in what a
box is measured *in*: `px_len` and the window are still floats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:40:42 -04:00
iris-aiandClaude Opus 5 4e28f1047e Put positions on the grid, and decode them in the shader
`UiScalar` is `Rel` beside `Px` rather than two floats, so composing a
position down a chain of boxes adds exactly and rounds only at the two
multiplies `within` makes. `UiSpan`, `UiRegion` and `UiVec2` follow it, the
hand-written `Hash` goes away with the bits it hashed, and `impl_op!` grows a
`same` form for a type whose fields are not the same kind of number.

`Len` is still floats, so the seam converts: `Px::from_f32` where a span adds
a child's length to its cursor, and `to_f32` where something outside layout
wants pixels. Those go when `Len` follows.

The GPU reads what the CPU wrote: the instance attributes are `Sint32x2` and
the shader decodes by `1/64` and `1/2^24`, both exact in `f32`, then composes
the move chain in floats as before. It has to agree with itself frame to
frame rather than with the CPU to the last bit.

Two things fell out of making the numbers exact.

`floor` at the rasteriser was picking the pixel below wherever a fraction
divided a window exactly. A fifth of 1920 is 383.99998 through a rounded
`Rel` -- and was 384.0 through an `f32` that happened to round up -- so five
tabs each lost their last column. `snap_floor` takes a coordinate within half
a step of a boundary to be on it, which is the same rule as everywhere else
here: decide where values do not land.

A widget measured on one layer and drawn again on another kept the first
layer, because `try_reuse` compared everything about a retained drawing
except which list it sits in. `Stack` does exactly that for its background,
so every panel's text went under its own background. It only worked before
because the two asks differed by a rounding and forced a redraw;
`tests/retained.rs` pins it now, and `ReuseOutcome` can say `WrongLayer`.

Checked: fmt, clippy, 100 tests, 100 generated seeds in 70 s, all five
shrinker cases at 300 seeds. `tabs`, `view` and `minimal` render
byte-identical at 1920x1200; `random` differs in 36 pixels by one level;
`text` differs where glyph origins moved onto the grid -- same positions,
same spacing, different subpixel coverage, checked at 6x against the old
render.

Measured on the way: with the fuzzer comparing for *equality* rather than
within 0.05 px, `resize`, `repaint` and `size-change` already pass 100 seeds.
`reorder` fails one seed by exactly one step, which is the `Len` seam above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:22:12 -04:00
iris-aiandClaude Opus 5 7548139861 Add a fixed-point number for layout to decide on
Layout reaches one place by more than one route -- a box composed down the
chain, and the same box summed from what its children asked for -- and the
two land a few bits apart in floats. Where that decides something structural
rather than something positional, a warm tree disagrees with a cold one:
`5ed9e87` is the instance, and its margin is a patch over the representation
rather than a fix to it.

`Fixed<SHIFT>` is a count of `1 / 2^SHIFT`s in an `i32`. Adding and
subtracting are exact, a multiply rounds once back onto the same steps, and
two routes that come within half a step land on the same number -- so the
comparisons downstream can ask for equality rather than for nearness.
`Px = Fixed<6>` and `Rel = Fixed<24>`: a sixty-fourth of a pixel is finer
than a display and still exact in `f32` up to 262,144 px, and twenty-four
bits of fraction matches `f32` at a half, beats it above one where anchors
sit, and leaves +/-128 of range to sum relative children in.

Nothing uses it yet. The arithmetic saturates rather than wrapping, because
a clamped coordinate keeps the ordering a wrapped one inverts, and the ends
are what an unbounded interval will be written with.

Checked: fmt, clippy, 99 tests including ten for this type -- the round trip
through `f32`, halves rounding away from zero either side, saturation at both
ends, and 20,000 additions landing exactly where the count says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:34:01 -04:00
iris-aiandClaude Opus 5 5ed9e874a3 Keep a span's leftover decision off the box its parent hands back
The shrinker's `reorder` case had two red seeds at depth 5, and neither was
about reordering. A span asks whether anything is left over by comparing its
box in pixels with what its fixed and relative children fill. Where the
parent sized that box from this span's own answer those are the same number,
and the box returns through the chain a few bits off, so 0.00003 px decided
it: warm rounded under and left a leftover-only child undrawn, cold rounded
over and drew it at zero length. Both are stable, and the pixels are the
same either way, which is why nothing but the oracle could see it.

The room to divide is `len * fixed - total.px`, and under `HOLDS_EPSILON_PX`
of it is now none. That moves the boundary off the length boxes land on
rather than making the comparison tolerant: the validity range is still
split at the boundary exactly, as generated seed 16 requires, and what it
gives up is a share of under a twentieth of a pixel. The same margin answers
the `fixed == 0` arm, where the only room is what negative pixels leave.

`tests/unsettled.rs` gets the six-widget tree, shrunk from 266. It needs the
span above the one that divides: without a box composed through it both
trees round the same way and the boundary is never crossed.

Checked: fmt, clippy, 89 tests, 100 generated seeds agreeing in 68.5 s, and
all five shrinker cases at 1000 seeds of depth 6 (159,024 widgets each).
`tabs`, `view`, `minimal`, `text` and `random` render byte-identical at
1920x1200 against the same worktree without the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:48:19 -04:00
iris-ai d3b0ebf90c Make alignment a widget property 2026-09-15 23:12:16 -04:00
iris-ai 8220a78d4a Carry a length as a rule beside a widget, not a widget around it
`.width()` built a `SetSize` whose whole job was to answer `size_hint`, so
every declared length cost a widget, an `ActiveData` and a link of chain to
say one number. It is now a `SizeRule` per axis on `WidgetData`, beside
`region_node`, resolved by `Painter` where the widget is drawn. `SetSize`
and `MaxSize` are gone; `MaxSize` had no caller but its own builders.

That settles which of two answers is the size. A rule wins on the axis it
names and the `Size` returned by `draw` answers the rest, applied once in
`draw_inner` rather than by each widget that could carry one -- so the
widget under a rule never learns of it. `Painter::size_hint` reads the rule
first for the same reason: a rule that beats what a widget would draw has
to beat what it says about itself.

`declared_lens` still falls back to a non-leftover `size_hint`, which is
how an image or a gap gets its own pixel size rather than the whole offer.
That is the offer's business rather than a declaration's, and it falls away
when a widget occupies its reported size inside the box it was offered.

`known` and `declared` are separate because a share is a length to whoever
divides one and not to whoever composes a box: `.width(leftover(3))` is
known without drawing but cannot narrow anything.

Checked: fmt, clippy, 85 tests, and 100 generated seeds agreeing warm
against cold in 67.6 s. `minimal`, `text` and `view` render byte-identical
at 1920x1200; `tabs` differs only in the widget count it prints about
itself, which is two wrapper types smaller.
2026-09-15 19:44:21 -04:00
iris-ai 0283c9d6c7 Pin that a moved subtree does not drift from a cold layout
A move rewrites a retained subtree's stored regions, and those stores are
the only record of where it is. So a move that works from the last answer
integrates its own rounding with nothing to correct it, while one that
re-expresses each part as the same fraction of the new box is anchored to
that box and cannot.

Nothing was checking which of those `try_reuse` does. Replacing the fraction
with an offset added to both endpoints -- which is cheaper, and looks like it
should be exact for a translation -- shortens this fixture's row by 0.071
over 20,000 moves and by 0.712 over 200,000, growing with the count rather
than settling. That is five minutes of scrolling at 60Hz to pass the 0.05
physical pixels layout treats as the same place, and it keeps going. Placing
the far end from the near one instead of offsetting both leaves 0.069, since
the length is re-derived from the endpoints either way.

The existing warm-against-cold checks did not reach it: the generated oracle
compares within 0.05, and `unsettled.rs` compares exactly but only over a
handful of frames, where the drift is still 6e-5.
2026-09-15 19:26:25 -04:00
iris-ai 71c9c39523 Replace placement calls with region nodes 2026-09-15 18:02:28 -04:00
iris-ai f437495309 Add explicit orthogonal span sizing 2026-09-15 16:40:09 -04:00
iris-ai 29c7881c8a Track retained layout validity explicitly 2026-09-15 16:03:53 -04:00
iris-aiandClaude Opus 5 691e3eb23c Rename rest to leftover
The length kind that asks for a part of what is left once the fixed
lengths are taken is called leftover: Len::leftover(2), Len::LEFTOVER,
Size::LEFTOVER, Len::leftover the field, and apply_leftover. It says
what it is where "rest" reads as "the remainder of the list" as often as
"the remaining space", and every agent who has touched this has reached
for a third word for it.

Locals called rest that meant a region or a widget are renamed with it,
since the word now names something else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:22:52 -04:00
iris-aiandClaude Opus 5 9644971daf Inline the write a glyph goes through
Whether the inliner took DrawLayers::write into Painter::glyphs turned
out to depend on unrelated code elsewhere in iris-core: adding the
declared-length resolution pushed it out, and a call per glyph cost 12%
of a resize frame with every counter -- widget draws, primitive writes,
text renders -- unchanged. Saying so directly leaves the two decisions
independent. The random-tree rig is 12.59B instructions where it was
13.25B before either change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:37:34 -04:00
iris-aiandClaude Opus 5 de9ddc0ad4 Resolve a declared length where the widget is drawn, not inside it
SetSize took its declared length out of UiRegion::FULL, which is the box
it was already given, so a span that had sized that box from the same
hint had the fraction taken twice: .width(rel(0.5)) in a 400-wide span
drew its child 100 wide. It held under a Pad or the root, which do not
honour a hint, and hid under px, where 200 of a 200-wide box is all of
it. The text example was 111,923 pixels from upstream/main because of
it.

Whoever draws a widget now takes its declared length, in its own box,
which is what a fraction of one means, and is the identity for a caller
that already reserved the space. rest is not taken: a share of what is
left over is only a length to the widget dividing one, so it passes up
in the size as it does out of a span. SetSize keeps only what it
declares.

A declared length is then part of the box its parent decided, so
changing one has to redraw the parent; the lengths resolved into a box
are kept beside it and compared. Assuming instead that any dirty widget
which declares a length needs its parent costs 17% of a frame that
dirties 130 of 260 widgets, and buys nothing.

All five reference renders, the resize render and the image replay are
byte-identical to upstream/main, the 100-seed sweep passes, and the
resize fixture is 1.286 ms against 1.289 before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:37:34 -04:00
iris-aiandClaude Opus 5 169db7f16f Compose a position in the shader the way the CPU composes it
The shader used mix() where UiScalar::within writes from + (to - from) *
t, so the two associate the arithmetic differently and can put an edge on
either side of an integer. Writing it out matches them, and is a
multiplication cheaper. The five reference renders and the resize render
are byte-identical either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:31:31 -04:00
iris-aiandClaude Opus 5 4063635f39 Check that a one-pixel line keeps its pixel through the chain
Both edges of a fixed length share their box's fraction, so composing
the chain moves them together and the shader's floor can shift the pixel
between them but not round it away. The second test is the case that
makes the first one worth having: a span short of room takes it from its
shares, which go to nothing and then past it, and never from the fixed
lengths between them. Expressing the same line as a fraction of the
output fails both, which is what the tests are there to keep visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:31:31 -04:00
iris-aiandClaude Fable 5.1 f61e8936f1 Restore abs() in the rect shader, and validate every shader without a device
`7c50a3e` renamed a length's `abs` component to `px` and took the WGSL `abs()`
builtin in the rounded-rect distance with it, so every window failed shader
validation on the first frame while `cargo test` stayed green. `naga` is
reachable through `wgpu`, so a unit test now composes each shader file with the
prelude the way the renderer does and parses and validates it; it reads the
shader directory rather than naming primitives, so a new one is covered by
adding its file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:16:56 -04:00
iris-aiandClaude Fable 5.1 02ff8c7454 Measure a dirty widget where its parent asked, not in a box its answer decided
A local redraw drew a dirty widget in the box it was placed in. When a reader
decided that box from the widget's own answer -- an aligned span sized to its
children, a text at the tail of a row, a scroll's content -- the old answer is a
fixed point of measuring there whatever the content now says, so the layout had
two stable answers and which one it reached depended on the tree's history.
`tests/unsettled.rs` has the two shrunk cases: the four-widget aligned span,
and a scroll placing a pass-through `SetSize` in a box the content decided,
where the span under it was placed once and nothing at its own edge said so.

`ActiveData::offered_px` keeps the pixel size of the box the parent first asked
about the child in, whether through `known_len` or a first `place`, beside `px`,
the box it drew against. A dirty widget whose size reads an axis on which some
reader up its chain gave what it read a box other than the one it asked in is
not drawn locally: the chain is marked and the parent of the highest such
placement draws, since above it every box is a constraint rather than an
answer. The walk goes up the whole reader chain because a pass-through hands a
derived box down unchanged.

`Scroll` read its box's length for the clamp through `px_len`, which records
the reported size as depending on it, and it does not: its size is its
content's. That made every scroll tick a size question asked in a derived box,
at 34x the instructions. `Painter::px_len_for_draw` is the read that records
nothing. Instructions per frame on the depth-8 rig against the previous head:
`many` at 32 dirty 0.66M to 0.74M, at 130 dirty 27.7M to 26.5M, `resize` 15.8M
to 15.0M, `scroll`, `repaint` and `size` unchanged. The shrinking fuzzer passes
200 trees at depth 7 in all four cases, the hundred-seed sweep passes, and the
five reference renders and the resize render are byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:16:56 -04:00
iris-aiandClaude Opus 5 65f68bbb8a Reorder a span's children in the fuzzer, and find two fixed points
The shrinking fuzzer had no case for what `generated.rs` calls a reshuffle,
which was the only thing still failing there. `Case::Reorder` rotates every
span's children after a warm frame and compares against a tree grown that
way -- which needs a span's creation order kept apart from the order its
children are attached in, or the two trees make the same widgets in
different orders and cannot be lined up.

It found a four-widget tree, from 486, and the trace says the layout has
more than one answer rather than one answer reached twice.

    Aligned(mid, -, Span[ Text(wrap), OneLine ])

A span measures its children in its own box. Its own box is what its parent
gave it, from the size it reported, from those children. So with the
wrapping text second it is offered `cursor..end` of a span 663.376 wide and
asked for 357.44, which is what it already holds -- the size is valid, the
span reports 663.376 again, and nothing moves. Grown in that order from
scratch the span is offered the window, the text is asked for 334.06 and
answers 318.45, and the span settles at 624.38. Both are stable. Which one
you get depends on what the tree was before.

So this is not a stale drawing kept too long, and no rule about when to
keep one will fix it: it is a circular dependency with two solutions.
`Painter::settle` in `Aligned` -- place into the child's own size without
measuring there -- makes all four cases in `unsettled.rs` pass and breaks
two in `generated.rs`, whether or not the child is drawn first. Not kept;
the shape of the fix is the constraint a container measures under being
something it is given rather than something it ends up with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:38:26 -04:00
iris-aiandClaude Opus 5 99131940ab Answer a break from the one in hand wherever it is still the same break
A parent that sizes to a child offers it back the length it just reported,
so a wrapping text was re-broken at exactly its own longest line. That is a
knife edge: the length is composed back through the box chain, so it lands
an ulp either side of where it started, and which side decides whether the
longest line still fits. One side kept three lines at 167.41, the other
took four at 163.49 -- from the same text in the same box, differing only
in what the output size had been.

A greedy break does not need recomputing there. Breaking at one width gives
lines that each fit, none of which could have taken another word; at any
narrower width down to the longest of them, every line still fits and none
can take a word that did not fit in more room. So one break answers a whole
interval, and the cache now hits across it rather than on the exact width.

The tolerance is what makes it hold at the edge, which is the case that
matters: sub-pixel, so no break it admits is one anybody could see.

The generated sweep passes at depth 6, where it failed; the shrinking
fuzzer agrees over 800 trees at depth 7 on all three scenarios, where two
of them failed. `tests/unsettled.rs` is green, so the whole suite is.
Depth 7 of the generated sweep still fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:29:24 -04:00
iris-aiandClaude Opus 5 e5a3e640d4 Add the second shrunk case, and a trace rig for what box a text is drawn in
Six widgets from 905, and it fails in 0.06s: everything inside a declared
189x176 box is the same size whatever the output is, so a resize may not
reach any of it, and the text still comes out 3.92px narrower warm than
cold.

`tests/trace_unsettled.rs` says why, and it is not what it looked like. A
span measures a content-sized child in the space remaining, is told 167.41,
and then offers that back as the child's box -- so the text is re-broken at
exactly its own longest line, which is a knife edge: warm lands on four
lines and 163.49, cold stays on three and 167.41. Measuring an answer
against itself is unstable precisely at the fixed point.

`Painter::settle` -- move the child's slot, keep the drawing, never measure
again -- is the shape of the fix and does not work yet. In a span it breaks
five cases, because a container child may have laid its own children out as
fractions of the box it drew in, so moving it into a shorter one shrinks
them; reporting a length in pixels does not mean the drawing is positioned
in pixels. In `Aligned` alone it breaks two. Recorded rather than kept: the
condition wants to be something a widget declares, near `OnResize`, rather
than something its caller infers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:10:45 -04:00
iris-aiandClaude Opus 5 c596bf12c6 Measure a child in the length its parent declared, not the box it was offered
`SetSize` drew its child in whatever box it had been given and then
reported its declared length, so the child answered about a box it was
never going to have -- and the answer on the *other* axis was taken under
that. A wrapping text under `SetSize(x: 76px)` was measured in the whole
640 available, reported one line, and the parent sized itself to one line.
The text was then drawn again at 76 and reported two, but by then its box
was settled and nothing revisited it. A repaint put it right, which is why
the first frame and the second disagreed.

So the layout was not a function of the state, and "cold" was not a fixed
point -- which means the warm-against-cold oracle has been measuring
against a tree that had not settled, and some of what it reported as a
retained-layout defect was the cold side being wrong. Nothing about
retained state is involved in this: it reproduces in six widgets on a
first frame.

The declared length is what the child gets, so that is where it is
measured. `apply_rest` carries `rel` and `rest` through unchanged, and a
`px` length composes as an offset, so the child's box does not move again
when this widget's own box shrinks to what it declared.

`tests/unsettled.rs` passes, and the generated sweep now passes at depth 5
where it failed. Depth 6 and 7 still fail; there is more than one of these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:01:57 -04:00
iris-aiandClaude Opus 5 f0c7df06ac Let the unsettled-layout tests fail
Ignoring is for cost, not for status: a fuzzer earns it, a known defect
does not. Hiding this one behind an attribute turns a loud failure into a
quiet one nobody goes looking for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:52:07 -04:00
iris-aiandClaude Opus 5 b7caab3b9e Grow trees that can be taken apart, and find that a first frame is wrong
Reconstructing a generated failure by hand had failed three times: a seed
reproduces a tree of hundreds of widgets, and the printed chain is not
enough to see which part matters. `tests/shrink.rs` grows trees from a
description it can simplify -- drop a child, unwrap a wrapper, shorten a
text, drop a declared length -- and takes the first simplification that
still fails until none does. It lives in the tests; nothing in the library
knows about it.

It works: with the box-length check in `try_reuse` deliberately disabled
it reduced a 96-widget tree to 2. That check is worth keeping, because a
fuzzer that cannot fail is a fuzzer that agrees with everything.

What it found is not what any of this was looking for. Six widgets, shrunk
from 402:

    Span[ Stack[ Text("Wrapping"), Aligned(pos,pos,
          SetSize(x: 76px, Text("Wrapping shapes", wrap))) ] ]

The wrapping text is one line on the first frame and two after a repaint,
and two is right for a 76px box -- so the *cold* tree is the one that has
not settled. `generated.rs` has been comparing a warm frame against a cold
one and calling the difference a retained-layout defect, while at least
some of it is the first frame shaping a text at a width it was measured in
rather than the one it was given. Retained state is not involved.

`tests/unsettled.rs` is that case by hand, in 0.06s. Both of its tests
fail, so both are ignored with the reason rather than left to break the
build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:49:00 -04:00
iris-aiandClaude Opus 5 386a0d1b8f Steer the fuzzer, and print enough of a failure to rebuild it by hand
`DEPTH` was a constant at 4, and the generator widens two to four ways per
level, so raising it buys overlap between dependency paths rather than
ancestry. `IRIS_GENERATED_DEPTH` and `IRIS_GENERATED_SEEDS` select the
load; the default is what it was.

Depth 4 was hiding divergences. At depth 5 and beyond the sweep fails on
the tree as it stands, with no `Branch` node and every span filling across
its axis, so it is neither of the things I suspected -- it predates both.

A failure printed a chain of type names, which is not enough to write the
tree out again, and hand-reconstruction from one has failed three times
now. `describe` prints what each ancestor was configured with, so a run
says `Text < SetSize{x:34 px;} < Aligned{x:neg,y:pos} < SetSize{x:35 px;}
< Stack{n:2}` and the fast test that replaces the seed can be built from
that. `Widget: Any`, so this needs no new plumbing.

Two fixtures assumed every tree grows a declared size to change, and one
assumed a span it shuffles is drawn -- a span behind a branch nobody took
is not. Both are vacuous seeds rather than failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:27:41 -04:00
iris-aiandClaude Opus 5 1b1378b05a Branch on a measurement, so a wrong one shows as a different tree
Comparing boxes catches a widget that moved. It does not catch one that
measured a child, was handed an answer a cold start would not have given,
and took the other branch -- the same defect, arriving where a pixel
comparison cannot see it. Branching on what the painter tells you is
something a widget is allowed to do, so the library owes the same answer
warm and cold; only a widget changing its own configuration is exempt.

`random::Branch` measures a child and draws one of two others on the
result, with both grown either way so the ids match whichever is drawn.
It joins the generator, which makes every existing scenario a control-flow
oracle as well as a geometric one. `tests/determinism.rs` is the same
widget by hand across eight thresholds, including either side of the
answer, and is the fast check -- the sweep is a fuzzer and confirms at the
end rather than being iterated against.

A span behind a branch nobody took is not drawn, so shuffling it cannot
move anything; `reshuffled` now treats that as vacuous, the way it already
treats a tree with no spans, rather than as a shuffle that had no effect.

Both new tests pass, and the sweep passes at depth 4 and 5 over 200 seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:06:54 -04:00
iris-aiandClaude Opus 5 60175c3821 Check that measuring a text and giving it that width is a fixed point
A span that sizes to its children measures one, is told a length, and
hands that length back -- so whether measurement is idempotent decides
whether the two chase each other. Nothing checked it.

It holds: a wrapping text in a `Dir::RIGHT` span, which is the wrap axis
and the span's own axis together, stays at 881.84 across six repaints
that change nothing. So the narrowing recorded against LAYOUT.md §4 is
not something text does on its own, and looking for the cause there is
looking in the wrong place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:51:29 -04:00
iris-aiandClaude Opus 5 b165164e59 Carry a span's rest weight up instead of collapsing it to one share
A span reporting `Len::default()` whenever a child had a share threw away
how many shares it was holding, so each level of nesting re-divided a
share rather than dividing the same space. One span of a rect beside a
span of three gave 1/2 and 1/6 each, where the same four rects directly
in one span get a quarter.

A span that sizes from its children does not resolve `rest`, it passes
the weight up; resolution belongs at the nearest ancestor with a length,
and since the output became a box there is always one. The placement loop
already divides by `len.rest / total.rest`, so it consumes carried
weights unchanged -- only what the span reported was wrong.

The uneven nesting is the case that fails without this; the even one
passes either way and is here as the statement of intent.

Decided by the owner, 2026-09-14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:29:25 -04:00
iris-aiandClaude Opus 5 ef815dadfd Let OnResize answer for the window too, and delete the second rule
A resize had its own mechanism: `reads_output` recorded that a widget had
looked at the output, `update` scanned every active widget for one whose
`output_px` had moved, marked it and its whole reader chain, and
`resize_marks` kept those marks from counting as content dirtiness --
while `on_resize` answered the same question for every other box. Two
answers to "does this drawing survive its box changing length", and the
one that applied to the window ignored what the widget had declared.

With the output held as the root of the chain there is one question. A
resize offers the root widget its box again, `try_reuse` answers per axis
from `on_resize`, and `redraws_under` prices the subtree. Gone with it:
`reads_output`, `resized`, `resize_marks`, the scan, the eager reader
marking, and the shallowest-first branch in `redraw_updates`, which only
existed because resize marking worked differently -- the settle loop now
has one order.

Two things this needed. An unslotted widget may be reused when only its
parent's box changed length: it has nothing of its own to write, and what
it drew is a fraction of that box, so the slot above it already carries
the change. And `root_readers` holds the widgets whose size came from the
output rather than their own box -- `MaxSize` -- since no box of theirs
need have changed; they are marked per axis, from a set kept as they draw
rather than by scanning.

`a_resize_does_not_redraw_what_the_shader_can_move` now says `Scale`,
which is what it was always describing, and `a_resize_redraws_what_does
_not_scale` is its other half. `ReadsWidth` declares `Scale` across the
axis it does not read, so per-axis precision comes from the widget rather
than from which output axis it happened to touch.

Resize phase, seed 1 depth 8: 6.45M instructions per frame to 5.84M.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:50:06 -04:00
iris-aiandClaude Opus 5 9f4311774b Hold the output as the box every chain bottoms out in
A position was composed up the slot chain to a normalized region and then
multiplied by the output's size, so the window was the one box in the
system that was not a box. Seeding the chain with a root slot holding it
in pixels makes composing through it leave everything below in pixels,
which is what the multiplication was doing.

`within` already does the arithmetic: a child at `rel` 1 inside a span of
`px` 0 to `px` 1920 composes to `px` 1920 and `rel` 0, so the trailing
`to_px` becomes the identity rather than a step. The shader walks the
same chain and needs no change for the same reason.

This is the shape the resize machinery wants before it can be deleted: a
resize becomes one slot written, which `try_reuse` and `redraws_under`
already carry. Nothing is removed yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:32:53 -04:00
iris-aiandClaude Opus 5 7c50a3e51b Rename a length's abs component to px
`dp` is coming, and then `abs` says which of the two it is not. The
component has always been a pixel count, so the name that admits it is
the one that leaves room for a second unit beside it.

Mechanical: the field on `Len` and `UiScalar`, their constructors,
`to_abs`/`get_abs`, the matching WGSL struct member and the locals
composing it. Field order and types are unchanged, so the `Pod` layout
the shader reads is the same bytes. `f32::abs` is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:29:20 -04:00
iris-aiandClaude Opus 5 3f7cd8251b Carry a widget's depth down the draw instead of walking up for it
Choosing which dirty widget to settle next asked every one of them how
deep it was, and answering meant walking its ancestry to the root. At
130 of 260 widgets dirty that was 25.8% of the frame -- more than laying
out or rendering.

A widget's depth is known where it is drawn: its parent's plus one. So
`Painter` carries it and `ActiveData` keeps it, and the choice reads a
field. Being reused counts as being visited, so the two reuse paths keep
it current too; only a subtree nothing looked at can hold an old one,
and nothing under an unvisited subtree is being ordered.

The order is unchanged, so nothing about the layout is: the five
reference renders and the resize render are byte-identical. What the
carried depth might get wrong is itself, so `depth` asserts it against
the ancestry in debug builds, and the hundred-seed sweep passes with
those assertions on -- including the reshuffles, which are what move a
widget to another parent.

Same load, 1000 frames, 130 of 260 dirty: 8.16M instructions per frame
to 7.14M, median 0.813 ms to 0.639, and the choosing from 25.8% of the
frame to 4.7%. What is left of it is iterating the dirty set itself,
which a `HashSet` walks by capacity rather than by length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:10:27 -04:00
iris-aiandClaude Opus 5 bf9438087a Say what the settle order is holding up
`try_reuse` asks whether the widget in front of it is dirty and, if not,
hands its parent the size it last reported. Nothing asks whether a dirty
widget sits under it through the size dependencies -- which is the check
`retained_size` makes, for exactly this reason, on the path that does not
draw.

What covers the gap is the order `redraw_updates` settles in: taking the
deepest dirty widget first means that by the time a reader draws, what it
reads has already drawn and propagated. Drawing in any other order
returns a stale size. Measured rather than reasoned: picking whatever the
dirty set yields first fails seed 2 of `tests/generated.rs` with 24
widgets wrong, a subtree keeping a 317 px width where a cold tree has
147, and the traces are identical until a `Span` reports 317 against 147
from the same child sizes -- it had reused a subtree holding a `SetSize`
whose declared width had changed.

So the coupling is real and was written down nowhere. Say it in both
places, since a reader of either would otherwise conclude the order is
about cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:57:08 -04:00
iris-aiandClaude Opus 5 77bb75e5de Dirty many widgets at once, which nothing was checking
Every generated case changes one thing: four declared sizes, or one
span's children, or the output. A frame settling one dependency path
says nothing about a frame settling a set of them that overlap, which
is the case the settle order exists for.

So two more: every declared size in the tree changing at once, and a
spread of widgets marked for redraw together. The second changes
nothing, which is the point -- no box may move, and the order the
dirty set is taken in is all that can make one. The hundred-seed sweep
is 1,000 comparisons now rather than 800, and passes.

`IRIS_PHASE=many` is the same load for the diagnostics rig, with
`IRIS_DIRTY` widgets marked per frame. It says what one repainting leaf
cannot: at 130 of 260 widgets, choosing which dirty widget to settle
next is 24.5% of the frame, because the dirty set is scanned once per
widget settled and a hash set is walked by capacity rather than by
length. Memoizing the depth walk inside one scan does not pay -- it
trades parent lookups for memo lookups and costs 4% more instructions --
so the fix is to stop rescanning, which changes the order widgets
settle in and wants agreeing first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:21:47 -04:00
iris-aiandClaude Opus 5 2525637e26 Re-break a text's lines for a new width instead of shaping it again
Only the line breaking depends on the width. The shaped runs under it --
the font selection, the unicode analysis, harfrust -- are a function of
the text and the attrs, and parley re-breaks them in place; its own
editor does exactly this on every resize. So a new width is a break and
a placement, not a shaping.

On the depth-8 tree that is 107 breaks at 0.119 ms where the shapings
they replace were 4.0 ms, and it holds however far the width moves,
which is what the store could not do: a width the layout has not seen
before is a miss, and a drag never sees one twice. Instructions per
frame over 500 resize frames of `tests/revision_cost.rs`, for widths
that alternate and widths that never repeat:

    #18 head             124.2M   123.6M
    a store of shapings   17.7M    45.9M
    re-breaking alone     32.9M    32.8M
    both                  20.6M    24.2M

The store stays because re-breaking does not place the glyphs, so it now
holds those instead: fewer instructions than either alone in the case
that never repeats, and 3 MB rather than 4 MB on a tree of 4,000 texts,
against the 132 MB the code before #16 reaches after the same resizes.
The worst frame is 2.54 ms where that code's is 6.47 ms, and the two
gestures are within a millisecond of each other rather than a factor of
two apart.

Count the breaks and time them separately from shaping, since which of
the two a frame is doing is the whole question here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:21:20 -04:00
iris-aiandClaude Opus 5 e5f8b6b244 Shape a text once per width, not once per ask
A container measures a child by drawing it in a box it may not keep, so
one layout asks a text for a dozen widths and comes back to widths it
has already had -- the hottest text in the depth-8 tree draws 32 times.
Each ask re-ran the shaper, because the two caches in front of it held
one entry each and a trial width alternating with a final width evicts
the answer about to be wanted again. `perf record` put 63% of a resize
frame in text and 0.9% in `draw_inner`.

So keep more than one: a bounded store of shapings on `TextData`, keyed
by the text, the attrs and the width, holding the parley layout and the
glyphs placed from it. Bounding the store rather than each buffer is
what keeps it a fixed cost -- +4 MB on a tree of 4,000 texts, which is
19 MB less than the code before #16 holds after the same resizes.

`TextBuffer` now holds the glyphs of the shaping it is drawn as, which
is where `TextView::tex` was. That leaves one place to invalidate rather
than two, so the `MutDetect` flags on a view's text and attrs have no
reader and go, along with the `buf.changed = true` after every edit.

On a 40-row tree of distinct random paragraphs, 500 resize frames:
124.2M instructions per frame before, 17.7M after, and 45.9M when the
width never repeats. The five reference renders and the resize render
are byte-identical, and the 100-seed sweep passes.

`tests/revision_cost.rs` is that tree, written in the API subset
`43ce8c7` shares so the same source measures the code this replaced.
Report the worst frame and p99 beside the median, since a stutter is
what somebody sees. Count glyph placements, and count a text render per
ask rather than per shaping, so the store cannot hide how many times a
layout drew the same text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:49:45 -04:00
iris-aiandClaude Opus 5 f1a47e9b7b Say what three retained-layout details mean
Reading this back, three things claim something they do not do.

`OnResize::Translate` is returned by `TextView::on_resize` under a
comment weighing anchored glyphs against reshaping ones, but nothing
consumes it: `try_reuse` asks only whether the answer is `Scale`, so a
widget saying `Translate` is redrawn. Say so on the variant, since the
comment beside it reads as a description of behaviour.

`depend_on_size(child, false)` and `depend_on_size(child, true)` are the
difference between a hint, which is context-free, and a size the child
produced by drawing, which carries every pixel axis the child read. That
is the subtlest rule in the file and it was spelled as a bool; give the
two cases their names.

`draw_started` is the record of what has drawn during the pass under
way, and it worked only because `redraw` removes an id before asking
about it -- nothing emptied the set, so it accumulated the id of every
widget ever drawn, including ones long gone. Empty it with the pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:49:08 -04:00
iris-ai b1b3eca1c0 Retain layout sizes by pixel axis 2026-09-14 17:47:21 -04:00
iris-ai 82fa6c1123 Coalesce resize layout diagnostics 2026-09-14 17:05:11 -04:00