Files
ai-app/docs/HANDOFF.md
T
iris-ai cf7b33da39 Record why a move recomposes, and settle alignment's ownership
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.
2026-09-15 19:27:30 -04:00

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.