iris: Scroll pans on a finger drag; a vertical drag in a focused field scrolls rather than selects

IRIS_TODO.md's "the composer has no touch-drag scroll". `Scroll::drag`
takes its pan from the same `sense::DragGesture` `List` is driven by --
arbitration, DRAG_SLOP, velocity and pointer capture all stay in sense.rs
and only what a committed pan *means* is decided per caller -- and
`WidgetLike::scrollable()` registers it beside the wheel handler it already
registered, so every scroll area pans on a finger with nothing added at the
call site. No fling: `Scroll` has no per-frame tick to animate one and the
areas it wraps are at most a screenful. `Scroll::amt()` exposes the pan
position.

`attr.rs`'s `on_press` treated an already-focused field as the plain
click_or_drag case, so every Pressing frame extended a selection. It now
applies the same DRAG_SLOP rule its unfocused branch already did: a press
past the slop vertically abandons its pending selection for the rest of the
gesture, so the scroll area around the field wins it. That is Android
EditText's own behaviour and it is what lets a swipe up over the composer
scroll instead of dragging a highlight through what you typed.

Also fixed, found doing it: `ActiveData::mask` stored the mask a widget
*set* rather than the one it was drawn *under*, and `redraw` feeds that
field back in as the inherited mask -- so a targeted redraw of any `Masked`
handed it its own mask and aborted on `set_mask`'s nested-mask assert. A
real abort on the emulator, `assertion failed: self.mask == MaskIdx::NONE`.

And the per-frame orphan guard from 76b1f99 is now a count comparison
(O(active widgets)); the O(primitives) walk only runs to build the failure
message, because running it per frame made a debug build on the emulator too
slow to finish a bench run at all.

Tests: four in scroll.rs (pan past the slop, a tap inside it, a horizontal
drag, the end clamp), `a_finger_drag_over_a_scroll_area_pans_it` in
sense_tests.rs driving the whole registration/dispatch/capture path (fails
with "got 0" without the new registration), and
`redrawing_a_masked_widget_does_not_nest_its_own_mask` in layout_tests.rs
(aborts on the pre-fix code).

The composer itself is deliberately still not `.scrollable()`: `Scroll`
measures against the window rather than its own offered box, so inside the
`MaxSize` capping it at six lines it pans the field out of the bar --
measured, reverted and written down in RUST.md and DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-06 16:45:56 -04:00
1 parent 76b1f99277
commit fb6b459c2c
13 files changed
+608 -31

No files matched your search

