Commit Graph
19 Commits
Author SHA1 Message Date
iris-ai 5642f2010a Say region and placement, not extent
The split box was named `region` and `placement` on 2026-09-17; `frame`
came back as a length and survived, `extent` did not. It stayed as the
name for both halves, distinguished only by prose: `draw_at` bound the
caller's `part` to a parameter called `extent`, and `ActiveData` held two
`UiRegion`s that `draw_at` wrote `part: extent` from.

The box a parent asks a widget in is now the region, and where its
drawing ends up is its placement. `Painter`'s four holds accumulators
become the one `LayoutHolds` they were assembled into, which also drops
the name mapping between them.

The cold dump of 400 depth-5 trees is byte-identical across the change.
2026-09-19 14:49:56 -04:00
iris-ai add6774980 Keep retained masks and reparented drawings alive, and advance collapsed slots 2026-09-19 13:43:11 -04:00
iris-aiandClaude Fable 5.1 3091fb86df Ask each child once and place its answer by re-expression
A widget draws in the box it is asked in and its answer is placed inside
that box by re-expressing the drawing; nothing is drawn again in a box an
answer chose. The offer machinery, whose job was to tell a measuring draw
from a placing one, goes with the placing draw. A span measures each child
from its cursor and moves fixed children to their slots with place_at; a
share child is asked once more in its decided slot with its frame narrowed
to it. A stack asks non-sizing children in the box its sizing child
decided. A scroll asks its content once and moves it to the scrolled
offset. A local redraw asks the retained question again and puts the
answer back where the parent placed it.

A symbolic length a child pinned composes through Part::Of exactly where
the part is the whole box less pixels, and pins the parent's own length
otherwise; dropping it let a pad reuse a drawing across a narrowed frame
of the same pixel length (shrinker seeds 60, 248 and 384 at depth 5).

Suite 114/114 including the two decided-box pins, fast oracle 11/11,
shrinker 400 seeds at depth 5 over all fifteen cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:20:57 -04:00
iris-ai 0954770ceb Say what a child gets of a container's box in that box's own lengths
Three things the measurements asked for, all about how much a box that came
from an answer costs.

**A part in the box's own coordinates.** Saying "less eleven pixels at the
end" in frame lengths from the box's start means reading how long the box is,
and a container whose box is its own answer then depends on its own answer:
`Pad` drew sixty-four times in one resize frame at seed 13, chasing its own
width. `Part::Of` says the same thing as a part of the box, which composes
without a length -- pixels are pixels wherever the box lands -- and what a
child under it holds for maps back through that part onto the container's
own box rather than onto the frame.

**One axis of the box at a time.** `extent_len` pinned both axes, so a span
dividing one of them held for one length of the other as well, and a resize
broke every span whose cross-axis answer moved.

**No lazy placement.** Leaving a child's answer to be placed at the end of
the parent's draw, rather than as the child answers, was meant to save a
recomposition. It costs one instead: the drawing is put in the part first
and in the answer's box after, and where it does not hold for both that is
two drawings rather than one. Seed 1 at depth 8 went from 391 widget draws
on a resize to 29 with it gone. The test that pinned three draws for a
numeric leaf in a span goes with it.

Seed 1 at depth 8, widget draws / distinct widgets / update, against #18's
head and against the commit this branch started from:

| phase   | e44dea3      | 34cafb6      | here          |
| ---     | ---          | ---          | ---           |
| cold    | 369/261/10.6 | 463/274/13.3 | 516/288/12.0  |
| repaint | 1            | 1            | 1             |
| many    | 157/95/0.33  | 263/108/0.59 | 187/119/0.52  |
| size    | 16/12/0.018  | 3/3          | 3/3/0.010     |
| scroll  | 2/0.002      | 1            | 1/0.004       |
| resize  | 13/13/0.019  | 22/15/0.032  | 24/76/0.090   |

Seed 13 at depth 8 is where the protocol still costs: `many` 1091 draws
against #18's 524, and `resize` 2215 against a frame #18 does not draw at
all. Both are the same shape -- an answer measured in one box and drawn in
another -- and the handoff says where that comes from.

