The measurement is the part worth keeping: adding an offset to a moved subtree's stored regions drifts linearly and without bound, so the fraction path is there for accuracy, not generality. `tests/drift.rs` on the Iris branch pins it. Built-in alignment and size moves onto #18 rather than waiting for it, with ownership agreed -- properties on the `region_node` mechanism, a declared length winning per axis, and a size rule that carries min/max/clamp so a clamp is resolved where the box is decided instead of by a widget reading its own pixels.
286 lines
15 KiB
Markdown
286 lines
15 KiB
Markdown
# Handoff
|
|
|
|
Where the work in flight stands for a session picking it up cold. Keep current
|
|
invariants, measurements, and failed hypotheses here; this is not a decisions
|
|
log.
|
|
|
|
## Where things stand
|
|
|
|
Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18
|
|
`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`; its local
|
|
head is **`0283c9d`**, forty-eight commits, pushed to the fork. No PR reviews
|
|
were present when checked on 2026-09-15; the verification summary is posted on
|
|
the PR.
|
|
|
|
The current head completes LAYOUT.md §2's position chain and the requested
|
|
`leftover` behavior. A child whose length is only `leftover` is not drawn when
|
|
nothing is left. A child that also asks for pixels or a relative fraction keeps
|
|
that part and overflows as before.
|
|
|
|
`29c7881` replaces the parallel resize rules with one retained-layout contract,
|
|
`Holds`: the interval of box lengths for which a widget's drawing and reported
|
|
size stay valid. Reading `Painter::px_len` or `px_size` narrows the interval to
|
|
the length read; `Painter::holds` lets the widget widen it. Parent validity is
|
|
the intersection of the ranges its children induce. This contract is trusted.
|
|
A widget that declares an incorrect range is a defective widget; Iris does not
|
|
add defensive work to recover optimizations from a false declaration.
|
|
|
|
`f437495` restores explicit `Span::ortho(OrthoSize::{Children, Full})` sizing.
|
|
`Children` remains the default and preserves the old conservative behavior:
|
|
the largest fixed orthogonal length is reported, while any relative or
|
|
`leftover` child makes the span report `leftover`. `Full` reports exactly
|
|
`Len::rel(1.0)`. It does not read the children's orthogonal sizes, but their
|
|
`Holds` ranges still propagate through final-box drawing, so a resize
|
|
repositions them without redrawing when their own contracts permit it.
|
|
|
|
`71c9c39` replaces the public `Painter::place` distinction with an opt-in
|
|
widget property. `.region_node()` gives a widget one independently movable
|
|
retained region; `Widgets::set_region_node` can change that choice at runtime
|
|
and causes one structural redraw. Widgets without the property remain at the
|
|
default shallow chain depth: Iris recursively remaps their retained primitive
|
|
and mask regions when they move. `.scrollable()` enables a region node on its
|
|
content once as its convenient default; raw `Scroll::new` respects the
|
|
caller's choice, and the property can be disabled later without breaking
|
|
scrolling. `Span` and `Align` do not add nodes to their children.
|
|
|
|
Do not change `Children` to select the pixel-longest arbitrary `Len` at the
|
|
span's current width. A fixed child and a relative child can create multiple
|
|
self-sizing fixed points; generated seed 13 settled differently warm and cold
|
|
under that attempted implementation. A `Holds` interval says where an already
|
|
chosen answer stays valid, but cannot make that circular choice unique.
|
|
|
|
The implementation also fixes three counterexamples found while finishing the
|
|
rewrite:
|
|
|
|
- An asked-but-undrawn size dependency must name the widget that asked as its
|
|
parent. Using the asker's parent skipped a reader and made generated seed 10
|
|
settle differently warm and cold.
|
|
- `Scroll` must return the answer from the first box it asked about, whether
|
|
that answer came from a retained length or a fresh measurement. Returning
|
|
the final placed answer only on the retained path advanced one fixed-point
|
|
iteration and broke seed 86.
|
|
- A widget retains the layer it was entered on, not the last child layer its
|
|
painter visited. The old value drifted on local redraw and put a redrawn tab
|
|
background above its retained text.
|
|
- `Span`'s leftover/no-leftover split is a strict layout decision, not a
|
|
rounding tolerance. Its `Holds` range must use the same exact divided
|
|
boundary as drawing; a tolerant endpoint retained zero-height children at
|
|
the boundary in generated seed 16.
|
|
|
|
`core/src/ui/holds.rs`, the retained tests, and the generated cold-layout oracle
|
|
pin those rules. Seeds 10 and 86 are now in the ordinary generated set.
|
|
|
|
## Verification at `0283c9d`
|
|
|
|
- `cargo fmt --all --check`
|
|
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`
|
|
- `cargo test --workspace --all-features`: 85 passed, 10 ignored
|
|
- `cargo test --release --test generated -- --ignored
|
|
a_long_run_of_seeds_agrees --exact`: 100 seeds passed in 64.77 s
|
|
- The `tabs` reference at 900x1200 remained byte-identical to `f437495`: zero
|
|
differing pixels out of 1,080,000 after the region-node rewrite.
|
|
- At preceding head `29c7881`, reference renders against
|
|
`/home/bob/repos/iris-main-cmp` at `ca2b4b2` covered:
|
|
`tabs`, `view`, `minimal`, and `text` at 1920x1200; `tabs` cold at
|
|
900x1200; live resize from 1920x1200 to 900x1200; and the tab interaction
|
|
before and after replay. Every comparison had zero differing pixels. The
|
|
live-resize image is also byte-identical to the cold 900x1200 image.
|
|
|
|
The final pre-submit review was run in four passes. The region-node review
|
|
caught an index-reuse hazard when removing a node; its move entry now remains
|
|
alive until every descendant has migrated. The generated oracle then exposed
|
|
the exact `Span` threshold described above. The earlier retained-layout
|
|
review caught the layer defect above, corrected validity-range inversion for
|
|
negative relative extents, and removed an impossible-state `unwrap` from
|
|
`Scroll`. The orthogonal-sizing review caught both the circular longest-child
|
|
choice and the incompatible visual effect of making `Full` the default.
|
|
|
|
### Performance
|
|
|
|
The retained rewrite was compared with #18's previous head `691e3eb` using the
|
|
same release `layout_diagnostics` fixture, seed 1, depth 8, 500 frames, 130
|
|
dirty widgets:
|
|
|
|
| phase | `691e3eb` | `29c7881` |
|
|
| --- | ---: | ---: |
|
|
| many | 25.17M instructions/frame | 6.14M |
|
|
| resize | 16.08M | 8.39M |
|
|
| scroll | 0.720M | 0.691M |
|
|
| repaint | 0.720M | 0.689M |
|
|
| size | 0.730M | 0.698M |
|
|
|
|
The final `29c7881` measurements were 3,070,693,965 instructions for 500
|
|
`many` frames and 4,192,804,214 for 500 `resize` frames. Re-run before quoting
|
|
tighter figures.
|
|
|
|
## Retained-layout invariants
|
|
|
|
- A region node holds a whole `UiRegion` in its parent node's coordinates.
|
|
`UiRegion::FULL` is the identity. Widgets opt in with `.region_node()` or
|
|
`Widgets::set_region_node`; ordinary widgets share the nearest ancestor
|
|
node. Region nodes therefore add chain depth only where moving a whole
|
|
subtree through one entry is useful.
|
|
- A widget's `ActiveData::region` is its box in its parent node. A region-node
|
|
widget draws in `FULL`; its box lives in its node. Moving an ordinary
|
|
retained subtree instead remaps its primitive, mask, and active regions.
|
|
Remapping stops at a descendant region node after rewriting that one entry.
|
|
- Changing `region_node` redraws the subtree once to rebuild the coordinate
|
|
boundary. The property belongs to widget identity, which is safe because a
|
|
widget has one parent. `.scrollable()` sets it once; raw `Scroll::new` does
|
|
not, and `Scroll` never reasserts it while drawing.
|
|
- The first box a parent asks about is the offer. A later box chosen from the
|
|
child's answer is the final box, not another independent answer. Dirty
|
|
widgets are re-asked at the offer and only then drawn in the final box.
|
|
- A retained drawing can be reused only when its `Holds` interval contains the
|
|
new pixel box on both axes, its parent node is unchanged, its region-node
|
|
choice matches the retained structure, and the widget is clean. A valid
|
|
ordinary subtree may move without redrawing because its regions are
|
|
recursively remapped.
|
|
- `Painter` records size-dependency edges only when a parent reads a child's
|
|
size or hint. An undrawn measured child remains recorded so a later change
|
|
reaches the parent that decided whether to draw it.
|
|
- Dirty widgets settle deepest-first. `dirty_size_under` prevents a reader
|
|
from taking a retained answer while something below that answer is still
|
|
dirty; the walk is an optimization against laying out twice, not a second
|
|
validity mechanism.
|
|
- Declared non-`leftover` lengths are resolved by the widget's parent where the
|
|
widget is drawn. A declared-length change therefore redraws the parent.
|
|
- Pixel comparison uses a 0.05 physical-pixel tolerance, against the last
|
|
actual layout. Repeated subpixel changes accumulate and eventually redraw.
|
|
- Text shaping is retained separately from line breaking. A greedy line break
|
|
remains valid from its longest produced line through the width at which it
|
|
was made, and `TextView` reports that interval through `Painter::holds`.
|
|
- `Span`'s decision to distribute `leftover` is a pixel question. Its validity
|
|
interval is split exactly at the length where fixed parts fill the box;
|
|
pure `leftover` children are undrawn on the no-space side. Exact/open ranges
|
|
are for hard widget decisions; the ordinary tolerant ranges remain for
|
|
accumulated coordinate rounding.
|
|
- `Scroll` reports its content's first measured size. Its drawing can survive
|
|
container-length changes only over the interval in which clamping and its
|
|
current offset do not change.
|
|
- **A move must re-express each part as the same fraction of its new box, not
|
|
add an offset to the last answer.** A subtree's stored regions are the only
|
|
record of where it is, so an offset integrates its own rounding and nothing
|
|
recomputes it. Measured 2026-09-15 on `tests/drift.rs`: offsetting both ends
|
|
of a span shortens that fixture's row by 0.071 over 20,000 moves and 0.712
|
|
over 200,000, growing with the count; placing the far end from the near one
|
|
leaves 0.069, because the length is re-derived from the endpoints either
|
|
way. 20,000 moves is five minutes of scrolling at 60Hz, which is when it
|
|
passes the 0.05 physical pixels layout treats as the same place. The
|
|
fraction path is exact at 200,000. So `RegionRemap` is load-bearing for
|
|
accuracy rather than for generality, and its multiplies are not what is
|
|
being paid for. The generated oracle compares within 0.05 and `unsettled.rs`
|
|
runs too few frames to reach it, which is why `tests/drift.rs` exists.
|
|
|
|
## Rigs and reproduction
|
|
|
|
Ordinary framework verification:
|
|
|
|
```sh
|
|
cd /home/bob/repos/iris-pr18
|
|
cargo fmt --all --check
|
|
cargo clippy --workspace --all-targets -- -D warnings
|
|
cargo test --workspace
|
|
```
|
|
|
|
Run the long generated oracle only after ordinary tests pass:
|
|
|
|
```sh
|
|
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
|
|
```
|
|
|
|
`tests/generated.rs` compares a warm incremental tree with a cold tree of the
|
|
same state. `IRIS_GENERATED_SEED`, `IRIS_GENERATED_SEEDS`, and
|
|
`IRIS_GENERATED_DEPTH` select failures. `tests/shrink.rs` reduces a failing
|
|
tree; use it to turn a seed into a readable regression rather than leaving the
|
|
seed as the only record.
|
|
|
|
The headless reference set must be run one process at a time because the rig
|
|
reuses one compositor. Comparison worktrees need separate target directories.
|
|
Useful commands:
|
|
|
|
```sh
|
|
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/tabs.png
|
|
./scripts/run-headless.sh tabs --mode 900x1200@60Hz --shot /tmp/cold.png
|
|
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \
|
|
--resize 900x1200@60Hz --shot /tmp/resized.png
|
|
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \
|
|
--replay /tmp/tabs.touch --shot /tmp/replay.png
|
|
```
|
|
|
|
The replay used for the final check was:
|
|
|
|
```text
|
|
0 down 1728 24
|
|
80 up 1728 24
|
|
400 down 1836 1116
|
|
480 up 1836 1116
|
|
800 down 1836 1116
|
|
880 up 1836 1116
|
|
```
|
|
|
|
`tests/layout_diagnostics.rs` is the retained CPU rig. Select `cold`, `many`,
|
|
`repaint`, `size`, `scroll`, or `resize` with `IRIS_PHASE`; use the feature for
|
|
explanatory counters and an uninstrumented release binary under `perf` for
|
|
instruction totals.
|
|
|
|
## Next
|
|
|
|
The next small LAYOUT.md §2 item is `LazySpan`. Region nodes now cover the
|
|
independently movable-subtree use case; do not restore a separate
|
|
child-placement API. `docs/LAYOUT.md` §2 is stale: it still describes
|
|
`Painter::place`, which `71c9c39` replaced.
|
|
|
|
**Built-in alignment and size is next, and goes on #18 rather than after it**
|
|
(Bryan, 2026-09-15: #18 is unreviewed and already large enough that most lines
|
|
get read anyway). Ownership is agreed:
|
|
|
|
- Declared size and alignment become **widget properties on the same mechanism
|
|
as `region_node`**, read by `Painter::widget_at`. `SetSize`, `MaxSize` and
|
|
`Aligned` are deleted, and `.width()`, `.sized()`, `.max_width()`, `.align()`
|
|
and `.center()` stop building widgets. `Painter` then owns measure-then-place,
|
|
which `Span`, `Aligned` and `Scroll` each implement separately today.
|
|
- A **declared length wins per axis**; the `Size` returned by `draw` answers
|
|
only the axes with no declaration. The rule becomes an enum per axis --
|
|
`Exact`, `Min`, `Max`, `Clamp` -- so a clamp is resolved where the box is
|
|
decided instead of by a wrapper. `MaxSize::draw` currently calls
|
|
`px_size()`, which pins its interval to one exact box on both axes and
|
|
redraws its whole subtree on any resize; the parent resolving the clamp
|
|
reads px itself and can declare the interval over which the answer holds.
|
|
- The clamp boundary is a **hard layout decision, not a tolerance**, the same
|
|
shape as `Span`'s leftover split. Its `Holds` range must be exact and split
|
|
at the crossover; generated seed 16 is what a tolerant endpoint costs.
|
|
- Two things to settle while implementing. `max_width` today does not
|
|
constrain anything -- `MaxSize` draws its child in its full box and only
|
|
caps the `Size` it reports upward -- so resolving the rule in the box is a
|
|
behaviour change. And `declared_len` derives from `size_hint`, so one
|
|
channel carries both "my size, for a span to divide around" and "resolve my
|
|
box to this length"; those separate.
|
|
|
|
Why it comes before anything else that touches layout: a widget's `region`
|
|
becomes **its reported size placed by its alignment inside the offer**, rather
|
|
than the offer itself. A span's measure-then-place then moves a child between
|
|
boxes of the same length, so it is a translation by construction, and the
|
|
measuring draw disappears wherever the size is knowable without one. That
|
|
changes what `.background()` sits behind, so the reference renders stop being
|
|
byte-identical to `ca2b4b2` and the generated cold-layout oracle becomes the
|
|
verification.
|
|
|
|
Do not restore `OnResize::Translate`; retained translation is now expressed by
|
|
the same `Holds` contract and box chain.
|
|
|
|
Other queued work, in dependency order:
|
|
|
|
- `UiRenderState` behind `Rc<RefCell<_>>`.
|
|
- Split `Len`/layout length and add density-independent pixels.
|
|
- Input restructuring: pointer capture, drag slop and axis, cancellation,
|
|
mask-aware hit testing, and timestamps.
|
|
- Retained paints, selection, overlays, and shared runtime state.
|
|
- Generic desktop/Android hosts and reusable example/APK tooling.
|
|
- Application-owned fonts and replaceable glyph-atlas buckets.
|
|
- Positioned text overflow and cluster-safe ellipsis.
|
|
|
|
The archive is a reference, not a patch: it predates returned `Size`, the
|
|
current box chain, and the current length types. Recreate changes on current
|
|
types and keep app/session concepts out of Iris.
|