Files
ai-app/docs/IRIS_TODO.md
T

255 lines
15 KiB
Markdown

# 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
- [x] **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 but `click()` blocked a `Scroll` meant 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 new `TypeEventManager::registered`, which lists what a widget
registered without running anything) consumes it, so a widget with only
`Hovering`/click handlers can no longer block a scroll from reaching a
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
`Stack` with a plain `HasEvents` impl (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.
- [x] **Appending one image to an already-loaded list rebuilds every other
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
triggered `rebuild_image_bind_groups` — a loop over *every live
standalone image*, rebuilding its `BindGroup` — whenever the shared
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
moves_resized` in `UiRenderNode::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) could be exactly what grows that
buffer. So one new message with one new image, appended to a transcript
that already has N images loaded, did not cost O(1): it cost one
`create_image` for the new image plus one `make_image_bind_group` per
*existing* image, because the new widget's own move slot pushed the
arena past its capacity. Measured directly in
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
already-settled ones reported **1,001** bind-group creates for that one
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
bind group (group 2) in the first place — the group also holds that
image's own texture view, which is the only thing that is genuinely
per-image, so a buffer shared by *everything* forced a rebuild of
*every* group the moment it moved. Gave masks/move_offsets their own
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
once per draw call, instead of duplicating them into every per-image
group. `GpuTextures` and its image bind groups now know nothing about
either buffer — `rebuild_image_bind_groups` is called only from
`grow_array` (the atlas array texture growing, which genuinely does
change what every image's own bind group must reference) — so a
masks/move_offsets resize now touches exactly one bind group, ever,
regardless of how many images are live. Numbers after the fix, same
benchmark and command:
./run-bench.sh images
frame=1 bind_group_creates=1000 (cold load, unchanged)
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1 (was 1001)
frame=6 bind_group_creates=0
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
confirming the bind-group restructuring changed nothing about what is
drawn.
- [x] **Bind-group creation takes two frames to reach the steady state, not
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
loading 1,000 images cold used to report 1,000 creates on frame 1
(expected — `create_image`, one per new image) *and again* 1,000 on
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
firing a second time for the same masks/move-offsets buffer-growth
reason as the item above, confirming the guess recorded here — the two
were exactly the same root cause measured two different ways. Frame 2
now reports 0 (see the numbers above); not a separate fix.
## Build
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
`benches/` or a script under `iris/`, never in `cargo 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 `wgpu`
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
most of this runs as an ordinary binary — the same property
`layout_tests.rs` relies on):
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
the file's own header for why (short version — every scenario here
reduces to a *count* `UiRenderState::take_counters` already 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 bench` builds the `bench` profile, which is optimized).
- `iris/examples/bench_images.rs` — needs a real device, so it runs
through `iris/run-headless.sh bench_images`, printing
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
mirroring `UiRenderState::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 touching `Scroll`, `Span`, `Sized`, the move-offset
chain, or `GpuTextures`.
**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 (2026-09-05, before the fix)
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=0
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
frame=2 bind_group_creates=0
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
frame=6 bind_group_creates=0
**Reading 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, until the fix above moved
masks/move_offsets out of the per-image bind group — now flat at O(1)
the same way (b) and (c) are.
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
left, each recorded at the point in the code it would go rather than
silently dropped. See RUST.md's I5 box for the full account of what
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
composer).**
- [ ] **Android integration for this screen does not exist yet.** No
cdylib/Gradle shell the way `iris-android-app` wraps `tabs-ui` (I2),
so `transcript-bench.sh`'s render-number pass condition against the
Compose baseline cannot be run. Needs: real `client-core::ApiClient`/
`event_stream::follow_session_events` wiring against
`app/ui-sandbox.sh --delay` (this crate deliberately fetches nothing
itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle
module, then the bench script pointed at it.
- [x] **Touch-drag panning over a row's own rendered text — done,
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
registration won `core/src/sense.rs::run_sensors`'s per-layer
arbitration on every frame it was pressed, not just the frame the
press started, so a list pan gesture registered on `List` itself never
got a turn while a row was under the finger. Fixed with
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
machine per list deciding pan vs. select the way Android does (a
vertical drag pans immediately; a stationary press held `LONG_PRESS`
(500ms) starts a selection which further drag extends; a horizontal
drag while something is already selected extends immediately) —
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
every row's drag now routes through. 8 new unit tests
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
clean; `run-headless.sh` screenshot byte-identical to before the
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
- [ ] **Row-level accessibility names.** The composer carries
`.label("Message")`; transcript rows do not carry a `.label()` of
their own yet, so `Widgets::named()` (I4) does not include them —
`row.rs`'s `build_text_row` is where one would go, keyed to something
stable per row (its sender + a short excerpt, matching what a screen
reader announcing a chat message would say).
- [ ] **A tappable link and a background chip behind inline code.**
Both need per-range glyph geometry that `TextEditCtx` does not expose
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
private) — see `markdown.rs`'s module doc for the exact shape the fix
would take (the same primitive `TextEdit::draw`'s own selection
highlight already uses internally,
`iris/src/widget/text/edit.rs:99`).
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
is selected in full (`select_all`) the moment the drag leaves it,
rather than "from the click point to whichever edge points away from
the drag" — needs the same private `layout()` access as the item
above. `selection.rs`'s module doc has the exact reasoning.
- [ ] **No syntax highlighting inside a fenced code block.**
`client_core::highlight` exists (built for the file explorer) and
could feed per-token `SpanStyle`s into a code block's span; wiring it
in was not attempted this pass.
- [ ] **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 into `draw`, composing may be easy enough that `View` is
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.