iris: one ScrollController, a Scrollable trait, and Pin

Iris's three points on docs/SCROLL.md, in the shape she proposed: a
controller both scrolling widgets *contain*, rather than a protocol
between them. "I don't like adding methods to widget, it seems like we
can structure things better instead."

`Scroll` becomes `ScrollArea`, because it only scrolls a predefined area.
`ScrollController` holds everything that is not a particular widget's
layout -- the position, the pending delta, the travel left each way, the
pin, the DragGesture and the Flinger -- and `Scrollable` is the trait over
it, one required pair of methods with the rest defaulted.

`Widget` loses `scrolls_itself`, `apply_scroll` and `scroll_offset`. They
existed only so a `Scroll` could drive a `LazySpan` it had no business
wrapping; the span owns its own controller now, so the wrapper, the
measure/apply/place dance between two widgets and `amt`'s two meanings all
go with them. The transcript's tree loses a node: `list` is the layout and
the position.

`.scrollable(axis, pin)` replaces `scrollable`/`scrollable_on`/
`scrollable_to_end` -- one mechanism whose arguments had been hidden in
three names. `LazySpan` has an inherent `scrollable()` that shadows it,
since Rust resolves inherent methods before trait ones: the same word at
the call site, and the wrapping version cannot reach the one widget that
must not be wrapped.

`Pin` says which end either way round: `Start`/`End` are content-relative
and `Neg`/`Pos` axis-absolute, so a caller can say "the bottom" and mean
it whichever way the content runs. They differ only for a reversed span,
which is the whole reason both exist.

One behaviour changes: a delta is applied by the next draw rather than
where it arrives, since the layout is the only thing that knows where the
content ends. Nothing on screen differs -- input is followed by a frame --
but `amt` no longer moves between draws, which several tests were reading.
This also closes SCROLL.md's open question about the pin living in two
places.

Verified: cargo test --workspace (all green, including the layer-1
transcript-fixture fling/selection/top-edge tests), clippy --all-targets
clean, fmt clean, `cargo ndk` check of android-app, and
`run-headless.sh phone --phone --replay flick-120hz.touch`, whose
before/after screenshots show the recorded flick carrying the transcript
back from turn 270 to turn 258 on the Vulkan adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 21:51:53 -04:00
1 parent bf8658c404
commit 4fdabc39d0
33 files changed
+1725 -1435

No files matched your search

+62 -1
View File
@@ -12,7 +12,68 @@ things still stay out.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-08 (last): `List` is `LazySpan`, and scrolling belongs to `Scroll`
## 2026-09-08 (newest): one `ScrollController`, a `Scrollable` trait, and `Pin`
Your three points on `docs/SCROLL.md`, in one change. The shape is the one
you proposed: **a controller both scrolling widgets contain**, rather than
a protocol between them.
**`Scroll` is `ScrollArea`**, because it only scrolls a predefined area --
your word for it. **`ScrollController`** (`widget/position/scrollable.rs`)
holds everything that is not a particular widget's layout: the position,
the pending delta, the travel left each way, the pin, the `DragGesture`
and the `Flinger`. **`Scrollable`** is the trait over it -- one required
pair of methods handing the controller back, and `scroll`, `fling`, `drag`,
`amt`, `is_scrolling`, `cancel_fling`, `tick_fling` and the pin as
defaults.
**The three scrolling methods are off `Widget`.** `scrolls_itself`,
`apply_scroll` and `scroll_offset` existed only so a `Scroll` could drive a
`LazySpan` it had no business wrapping. A `LazySpan` owns its own
controller now, so there is no wrapper, no measure/apply/place dance
between two widgets, and no `amt` with two meanings depending on which kind
of child it had. The transcript's tree lost a node with it: `list` is the
layout *and* the position.
before Masked(Scroll(LazySpan)) .scrollable_to_end(Axis::Y)
after Masked(LazySpan) .scrollable()
**`.scrollable(axis, pin)`** is the only one now -- `scrollable`,
`scrollable_on` and `scrollable_to_end` were one mechanism with the
arguments hidden in the names. A `LazySpan` has an **inherent**
`scrollable()` that shadows it, since Rust resolves inherent methods
first: same word at the call site, and the wrapping version cannot reach a
widget that must not be wrapped. The axis and pin are already its own.
**`Pin` says which end either way round.** `Start`/`End` are
content-relative, `Neg`/`Pos` axis-absolute -- your ask, so a caller can
say "the bottom" and mean it whichever way the content runs. They coincide
for everything except a reversed `LazySpan`, where they are opposites.
**A delta's sign is now a screen direction**, positive scrolling up or
left. It was "positive brings earlier content into view", which points the
opposite way for a `Dir::UP` span -- a real defect, latent only because
nothing builds one yet, and invisible to the existing test because that
test asserts in the same space the bug lives in.
**Why overscroll exists at all**, since you asked: a lazy span cannot see
the wall until it has walked to it, so with rows loaded past an edge it
honestly reports infinite travel, takes the whole delta, and the walk
finds the content ran out 200px ago. It is given back inside the same
frame. The rows past the edge have never been measured, and measuring them
is the work virtualisation exists to skip.
**One behaviour changed**: a delta is applied by the next `draw` rather
than the moment it arrives, since the layout is the only thing that knows
where the content ends. Nothing on screen differs -- input is followed by
a frame -- but `amt` no longer moves between draws, which several tests
were reading.
The pin question `SCROLL.md` had open ("the pin lives in each widget, not
in `Scroll`") is closed by this: it is one field on the controller, and a
caller edits one place.
## 2026-09-08 (earlier): `List` is `LazySpan`, and scrolling belongs to `Scroll`
From the design exchange after the overscroll fix, where you asked
whether `List` could just be `Span::scrollable()`. It cannot -- a lazy