diff --git a/docs/IRIS_EXTRACTION_HANDOFF.md b/docs/IRIS_EXTRACTION_HANDOFF.md index 9b5faf1..a0053a0 100644 --- a/docs/IRIS_EXTRACTION_HANDOFF.md +++ b/docs/IRIS_EXTRACTION_HANDOFF.md @@ -9,8 +9,10 @@ Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are in. **#18 `split/18-position-chain`** is open and finished apart from one decision: -worktree `/home/bob/repos/iris-pr18`, head `b1b3eca`, eighteen commits, -workspace tests passing, fmt and clippy clean. It is LAYOUT.md §2's position chain, generalised +worktree `/home/bob/repos/iris-pr18`, head `e5f8b6b`, twenty commits, +workspace tests passing, fmt and clippy clean. The last two are the text +shaping store and three retained-layout details that claimed something they +did not do. It is LAYOUT.md §2's position chain, generalised to boxes. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean. **The one thing waiting on the owner.** `tabs` at 1920x1200 is no longer @@ -159,16 +161,14 @@ above were measured on. Earlier statements here that the retained layout was "about 1.6x the old 3.14 ms resize" compared a 260-widget depth-8 tree against that 1061-widget depth-7 one, and are withdrawn. -`tests/fair.rs` in `/home/bob/repos/iris-perf2` is the load that does carry it: -one hand-written tree -- 40 rows of `Dir::RIGHT` span holding a 40px rect and a -`Dir::DOWN` span of one wrapping and one non-wrapping text -- in source that -compiles unchanged on `43ce8c7` and on #18. 500 resizes alternating 900 and 892 -wide at 1200 tall, `perf stat -e instructions:u` on the test binary directly: - -| revision | instructions/frame | median frame | the first paragraph's right edge | -| --- | --- | --- | --- | -| before #16 (`43ce8c7`) | 60.4M | 5.49 ms | **914.2 px, past the 900 px output** | -| #18 head (`b1b3eca`) | 133.4M | 11.8 ms | 881.4 px | +`tests/revision_cost.rs` is the load that does carry it: one hand-written tree +-- 40 rows of `Dir::RIGHT` span holding a 40px rect and a `Dir::DOWN` span of +one wrapping and one short non-wrapping text, each its own random words -- in +source that compiles unchanged on `43ce8c7` and on #18. Drop it into an old +worktree and run it there; take the number from `perf stat -e instructions:u` +on the test binary rather than the clock. It also prints where the layout put +the paragraphs, which is the other half of the comparison, and `text_memory` +in the same file reports what the tree holds. **The old code is faster because it skips the question, not because it answers it more cheaply.** Two shortcuts pay for the 2.2x, and both are wrong: @@ -182,8 +182,8 @@ it more cheaply.** Two shortcuts pay for the 2.2x, and both are wrong: rather than the part left for it, which its own comment records as a deliberate choice ("tempting to subtract the abs & rel from the ctx outer, but that would create inconsistent sizing"). In the fixture above that shapes - the paragraph at 900 rather than the 860 left beside the rect, and it draws - 14 px past the edge of the output. + a paragraph at 900 rather than the 860 left beside the rect, and it draws + 29 px past the edge of the output. So the amplification #18 pays is the cost of asking in the right box. That does not excuse the size of it, and most of it turns out not to be layout at all. @@ -320,38 +320,55 @@ it (`TextView::width`/`tex` and `TextBuffer::layout_key`) each hold exactly one entry and a trial width alternating with a final width evicts the answer that is about to be asked for again. -Two capacity changes, prototyped in `/home/bob/repos/iris-perf2` and measured -on the fair fixture above: +`e5f8b6b` keeps more than one. A bounded store of shapings on `TextData`, +keyed by the text, the attrs and the width, holds the parley layout and the +glyphs placed from it; `TextBuffer` holds the one it is drawn as, which is +where `TextView::tex` was, so there is one place to invalidate rather than +two. Bounding the store rather than each buffer is the whole point: a +per-buffer cache of eight cost +79% on a tree of 4,000 texts, and lazy +eviction cannot fix that, because the texts holding the memory are exactly the +ones a retained layout has stopped drawing. -| | instructions/frame | median frame | -| --- | --- | --- | -| #18 head | 133.4M | 11.8 ms | -| + `TextBuffer` keeps 8 shaped layouts | 30.8M | 3.09 ms | -| + `TextView` keeps 8 placed results | 18.2M | 1.85 ms | +On `tests/revision_cost.rs`, 500 resize frames, every paragraph its own random +words: -That is **3.3x fewer instructions than the pre-#16 code**, on the tree where the -pre-#16 code puts the paragraph 14 px outside the output. On the random tree at -depth 8 the same two changes take a resize from 55.1M to 18.7M instructions per -frame (5.28 to 1.80 ms) and a cold frame from 13.4 to 11.5 ms. Widget draws do -not move at all -- 449 either way -- so this removes work per draw and leaves -every retained-layout rule alone. +| | instructions/frame | median | p99 | max | the paragraph's right edge | +| --- | --- | --- | --- | --- | --- | +| before #16 (`43ce8c7`) | 56.1M | 4.99 ms | 5.71 ms | 6.47 ms | **929 px, past the 900 px output** | +| #18 head (`b1b3eca`) | 124.2M | 11.76 ms | 13.78 ms | 14.56 ms | 877 px | +| `e5f8b6b` | 17.7M | 1.76 ms | 2.23 ms | 5.39 ms | 877 px | -Neither is the frame-local answer cache seed 98 rejected. That one let a parent -lay out from a size the child's one drawing might not realize; these hold a -*drawing* keyed on the width that produced it, and the widget still draws. The -existing single-entry caches already make exactly this assumption; only their -capacity changes. Workspace tests and the ignored 100-seed sweep pass with both -in. Still to do before either lands: headless reference renders, an eviction -rule better than a FIFO cap (a transcript holds thousands of texts, and eight -layouts each is real memory -- entries older than the current frame are what -wants dropping), and a decision on whether this belongs in #18 or a slice of -its own. +**Two widths in turn flatters anything that remembers an answer**, so +`SWEEP=1` never repeats a width -- a drag rather than a toggle, and the case +nothing can hit across frames. There the same three are 56.2M, 123.6M and +45.9M instructions per frame: still 2.7x better than #18's head, and a little +better than the old code rather than three times better. Both numbers are the +honest ones, for different gestures. -The remaining costs after those are: +Memory, same rig at 2,000 rows: 108.3 MB on #18's head, 112.7 MB with the +store, and **132.0 MB before #16**, which grows 23 MB over the resizes where +neither of the others grows at all. The store is 128 entries for the whole +`TextData`; 32 is not enough under `SWEEP=1` and 64 is. + +Widget draws do not move at all -- 449 either way -- so this removes work per +draw and leaves every retained-layout rule alone. That matters for more than +purity: **text is the load that makes a redundant draw expensive, and so the +thing that shows when the layout is drawing more than it needs to.** The +counters keep saying so -- a text render is counted per ask rather than per +shaping, and glyph placements are counted separately -- so the store cannot +hide the amplification it is paying for. + +This is not the frame-local answer cache seed 98 rejected. That one let a +parent lay out from a size the child's one drawing might not realize; this +holds a *drawing* keyed on what produced it, and the widget still draws. The +five reference renders and the resize render are byte-identical, and the +100-seed sweep passes. + +The remaining costs after this are: - **Writing a glyph instance per glyph per draw.** With shaping and placement - memoized, `Painter::glyphs`, `Layers::write` and `InstanceList::push` are - most of what is left. A cold frame writes 48,050 instances for a tree that + kept, `Painter::glyphs`, `Layers::write` and `InstanceList::push` are most of + what is left. A cold frame writes 48,050 instances for a tree that holds 10,872. What reaches the GPU is 10,872, since a layer uploads whole and `set_instance` cancels a dirty mark when the bytes are unchanged -- so this is CPU cost only, but a one-leaf repaint still uploads 5,863 instances where the @@ -359,7 +376,7 @@ The remaining costs after those are: - **A container measures a child by drawing it in a box it will not keep.** The remaining-region trial removes many mismatches, but an unknown child's measured length can still make its final box differ, and nested containers - compound those redraws. The general form of the text caches is a widget + compound those redraws. The general form of the shaping store is a widget answering from a box it has already drawn in, which -- unlike a size answer -- is realized by definition; the cost is retaining more than one drawing's primitives, which is why text, where only the shaped layout has to be kept, diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index 6e42f6d..5df415c 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -4,6 +4,11 @@ A widget draws once and records its size on the `Painter`. Reading a child `DrawResult::size()` records a retained size dependency; drawing the child without reading that result does not make the parent's size depend on it. +§1 and §2 have landed in Iris (#16 and #18) and the notes below have been +brought to what shipped rather than what was proposed; §3 to §6 describe the +same design as it stands. `docs/IRIS_EXTRACTION_HANDOFF.md` has the invariants +the code now rests on and what is still to do. + ## Design ### UI ownership and frame access @@ -28,17 +33,15 @@ draw hooks so dispatch never has to scan every active widget. ```rust pub trait Widget: Any { - fn draw(&mut self, painter: &mut Painter); + fn draw(&mut self, painter: &mut Painter) -> Size; fn size_hint(&self, axis: Axis) -> Option { None } - fn is_size_independent(&self) -> bool { - false - } + fn on_resize(&self, axis: Axis) -> OnResize { OnResize::Redraw } } ``` -Every implementation calls `painter.set_size(size)` exactly once. A child +A widget returns what it used of the box it was given. A child draw returns a `DrawResult` that keeps the painter borrowed; calling `.size()` on that result reads the child's retained size and records that the current widget depends on it. Dropping the result without reading it draws the child @@ -76,11 +79,13 @@ rewriting the row whenever an ancestor moved and would restore the very O(subtree) work this design removes. Chain depth is bounded in both Rust and WGSL. -`Painter::set_child_offset` inserts a retained coordinate slot between a -container and its direct children. `LazySpan` uses one so visible row boxes -remain stable while scrolling changes a single shared translation. Ordinary -window-relative positions remain `rel + abs`; move slots carry translation -only, not general remapping. +`Painter::place` draws a child whose box its parent decides and may decide +again, and gives that child a slot of its own; `widget` and `widget_within` do +not, and share the nearest ancestor's. A slot carries a whole **box**, not a +translation: a pixel-space scale and offset would scale a child that has to +keep its pixel length, and the `rel`/`abs` pair is what distinguishes the two. +(That slots carry translation only was an agent's choice on 2026-09-04, never +asked for, and #18 replaced it.) `UiRenderState::resolved_region` performs the same chain walk on the CPU for hit-testing, accessibility, and public window-coordinate queries. Masks store @@ -120,19 +125,17 @@ dirty widgets first, with a changed returned size propagated one reader edge at a time. Re-reporting the current output size is a no-op. **(b) A widget's `available` (its parent's offered region) can change -without the widget's *content* changing — this is what -`is_size_independent` (§1) answers.** When a container's own layout shifts -(a sibling grew or shrank, changing this widget's offered box), a widget -that returns `true` from `is_size_independent` is not redrawn: its -primitives are unaffected by size, only by placement, so the parent -either (i) issues a move (§2) if only position changed, or (ii) rewrites -the primitive's `region` fields directly via `region_mut` if the box -changed shape too (still O(primitives owned directly by this widget, not -its subtree, since a size-independent widget by definition has no -size-dependent descendants worth distinguishing — in practice this is -always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns -`false` (the default) is redrawn in full whenever `available` changes, -which is correct always, just not free. +without the widget's *content* changing — this is what `Widget::on_resize` +answers, per axis.** When a container's own layout shifts (a sibling grew or +shrank, changing this widget's offered box), a widget that says `Scale` on the +axes that changed is not redrawn: everything it drew is a fraction of its own +slot's box, so writing that one box moves and stretches all of it. `Span`, +`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`; +`Scroll` and `MaxSize` read their box in pixels and cannot. `Redraw`, the +default, is correct always and free never. `Translate` — an unchanged drawing +placed somewhere else in a bigger box — is reserved: nothing reads it until a +widget can say where in that box its drawing belongs, which is the alignment +work. **Size invalidation travels upward before drawing; drawing itself travels only downward.** Every active widget retains the direct children whose size it read @@ -180,7 +183,7 @@ length is known, flexible space is allocated and `Painter::place` moves each retained child into its final box. A child is redrawn only when that box changes the size it was drawn for. -Hints are optional and affect cost, never correctness. `Sized` can report +Hints are optional and affect cost, never correctness. `SetSize` can report its declared axis without inspecting its child, which covers the important `.height(rest())` case. A debug assertion compares every hint with the eventual `draw` result. Widgets whose answer depends on shaping or on a @@ -201,11 +204,11 @@ buffer. That check is kept exactly as it is; it is the caching mechanism, and it already operates at (id, region) granularity, which subsumes "(id, available size)" once size *is* what a region change means. -`ActiveData::size` stores the value the widget recorded with -`Painter::set_size`. This is what a parent placing the widget for a second +`ActiveData::size` stores the value the widget's `draw` returned. +This is what a parent placing the widget for a second frame without redrawing it reads instead of recomputing — it replaces `Cache.size`'s role of "answer a size question without a full draw" with -"read the size of the last actual draw." `ActiveData::size_dependencies` +"read the size of the last actual draw." `ActiveData::size_deps` stores the direct children whose `DrawResult::size()` or known length the widget observed during that same draw; the next draw replaces the list, so a dependency disappears as soon as the widget stops reading it. Both fields