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.
+105
View File
@@ -12,6 +12,111 @@ 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`
From the design exchange after the overscroll fix, where you asked
whether `List` could just be `Span::scrollable()`. It cannot -- a lazy
layout is a real thing a `Span` is not, for reasons measured below -- but
almost everything you named as out of place was, and it has all moved.
**`List` -> `LazySpan`** (`ListRow` -> `LazyItem`, `RowKey` unchanged),
living beside `Span` under `widget/position/`. It is what `Span` is, laid
out lazily from an anchor rather than eagerly from the start, and the name
says so. It also stops colliding with `BlockKind::List` in the markdown
code.
**It takes a `Dir` instead of an `Axis`**, meaning what it means in `Span`:
which end item 0 sits at. That is a **different question from which end
the view is pinned to**, and conflating them would stand a transcript on
its head -- its oldest message is item 0 and sits at the top (`Dir::DOWN`)
while the view clings to the bottom. So the pin is its own argument:
`LazySpan::new(dir, at_end)`, spelled like `Scroll::new`'s. `Dir::UP` is
real rather than nominal: the walk works in direction-relative pixels from
the leading edge, with `abs_region` flipping the box and `flip_pos`
converting the screen-space positions the hit-testing helpers speak in.
**Everything about scrolling left the list.** 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 are gone. `Scroll` was the only other `Flinger` user, so there is
now exactly one implementation of the physics and `sense.rs` keeps the
parts both ever shared. A transcript is `list.scrollable_to_end()` like
anything else.
### The new public surface: three `Widget` methods
```rust
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 if the child says yes it stops sliding the
child about as a lump and starts handing it deltas. Each method is `&self`
or `&mut self` for a reason worth keeping: reaching a widget through
`Widgets::get_dyn_mut` *marks it dirty*, so asking the capability question
through `apply_scroll` would dirty every ordinary child on every scroll
tick and cost exactly the O(1) move the whole scheme exists for.
`Scroll::draw` is then measure, apply, place -- the same measure-then-place
idiom it already used for its own content length. The measuring draw is
free in the common case (unchanged region, nothing dirty, so `draw_inner`
returns immediately and the child's stored walls are still correct) and
really walks exactly when the content changed, which is when they need
re-reading. **Nothing is marked by hand**: reaching the child to hand it
the delta is itself what dirties it, so the placing draw really draws.
That is why `Painter::draw_again` could stay deleted.
### Why `scroll_offset` exists
`apply_scroll` leaving a remainder was meant to be the whole story, and it
is not quite. 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 exact only when the wall was already visible. `Scroll`
adding remainders up would over-count by every overshoot and never
correct, so it reads the child's accumulated movement after the placing
draw instead, and `amt` is set from that. `amt` therefore always equals
what is on screen.
For a self-positioning child `amt` is **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, and a scrollbar would need
a real content length before it could use either.
### One convention for a scroll delta
There were two, and they read alike: `Scroll::scroll(+)` moved toward the
*start* while `LazySpan::scroll(+)` moved toward the *end*, with the
latter's doc claiming to mirror the former. Every call site had to
remember which it was talking to, and `Selection::drag` negated on the way
in. There is one now -- the finger's, which is `Scroll`'s -- and
`LazySpan::scroll` is private with the single negation inside
`apply_scroll`. `a_negative_delta_moves_toward_the_end` pins it across the
whole handoff, since no type can catch a scroll running backwards.
### What the measurements said, for the record
- A `Span` is skipped entirely in the steady state (`(0,0,0)` counters),
but **when it is redrawn it costs two draws per child** -- 21 draws for
10 children -- because phase 1 offers each child the ambient region to
learn its length and phase 2 offers it its real share. Any mutation of a
`Span` therefore redraws all of it: 24 draws for 11 children after one
prepend. That is why a transcript cannot be one.
- A settled scroll tick of the lazy span with 31 rows on screen is
**1 real draw and 31 move-slot writes**, no primitive rewrites and no
text reshaped; an idle frame is `(0,0,0,0)`. That is the number against
which "store the edges and only recompute what changed" would be
judged, and it is why the walk was left alone.
- The framework's own `ActiveData::size` cannot serve as the row-height
cache: `remove_rec` frees it the moment a row is virtualised away,
which is exactly when the walk needs it. The cache stays in the
container, keyed by `RowKey` -- which is also right for the reason you
gave, that a widget may one day render in two places and a size keyed
by `WidgetId` would break.
## 2026-09-08 (later still): a `List` clamps its overscroll in the same frame, and `draw_again` is gone
The last place in iris that corrected itself on a later frame. `List`'s
+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