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:
1 parent
76b1f99277
commit
fb6b459c2c
13 files changed
+608
-31
No files matched your search
+84
-14
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user