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

+39 -1
View File
@@ -52,7 +52,45 @@ order and what "done" looks like. Tick and date them in place.
honest for every current use and must be written at the field so
nobody builds a scrollbar on it.
Step 1 is done. Steps 2 and 3 are not.
Done 2026-09-08, in two commits (the rename, then steps 2 and 3
together -- deleting the fling before `Scroll` could drive it would
have left the app unable to scroll at all).
**Two things the plan did not anticipate, both settled in the code:**
- **`apply_scroll`'s remainder is not enough on its own, so `Widget`
gained a third method, `scroll_offset`.** 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
that follows gives part of it back. The remainder is therefore right
only when the wall was already visible, and `Scroll` adding
remainders up would over-count by every overshoot and never correct.
`scroll_offset` is the child's accumulated movement, read `&self`
after the placing draw, and `Scroll::amt` is set from it -- so `amt`
equals what is on screen rather than what was asked for. There is a
test, `amt_counts_only_what_the_child_could_take`.
- **There were two opposite scroll-delta conventions**, and the
handoff made keeping both impossible. `Scroll::scroll(+)` moved
toward the *start* while `LazySpan::scroll(+)` moved toward the
*end*, and `LazySpan::scroll`'s own doc claimed to mirror `Scroll`'s.
There is one now -- the finger's, which is `Scroll`'s -- and
`LazySpan::scroll` is private, with the single negation inside
`apply_scroll`. Call sites that used to pass `-dy`/`-v` pass them
through, and the fixture recordings' expected velocity flipped sign
with its magnitude unchanged.
**Still open, and the one thing to decide:** the *pin* ("stay at the
end as rows are appended") is still each widget's own -- `Scroll` has
`snap_end` for an ordinary child, `LazySpan` has one for itself, and
the constructor argument sets each. Iris asked for `amt` and "other
controls (iirc only at end for now)" to live in `Scroll` so a caller
always edits the `Scroll`; that half is done for `amt` and not for the
pin, because a pin has to be *applied* when a row is appended --
between frames, with no painter in hand -- so moving it needs either a
fourth `Widget` method or a parameter on `apply_scroll`. Nothing
external edits a pin today (the transcript sets it once at
construction and calls `jump_to_end` on the span for the rest), so
this is a design question rather than a missing capability.
- [x] **`List::clamp_to_content` still corrects on the next frame