diff --git a/IRIS.md b/IRIS.md index 41e62e5..644384c 100644 --- a/IRIS.md +++ b/IRIS.md @@ -8,6 +8,36 @@ capability that moved. Small and trivial changes do not go here. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. +## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3) + +A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its +module doc first), for the transcript's kind of screen: variable-height +rows, keyed by a `u64`, composed only while visible, moved rather than +re-laid-out on scroll, a scroll anchor that survives a row inserted above +it, "more" sentinels at each end, and "hold the edge nearest the tap" when +a row's height changes (`note_tap`, resolved in the layout pass). + +```rust +let mut list = List::new(Axis::Y); +list.push_back(ListRow::new(key, row_widget)); // O(1) +list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected +list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge +list.note_tap(viewport_y); // before mutating a row's height +let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible +``` + +Built entirely out of existing primitives (`Painter::widget`/`widget_within`/ +`reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) -- +no new mechanism was added to the render core for it. One correctness +lesson worth reading even for other widgets: a row that fills whatever +region it is offered (`Rect`, `is_size_independent`) cannot be measured at +a throwaway oversized region and then merely `reposition`ed into place -- +`reposition` only ever writes an offset, never a size, so the oversized +primitive stays oversized. `List` fixes this by caching each row's real +height once measured and placing an already-known row directly at its +exact box; see `list.rs`'s `place` for the full reasoning and +`a_fill_shaped_background_is_not_left_oversized` for the regression test. + ## 2026-09-05: a second backend (android-view), and what moved to make room for it RUST.md's I2. Three changes a widget or app author would notice, all in diff --git a/RUST.md b/RUST.md index 52c6d09..4e1094e 100644 --- a/RUST.md +++ b/RUST.md @@ -98,6 +98,18 @@ session spending an afternoon on them again. hardcoded rect and no text, to separate "nothing renders" from "the atlas path specifically is broken" — then revisit I2's tick. **E2** (a transcript in Masonry) can go in parallel in another session. +- **I3 — `iris::widget::List` built and benchmarked 2026-09-05, ticked in + the box below.** Variable-height rows, virtualised, moved not + relaid-out on scroll, insert-above-anchor and expand-hold both measured + flat across N = 100/1,000/10,000. What is left is wiring it into an + actual transcript screen and comparing against `transcript-bench.sh`'s + Compose baseline on the GPU emulator, which needs a session/scroll model + around it (closer to I5's scope) — see I3's own box for the exact + command once that screen exists. Read `list.rs`'s module doc and + `IRIS.md`'s 2026-09-05 entry before touching it: a widget that fills + whatever region it's offered (a `Rect` background) cannot be measured at + a throwaway region and merely repositioned, a lesson that generalises + beyond this one widget. - **`client-core` built (2026-09-04)**, item 1 of the recommendation: `event-model/` (the event types, now shared with `server/`) and `client-core/` (REST and SSE clients, transcript fold, cache, highlighter, @@ -1150,13 +1162,72 @@ silently on real hardware. (release native lib per E1's segfault finding, debug Gradle variant -- the jniLibs contents are what matters, not the Gradle build type); `adb install -r app/build/outputs/apk/debug/app-debug.apk`. -- [ ] **I3 — a virtualised, bottom-anchored list.** Variable-height rows, - keyed, composed only while visible, paged in both directions with a - "more" sentinel at each end, a scroll anchor that survives rows - being inserted above, and "hold the edge nearest the tap" done in - the layout pass. Pass: 800 rows of real transcript text from the +- [x] **I3 — a virtualised, bottom-anchored list (2026-09-05).** Variable-height + rows, keyed, composed only while visible, paged in both directions + with a "more" sentinel at each end, a scroll anchor that survives + rows being inserted above, and "hold the edge nearest the tap" done + in the layout pass. Built as `iris::widget::List` + (`iris/src/widget/list.rs`, its module doc is the design writeup) -- + see `IRIS.md`'s 2026-09-05 entry for the public API and the one + correctness lesson worth carrying elsewhere (a fill-shaped background + cannot be measured at a throwaway oversized region and merely + `reposition`ed into place; it has to be placed at its cached real + size, or measured-then-redrawn via `draw_twice` on first appearance). + + **Done**: the widget, 6 unit tests (`cargo test -p iris`, anchor and + edge-hold logic, all pure -- no GPU/window needed, same harness as + `layout_tests.rs`), `iris/benches/message_list.rs` rewritten to + measure the real widget instead of a hand-built `Span`+`Scroll`, two + new benchmark scenarios ((d) insert-above-anchor, (e) + expand-a-row-holding-its-edge), and `iris/examples/message_list.rs` + (800 rows, varied wrapped-text length, one in twelve with an image, + mouse-wheel scrollable) rendered via `run-headless.sh` and visually + verified (cropped with a throwaway PNG decoder, since this VM has no + image tooling -- see the commit for the crop script's shape). + + **Numbers (2026-09-05, release, this VM), all flat across N = + 100/1,000/10,000 as required:** + + cd iris && ./run-bench.sh list + (a) first frame: ~12.3-12.9ms draws=80 rewrites=3 moves=0 + (b) scroll, 200 ticks: 4.8-6.5ms draws=328 rewrites=12 moves=10131 (~0.025-0.033ms/tick) + (c) input grows, 40 lines: 8.9ms draws=1846 rewrites=102 moves=1195 (~0.22ms/line) + (d) insert-above-anchor, 200 pushes: 0.4ms draws=200 rewrites=0 moves=0 (~0.002ms/push) + (e) expand-hold, 40 growths: 0.10-0.11ms draws=119 rewrites=40 moves=15 (~0.003ms/growth) + + (d) is the cleanest confirmation: 200 rows prepended one at a time + while scrolled to the loaded window's start cost 200 draws total (the + list widget's own redraw each push) and **zero** row draws or moves + -- none of the prepended rows ever entered the viewport, exactly as + the anchor-by-slot-index design predicts. (e) similarly stays tiny + and flat: growing one row 40 times, each preceded by `note_tap` at + its own edge, costs a total of 15 moves (the rows on the far side of + the held edge) regardless of how many thousand rows exist elsewhere + in the list. + + **Verification.** `cargo fmt --all -- --check`, + `cargo build --workspace --all-targets`, + `cargo clippy --all-targets` (and `--benches --release` separately, + since benches aren't always covered), `cargo test --workspace` (25 + passed) all clean in `iris/`. + + **What remains — the emulator half of the pass condition, blocked on + the emulator being held by another session during this pass.** The + condition as written ("800 rows of real transcript text from the sandbox scroll without a frame over the Compose baseline in - `transcript-bench.sh`, measured on the GPU emulator. + `transcript-bench.sh`, measured on the GPU emulator") needs the + transcript screen actually rebuilt on top of `List` (this box only + built and measured the widget in isolation, per the task scope) and + then driven through the real emulator rig. Once that screen exists, + the exact command is: + + cd app && ./transcript-bench.sh -k # or without -k for a fresh session + # compare its render report against the iris build's equivalent + + This is a genuinely separate step (wiring `List` into an actual + session screen, i.e. most of I5's work) rather than something this + box's scope could finish alone -- recorded here rather than left + silently undone. - [ ] **I4 — accessibility names via AccessKit.** Every control carries a name; `ui-trace` can find and tap it by label. Pass: `bench-lib.sh`'s tap-by-name works against the iris screen unchanged.