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:
1 parent
8e5928cc6a
commit
b7474f61b0
19 files changed
+1016
-611
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user