A row's own click_or_drag() selection handler always won the same gesture a list-level pan wanted, since run_sensors gives the inner layer first refusal every frame it's pressed. DragArbiter (iris/src/sense.rs) decides pan vs. select the way Android does: vertical drag pans immediately, a held stationary press starts a selection after LONG_PRESS, and a horizontal drag on already-selected text extends immediately. transcript-ui's Selection::drag routes every row's drag through one arbiter per list, driving List::scroll for a pan instead of a second scroll mechanism. 8 new unit tests (iris::sense::drag_arbiter_tests); cargo fmt/clippy/test --workspace and cargo ndk (iris, transcript-ui) all clean; run-headless.sh screenshot byte-identical to before the change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
255 lines
15 KiB
Markdown
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.
|