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

+140 -134
View File
@@ -4,22 +4,38 @@ How anything in iris scrolls, as of 2026-09-08. This is the current
design, not a history — `docs/IRIS.md`'s dated entries have the account of
how it got here, and `docs/IRIS_TODO.md` has what is still open.
Read this before touching `iris/src/widget/position/scroll.rs`,
`iris/src/widget/position/lazy_span.rs`, or anything that pans, flings or
lays out a long list.
Read this before touching `iris/src/widget/position/scrollable.rs`,
`scroll_area.rs`, `lazy_span.rs`, or anything that pans, flings or lays
out a long list.
## The one rule
**`Scroll` owns the position, the gesture and the fling. Nothing else
does.** A widget inside it either gets moved by it or is handed deltas to
apply itself, and either way the scroll state lives in the `Scroll`. There
is exactly one `Flinger` in the crate's widgets and one `DragGesture`
implementation, both in `iris/src/sense.rs`.
**Everything a scroll position is made of lives in one `ScrollController`,
and the widget that scrolls owns one.** The position, the pending delta,
the travel left, the pin, the `DragGesture` and the `Flinger` are all in
that struct (`scrollable.rs`); there is exactly one `Flinger` and one
`DragGesture` implementation in the crate's widgets. A widget with one
implements `Scrollable`, whose one required pair of methods hands the
controller back, and gets `scroll`, `fling`, `drag`, `amt`,
`is_scrolling`, `tick_fling` and the pin as default methods.
So: `.scrollable_on(axis)` / `.scrollable_to_end(axis)` is how anything
becomes scrollable, including a `LazySpan`. Do not give a widget its own
fling, its own scroll amount, or a `RequestRedraw` handle — that is what
was just removed.
Two widgets have one, and they differ only in how they spend a delta:
- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, measured whole and
then slid about as a lump, which is what makes a scroll tick an O(1)
move of one subtree. `.scrollable(axis, pin)` wraps anything in one.
- **`LazySpan`** (`lazy_span.rs`) — lays its own rows out from an anchor,
so it cannot be a lump and is not wrapped in anything. Its own
`.scrollable()` registers the same two senses against the controller it
already has.
Do not give a widget its own fling, its own scroll amount, or a
`RequestRedraw` handle. And do not add a scrolling method to the `Widget`
trait: the three that used to be there (`scrolls_itself`, `apply_scroll`,
`scroll_offset`) existed only so a `Scroll` could drive a `LazySpan` it
had no business wrapping, and they are gone (Iris, 2026-09-08: "I don't
like adding methods to widget, it seems like we can structure things
better instead").
## One convention for a delta
@@ -49,101 +65,84 @@ way_on_screen` checks the two `dir`s against **where rows were drawn** —
an assertion written in the walk's own space passes with the flip
deleted, because it checks the bookkeeping against itself.
## The `Widget` handoff
## The contract between a controller and its owner
Three default methods on `Widget` (`iris/core/src/widget/mod.rs`):
Two calls, both inside the owner's `draw`, because a `draw` is the only
place that knows where the content ends:
```rust
fn scrolls_itself(&self) -> bool { false }
fn apply_scroll(&mut self, delta: &mut f32) {}
fn scroll_offset(&self) -> f32 { 0.0 }
```
1. **`take_delta()`** — everything a wheel, a drag or a fling asked for
since the last layout, in one number, already clamped to the travel
that layout reported. Clipping it stops a fling.
2. **`set_travel(Travel)`** at the end, plus whichever of **`moved_by`**
(movement) or **`set_amt`** (an absolute position) fits how that owner
knows where it ended up.
- **`scrolls_itself`** — "I position my own content; hand me deltas rather
than sliding me about." Default `false`: an ordinary child is a lump its
parent moves, which is what makes a scroll tick an O(1) move of one
subtree instead of a redraw.
- **`apply_scroll`** — take as much of `delta` as you can actually move,
leave the rest. What comes back short is how the parent learns the
content ran out.
- **`scroll_offset`** — accumulated content movement, so the parent can
keep an honest account. See "why the remainder is not enough" below.
`Travel` is `{ back, fwd }` in the same screen-space units as a delta:
`back` bounds a positive one, `fwd` a negative one, and `f32::INFINITY`
means "the end is not in sight". That last is not a placeholder — a lazy
layout genuinely cannot say how far its content runs without walking
there, and `clamp` takes the answer with no branch of its own.
**`scrolls_itself` and `scroll_offset` are `&self` on purpose.** Reaching a
widget through `Widgets::get_dyn_mut` *marks it dirty*
(`iris/core/src/widget/widgets.rs`). Asking either question through a
`&mut` path would dirty every ordinary child on every scroll tick and cost
exactly the O(1) move the scheme exists for. `Painter::scrolls_itself` and
`Painter::scroll_offset` go through `get_dyn`; only
`Painter::apply_scroll` takes `&mut`.
**Why the delta is banked rather than applied where it arrives.** A wheel
event, a drag frame and a fling tick all land between draws, and none of
them can know whether there is content to move into. Applying them at the
layout that follows is also what keeps layout a pure function of the state
(Iris, 2026-09-08). The visible consequence, and the thing that catches a
test out: **`amt` does not move until the next draw.**
### Why the remainder is not enough on its own
### Why a remainder was not enough
`apply_scroll` leaving a remainder was meant to be the whole story. It is
not, because **a lazy layout usually cannot say where its content ends
until it has walked there.** With the wall out of view it takes the delta
in full, and the walk that follows gives part of it back. So the remainder
is exact only when the wall was already visible, and a parent adding
remainders up would over-count by every overshoot and never correct.
The `apply_scroll(&mut delta)` this replaced left the part it could not
take in the caller's variable, and that was meant to be the whole story.
It is not, because **a lazy layout usually cannot say where its content
ends until it has walked there.** With the wall out of view it takes the
delta in full, and the walk that follows gives part of it back. So the
remainder is exact only when the wall was already visible, and a parent
adding remainders up would over-count by every overshoot and never
correct. Now the owner reports what it *did* (`moved_by`, from the one
place its anchor moves) as well as what it *can* do, and
`amt_counts_only_what_the_child_could_take` is the test.
`Scroll` therefore reads `scroll_offset` *after* the placing draw and sets
`amt` from it. `amt_counts_only_what_the_child_could_take` is the test;
it fails if you try to go back to remainders alone.
## What `amt` means
## `Scroll::draw` — two paths
The same direction for both owners, and a different origin:
`Scroll` reads `scrolls_itself` every draw and branches once, on the
capability rather than on any concrete type.
**Ordinary child (`draw_moved_child`)** — unchanged from before: offer the
child a box as long as last frame's content length to measure it, apply
the end-pin and the clamp against the measured length, then place it at
the length just measured. Two draws, the second free unless the content
changed. `amt` is a distance from the start of the content.
**Self-positioning child (`draw_self_scrolling_child`)** — measure, apply,
place:
1. `painter.widget_within(child, UiRegion::FULL)` — the measuring draw.
2. `painter.apply_scroll(child, &mut delta)` — the child takes what it can.
3. `painter.widget_within(child, UiRegion::FULL)` — the placing draw.
4. `amt = -painter.scroll_offset(child)`.
Two properties make this work, and both are easy to break:
- **The measuring draw is free in the common case.** It offers the same
box as last frame, so with nothing dirty `draw_inner` returns
immediately and the child's stored walls from its last walk are still
correct — because nothing changed. When the content *did* change the
child is dirty, really walks, and the walls are fresh, which is exactly
when they need to be.
- **Nothing is marked dirty by hand.** Reaching the child through
`get_dyn_mut` in step 2 is itself what dirties it, so step 3 really
draws rather than taking `draw_inner`'s unchanged-region skip. This is
why there is no `Painter::draw_again` and why one should not come back:
a mechanism for "give me a corrective frame later" is the thing this
shape replaces.
### What `amt` means
- Ordinary child: distance from the start of the content, clamped into the
- `ScrollArea`: distance from the start of the content, clamped into the
scroll range. An absolute position.
- Self-positioning child: **movement, not position.** Paging rows in above
moves the origin and the child cannot say by how much, never having
measured them. The direction is the same as an ordinary child's; the
absolute value is not comparable between the two.
- `LazySpan`: **movement, not position.** Paging rows in above moves the
origin and the span cannot say by how much, never having measured them.
A scrollbar needs a real content length before it can use either, and a
lazy child has none. Do not invent one.
lazy span has none. Do not invent one.
## `ScrollArea::draw` — measure, then place
1. `take_delta`, and move to where it asks.
2. Draw the child in a box as long as **last frame's** length, to measure
it. This is free in the common case: the same region as last frame
means `draw_inner` returns immediately.
3. Apply the pin and clamp against the length just measured.
4. Draw the child again, at that length and position.
Only the second draw decides anything, and a frame on which the content
did change pays one real extra draw — a frame on which it was being
redrawn anyway. Placing against the hint and letting the next frame fix it
is what hung the composer's text half a line outside its box on Iris's
phone: **layout is a pure function of the state, not of how many frames
have been drawn**, and there may be no next frame.
The pin only re-pins on a frame with **no delta of its own**: the pin
means "stay flush with the end as the content grows", and a reader who has
just scrolled away has said otherwise.
## `LazySpan`
`iris/src/widget/position/lazy_span.rs`. A virtualised sequence of
variable-height rows, laid out from an anchor. It is what `Span` is, done
lazily. It **does not scroll** — it lays out and answers honestly about
how far it can go.
lazily, and it drives its own controller: the walk is the only thing that
can say how far it may go, so nothing above it is in a position to.
### Why it is not `Span::scrollable()`
### Why it is not a `Span` inside a `ScrollArea`
Measured 2026-09-08, and worth not re-deriving:
@@ -152,27 +151,35 @@ Measured 2026-09-08, and worth not re-deriving:
phase 1 offers each child the ambient region to learn its length,
phase 2 offers it its real share. So any mutation of a `Span` redraws
all of it — 24 draws for 11 children after one prepend.
- A `Scroll`'s efficiency and virtualisation pull opposite ways: a scroll
tick offers a same-size moved region, `draw_inner` takes the `mov` path,
and the child's `draw` never runs. A virtualising child inside a plain
`Scroll` would never update which rows it shows. That is what
`scrolls_itself` resolves.
- A lazy child cannot report a content length, so `Scroll`'s clamp,
end-pin and any future scrollbar have nothing to work against. Walls
are *discovered* by the walk instead.
- A `ScrollArea`'s efficiency and virtualisation pull opposite ways: a
scroll tick offers a same-size moved region, `draw_inner` takes the
`mov` path, and the child's `draw` never runs. A virtualising child
inside one would never update which rows it shows. That is why a
`LazySpan` owns its controller instead of being wrapped in one.
- A lazy child cannot report a content length, so an area's clamp,
end-pin and any future scrollbar would have nothing to work against.
Walls are *discovered* by the walk instead.
### Direction and pin are separate questions
`LazySpan::new(dir, at_end)`.
`LazySpan::new(dir, pin)`, and `ScrollArea::new(inner, axis, pin)`.
- **`dir`** means what it means in `Span`: which end of the box item 0
sits at, and which way the sequence grows.
- **`at_end`** is the pin: which end the view clings to as rows arrive.
- **`pin`** is which end the view clings to as rows arrive.
A transcript is `Dir::DOWN` (oldest message is item 0, at the top) with
the pin at the end (the view sits at the bottom). Conflating the two would
stand it on its head. `Scroll::new`'s third argument is the same flag for
the ordinary case.
`Pin::End` (the view sits at the bottom). Conflating the two would stand
it on its head.
**`Pin` says it either way round**, because there are two questions and
they are not the same one (Iris, 2026-09-08). `Start`/`End` are
content-relative — the first row or the newest one, wherever the layout
puts it — and `Neg`/`Pos` are axis-absolute: the top/left edge and the
bottom/right one, whichever end of the content is there. They coincide for
everything except a reversed `LazySpan`, where they are exact opposites,
which is the whole reason both exist. The one question a scrollable acts
on is `pinned_to_end`, and `dir` is what resolves a `Pin` into it.
### Two coordinate spaces, two conversion points
@@ -211,13 +218,22 @@ framework:
Virtualisation *means* traversing rows without drawing them, and a size
you can only get by drawing is no use for deciding not to draw.
### Overscroll
### Overscroll, and why it happens at all
`apply_scroll` only takes what the walk says is there, so **scrolling
cannot enter overscroll.** What it cannot prevent is the content or the
viewport changing under a settled anchor, and for that `overscroll_gap`
measures the gap from the ends the walk already placed and `draw` moves
the anchor and walks a **second time inside the same frame**.
**Because the span cannot see the wall until it has walked to it.** With
rows loaded past an edge it reports `INFINITY` of travel that way, takes
the whole delta, and the walk that follows discovers the content ran out
200px ago. Nothing else could be reported: the rows past the edge have
never been measured, and measuring them is exactly the work
virtualisation exists to skip. The other source is the content or the
viewport changing under a settled anchor — a row that grew, a page
dropped, the keyboard opening — where nothing scrolled at all.
So `overscroll_gap` measures the gap from the ends the walk already
placed, and `draw` moves the anchor by it and walks a **second time
inside the same frame**. `moved_by` counts that correction along with the
move that caused it, which is why `amt` stays equal to what is on screen
rather than drifting by every overshoot.
Layout is a pure function of the state, not of how many frames have been
drawn (Iris, 2026-09-08). A correction that lands next frame is a frame
@@ -228,20 +244,21 @@ asking for one.
`iris/transcript-ui/src/lib.rs`, `build_tree`.
The transcript builds its `Scroll` **by hand rather than through
`.scrollable_to_end()`**, and this is not an oversight. That helper
registers a finger drag driving `Scroll`'s own `DragGesture`, and the
The transcript registers the wheel **by hand rather than calling
`LazySpan::scrollable()`**, and this is not an oversight. That helper also
registers a finger drag driving the span's own `DragGesture`, and the
transcript already has an arbiter — `Selection`, which must decide between
panning and selecting text and so cannot let a second `DragGesture` see
the same frames. `DragGesture`'s doc states the rule: one gesture, one
arbiter, each frame delivered exactly once. The wheel handler registered
here is identical to the helper's; only the drag differs.
`Selection` is given the scroll area by `set_scroll_area` after the
`Scroll` exists (rows need a `Selection`, and the `Scroll` needs the
rows), and hands it committed pans and releases. `TranscriptScreen` exposes
both `list` (layout, `extent`/`key_at`/`jump_to_end`) and `scroll`
(position, fling, `amt`).
`Selection` is given the span by `set_scroll_area` after it exists (rows
need a `Selection`, and the span needs the rows), and hands it committed
pans and releases through `Scrollable::scroll`/`fling`. There is no
wrapper widget: `TranscriptScreen::list` is the layout (`extent`,
`key_at`, `jump_to_end`) *and* the position (`amt`, `fling`,
`is_scrolling`).
A `Selection` with no scroll area still selects and still reports taps but
cannot pan; there is a `debug_assert` in `drag` naming that.
@@ -259,33 +276,22 @@ cannot pan; there is a `debug_assert` in `drag` naming that.
write is moving the content as a unit, which needs a content length —
which a lazy layout cannot supply.
## Still open
**The pin lives in each widget, not in `Scroll`.** Iris asked for `amt`
and the at-end control both to live in `Scroll` so a caller always edits
the `Scroll`. `amt` does; the pin does not, because applying a pin happens
when a row is appended — between frames, with no painter in hand — so it
cannot arrive through `apply_scroll` as it stands. Moving it needs either
a fourth `Widget` method or a parameter on `apply_scroll`
(`apply_scroll(&mut self, delta: &mut f32, pinned_to_end: bool)` reads
best: one method, and the signature says "here is the state you need from
me"). 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. **Ask Iris which she
wants before building it.**
## Tests that pin the behaviour
In `lazy_span.rs`, all of these fail if the corresponding piece is undone:
- `a_negative_delta_moves_toward_the_end` — the sign, end to end.
- `amt_counts_only_what_the_child_could_take``scroll_offset`'s reason
for existing.
- `a_delta_moves_both_directions_the_same_way_on_screen` — the sign is a
screen direction, checked against where rows were *drawn*.
- `amt_counts_only_what_the_child_could_take` — why the owner reports what
it did rather than the caller adding up what it asked for.
- `a_fling_stops_at_the_first_row`,
`scrolling_past_the_start_lands_on_it_in_the_same_frame` — the walls,
with no settling frame drawn on purpose.
- `a_dir_up_span_grows_upward_from_item_zero`,
`a_reversed_span_hit_tests_in_screen_space` — the two conversions.
`a_reversed_span_hit_tests_in_screen_space` — the position conversions
(`flip_pos`), as `a_delta_moves_both_directions_the_same_way_on_screen`
is the delta one (`flip_delta`).
- `a_registered_fling_is_driven_by_tick_animations_and_then_unregisters`
a fling that nothing registers never moves, whatever its velocity.