The sweep over the 6,300 lines of tests #19 adds, and over the seventh sweep's own commit. Seven findings, in iris `77ed7a2`. Also corrects a number four earlier rounds carried: the cold dump over 400 depth-5 trees is 34,488 boxes, not 34,492. The run prints eight lines that are not a box, so `wc -l` gives 34,496 and a partial filter gives whatever it gives. Nothing any round concluded moves, because each compared two dumps rather than their line counts -- but the handoff now says how to count, so the next round does not have to guess which figure was right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
717 lines
41 KiB
Markdown
717 lines
41 KiB
Markdown
# Layout findings log
|
|
|
|
What the sessions reviewing Iris's retained layout found, kept so that
|
|
nothing here is rediscovered. Each entry says who found it and when.
|
|
**Delete this file when #19 lands and its fixes are in**; what must outlive
|
|
it (settled design, the measurement method) belongs in `docs/LAYOUT.md`, and
|
|
the current plan is in `docs/HANDOFF.md`.
|
|
|
|
## Eighth sweep: the tests, and the seventh sweep's fix (2026-09-20)
|
|
|
|
Over the part no earlier round named -- the 6,300 lines under `tests/`, which
|
|
is more than half of what #19 adds -- and once more over `f8aa0c5`, which was
|
|
the seventh sweep's own fix and so unreviewed. Seven findings, all in
|
|
`77ed7a2`. No library code changed: the cold dump over 400 depth-5 trees is
|
|
byte-identical to `f8aa0c5` across all **34,488** boxes, and the seed scans
|
|
have nothing to find, since nothing that decides a layout moved.
|
|
|
|
That count is not the 34,492 the four rounds before this one recorded, nor
|
|
the 34,490 `77ed7a2`'s own message gives. The dump prints eight lines that
|
|
are not a box -- cargo's two, libtest's four, and two blanks -- so a `wc -l`
|
|
of the run comes to 34,496 and any partial filter lands somewhere between.
|
|
The boxes are the lines matching `^[0-9]+ [0-9]+ `, and there are 34,488 of
|
|
them at both `f8aa0c5` and `77ed7a2`. What every round actually established
|
|
is still true, since each diffed two dumps rather than trusting a count.
|
|
|
|
**A shrunk fixture that names one widget three times.** `width`, `sized` and
|
|
`align` set a rule on the widget they are handed and give its own id back;
|
|
only `pad` and `wrapper` make a new widget. So in
|
|
|
|
let wrapped = wtext("Wrapping shapes").size(16).wrap(true).add(&mut h.rsc);
|
|
let sized = wrapped.width(76).add(&mut h.rsc);
|
|
let aligned = sized;
|
|
|
|
all three names are one text, and all three went into the vector of ids the
|
|
case compares warm against cold. Measured: `plant` and `plant_fixed` list six
|
|
ids and hold four widgets, `plant_pair` lists four and holds three,
|
|
`plant_scrolled` lists eight and holds seven; the other nine fixtures are
|
|
honest. So four cases check fewer boxes than they say, and the doc comment of
|
|
each quotes the inflated count as the size of the tree the shrinker reduced
|
|
to -- which is the number a reader uses to judge whether a case is still the
|
|
minimal one. The names are gone and the counts are what the fixtures build.
|
|
`trace_unsettled.rs` carries a copy of two of these fixtures and had the same
|
|
aliases.
|
|
|
|
The check that mattered here is that a rebuilt fixture is the same tree: a
|
|
regression test whose fixture quietly changed still passes and no longer
|
|
covers its defect. Each was diffed against its old self -- same widget slots,
|
|
same regions, for both settings of `swapped`.
|
|
|
|
**A helper at the top of the file, and seven copies of its body below it.**
|
|
`assert_same_regions` collects every widget whose box moved and reports them
|
|
together. Six tests call it; seven more spell the eight lines out instead,
|
|
byte for byte. They call it now, and it took `#[track_caller]` so the panic
|
|
names the case rather than the helper.
|
|
|
|
**The GPU rigs' adapter probe, written twice.** `draw_cost` and `chain_cost`
|
|
each held a `config` (identical) and an adapter probe (identical but for the
|
|
feature it asks for and what it returns). The probe leaks its `Instance` on
|
|
purpose -- a Vulkan loader may unload the driver as a test thread exits --
|
|
and only `draw_cost` said so, with `chain_cost` referring the reader to it.
|
|
Both come from `tests/gpu/mod.rs` now, shared through `#[path]` the way
|
|
`scenario/mod.rs` already is, with the justification on the thing it is
|
|
about.
|
|
|
|
**The mask a widget is clipped by, resolved three times**, two of them a
|
|
byte-identical closure defined inside a loop. `mask_bounds` takes the
|
|
`MaskIdx` rather than the widget, because the third site reads the slot it
|
|
saved before the frame: that a redraw keeps the same slot is exactly what it
|
|
is checking, and a helper that looked the slot up again would have made that
|
|
assertion pass for the wrong reason.
|
|
|
|
**A field nothing reads.** `Layered::_revision` existed to be incremented, to
|
|
mark its widget dirty. Two tests in the same file already do that with
|
|
`widgets_mut().get_dyn_mut(id)`, which is the idiom `tests/scenario/mod.rs`
|
|
uses as well. The underscore was hiding the dead-code warning that would have
|
|
said so.
|
|
|
|
**A claim the test below it does not make.** `plan.rs` said "Every
|
|
simplification is strictly smaller, so taking them in turn reaches a fixed
|
|
point instead of circling", and then asserted `small.size() <= node.size()`.
|
|
Measured: 53 of one tree's 101 simplifications keep the widget count, because
|
|
`Plan::size` counts widgets and dropping an alignment or stepping `Wrapped ->
|
|
OneLine` does not change it. The assertion is the right one and the claim was
|
|
not. What actually rules out circling is that those steps are one-way too --
|
|
a `Some` becomes a `None`, a kind steps down a ladder with no way back up --
|
|
and the comment now says that. The test is `no_simplification_of_a_plan_is_larger_than_it`.
|
|
|
|
**Numbers and lines that had gone stale.** `generated.rs` says "Eight that
|
|
have never failed" and "the ten the others check"; both said one fewer,
|
|
having been written when `SEEDS` had nine entries and not updated when
|
|
`d8ae9c3` added seed 20. The `should_panic` scroll test ended in an
|
|
`h.frame()` that cannot run, because `Harness::set_root` lays the tree out
|
|
and is where the panic comes from -- verified by deleting it. Two
|
|
`drop(tree)` calls sat at the end of their own scope.
|
|
|
|
### Tripped a rule and left as it stands
|
|
|
|
- `env` is written four times under `tests/` (`layout_dump`,
|
|
`layout_diagnostics`, `revision_cost`, `scenario`). `revision_cost`'s is
|
|
deliberate and documented: that file is kept in the API subset an old
|
|
worktree also has, so it can be dropped in and measured there, and a
|
|
`#[path]` module would break that. Sharing the other three means either a
|
|
new file for six lines of `std` or pulling `scenario`'s 494 lines into two
|
|
more binaries.
|
|
- `determinism.rs`'s `BranchesOnMeasurement` is `iris::random::Branch` with
|
|
the same four fields and nearly the same body, and `unsettled.rs` beside it
|
|
uses the real `Branch`. The difference is load-bearing: the copy states no
|
|
contract, so the framework must re-ask it at every width, which is the
|
|
whole point of a test about whether a re-measure branches the same way.
|
|
`Branch` also has a `size_hint` that changes how a span treats it.
|
|
- Three tests call `h.frame()` immediately after `h.set_root`, which already
|
|
frames. Unlike the `should_panic` one, these are reachable and assert that
|
|
a settled second frame adds no draws. Left; the redundancy reads as noise
|
|
but removing it removes a check.
|
|
- `primitive_bounds` and `primitive_masks` share the walk from a widget's
|
|
primitives to their instances and differ only in the field they take.
|
|
Left: naming the intermediate means exporting the instance type into the
|
|
test, which is more coupling than the four shared lines are worth.
|
|
- `Harness::replay` ignores each sample's `t_ms`, and the parser rejects
|
|
time running backwards with a comment about the wait between samples --
|
|
which is `replay-touch`'s behaviour, not the harness's. Left: the parser
|
|
is shared by both replays and the rule is the real one, but a harness
|
|
gesture has no timing, so nothing in-process can measure a fling velocity.
|
|
- `layout_diagnostics.rs`'s `report` takes `_harness` and reads it, because
|
|
it is only used under `layout-diagnostics`. An underscore on a parameter
|
|
the body uses is backwards, but the alternative is a `cfg_attr`ed allow.
|
|
|
|
## Seventh sweep: the rigs, `Fixed`, and the sixth sweep's fix (2026-09-20)
|
|
|
|
Over what no earlier round named -- `src/random.rs` and `tests/scenario/`,
|
|
`core/src/fixed.rs`, `scripts/run-headless.sh` -- and once more over
|
|
`b7b8d09`, which was itself unreviewed because it was the sixth sweep's own
|
|
fix. Six findings, all in `f8aa0c5`. The cold dump over 400 depth-5 trees is
|
|
byte-identical to `b7b8d09` across all 34,492 boxes, and all three seed scans
|
|
pass (400 at depth 5 in 62.75s, 1,000 at depth 6 in 162.37s, 2,000 at depth 4
|
|
in 305.25s).
|
|
|
|
**A scroll still asking a question it has already answered.** The sixth
|
|
sweep found `anchor != Px::ZERO` in `Scroll`'s content test and removed it;
|
|
the operand beside it is the same defect and survived. `self.content_len` is
|
|
`answer_px.max(container_len)`, so content that fits has nothing to scroll
|
|
through, and `update_amt` on the line above clamps `amt` to
|
|
`content_len - container_len`, which is then zero. So in
|
|
|
|
let content = match self.amt != Px::ZERO || self.content_len != self.container_len {
|
|
|
|
the first disjunct can never decide the match: `amt != ZERO` implies
|
|
`content_len != container_len`, which the second already tests. It is now
|
|
`match self.content_len > self.container_len`, the exact complement of the
|
|
`content_len <= container_len` that guards the contract fifteen lines above.
|
|
Verified by asserting the implication in place and running the whole suite,
|
|
the scrolling tests included; it held. This is the lesson of "the fixes a
|
|
review produces are themselves unreviewed code" arriving on schedule -- one
|
|
round's fix left the same mistake in the expression it was editing.
|
|
|
|
**Things nothing reads, in the new arithmetic.** `Fixed::to_scale` converts a
|
|
value between two fixed-point grids, and `shift_round` exists only to serve
|
|
it. Both arrived on this branch; the only caller either has ever had is
|
|
`a_coarser_grid_rounds_and_a_finer_one_does_not`, the test written for them.
|
|
Every other `Fixed` method has real callers (checked one by one), and nothing
|
|
needs a grid conversion: `ratio`, `mul` and `div` already cross grids where
|
|
layout has to. All three deleted. `to_scale` was also the one operation in
|
|
the file that could overflow silently without documenting it -- going to a
|
|
finer grid is `self.0 << (TO - SHIFT)` and the test only ever went coarse,
|
|
fine, coarse with a value small enough to survive the trip.
|
|
|
|
**`Len` arithmetic written a component at a time.** `Len::align` built a
|
|
`Len` whose `px` is always `Px::ZERO`, then added to and subtracted from both
|
|
of its components by hand:
|
|
|
|
start: Len::from_parts(at.rel.sub(self.rel.mul(rel)), at.px.sub(self.px.mul(rel))),
|
|
end: Len::from_parts(at.rel.add(self.rel.mul(rest)), at.px.add(self.px.mul(rest))),
|
|
|
|
`Len::scale` is "both parts by the same fraction" and `Len` has `Add` and
|
|
`Sub`, so the whole rule is `at - self.scale(rel)` and
|
|
`at + self.scale(Rel::ONE.sub(rel))` -- the point the alignment names, less
|
|
the part of the length before it. Identical arithmetic, which the dump
|
|
confirms. It is the only place in the codebase that expanded a `Len`
|
|
operation like this; `LayoutLen::apply_leftover` touches `rel` alone, which
|
|
is genuinely one component.
|
|
|
|
**A question asked through a value, one line from its sibling.** `445287c`
|
|
moved every method that answers a question about a value to `&self`.
|
|
`LayoutLen::without_leftover` was missed, and its own doc comment calls
|
|
`apply_leftover` -- which takes `&self` -- "the opposite reading of the same
|
|
value". Now `&self` too. Every other by-value method in the workspace that
|
|
does not return `Self` was checked: the rest are conversions on numbers
|
|
(`Fixed::raw`, `to_f32`) or builders.
|
|
|
|
**A rig that scales a gesture against a mode the output no longer has.**
|
|
`run-headless.sh --mode` sets `out_w`/`out_h` beside the `swaymsg output ...
|
|
mode`, because those two numbers are the extent `replay-touch` passes to
|
|
`zwlr_virtual_pointer_v1::motion_absolute` -- the recording's coordinates are
|
|
a fraction of them. `--resize`, added in `b7b8d09`'s round, changed the mode
|
|
and left the extent alone, so `--resize 800x600@60Hz --replay flick.touch`
|
|
replayed every sample at `x * 800 / 1920` and finished looking like a run
|
|
that worked. Both are one `set_mode` function now, so a third mode change
|
|
cannot get it wrong. Found by reading rather than by running: this VM has no
|
|
recorded gesture that also resizes, which is exactly why it went unnoticed.
|
|
|
|
**A comment the plan/build split stranded.** `src/random.rs`'s "a row takes
|
|
the height it is given rather than its tallest child" sat above
|
|
`let gap = self.rng.below(3) as i32 * 4`, telling a reader that the line
|
|
consumes no randomness. It describes the `set_size_rules` that `98d4e98`
|
|
moved into `Build::kind`, and the line it was left above is one of the two
|
|
draws in the function. Moved to the rule it is about, and the
|
|
seed-stability half dropped: building a plan consumes no randomness at all
|
|
now, so there is nothing left for that clause to say.
|
|
|
|
### Tripped a rule and left as it stands
|
|
|
|
- `Align::tuple`, `UiVec2::partial_align` and `Vec2::partial_align` have no
|
|
callers anywhere. All three pre-date this PR and are outside its diff, so
|
|
they belong to the pre-review-gate sweep rather than to this branch.
|
|
- `Vec2::align`/`partial_align` are `UiVec2`'s two functions again with
|
|
`UiVec2::from(*self)` in front. Also pre-existing, and merging them means
|
|
deciding whether `Vec2` should have them at all.
|
|
- `impl_op!` has four grammars for one macro, and `core/src/util/vec2.rs`
|
|
spells two of them one line apart -- `impl_op!(impl Add for Vec2: add x y)`
|
|
beside `impl_op!(Vec2 Sub sub; x y)`. The `impl ... for ...:` arm has that
|
|
one caller. Pre-dates this PR; the two arms this PR added (`same ...`) are
|
|
a real distinction, since a type mixing a fraction and an offset has no
|
|
meaning for a bare `f32`.
|
|
- `AxisAlign`, `RegionAlign` and `Align` lost `Eq` when `AxisAlign` became a
|
|
`Rel` wrapper, which `Rel` derives. Nothing needs it, so it was left rather
|
|
than adding a derive with no reader.
|
|
- `Holds::through` special-cases a fully unbounded range before inverting a
|
|
fraction, which the general path would also answer correctly through
|
|
`narrow`. Left: it is the one place a `Px::MIN`-to-`Px::MAX` interval is
|
|
shifted and divided, and the early return says that no fraction can narrow
|
|
"every length".
|
|
- `Scroll`'s `content_len <= container_len` can only be equality, given the
|
|
`max` that built it. Left: `<=` reads as "the content fits", which is what
|
|
the branch means, and the mirror `>` now reads as "it overflows".
|
|
- `scenario::Case::name`, `window` and `Shuffle::of` take `self` on `Copy`
|
|
enums. They are test-rig code the `&self` pass did not cover, and an enum
|
|
with no fields is the one place taking a value costs a caller nothing.
|
|
|
|
## Sixth sweep: the shader boundary and the position widgets (2026-09-20)
|
|
|
|
Over what the five earlier rounds did not name -- the WGSL prelude and how
|
|
it is assembled, the position widgets, `orientation/`, and the sensor walk.
|
|
**Scoped against `upstream/main` at `ca2b4b2`, which is PR #19's real base**;
|
|
the first half of this sweep used the local `main` and had to be redone, for
|
|
which see "The branch layout" in `docs/HANDOFF.md`. Two findings.
|
|
The cold dump is byte-identical to `1096c31` and all three seed scans pass
|
|
(400 at depth 5 in 69.07s, 1,000 at depth 6 in 169.29s, 2,000 at depth 4 in
|
|
300.75s).
|
|
|
|
**A number both sides count in, written twice.** `module_source` already
|
|
builds each shader's preamble from `iris_core`'s own constants, with a
|
|
comment saying why: "a grid the two disagree about puts every coordinate
|
|
somewhere else". The move-chain work then added, to the very file it
|
|
prepends, a second copy of two of its own numbers -- `const MOVE_NONE` and
|
|
`const CHAIN_LIMIT`, under "Keep in step with `iris_core::CHAIN_LIMIT`" --
|
|
asking a reader by hand for what the mechanism beside it exists to do, and
|
|
duplicating the reasoning `CHAIN_LIMIT`'s Rust declaration already carries.
|
|
Both are injected now and the shader declares neither.
|
|
`every_shader_validates` composes the real preamble so it covers the change;
|
|
what it could never have caught is the two numbers drifting apart, which is
|
|
now unrepresentable.
|
|
|
|
`MASK_NONE` went in beside them, replacing a bare `4294967295u` in `masked`
|
|
-- where the CPU deliberately keeps `MaskIdx` and `MoveIdx` as separate
|
|
types so the two cannot be swapped. **That literal pre-dates this PR**; it
|
|
is a one-line drive-by in a block the PR was already rewriting, taken
|
|
because leaving it means a named sentinel for one index and a magic number
|
|
for its sibling four lines apart. Drop it if the scope matters more.
|
|
|
|
**A scroll positioning content it does not position.** Two halves, one
|
|
mistake, both new in this PR -- the base's `Scroll` has none of this
|
|
machinery. The author believed `Scroll` places its own content.
|
|
|
|
`content_len` is `answer_px.max(container_len)`, so it is never less than
|
|
the box. Two lines on, `slack` was `(container_len - content_len).max(ZERO)`
|
|
and `anchor` was `slack * align.rel()` -- provably always zero, whatever the
|
|
alignment, with `moved` then testing `anchor != ZERO` for nothing.
|
|
Instrumented at `1096c31`, a centred scroll over 50 px of content in a
|
|
200 px box prints `slack=0 align=AxisAlign(0.5) anchor=0` and centres the
|
|
content anyway: the framework does it, by placing the inner's answer in the
|
|
whole box, which comes out as `rel 0.5 - px 25` and resolves at any length.
|
|
The comment credited the arithmetic for behaviour it could not produce.
|
|
|
|
The same belief cost a redraw. The contract for content that fits was
|
|
guarded by `align == AxisAlign::NEG`, on the reasoning that at any other
|
|
alignment "it moves with every length the box takes and the drawing holds
|
|
for that length alone". It does not move -- the framework's placement is a
|
|
fraction of the box -- and the default alignment is the middle, so the
|
|
common case was the guarded one. `Painter::px_len` holds a drawing to the
|
|
length it read unless the widget says otherwise, so with no `holds` stated
|
|
the scroll redrew on every box change. Measured with `distinct_widgets`: 1
|
|
at `CENTER`, 0 at `TOP_LEFT`. Dropping the alignment test gives 0 at all
|
|
three, which is what `a_fitting_scroll_holds_for_every_box_its_content_fits_in`
|
|
asserts; it fails at `1096c31` on the `CENTER` case. `align` had no other
|
|
reader, so the widget no longer asks its own alignment at all -- which is
|
|
the tell that both halves were one mistake.
|
|
|
|
**`UiSpan::translated` and `UiRegion::translated`** arrived on this branch
|
|
and are reachable only from each other, which is why a plain "is this name
|
|
used anywhere else" scan does not see them: neither looks unused on its own.
|
|
The region one carried a performance argument for a function nobody calls.
|
|
Both deleted.
|
|
|
|
### Kept although they pre-date this PR
|
|
|
|
`GlyphAtlas::glyph_count`, `TextBuffer::new_empty` and the let-chain in
|
|
`TextEdit::apply_event` that #10 had expanded into a nested `if`. All three
|
|
were found under the wrong base and are outside #19's diff; Bryan said to
|
|
keep them anyway (2026-09-20), since the two are dead either way and the
|
|
third is a straight restoration.
|
|
|
|
### Tripped a rule and left as it stands
|
|
|
|
- `diag::untrace_widget` is new here with no caller, which is the test the
|
|
`translated` pair was deleted by. Left: it is the "removed" half of what
|
|
`trace_widget`'s own doc promises ("until explicitly removed or cleared"),
|
|
and `clear_traced_widgets` is the "cleared" half and does have one. A rig
|
|
drives this API from outside.
|
|
- `UiSpan::flip` swaps `start.rel` with `end.rel` and `start.px` with
|
|
`end.px`, where `Len` has exactly those two fields and one swap of the
|
|
structs would do. Pre-dates this PR.
|
|
- `UiVec2` translates under three names -- `shift`, `offset`, and
|
|
`UiRegion::offset` -- beside `Len::offset`, which adds pixels to one end
|
|
and is a different operation. Pre-existing, and one vocabulary is Bryan's
|
|
call rather than a sweep's.
|
|
- The nine `#[allow(unused_variables)]` are all on trait methods with empty
|
|
default bodies, where the parameter names are the signature's
|
|
documentation and underscoring them would hide it from implementors.
|
|
- `GpuPages::update` grows before it drains and both go through the one
|
|
queue, so the copy is ordered before the writes. Correct as it stands.
|
|
|
|
## Fifth sweep: the retained path, the renderer and the diagnostics (2026-09-20)
|
|
|
|
Over the parts the four earlier rounds did not read -- the renderer, the
|
|
text store, the input default, the harness -- and once more over `redraw`.
|
|
Six findings, `d8d5122` through `1096c31`, the last two from Bryan's reading
|
|
of the first four. The cold dump is byte-identical to `781199a` and all three
|
|
seed scans pass (400 at depth 5 in 69.45s, 1,000 at depth 6 in 214.69s, 2,000
|
|
at depth 4 in 419.50s).
|
|
|
|
**A contract was kept where it no longer held** (`d8d5122`). The sibling of
|
|
`713e3e7`, in the same function. `redraw` keeps the narrower of the old and
|
|
the fresh contract so widening and narrowing back do not churn the parent.
|
|
The drawing's half asks `was_holds.contains(window, rel_base, region)` first;
|
|
the answer's half did not. A widget whose answer contract widened in a frame
|
|
that also resized the window therefore kept a range the new window is
|
|
outside, and the parent's next ask refused it and redrew the whole subtree --
|
|
throwing away the drawing that widget had just made. Cost, not geometry: the
|
|
size kept is the size just reported. It needs both a resize the root does not
|
|
absorb and a mark on a deeper widget in the same frame, which is what
|
|
`a_contract_this_window_is_outside_is_not_kept` builds; it draws the leaf
|
|
twice at `781199a` and once with the guard in.
|
|
|
|
**Configuring a surface under its own texture** (`02048ea`). wgpu 30 says at
|
|
both `Surface::configure` and `Surface::get_current_texture` that configuring
|
|
while a texture the surface handed out is still alive panics. The
|
|
`Suboptimal` arm of `UiRenderer::draw` configured with the texture it was
|
|
about to draw with in hand, so the first suboptimal frame -- a resize or a
|
|
display change on some drivers -- takes the app down rather than rebuilding
|
|
the swapchain. The texture is good for that frame, so it is drawn with and
|
|
presented and the rebuild happens after `present` consumes it. Not
|
|
reproducible on demand here; the claim rests on wgpu's own documented panic.
|
|
|
|
This one is the reason for the sweep recorded under "The code written before
|
|
the review gate" below: nothing about the arm was hard, and it was written
|
|
that way anyway.
|
|
|
|
**A counter named the wrong contract** (`9b4cc32`). `AxisHolds` is four
|
|
contracts and `diag::outside` counted three: a refusal because this window
|
|
is outside the range the drawing was made for bumped "reuse outside: a rel
|
|
base". A window range is pixels and a rel base pin is a window-unit length an
|
|
unchanged window can still change, so the rig answered "why did that
|
|
redraw?" with the wrong one for every resize. Same class as `8088a1f`.
|
|
|
|
**Things nothing reads** (`7502176`, `1096c31`). `Axis::pair`,
|
|
`RegionAlign::NEAR` and `Painter::text_data` arrived on this branch with no
|
|
caller and never got one. The comment beside a span's cross-axis accumulator
|
|
said a scalable child "makes Children scalable too" -- `Children` names
|
|
nothing in this repository, and what it makes scalable is the span.
|
|
|
|
`text_data` was held back a round on the grounds that it is the only way a
|
|
widget inside `draw` can reach `TextData`, and the app's integration might
|
|
want it. That reasoning is wrong: nothing in iris is kept for the app's sake,
|
|
because the app is to be largely rewritten against this API rather than
|
|
ported call by call (Bryan, 2026-09-20).
|
|
|
|
**A question asked through a value** (`445287c`). `7502176` moved
|
|
`Holds::contains` from `&self` to `self` to match its five siblings, which
|
|
was the wrong way to reconcile them. A method taking `self` can only be
|
|
called on a value, so a caller holding a reference has to dereference to ask
|
|
-- `Copy` or not (Bryan, 2026-09-20). Every method that answers a question
|
|
about a value now takes `&self`: `Holds`, `AxisHolds` and `LayoutHolds`
|
|
throughout, `LayoutLen::{is_px, is_only_leftover, declared, fills}` and
|
|
`Size::within_box`. Builders that return a changed copy still take `self`.
|
|
|
|
### Tripped a rule and left as it stands
|
|
|
|
- `TextData`'s spare store clones the whole string into `Placed` on every
|
|
re-break. Bounded at 128 entries, but the clone is per re-break and
|
|
proportional to the text; a transcript-sized text would pay it on every
|
|
width change. Left because a cheaper key changes what "two texts of the
|
|
same words share an answer" means, which is a design question.
|
|
- `TextEdit`'s undo history pushes a whole copy of the text per changed
|
|
keystroke and is never bounded, and `apply_event` clones the text on every
|
|
event including the arrow keys. **`apply_event` is not in this PR's diff at
|
|
all**: it was last touched by #10 and #16, both already on `upstream/main`.
|
|
It keeps resurfacing in sweeps because they diffed against the local `main`
|
|
-- see "The branch layout" in `docs/HANDOFF.md`. Bryan wants the
|
|
unbounded push and the clone dealt with as a change of their own
|
|
(2026-09-20).
|
|
- `ActivationState::update` writes four arms where the `Start`/`On` and
|
|
`End`/`Off` pairs are identical, and `is_off` is `!is_on`. Also verbatim
|
|
from `main`.
|
|
- `TextView::draw`'s empty-with-hint branch matches on `self.hint` again
|
|
after `is_some()` guarded it, so its `None` arm is unreachable. The guard
|
|
cannot become an `if let` because `self.render(painter)` needs `&mut self`
|
|
between the two. Left rather than cloning the handle to satisfy the shape.
|
|
- `CurrentSurfaceTexture::Lost` is answered by reconfiguring, where wgpu says
|
|
to recreate the surface. It will not panic, and recreating needs the
|
|
window; worth doing with the next renderer change rather than this one.
|
|
|
|
## Quality sweep of the whole branch (2026-09-20)
|
|
|
|
A fourth sweep, over the layout core, the arithmetic, the atlas, the sensor
|
|
walk and the fuzz rig rather than over naming. Four findings, all on
|
|
`layout/one-ask` past `1ebd4d3`; the cold dump is byte-identical to it and
|
|
all three seed scans pass (400 at depth 5 in 65.09s, 1,000 at depth 6 in
|
|
161.27s, 2,000 at depth 4 in 301.90s).
|
|
|
|
**A kept contract was judged against the wrong box** (`713e3e7`). `redraw`
|
|
keeps the narrower guarantee a parent holds when the fresh drawing covers
|
|
it, so widening and narrowing back do not churn the parent. It asked
|
|
`was_holds.contains(.., active.placement)` -- where the answer put the
|
|
drawing -- when `holds` is about `active.region`, the box the drawing was
|
|
made in. The two differ on every axis a widget reported less than it was
|
|
offered, so such a widget marked its parent every time its contract
|
|
widened. Cost, not geometry: accepting is always safe, since `region` is
|
|
always inside the old range, so refusing only escalates. `resize` and
|
|
`try_reuse` both already ask about `region`.
|
|
`widening_what_a_drawing_holds_for_does_not_relay_out_the_parent` fails at
|
|
`1ebd4d3` and passes with the line changed; the existing
|
|
`widening_and_restoring_a_contract_does_not_invalidate_its_reader` cannot
|
|
see it, because its leaf reports `LEFTOVER`, which fills its box.
|
|
|
|
**Two things nothing read** (`aea0387`). `ActiveData::size_deps` was written
|
|
on every draw and cleared on every undraw, and read nowhere -- a `Vec` per
|
|
active widget. The `Painter`'s own copy is the live one, used in `draw_at`
|
|
to record whoever asked about a child it did not draw. `SizeRule::apply`
|
|
had no caller and would have been wrong with one: it answers the rule's own
|
|
length where `draw_at` resolves a fraction against the rel base first.
|
|
|
|
**Three reuse rejections said nothing** (`8088a1f`). Of the eight
|
|
rejections in `try_reuse`, a changed inherited mask counted and traced
|
|
nothing, an undrawn record traced without counting, and a changed
|
|
region-node choice counted without tracing. The mask one is what this
|
|
branch's repair was about, so the rig could not answer "why did that
|
|
redraw?" for it. Adding a counter meant editing a variant list and a name
|
|
list at the same index; they are one declaration now.
|
|
|
|
**A fuzz case ran only in the long scan** (`69ba915`). `Case::SizeResize`
|
|
was in `ALL` and in none of `generated.rs`'s `case!` invocations, so the
|
|
size-then-resize order -- which the enum's own comment argues is not the
|
|
same test as the other order -- was never checked by `cargo test`. The
|
|
tests and the list of which cases have one come from one macro invocation,
|
|
and a case missing from it now fails a test.
|
|
|
|
### Tripped a rule and left as it stands
|
|
|
|
- `Span` reads every child's cross length through `place_at(..).len(!axis)`
|
|
even where `has_exact_size(!axis)` makes it moot. The read looks like an
|
|
unwanted dependency, but `depend_on` only matters for a child that is not
|
|
in `children`, which is how `undraw` keeps a measured-then-dropped child
|
|
reachable. For a placed child it does nothing.
|
|
- `PixelRegion::contains` is inclusive at both ends, so two adjacent
|
|
widgets both claim the boundary step. Senses on one layer never block
|
|
each other, so both receiving it is what the design says.
|
|
- `CursorData::sense` is meaningless until `should_run` fills it, which the
|
|
code says in place and proposes a prepare stage for. A real
|
|
unrepresentable-state finding, but it is the event API's shape rather
|
|
than this branch's.
|
|
- `Wrapper` with no child answers `Size::default()`, which is `LEFTOVER`.
|
|
It reads as "nothing" but matches `impl Widget for ()`, whose comment
|
|
says a gap takes the default length so a span gives it a share.
|
|
- `ALL` in `tests/scenario/mod.rs` is still a hand-kept list of every
|
|
`Case`; `Case::name`'s match is the compiler-checked one. A variant left
|
|
out of `ALL` is invisible to the shrinker's `--case` selection too.
|
|
|
|
## Naming and logic sweep (2026-09-19)
|
|
|
|
Settled with Bryan across one session, on the branch past `58ce74d`. Nothing
|
|
here changed what layout computes: the cold dump is byte-identical to
|
|
`58ce74d` at every commit.
|
|
|
|
**How a description is said.** A `PlaceDescAxis` is built by chaining off the
|
|
value that says it -- `UiSpan::within_desc`/`shifted_desc`, `Len::as_desc` --
|
|
never by a constructor naming the type, because a constructor sends the
|
|
reader back to the start of the line. The `_desc` suffix is what says which
|
|
type comes out. `PlaceDescAxis::on_axis(axis)` lifts one axis into a pair
|
|
with the whole box across it; `on` alone was rejected as contentless and
|
|
reserved for events. `from_axes` is the constructor taking a function, beside
|
|
the `from_axis` taking one axis and two values.
|
|
|
|
**Arithmetic that needed a comment became a name.** `UiSpan::place` was the
|
|
aligned-placement rule written out three times; `LayoutLen::without_leftover`
|
|
was the sibling `apply_leftover` never had, at six sites; `is_px`,
|
|
`is_only_leftover` and `declared` name field comparisons the surrounding
|
|
comments had to translate; `Holds::covers` was interval containment by hand.
|
|
Seven module-level functions became methods on the value each took first.
|
|
|
|
**Every pair is a struct of two per-axis values, read with `[axis]`.**
|
|
`LayoutHolds` was four two-element arrays, so none of its own operations
|
|
could be written once; it is `AxisHolds` on `x` and `y`, and `and`, `covers`
|
|
and `contains` lost their loops. `impl_axis_index!` gives every pair
|
|
`Index<Axis>`/`IndexMut<Axis>`, replacing eighteen `axis`/`axis_mut`
|
|
methods -- `const_index` keeps them usable in const context. The bare
|
|
`[Option<LayoutLen>; 2]` became `Declared` of `Option<Len>`, which makes
|
|
"a share is never a declaration" structural rather than two filters and a
|
|
comment.
|
|
|
|
**Two findings in the logic, both one mistake.** A value computed from other
|
|
state was being stored as if it were state, and in both cases the visible
|
|
symptom was something that looked like an off-by-one:
|
|
|
|
- A `Span` carried `start` as a third accumulator beside `fixed` and `taken`,
|
|
assigned at three points, when every assignment was `reached(fixed,
|
|
taken)`. Both ends of a slot are now read where they are used; the variable
|
|
and two of the three calls per child go, and the gap added after the last
|
|
child derives nothing rather than needing to be subtracted.
|
|
- The measuring loop's `cursor` added `px` and `rel` by hand where the
|
|
placing loop below said `fixed += len.without_leftover()` -- the same sum,
|
|
one of them named.
|
|
|
|
**One property that held but nothing guarded.** A `Scroll`'s draw writes
|
|
`amt` and `snap_end`, so a second draw at another viewport reads what the
|
|
first wrote. Warm matches cold only because re-clamping is idempotent and
|
|
monotone. The seed scans build `Scroll`s and never scroll one, so this was
|
|
untested; `a_scrolled_view_resized_lands_where_a_cold_layout_puts_it` scrolls
|
|
four distances, one past the end, then widens. It passes.
|
|
|
|
## Follow-up implementation review (2026-09-19)
|
|
|
|
The ask/place split, window-unit frames, exact validity preimages, and
|
|
bottom-up dirty settling implement the settled design. Keep this approach.
|
|
It does not guarantee one body call per widget: an unhinted descendant that
|
|
reports leftover weight still needs a room ask and a slot ask. The explicit
|
|
measurement redesign remains deferred until an app screen justifies it.
|
|
|
|
The fixes below are on `layout/one-ask` in `/home/bob/repos/iris`:
|
|
|
|
- A collapsed share advances both span cursors. The regression covers one
|
|
and two collapsed children in all four directions.
|
|
- A masking widget owns a mask reference and reclaims its existing slot on
|
|
redraw. Primitives retain their own references. Removing the mask,
|
|
undrawing its owner, freeing the widget, and replacing the root release
|
|
ownership; a changed inherited mask rejects drawing reuse. Tests check
|
|
actual primitive mask indices, movement with and without a region node,
|
|
child draw counts, clip removal/addition, and empty-mask slot reuse.
|
|
- **A further handover defect:** `draw_inner` saved the old parent only
|
|
after a redraw replaced `ActiveData`. The old parent therefore kept the
|
|
child in its list and could undraw the subtree after its new parent drew
|
|
it. Capture the old parent before replacing the record. A branch-switch
|
|
regression reproduces disappearing content when its new parent owns a
|
|
region node, and also checks the ordinary reuse path.
|
|
|
|
The mask and handover tests fail on the reviewed code and pass with the
|
|
fixes. The mask fix preserves child reuse rather than redrawing descendants
|
|
on every mask repaint. No naming sweep or rounding-policy change is included.
|
|
|
|
Validation: workspace tests with and without diagnostics, the release fast
|
|
oracle, and all three prescribed seed scans pass. Comparing 34,488 cold
|
|
boxes against `cadfba0` finds 650 changes; withholding just the collapsed-slot
|
|
fix reproduces the baseline exactly. This is an expected geometry correction,
|
|
not a cost-only change whose dump should remain identical. The tabs example
|
|
and an exact 400 px collapsed-share fixture were rendered and inspected.
|
|
|
|
Two test-harness savings leave the random stream and coverage unchanged:
|
|
`generated` constructs one plan per seed for its sixteen scenarios, and the
|
|
warm/cold comparison constructs its diagnostic ancestry lookup only after
|
|
finding a mismatch. No overall speedup is claimed; no deep profile was run.
|
|
|
|
**Integration is larger than an API rename.** The app's pinned `32f6ad8`
|
|
has 45 commits not reachable from this review branch. In particular, the
|
|
app's nested/shape masks and shared `Ui` ownership are absent here: #19's
|
|
mask is still a single rectangle and `set_mask` rejects nested masks. Keep
|
|
the app pin until those existing capabilities have been integrated. The
|
|
"Masks" and "UI ownership" sections of `LAYOUT.md` describe the app-side
|
|
implementation, not everything already present on the upstream review branch.
|
|
|
|
## Original review of #19 at `cadfba0` (2026-09-19)
|
|
|
|
The original review read `cadfba0`, then the tip of `layout/one-ask`.
|
|
`cargo fmt --all --check`, clippy `-D warnings` and the 123-test suite were
|
|
clean there. These are the original failures, fixed by the follow-up above.
|
|
|
|
### A span misplaces the slot after a collapsed `leftover` child
|
|
|
|
`src/widget/position/span.rs:101` keeps two cursors while it places: `fixed`,
|
|
everything taken so far, and `start`, where the next slot begins. The branch
|
|
that drops a share child with no room to divide advances `fixed` by the gap
|
|
and not `start`:
|
|
|
|
```rust
|
|
if len.leftover > Weight::ZERO && len.px == Px::ZERO && len.rel == Rel::ZERO && !shares {
|
|
painter.undraw(child);
|
|
fixed.px += self.gap;
|
|
continue; // `start` still excludes this gap
|
|
}
|
|
let from = start;
|
|
```
|
|
|
|
In a 400 px row with `gap(10)` over children of 200 px, `leftover(1)` and
|
|
180 px -- exactly full, so nothing is left over and the share collapses --
|
|
the tail is placed at `(215, 0)..(395, 100)`. Its slot was 210..400, a gap
|
|
too long and a gap too early, and the declared 180 was then centred in it.
|
|
The row reports a total that ends at 400. A child drawn with `rel` lengths
|
|
is stretched into the extra gap instead of being centred in it, because the
|
|
slot fills.
|
|
|
|
Recomputing the cursor in that branch fixes it, and the tail lands at
|
|
`(220, 0)..(400, 100)`:
|
|
|
|
```rust
|
|
start = shared(fixed, taken, total.leftover, room);
|
|
```
|
|
|
|
The 123-test suite passes with the line in. Nothing in it or in the
|
|
generated oracle catches the defect: warm and cold layouts are wrong
|
|
identically, so an oracle comparing the two cannot see it. This is the
|
|
sharp form of the handoff's older "a vanished child leaves a double gap"
|
|
item, which described the accounting and not the misplacement.
|
|
|
|
### A reused child keeps the mask its parent replaced
|
|
|
|
`ActiveData::parent_mask` is recorded and documented as the inherited mask
|
|
"the one a redraw of it must not be handed back", but `try_reuse`
|
|
(`core/src/ui/render_state.rs:565`) never compares it with `info.mask`: it
|
|
gates on dirtiness, layer, move parent and region-node status only.
|
|
`Painter::set_mask` pushes a fresh `MaskIdx` on every draw, so a masking
|
|
widget that redraws while its child is reused leaves that child's
|
|
primitives naming a mask nobody updates again:
|
|
|
|
```
|
|
frame 1: masked mask=Id(0) inner mask=Id(0)
|
|
frame 2 (the masked widget alone marked): masked mask=Id(1) inner mask=Id(0)
|
|
frame 3 (the subtree moves up to y=10):
|
|
mask 0: y starts at 50 <- what the inner's primitives are clipped by
|
|
mask 1: y starts at 10 <- the live one, clipping nothing
|
|
```
|
|
|
|
The child's drawing is then clipped 40 px too high and its top is cut off.
|
|
`main` guarded this with `active.mask == mask` in its reuse gate and
|
|
re-marked the owners of a rebuilt mask in `remask_shape_users`; the rewrite
|
|
dropped both.
|
|
|
|
Returning `None` from `try_reuse` where `active.parent_mask != info.mask`
|
|
fixes the repro and keeps the suite green, but it redraws the whole subtree
|
|
whenever a masking ancestor redraws. The better fix is to give `set_mask` a
|
|
per-widget mask slot kept across redraws, the way `UiRenderState::move_slot`
|
|
already keeps a move entry, so the index is stable and `reposition` goes on
|
|
updating the one the descendants name.
|
|
|
|
### Regression coverage
|
|
|
|
Both paths now have regressions in `tests/cases/layout.rs` and
|
|
`tests/cases/retained.rs`; the follow-up above records the additional cases.
|
|
The descriptions above preserve the original failure at `cadfba0`.
|
|
|
|
### Clarity, in the order worth doing
|
|
|
|
Everything about naming is done: the unswept `extent`, the two same-typed
|
|
boxes on `ActiveData` and `Painter`'s four holds accumulators in `5642f20`,
|
|
`Part::All` in `aeb60e5`, and `Place`/`Part` themselves in `58ce74d`. The
|
|
settled vocabulary and the ask API are in `docs/LAYOUT.md`.
|
|
|
|
The clarity sweep of `3da1c71` and `7e2b4cd` closed the first three items
|
|
that stood here. Both are cold-dump identical to `6c84b6f`, so none of it
|
|
moved a box, and all three seed scans passed on `7e2b4cd` (400 at depth 5 in
|
|
66.25s, 1,000 at depth 6 in 188.34s, 2,000 at depth 4 in 300.80s) because
|
|
`reposition` and `redepth` were restructured on the retained path:
|
|
|
|
- `Answer` {size, holds} and `Drawn` {answer, drawing_holds} replace
|
|
`(Size, LayoutHolds)` and the three-tuple with two `LayoutHolds` in it.
|
|
`try_reuse` answers `bool` rather than `Option<()>`.
|
|
- `Span::along` is `Span::slot`; `far` is `row`, `shares` is `has_room`
|
|
beside a named `any_leftover`, and `reached` guards on the weight it
|
|
divides by rather than on the numerator.
|
|
- `DrawInfo::px` was the rel base in pixels, and all three readers printed
|
|
it as the box the widget drew in. Removed; each reads
|
|
`region.to_px(window)`. `Placing::window` existed only to feed it.
|
|
- `diag::outside` holds the 23 counter lines that were inside `try_reuse`.
|
|
- `ActiveData::is_region_node` replaces four copies of
|
|
`move_idx != parent_move`; `Axis::BOTH` replaces `AXES` in three modules;
|
|
`Len::rel_min`, `rel_max` and the unused `select_len` are gone.
|
|
|
|
`1ebd4d3` then closed the `PlaceSpan` item. `PlaceSpan` and `RelBase` are
|
|
`pub` and `ui/mod.rs` re-exports `place` by name rather than by glob, the way
|
|
it already did for `painter`, so the six `pub(crate)` accessors
|
|
(`stated_rel_base`, `narrows_rel_base`, `within_span`, `is_sized`,
|
|
`does_fill`, `with_rel_base`) are gone and `in_parent` matches
|
|
`(at.span, declared)`. `!at.is_sized()` was dead: `PlaceSpan::Sized` is built
|
|
only by `Len::as_desc`, which sets `RelBase::Len(self)` in the same literal,
|
|
and deleting `with_rel_base` removes the only writer that could have
|
|
separated them. Visibility here is plain `pub` plus a named re-export
|
|
wherever the path can be hidden (Bryan, 2026-09-19); `pub(super)` is for
|
|
inherent methods on types the crate exports, where it cannot.
|
|
|
|
What is left, none of it urgent:
|
|
|
|
- `widget_at` does three linear scans per child (`children.contains`,
|
|
`under.iter_mut().find`, `depend_on`), so a span of *n* children is
|
|
O(n^2) per draw. Not a problem at today's sizes; it is worth knowing
|
|
before a long transcript list lands on it.
|
|
- `DrawInfo` and `ActiveData` both carry `placed` and `asked`, two
|
|
`PlaceDesc` fields distinguished only by position in every literal.
|
|
They are genuinely different and documented, but the names are past
|
|
participles with no operand; a rename is Bryan's vocabulary call.
|