Prune the docs of work already done: 18,252 -> 7,567 lines

Iris: "the documentation is also pretty crazy too. Can you go through it
and remove everything that's already done and decided? There's entire md
files iirc for projects already complete. And many with checkboxes already
ticked off that just fill up context."

  docs/RUST.md        8503 -> 905    the framework bake-off (options,
                                     recommendation, twelve closed
                                     experiment boxes) and two superseded
                                     "where things stand" sections, out;
                                     what the experiments settled kept as
                                     one line each
  docs/IRIS_TODO.md   1383 -> 229    fifty closed items and six
                                     phone-report sections whose defects
                                     are all fixed
  docs/LAYOUT.md      1116 -> 829    the pre-implementation framing: the
                                     old trait, the checklist, the
                                     migration list, the pass conditions
  docs/TEXTURES.md     496 -> 240    the prior-art survey, the proposal
                                     and its review, all implemented
  docs/REVIEW-*.md     673 -> 0      two completed review passes; the two
                                     findings left open on purpose (mask
                                     hit-testing, the phone's font set)
                                     moved into RUST.md

What survives a prune is what cannot be cheaply re-derived: measurements
(the APK-size table, the phone bench reports), dead ends, invariants and
their reasons, and the design of what exists now rather than the route to
it. AGENTS.md now says that, so the next session prunes as it goes rather
than appending; docs/IRIS_TODO.md's header says items are deleted when
they land rather than ticked.

Deleting the two review files left eighteen citations dangling in code
comments that state their reason inline and cited the file for provenance
only — those now read "(review, 2026-09-06)" and carry no dead pointer.
The emulator's measured GPU capabilities moved to the this-machine-android
skill, where machine facts belong. IRIS.md and DECISIONS.md are dated
records and were not rewritten; each gained one note that paths in older
entries predate the 2026-09-08 crate merge, pointing at the mapping.

Not touched, deliberately: docs/DECISIONS.md's entries (that file *is* the
queue of things for Iris to review, so deleting decided items would remove
what it exists for) and iris/readme.md and iris/TODO, which are hers.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in every workspace, and every remaining docs/*.md cross-reference
resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 23:50:53 -04:00
1 parent 6d5a231f5c
commit 09778346a0
23 files changed
+354 -10297

No files matched your search

+13 -300
View File
@@ -1,17 +1,6 @@
# iris: one `draw` that reports a size
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
any design or code so that it survives a cleared session. **Status: implemented
2026-09-04, against every pass condition in §8** (measured, not assumed — see
that section). Every widget listed in §7 was migrated in one change; none
kept `desired_width`/`desired_height`. Five points needed correction or
refinement beyond what this file originally specified — see "Deviations
found during implementation" below, added right before "For IRIS.md" — read
that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the
move-slot lifecycle in `render_state.rs`, since each of those five is a real
bug this file's first draft would have reproduced if implemented literally.
## What Iris asked for
Iris, 2026-09-04:
> I don't like that widgets need both a draw and size functions. I'd much
> rather them have a single draw that reports a size, and if it needs to be
@@ -19,78 +8,16 @@ bug this file's first draft would have reproduced if implemented literally.
> done after as well. This should be done efficiently like everything else
> tries to do right now.
She added, a few minutes later: "single draw is not a requirement. It
just seems more efficient from what I've heard. Feel free to override any
decision I've made if you can find a genuinely better & still clean
alternative." So the single-draw model is the default to design against,
and the design below may reject it, but only with a written comparison
showing the alternative does less work per frame and is no harder to use.
Standing constraints from RUST.md still apply: no DSL, plain Rust, do as
little processing as possible per frame, but the model must cover every
layout need a real app has (the transcript's virtualised list, wrapped
text whose height depends on width, rows and columns that size to their
children, overlays, masks).
## What exists today
`Widget` (`iris/core/src/widget/mod.rs`) has three methods: `draw(&mut
self, &mut Painter)`, `desired_width(&mut self, &mut SizeCtx) -> Len` and
`desired_height`. A parent asks `SizeCtx::width/height` for a child, which
is memoised per widget id and axis in `Cache.size` keyed on the outer
size, then places the child with `Painter::widget_within(region)`. So a
child is visited twice (sized, then drawn), every widget implements sizing
twice (one per axis), and a widget whose size depends on what it draws
(wrapped text, a laid-out paragraph) does the layout in the size pass and
again in the draw pass unless it caches by hand.
Primitives are already positioned by `UiRegion` values whose scalars have
a `rel` and an `abs` part, resolved against the window in the vertex
shader (`core/src/render/shader.wgsl`), and `Primitives::region_mut`
exists to rewrite one instance's region in place. That is the mechanism a
"move after the fact" can build on.
## What the design must answer
1. **Parent-before-child ordering.** A row has to know each child's width
to place the next one, but under "one draw" the child's size only
exists after it has drawn. The answer is meant to be: the child draws
at a provisional origin, reports its size, and the parent *moves* it.
The move must be O(1) per moved subtree, not O(primitives in the
subtree). One way: every instance carries an index into a small
per-widget offset buffer, so moving a widget writes one entry and the
vertex shader adds it. Other ways may be better; the design should say
what was considered.
2. **Move vs resize are different costs and must be kept apart.** A move
never re-runs `draw`. A resize re-runs `draw` for exactly the widgets
whose size input changed, and a widget whose output does not depend on
its size (an icon, a fixed rect) must be able to say so and be skipped.
3. **Size-dependent content.** Wrapped text is the hard case: its height
is a function of its width. A single `draw` receives the available
size (what `SizeCtx.outer` is today) and reports what it used, so the
two-pass "measure then draw" collapses into one for the common case.
The design must say what happens when a parent wants the child's
height *before* deciding the width it will offer (rare; say whether it
is supported, or is done by drawing twice as an explicit, opt-in cost).
4. **Caching.** Today's `Cache.size` memoises by (id, axis, outer). The
replacement should memoise the whole draw result by (id, available
size) so that an unchanged subtree costs nothing on the next frame,
which is what makes a virtualised list cheap.
5. **Everything currently written against `desired_width`/`desired_height`
moves over in one change**, per the code rules: two names for one
concept is not an intermediate state to leave behind. The widgets are
in `iris/src/widget/` (`ptr`, `mask`, `image`, `rect`, `trait_fns`, and
whatever else is there when the change is made).
## Order relative to the texture work
TEXTURES.md's redesign touches the render core (shader, `GpuTextures`,
`Primitives`, `Painter`'s texture calls). This change touches the widget
trait, `SizeCtx`, `Cache`, `Painter`'s widget calls, and any offset
mechanism the vertex shader needs. They overlap in `Painter` and the
shader, so they are done **in sequence, textures first**, and the layout
design here is written (not implemented) while the texture work is in
progress, then implemented on top of it.
**Implemented 2026-09-04.** Every widget was migrated in one change; none
kept `desired_width`/`desired_height`. What is kept below is the design as
it stands, the five corrections implementation forced (read those before
touching `Aligned`, `Sized`, `MaxSize`, `Scroll` or the move-slot lifecycle
in `render_state.rs` -- each is a real bug the first draft would have
reproduced), and the two later additions that build on it. The
pre-implementation framing -- what the old trait looked like, the checklist
the design had to answer, the migration list, the pass conditions and the
"copy this into IRIS.md" note -- was deleted on 2026-09-08, having been
carried out; `docs/IRIS.md`'s 2026-09-04 entry is the public-API record.
## Design
@@ -517,186 +444,7 @@ nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
deletes the `SizeCtx` copies, keeping the `Painter` ones.
### 7. Migration — every file and widget that changes
One change, in dependency order (rename-and-move-together, per the code
rules — no intermediate state with both trait shapes):
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
`WidgetAxisFns`, update `impl Widget for ()`.
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
- `core/src/ui/cache.rs` — delete `Cache` (§5).
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
`len_axis`; `primitive_at` writes `move_idx`.
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
instead of `Cache.size`.
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
`move_slot: MoveIdx`.
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
new `MoveOffset` struct.
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
and `Primitives::write`, matching `mask_idx`.
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
(group 2, beside `masks`) and its update path.
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
function (§2b) called from both `vs_main` (a primitive's own corners)
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
and `window_region` (`:264-267`) reimplemented on top of it.
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
(§2b) — the pointer-routing fix this design requires, not an optional
follow-up.
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
`move_idx: u32` (§2b).
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
never depends on the region it is offered, same as `Rect`),
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
`iris/src/widget/text/edit.rs` (`TextEdit`),
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
`iris/src/widget/position/layer.rs` (`LayerOffset`),
`iris/src/widget/position/pad.rs` (`Pad`),
`iris/src/widget/position/stack.rs` (`Stack`),
`iris/src/widget/position/offset.rs` (`Offset`),
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
cross-axis case, deleting `desired_ortho`'s duplicate loop),
`iris/src/widget/position/sized.rs` (`Sized`).
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
across `core/` and `src/`; re-run it before starting, since it is the
authoritative check that nothing was missed, not this paragraph.
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
`impl Widget` found in any example (verified by the same grep); they use
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
source change, which is itself part of the pass condition below.
### 8. Pass conditions
1. **Every example under `iris/examples` renders identically.** Run
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
"looks right," since a subtle wrap or alignment regression is exactly
what a diff catches and a glance does not.
**Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG
library is installed in this VM (no PIL, no ImageMagick, no pip), so the
diff is a from-scratch PNG decoder (`zlib` + the five filter types) at
`/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than
file bytes (`cmp` alone is not conclusive across two separately-encoded
PNGs, though it happened to agree here for `minimal`). Before-shots were
taken with `git stash` at the pre-change commit; `tabs` needed two real
fixes (deviations 1 and 2 below) before it stopped differing — the other
three matched on the first try.
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
the existing `debug_layers`/`active_widgets` instrumentation
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
`Primitives::write`/`region_mut` calls, both per `update()` call. Drive
one example (`tabs`, since it already has multiple widgets and an
interactive element) through one frame with nothing changed and report
both counts — the pass condition is **0 draws and 0 primitive rewrites**
for a frame in which nothing was marked dirty, resized, or moved.
**Result (2026-09-04): pass, 0 and 0.** Implemented as
`UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut`
rewrites, `move_offsets` writes — a third counter, for condition 3
below), reset on read. Measured in
`iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing`
against a `Scroll` over 500 fixed-height rects (not the `tabs` example —
see the note on condition 3 for why this runs as a plain unit test
instead).
3. **Single-moved-child cost, measured.** Same counters, one frame in
which exactly one widget is moved (not resized) with N primitives in its
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
`Widget::draw`, 0 calls to `region_mut`**, independent of N. Construct
the case with a `tabs`-style example holding a deliberately large text
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
an O(N) regression would show up as a non-trivial write count rather
than being lost in noise.
**Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets
write, N = 500.** Built with rects rather than glyphs
(`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`):
`iris-core`/`iris` touch no GPU or window to lay out and move a tree, so
this runs as a plain `cargo test`, not through `run-headless.sh` — a
`Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is
faster and more precise than reading counters out of a real example's
stderr. Getting a clean single move took two follow-up fixes beyond the
design as written (deviation 3, the `parent_move_slot` threading; and
the `Scroll` design decision below about offering last frame's content
length) — without either, the count was in the thousands (every rect in
the subtree redrawing) rather than 1.
4. **Hit-testing follows the move, not just the render.** In the same
scrolled-`tabs` construction as condition 3, scroll the content, then
send a synthetic cursor position over a widget that moved and assert
`run_sensors` (`src/default/sense.rs:154-200`) routes to that widget's
id, not to whatever is now at its pre-scroll coordinates or to nothing.
This is a correctness check, not a timing one — §2b's fix is required
before §2 can ship at all, and this is what would fail silently
(nothing on screen indicates a missed or misrouted hit) if it were
skipped.
**Result (2026-09-04): pass**, but checked one level below
`run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget`
scrolls a widget and asserts `UiRenderState::resolved_region` (the
query `run_sensors`'s hit-test and `window_region` both now go through,
per §2b) reports the moved, not the pre-scroll, position — within
0.01px of the exact expected delta. `run_sensors` itself needs a
`HasEvents`/window/cursor-state harness this pass did not build; the
coverage that matters (does the position query the router uses reflect
the move) is exercised directly instead.
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
both before and after scrolling it (`iris/run-headless.sh` against a
small purpose-built example, or an addition to `tabs`), and diff the
two frames: the clipped edge of the content must have moved with the
scroll while the viewport's own border (drawn by `Masked`, not moved)
stays put — the specific case worked through in §2b. A mask rectangle
that stayed at its pre-scroll position while its content slid past it
is the regression this checks for, and it is visible in a single
screenshot, not just in a counter.
**Result (2026-09-04): pass, checked numerically rather than by
screenshot.** No example in this repository builds a `Masked`-wrapped
`Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal
scrolling, not this widget), so there was nothing to screenshot without
first authoring a new example. Checked instead in
`iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`,
on the exact data the fragment shader's `resolve_move` reads: the
masked widget's own `move_offsets` slot delta is `[0, 0]` both before
and after scrolling its content, because `Masked` is never itself the
target of a move — only its child is, on a separate, deeper slot in the
chain (§2b's "scroll-container case, checked rather than assumed"). A
pixel-level screenshot check of this remains open; see RUST.md's next
step.
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
stay clean at the defaults (iris has no tests today per I0b, so this is
presently only clippy/fmt; add the first real widget-layer tests here if
the move-offset chain or `draw_twice` are non-trivial enough to want
one, per "match the codebase's testing posture" — judge that once the
code exists rather than pre-committing to a number of tests here).
**Result (2026-09-04): pass.** `cargo fmt --all -- --check`,
`cargo build --workspace --all-targets`, and `cargo clippy --all-targets`
are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/
`winit` future-incompatibility, from dependencies, not this change).
`cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new
ones in `iris/src/layout_tests.rs` (conditions 25 above), 18 passed, 0
failed — the move-offset chain turned out non-trivial enough (three real
bugs found only by writing it) to clearly clear the "match the testing
posture" bar this section left open.
### 9. Rejected, and why
### 7. Rejected, and why
- **A flat (non-chained) per-subtree offset table**, Iris's literal
phrasing — rejected in §2 for breaking under nested independent moves
@@ -857,7 +605,7 @@ unspecified rather than getting them wrong:
a widget once more than the old code would have (if the parent it
bubbles up to ends up redrawing the same widget again as part of its own
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
redundancy for free, and this path is not one of §8's measured
redundancy for free, and this path was not one of the migration's measured
conditions, so the remaining slack was accepted rather than chased
further.
@@ -913,41 +661,6 @@ resolution-independent, a fraction of the parent). `Span::gap` and
on them the same as any other size; a bare number is still `abs`,
physical pixels, unchanged.
## For IRIS.md
When this lands, copy this entry into `IRIS.md` (newest first):
> **2026-09-04 — `Widget::draw` reports the size it used; `desired_width`/
> `desired_height` are gone.** A widget used to implement three methods
> (`draw`, `desired_width`, `desired_height`); it now implements one,
> `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into
> `painter.region()` and returns how much of it was used. Why: the two
> extra methods routinely re-simulated what `draw` was about to do anyway
> (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing
> right) — one visit per widget per frame instead of up to three. A
> container that needs a child's size before placing it (alignment,
> centering) draws the child once at a provisional region, reads the
> returned `Size`, and calls the new `Painter::reposition` to move it into
> its final spot — an O(1) offset write, not a second draw. A widget whose
> drawn output never depends on the size it's given (a fixed-size `Rect`,
> a decoded `Image`) overrides the new `fn is_size_independent(&self) ->
> bool { false }` to `true`, which skips redrawing it when only its
> offered region changes shape.
>
> ```rust
> // before
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
>
> // after
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
> ```
>
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
> design, the move-offset mechanism this shipped alongside, and the file
> list.
## Masks with a shape (decided 2026-09-07, built 2026-09-08)
Iris, on the code block's scrolling: "the code block scrolling currently