Files
ai-app/docs/HANDOFF.md
T

213 lines
9.8 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 **`f437495`**, forty-six 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 placement, so a resize repositions them
without redrawing when their own contracts permit it.
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.
`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 `f437495`
- `cargo fmt --all --check`
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`
- `cargo test --workspace`: 77 passed, 8 ignored
- `cargo test --release --test generated -- --ignored
a_long_run_of_seeds_agrees`: 100 seeds passed in 66.81 s
- The `tabs` reference at 900x1200 remained byte-identical to `ca2b4b2` after
retaining `Children` as the default.
- 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 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 slot holds a whole `UiRegion` in the coordinates of the slot it names.
`UiRegion::FULL` is the identity. Slots are opt-in: `Painter::place` gives a
child a slot; `widget` and `widget_within` share the nearest ancestor's.
- A widget's `ActiveData::region` is where it was placed in its parent's slot.
A placed widget draws in `FULL`; its box lives in its slot. This keeps chains
at positioning-container depth rather than full widget-tree depth.
- The first box a parent asks about is the offer. A later box chosen from the
child's answer is a placement, not another independent answer. Dirty widgets
are re-asked at the offer and only then placed again.
- A retained drawing can be reused only when its `Holds` interval contains the
new pixel box on both axes, its parent slot is unchanged, and the widget is
clean. A moved unslotted widget must draw again because it has no slot to
rewrite.
- `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 at the length where fixed parts fill the box; pure
`leftover` children are undrawn on the no-space side.
- `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.
## 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
Let PR #18 review. The next small LAYOUT.md §2 item is `LazySpan`;
`set_child_offset` is no longer separate, because placing a child is
`Painter::place`.
After #18 lands, the next structural design is built-in alignment and size.
Today a declared size and the `Size` returned by `draw` are two sources of
truth, and alignment can disappear behind a wrapper. Agree their ownership
before implementing it. 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.