IRIS_TODO.md's "Benchmarks" item: a message list of N wrapped-text rows (first-frame cost), scrolling it, and growing an input box above which the list must move rather than re-layout -- all as a plain, harness=false `cargo bench` binary (iris/benches/message_list.rs) since UiRenderState touches no GPU or window, chosen over criterion because every scenario here reduces to a count take_counters already answers exactly, and a new dependency wasn't worth it. Scroll (200 ticks) and the input-grow case (40 lines) are flat across N=100/1,000/10,000: LAYOUT.md's O(1) move chain holds. The many-images case (d) needs a real wgpu device, so it's a headless example (iris/examples/bench_images.rs) plus a new GpuTextures/UiRenderNode counter, take_image_bind_group_creates, mirroring take_counters. It found two real non-O(1) costs, recorded as new Fix items rather than redesigned: bind-group creation takes two frames to settle after a cold load instead of one, and appending a single image to an already-loaded 1,000-image list rebuilds all 1,000 existing bind groups (masks/move_offsets buffer growth triggers rebuild_image_bind_groups unconditionally). run-bench.sh wraps both. Numbers and commands are in IRIS_TODO.md. cargo fmt --all -- --check, cargo clippy --all-targets, and cargo test --workspace (19 passed) all clean; benches are not run by cargo test. Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
10 KiB
iris: known problems and things still to build
Iris's own list for the library, recorded 2026-09-04 in her words where it matters, so the agents working through RUST.md pick these up in a sensible order rather than rediscovering them. Each item says where it sits in the order and what "done" looks like. Tick and date them in place.
Fix
-
Input does not fall through by input type (2026-09-04).
SensorUi::run_sensors(src/default/sense.rs) used to set "consumed, stop checking lower layers" from mere hover — a widget registered for nothing butclick()blocked aScrollmeant for whatever was behind it, since "the cursor is over this widget" and "this widget handled the event" were the same check. Fixed by judging consumption per input kind: with no button transition and no scroll happening this frame ("momentary" activity), the topmost hovered widget still wins, same as before; when something momentary is happening, only a widget whose registered senses actually include a matching non-hover one (checked via a newTypeEventManager::registered, which lists what a widget registered without running anything) consumes it, so a widget with onlyHovering/click handlers can no longer block a scroll from reaching a list underneath.iris/src/sense_tests.rsbuilds a button-over-a-listStackwith a plainHasEventsimpl (no GPU or window) and checks both directions: a scroll over the button reaches the list, and a real click still reaches the button — confirmed to fail on the pre-fix code and pass after. -
Appending one image to an already-loaded list rebuilds every other image's bind group (2026-09-05). Found by the benchmark below, not designed against:
GpuTextures::update(core/src/render/texture.rs) triggersrebuild_image_bind_groups— a loop over every live standalone image, rebuilding itsBindGroup— whenever the sharedmasksormove_offsetsGPU buffer is resized (masks_resized || moves_resizedinUiRenderNode::update,core/src/render/mod.rs), and a widget getting its first move-offset slot (LAYOUT.md section 2 — every widget gets one on first draw) can be exactly what grows that buffer. So one new message with one new image, appended to a transcript that already has N images loaded, does not cost O(1): it costs onecreate_imagefor the new image plus onemake_image_bind_groupper existing image, because the new widget's own move slot pushed the arena past its capacity. Measured directly iniris/examples/bench_images.rs: appending a 1,001st image to 1,000 already-settled ones reports 1,001 bind-group creates for that one frame, not 1 (./run-bench.sh images, frame 5 in the transcript below). This is the same class of cost LAYOUT.md's move chain exists to avoid elsewhere in the codebase, just not yet closed off here — the fix is presumably to sizemasks/move_offsetswith headroom (the array texture already grows by doubling,grow_array, for the same reason) so an ordinary append does not cross a capacity boundary, or to stop tying the image bind group's contents to a buffer that changes on every new widget in the whole tree, image or not. Not designed further here per the "do not redesign, record it" instruction this benchmark was built under. -
Bind-group creation takes two frames to reach the steady state, not one (2026-09-05). Same benchmark: loading 1,000 images cold reports 1,000 creates on frame 1 (expected — this is
create_image, one per new image) and again 1,000 on frame 2, with nothing between the two frames marked dirty, before settling to 0 from frame 3. The second frame's 1,000 isrebuild_image_bind_groupsagain, for the same masks/move-offsets buffer-growth reason as the item above — the arena apparently does not finish growing to its steady size within the first frame the tree is drawn. Not chased further; recorded so whoever fixes the item above checks whether the fix also closes this one, since they look like the same root cause measured two different ways.
Build
-
Benchmarks, not unit tests, run on demand (2026-09-05; a
benches/or a script underiris/, never incargo test). The scenario that matters most is a message list — chat apps and this app's transcript alike — stressed with many messages and many images. One case in particular: resizing an input box (typing enough text to grow it) that pushes a long list of messages above it must stay very fast and recalculate almost nothing — a move of everything above, not a re-layout. That is exactly the O(1) move chain in LAYOUT.md; the benchmark is what proves it. Done when the numbers are in this file with the command, and the input-box case reports draws re-run, not just frame time.Built as two rigs, chosen per scenario by whether a real
wgpudevice is needed (UiRenderState/Widgetstouch no GPU or window, so most of this runs as an ordinary binary — the same propertylayout_tests.rsrelies on):iris/benches/message_list.rs— a plainInstant-timed binary ([[bench]] harness = falseiniris/Cargo.toml), not criterion: see the file's own header for why (short version — every scenario here reduces to a countUiRenderState::take_countersalready produces, which criterion's statistical machinery adds nothing to and which a new dependency is not worth pulling in for). Covers (a) first-frame cost of a message list of N wrapped-text rows (one in 20 also carrying a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost of scrolling that list, 200 ticks; (c) the input-box case — a fixed-height field at the bottom of the screen growing by a line 40 times, with the message list above it filling the rest of the screen. Run:cd iris && cargo bench --bench message_list(always release —cargo benchbuilds thebenchprofile, which is optimized).iris/examples/bench_images.rs— needs a real device, so it runs throughiris/run-headless.sh bench_images, printingUiRenderNode::take_image_bind_group_creates()(a new counter, added incore/src/render/texture.rsandcore/src/render/mod.rs, mirroringUiRenderState::take_counters) each frame. Covers (d): 1,000 image rows, checked both cold (does bind-group creation reach zero once loaded) and after appending one more image once settled (does that stay cheap) — the second question is what actually matters for a live transcript and is what turned up the two Fix items above.iris/run-bench.sh [list|images]runs either or both and is what to run before/after touchingScroll,Span,Sized, the move-offset chain, orGpuTextures.
Numbers (2026-09-05, release,
cargo bench/run-headless.sh, this VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):cd iris && cargo bench --bench message_list (a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0 (a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0 (a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0 (b) scroll, N=100/1000/10000, 200 ticks each: draws=200 rewrites=0 moves=200 (identical at every N) per-tick average: 0.0002ms (identical at every N) (c) input grows 40 lines, N=100/1000/10000 rows above it: draws=320 rewrites=40 moves=160 (identical at every N) per-line average: 0.0012-0.0013ms (identical at every N) cd iris && ./run-bench.sh images frame=1 bind_group_creates=1000 (cold load) frame=2 bind_group_creates=1000 (see Fix item above) frame=3 bind_group_creates=0 frame=4 bind_group_creates=0 (append one image here) frame=5 bind_group_creates=1001 (see Fix item above) frame=6 bind_group_creates=0Reading it: (a) is real, necessary work — shaping and laying out N never-before-seen text rows — and scales with N as it must, ~10x cost per 10x N. (b) and (c) are the pass conditions that matter: both are exactly flat across N = 100 to 10,000, confirming LAYOUT.md's O(1) move chain holds for both scrolling and for a growing input box pushing the message list — draws/moves per tick or per line do not grow with list size, and the per-operation cost (a fraction of a microsecond) is nowhere near a frame budget. (d)'s cold-load and steady-state halves behave as designed; its append half did not, which is the two Fix items above.
-
Masks defined relative to each other. Wanted: mask A multiplies by something and also applies mask B — a mask can reference a parent mask, the way the move chain references a parent offset. Today masks are independent regions. Design it beside the move chain (same shape: a parent index and a bounded walk in the shader); do it when a real widget needs it, not before.
-
Positions as a single float per scroll. Iris raised, and half rejected, letting a scroll update one float rather than positions: input handling cares about most elements in a list, so absolute positions must be computed on the CPU anyway. LAYOUT.md's design already lands here (GPU walks the chain, CPU resolves on demand for hit tests). Keep the CPU resolution lazy and per query; do not materialise every row's absolute position per frame.
-
Animations, last. Cosmetic, so after everything above. Must be modular — a piece of the library rather than a core part forced into everything, the same way input is. Whatever the mechanism, a widget that does not animate must pay nothing and import nothing for it.
Reconsider
WidgetView. Iris is unsure of it: what she wants is an easy way to compose a widget from others (a button is the main case). With sizing folded intodraw, composing may be easy enough thatViewis redundant. Decide after the layout change lands, by writing a button both ways and keeping the one that is shorter to explain; delete the other rather than keeping two ways.