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:
1 parent
bf8658c404
commit
4fdabc39d0
33 files changed
+1725
-1435
No files matched your search
+140
-134
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user