Checked: fmt, clippy with -D warnings, 108 suite tests, 20 core tests, the
11 generated cases, and the shrinker at 400 trees of depth 5, which fails
seeds 2 (repaint) and 108 (reorder).
2026-09-18 01:06:05 -04:00
iris-ai 1956be3f3d Lay out in a frame that passes through and a box placed in it
A widget is asked in two boxes rather than one. Its frame is what a fraction
it declares or reports is a fraction of, and it passes through a span, a
stack and a scroll unchanged, so `rel(0.5)` is half the same area however
many containers sit between: a frame is narrowed only by what is decided
above the widget -- a declared length, the root. Its extent is where the
drawing goes, given as a `Place` per axis: a part of the parent's own box,
measured in frame lengths from where that box starts, which the child either
fills or has its answer placed inside.

What that buys is that nothing under a container depends on where the
container sits. A container reads `extent_len` for the length it divides and
nothing about the start, so moving it re-places its children by re-adding
that start and draws nobody again; and a fraction is resolved once, against
the frame, rather than once per box it is composed through -- a stack sized
by a child that reports `rel(0.5)` no longer takes half of half.

`Place` replaces `DrawRegion`, `ExtentPlacement`, `widget_within`,
`measure_len`, `region()`, `placement()` and `box_of`. Primitives and masks
are written in the widget's own box's coordinates alone, so the drawing has
one reference rather than two. The placement pin goes with them: reading the
extent's length pins that length symbolically, and pins compose only where a
child's box is its parent's own.

Placing an answer waits for the end of the parent's draw or for the next ask
of that child in it, so a span child is one drawing and one move rather than
two moves.

`Pad` is transparent: its padding goes around what it pads and its child
keeps the outer frame, which is where `Outset` was going anyway. A fraction
under a pad is now a fraction of the frame rather than of the inset box.

Checked: fmt, clippy with -D warnings, 109 suite tests and 20 core tests in
debug, the 11 generated cases, and the shrinker at 400 trees of depth 5 over
all fifteen cases -- which still finds seed 108 under `reorder`, where a
wrapping text measured in one box and drawn in another settles differently
warm than cold. `redraw` therefore keeps the baseline's deferral for a box
that is not as long as the one the widget was measured in; the plan's step
6 is not done, and the next commit message or the handoff says why.
2026-09-18 00:40:59 -04:00
iris-aiandClaude Opus 5 e6ba570d07 Give a child a part of the container's extent rather than its raw box
`Pad` and `Stack` read `Painter::placement` to put their children inside
their own drawing, and reading it is what says the drawing holds for that
placement alone. So a pad or a stack anywhere in a row was drawn again --
with its whole subtree -- the moment an earlier sibling changed length,
however little else had moved.

`widget_within` now takes a `DrawRegion`, and `DrawRegion::Extent(part)`
gives the child a part of the extent without reading it. What is retained
is the part rather than the box it resolved to, so moving the extent
re-places the child through the same rule instead of redrawing the parent:
`inherited_children` becomes `extent_children`, carrying `Inherit` for the
wrapper case `Painter::widget` already had and `Within(part)` for the new
one.

The dependency that goes up is a range on the container's extent rather
than on its frame, since only the part's *length* reaches the child and
where the part sits is re-placed. A declared length is unchanged: it is a
length of the frame wherever the box it sits in came from. What still pins
the placement is a report with a fraction in it -- the same fraction of a
different extent is a different length -- and that pin is on the answer,
which `extent_frames_keep_fractional_reports_and_numeric_dependencies_valid`
fails without.

Three tests from the first attempt at this come with it, and the
diagnostics rig now says which of the three contracts refused a reuse,
which is what found the above.