+84 -14
View File
@@ -226,20 +226,90 @@ closes it.
at the top of `update` that it is empty between frames. Her build
has both.
- [ ] **Stale primitives, the phone's half**the `Compacted:` row
drawn twice, overlapping, and once more below the composer
(`docs/bench/iris-phone-v2-2026-09-06.md`). Everything the box
above lists as ruled out is ruled out; what is left needs the
phone, and it is worth noting the row is drawn *below the
composer*, i.e. outside the list's own clip, which points at
`List`'s row placement rather than at `Span::draw`'s two-phase
placement the earlier passes assumed. The `iris surface:`/`iris
insets:` log lines added 2026-09-06 are in the build she is
holding, so her next `adb logcat | grep -i iris` says what the
frame it happens on was doing.
- [ ] **Composer touch-drag scroll** for overflowed text — now that
dragging is a default-input `DragGesture`, `Scroll` should get its
touch pan from the same mechanism `List` uses, not a copy.
- [x] **Stale primitives, the phone's half****root-caused and fixed
2026-09-06, commit `76b1f99`.** It was neither `Span::draw` nor
`List`: `UiRenderState::draw_inner` *read* `needs_redraw` without
consuming it, and used it to skip the whole `if let Some(active)`
block — **including the `remove(id, false)` that frees a redrawn
widget's previous primitives**. So a widget that was both already
active and marked dirty, and was reached by an **ancestor's** draw
rather than by `redraw_updates` picking it first (the order a
`HashSet` makes arbitrary, which is why it was intermittent), wrote
a second full set of primitives and then had `active.insert`
overwrite the only handles that could ever have freed the first
set. Those instances stay in the layer's buffer for the life of the
process, with a leaked move slot and leaked mask refs, redrawn every
frame at whatever region they last had — and `List` sets no mask, so
a row measured at `GENEROUS_PADDING` leaves its ghost outside the
list's own box, which is the copy below the composer.
`Painter::draw_twice` (`List::place`'s measurement pass) reaches
`draw_inner` twice for one id in one frame and so hits the same
fault with no ancestor involved.
**Fix**: consume the mark at the top of `draw_inner` — this call *is*
the redraw it asked for — and free the old primitives on the dirty
path too.
**Why the earlier passes could not see it**: `replacing_the_last_row_
many_times_does_not_leak_primitives` counts *widgets*, and the
orphan's owner is very much alive; it is an earlier set of that same
widget's primitives that is stranded.
**Guard**: `UiRenderState::orphaned_primitives()` names every live
instance no `ActiveData` owns, and `update` `debug_assert!`s it empty
every frame in debug builds. The per-frame form is a *count*
comparison (`primitive_counts_agree`, O(active widgets)); the
O(primitives) walk only runs to build the failure message, because
running it per frame made a debug build on the emulator too slow to
finish a bench run at all (260s timeout, no report).
**Test**: `an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy`
(`iris/src/widget/list.rs`), which fails on the pre-fix code with
`1 primitive(s) survived their own widget's redraw`.
**Emulator evidence, 2026-09-06** (this checkout's `ai-app-2` AVD,
`build-apk.sh debug --abi x86_64 --features "transcript-screen bench
force-gles"`, a **debug** build so the guard is live): a complete
`run-bench.sh` run — 3,142 frames over 147s across the fling, the
400-event stream (which is 400 `apply` calls including the fixture's
compaction event), the typing and the keyboard phases — with the
assert firing zero times and `logcat` showing no abort. That is the
whole of the phone's reported scenario exercised with the invariant
checked on every frame.
- [~] **Composer touch-drag scroll** for overflowed text — **the
mechanism is done, the composer is not.** `Scroll::drag`
(`iris/src/widget/position/scroll.rs`) takes its pan from the same
`sense::DragGesture` `List` is driven by, and
`WidgetLike::scrollable()` registers it beside the wheel handler, so
every `.scrollable()` in the codebase pans on a finger with nothing
added at the call site. No fling (`Scroll` has no per-frame tick and
the areas it wraps are at most a screenful) — see IRIS.md and
DECISIONS.md. A vertical drag inside a *focused* field no longer
extends a selection either (`attr.rs`'s `on_press` now applies the
same `DRAG_SLOP` rule its unfocused branch already did), which is
Android `EditText`'s own behaviour and what lets the scroll area
around a field win the gesture.
**Tests**: four in `scroll.rs` (pan past the slop, a tap inside it,
a horizontal drag, the end clamp) plus
`a_finger_drag_over_a_scroll_area_pans_it` in `sense_tests.rs`, which
drives the whole path — `scrollable()`'s registration, `run_sensors`'
dispatch, `Scroll::drag`, arbitration and pointer capture — and fails
with `got 0` if the registration is removed.
**What is left, with the measurement**: wrapping the composer's field
in `.scrollable().masked()` was tried and reverted the same day.
`Scroll` resolves `content_len`/`container_len` against
`Painter::output_size` — the whole window — so inside the `MaxSize`
that caps the composer at six lines the two are in different spaces
and the field pans itself entirely out of the bar: measured on the
emulator with 474 characters in it (`iris text render: ...
size=(1016.7, 623.7)` against a 441px cap) the bar collapsed to its
padding with no text in it. Making `Scroll` measure against its own
offered box is the next step, and it touches a widget the transcript
and the bench shell both use.
One real bug **was** found and fixed on the way (`ActiveData::mask`
stored the mask a widget *set* rather than the one it was drawn
*under*, and `redraw` feeds that field straight back in as the
inherited mask — so a targeted redraw of any `Masked` handed it its
own mask and aborted on `set_mask`'s nested-mask assert; that is a
real abort on the emulator, `assertion failed: self.mask ==
MaskIdx::NONE`, reproduced as
`redrawing_a_masked_widget_does_not_nest_its_own_mask` in
`layout_tests.rs`).
- [ ] **Streaming re-layout** (IRIS_TODO.md's last section) — after the
above, since they make the stream phase unrepresentative today.
- [x] **client-core prerequisites for P1, in parallel** (pure Rust,