iris: scrolling belongs to Scroll, and a LazySpan only lays out

Steps 2 and 3 of the plan in docs/IRIS_TODO.md, together because
deleting the fling before `Scroll` could drive it would leave the app
unable to scroll at all. IRIS.md has the account and the measurements.

`LazySpan` loses its `Flinger`, its `density`, its
`Arc<dyn RequestRedraw>` -- which had no business existing in a
single-threaded frame loop -- its `tick`, and the whole
`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity`
surface. `Scroll` was the only other `Flinger` user, so there is now one
implementation of the physics rather than two, and a transcript is
`list.scrollable_to_end()` like anything else.

Three new `Widget` methods carry the handoff:

    fn scrolls_itself(&self) -> bool { false }
    fn apply_scroll(&mut self, delta: &mut f32) {}
    fn scroll_offset(&self) -> f32 { 0.0 }

`Scroll` asks the first, and a child that says yes is handed deltas
instead of being slid about as a lump -- which a lazy layout cannot be,
since which rows exist at all is a function of where it is scrolled to,
and it has no content length to be clamped against. `scrolls_itself` is
`&self` deliberately: `Widgets::get_dyn_mut` marks a widget dirty, so
asking through `apply_scroll` would dirty every ordinary child on every
tick and cost exactly the O(1) move the scheme exists for.

`Scroll::draw` is measure, apply, place -- the idiom it already used for
its own content length. The measuring draw is free in the common case
(unchanged region, nothing dirty, `draw_inner` returns immediately and
the child's stored walls are still correct) and really walks exactly
when the content changed. Nothing is marked by hand: reaching the child
to hand it the delta is what dirties it, which is why `draw_again` could
stay deleted.

`scroll_offset` was not in the plan and is needed. A lazy span usually
cannot say where its content ends until it has walked there, so it takes
a delta in full whenever the wall is not already in view and the walk
gives part of it back; the remainder is exact only when the wall was
already visible, and `Scroll` adding remainders up would over-count by
every overshoot and never correct. It reads the child's accumulated
movement after the placing draw instead, so `amt` equals what is on
screen. `amt_counts_only_what_the_child_could_take` is the test.

One convention for a scroll delta, the finger's. `Scroll::scroll(+)`
moved toward the start while `LazySpan::scroll(+)` moved toward the end,
with the latter's doc claiming to mirror the former -- so every call site
had to know which it was talking to. `LazySpan::scroll` is private now
and the single negation is inside its `apply_scroll`; call sites that
passed `-dy`/`-v` pass them through, and `phone_screen.rs`'s recorded
velocity flips sign with its magnitude unchanged.
`a_negative_delta_moves_toward_the_end` pins the sign across the whole
handoff, since nothing else can catch a list scrolling backwards.

The transcript builds its `Scroll` by hand rather than through
`.scrollable_to_end()`: that helper registers a finger drag, and
`Selection` is already the arbiter for those frames -- two `DragGesture`s
seeing one gesture is what its own doc rules out. Caught by
`a_long_press_and_drag_selects_text`, which failed when both were live.

Deferred, in DECISIONS.md and IRIS_TODO.md: the *pin* is still each
widget's own. Applying one happens when a row is appended, between
frames with no painter in hand, so moving it to `Scroll` needs a fourth
`Widget` method or a parameter on `apply_scroll`; nothing external edits
a pin today.

Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace green (21 suites), the arm64 release APK builds,
and the phone-shaped headless window replaying flick-120hz.touch scrolls
back through the transcript in the direction it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 20:39:33 -04:00
1 parent 8e5928cc6a
commit b7474f61b0
19 files changed
+1016 -611

No files matched your search

+28
View File
@@ -5,6 +5,34 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-08 (last: scrolling moves out of the list)
Agreed with Iris in the exchange that followed, so most of this is her
call rather than mine. IRIS.md has the account. What I decided along the
way, and would flag for reversal:
- **A third `Widget` method, `scroll_offset`**, beyond the two we agreed.
`apply_scroll`'s remainder is exact only when the wall was already in
view, and a lazy span usually cannot see its wall until it has walked
there -- so `Scroll` reads the child's accumulated movement after the
placing draw instead of adding remainders up, which would drift.
- **One scroll-delta convention, the finger's.** The two widgets had
opposite ones under the same name; `LazySpan::scroll` is now private and
the single negation lives in its `apply_scroll`. Call sites that passed
`-dy`/`-v` pass them straight through, and one fixture's expected
velocity flipped sign with its magnitude unchanged.
- **The transcript builds its `Scroll` by hand rather than through
`.scrollable_to_end()`**, because that helper registers a finger drag
and `Selection` is already the arbiter for those frames -- two
`DragGesture`s seeing one gesture is what `DragGesture`'s own doc rules
out. The wheel is registered identically; only the drag differs.
- **DEFERRED: the pin stays in each widget.** Iris asked for `amt` and
the at-end control to live in `Scroll`; `amt` does, the pin does not,
because applying a pin happens when a row is appended -- between frames,
with no painter -- so moving it needs a fourth `Widget` method or a
parameter on `apply_scroll`. Nothing external edits a pin today.
docs/IRIS_TODO.md carries it.
## 2026-09-08 (later still: the list's overscroll clamp, in frame)
Finishes the item the previous entry deferred. IRIS.md has the account.