Measured, seed 1 at depth 8, median frame: `many` 0.667 -> 0.613 ms and
`resize` 48 -> 32 us; seed 13's `many` 6.35 -> 5.15 ms. Green: fmt, clippy,
109 suite and 20 core tests, the oracle at 100 seeds, the shrinker at 400
trees of depth 5, 1000 seeds at depth 6, and 2000 seeds at depth 4 over all
fifteen cases. The five reference renders are byte-identical to `0e107f0`
on Venus, as are `tabs` resized to 900x1200 and `random` to 1280x800
against cold renders there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:15:17 -04:00
iris-ai 0e107f0e89 Keep valid layout guarantees when a redraw widens their range 2026-09-17 17:24:53 -04:00
iris-ai f860f716e6 Separate measured-answer dependencies from retained drawing validity 2026-09-17 17:10:55 -04:00
iris-ai c44bd198ee Retain child frames relative to the container extent 2026-09-17 16:40:54 -04:00
iris-ai a7307d95fd Resolve a text draw's glyph origin once 2026-09-17 16:17:47 -04:00
iris-ai 7601aa2a5d Measure container children without intermediate placement 2026-09-17 15:53:24 -04:00
iris-ai 2ed5503717 Recompose retained frames exactly and preserve text width validity
Keep each widget's original local frame and replay the same composition
order on reuse. Remove inverse region remapping, including its fixed-frame
fallback that forced otherwise valid subtrees to draw again.

Require exact pixel-region equality in the shared generated oracle. Check
primitive and mask geometry as well as draw reuse when fixed frames resize.
Publish text's retained line-break range, with no upper bound when there
are no soft breaks, and cover widening, explicit newlines, and empty text.

Compared with efb416b, the depth-8 diagnostic rig performs 7-9% fewer widget
evaluations in the affected phases. Uninstrumented release runs use 3.5%
fewer instructions for size changes and 5.0% fewer for resize. Repaint and
scroll use 0.7% and 0.6% more instructions. Container updates remain substantially more expensive than the e44dea3 baseline;
this is still an experimental continuation, not a production replacement.
2026-09-17 15:11:03 -04:00
iris-ai efb416bbc3 Retain frame and extent dependencies independently
Keep the original measurement placement separate from the assigned slot.
Validate frame and extent lengths before reusing an answer or drawing, and
represent hint-only records as having no measured answer.

Retain primitive and mask coordinates with their frame/extent reference.
Forwarded children follow a reused wrapper's placement without rerunning
valid draw bodies. Keep the single Widget::draw API.

Restore the eight failing suite cases from the region/placement prototype,
with regressions for mixed coordinate references, a changed inherited
extent, the sizing-stack fraction, and an undrawn share becoming visible.

This remains experimental: nested container updates do substantially more
work than e44dea3 despite restoring the leaf and wrapper reuse guarantees.
Do not merge it as a performance improvement.
2026-09-17 14:50:09 -04:00
iris-ai e44dea34b4 Record which widget is drawing a subtree that changed hands
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 as a child, 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. The move is
recorded on both sides where `draw_inner` already writes what the ask
decided, rather than guarded at each reader.

Its depth was also 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. `try_reuse` re-walks the
subtree's depths, and only where the top of it moved, which is what
makes that free in the ordinary case.

Two tests: one shape where the subtree's box does not move and the span
it left erases it, one where it changes depth and the change made under
it has to reach the span it moved to. Each fails without one half.
2026-09-17 13:05:15 -04:00
iris-aiandClaude Opus 5 ea6dbae0dc Hand a redrawn widget the mask it inherited, not its own
`ActiveData::mask` is the mask a widget's drawing is clipped to, which is
either one it set itself or the one it inherited. `redraw` passed it back as
the *inherited* mask, so a `Masked` widget settled on its own was handed its
own mask and `set_mask` asserted -- a panic on any local redraw of one, for
as long as there has been a local-redraw path. The two are separate facts, so
`parent_mask` keeps the second.

That also states the question `remap_subtree` was asking. It compared a
widget's mask with the one threaded down from its parent to find out whether
the widget owned it; the comparison is now between the two fields on the
widget, which is the same question asked where the answer lives, and the
parameter goes.

Checked: fmt, clippy, 87 suite tests including the new one, which panics
without this; 18 core unit tests; the release oracle at 100 seeds; the
fifteen shrinker cases at 400 seeds of depth 5; and `tabs` renders
byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:18:39 -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 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 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