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
+1017 -612

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 for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone. 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) ## 2026-09-08 (later still: the list's overscroll clamp, in frame)
Finishes the item the previous entry deferred. IRIS.md has the account. 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 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. 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 ## 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 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 honest for every current use and must be written at the field so
nobody builds a scrollbar on it. 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 - [x] **`List::clamp_to_content` still corrects on the next frame
+20 -19
View File
@@ -42,7 +42,7 @@ const STREAM_SECONDS: u64 = 20;
/// swipe with these any more. /// swipe with these any more.
const LEGACY_CYCLES: usize = 6; const LEGACY_CYCLES: usize = 6;
/// Fling phase (v2): a real fling through `LazySpan::fling`, not a tween -- /// Fling phase (v2): a real fling through `Scroll::fling`, not a tween --
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a /// Iris's ask was that it "travel way faster" than the v1 swipe, and a
/// tween can never exceed the distance/time it is given while a real /// tween can never exceed the distance/time it is given while a real
/// fling decays from an initial velocity the way a finger flick does. /// fling decays from an initial velocity the way a finger flick does.
@@ -908,14 +908,15 @@ where
} }
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away /// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
/// from it (toward older messages) through `LazySpan::fling`, then /// from it (toward older messages) through `Scroll::fling`, then
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll` /// `FLING_COUNT` back. Outward is *positive* in `Scroll::scroll`'s
/// convention (`LazySpan::scroll`'s own doc: positive moves *later* content /// convention, which is the finger's: a finger dragged down the screen
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses, /// brings earlier content into view. It was negative here until
/// since `TranscriptList`'s `LazyColumn` and this list define "positive" /// 2026-09-08, when the transcript's scroll position moved out of the
/// the other way around; the two apps' *travel* is still directly /// `LazySpan` -- whose anchor offset ran the other way -- and into the
/// comparable because both report it as a row index + pixel offset, not a /// `Scroll` around it. The two apps' *travel* is directly comparable
/// signed distance. /// whichever way the signs run, because both report it as a row index plus
/// a pixel offset rather than a signed distance.
async fn run_fling_phase( async fn run_fling_phase(
ctx: &mut iris::task::TaskCtx<Rsc>, ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>, redraw: &Arc<dyn RequestRedraw>,
@@ -937,8 +938,8 @@ async fn run_fling_phase(
for _ in 0..FLING_COUNT { for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| { ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen { if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S); (screen.scroll)(rsc).fling(FLING_VELOCITY_PX_S);
animate_list(screen.list, rsc); animate_scroll(screen.scroll, rsc);
} }
}); });
redraw.request_redraw(); redraw.request_redraw();
@@ -950,8 +951,8 @@ async fn run_fling_phase(
for _ in 0..FLING_COUNT { for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| { ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen { if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S); (screen.scroll)(rsc).fling(-FLING_VELOCITY_PX_S);
animate_list(screen.list, rsc); animate_scroll(screen.scroll, rsc);
} }
}); });
redraw.request_redraw(); redraw.request_redraw();
@@ -977,11 +978,11 @@ async fn read_anchor_position(
.await .await
} }
/// Register the list with the frame loop, exactly as a finger's own /// Register the scroll area with the frame loop, exactly as a finger's own
/// release does (`transcript_ui::Selection::drag`'s `Released` arm) -- /// release does (`transcript_ui::Selection::drag`'s `Released` arm) --
/// `LazySpan::fling` sets a velocity and drives nothing by itself. /// `Scroll::fling` sets a velocity and drives nothing by itself.
fn animate_list(list: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) { fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::Scroll>, rsc: &mut Rsc) {
let id = list.id(); let id = scroll.id();
rsc.ui_mut().animate(id); rsc.ui_mut().animate(id);
} }
@@ -991,7 +992,7 @@ fn animate_list(list: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &
/// spline-decided `duration()` already caps how long it can run. /// spline-decided `duration()` already caps how long it can run.
/// ///
/// **It observes; it does not drive.** Until 2026-09-08 this loop called /// **It observes; it does not drive.** Until 2026-09-08 this loop called
/// `LazySpan::tick_fling` itself every `POLL_MS`, which advanced the /// `Scroll::tick` itself every `POLL_MS`, which advanced the
/// fling in 16ms steps -- so on Iris's 120Hz phone every second frame /// fling in 16ms steps -- so on Iris's 120Hz phone every second frame
/// redrew the list at a position it had already drawn, and the benchmark /// redrew the list at a position it had already drawn, and the benchmark
/// looked distinctly less smooth than the same list under her finger. /// looked distinctly less smooth than the same list under her finger.
@@ -1010,7 +1011,7 @@ async fn wait_for_fling_settle(
let started = Instant::now(); let started = Instant::now();
while started.elapsed() < cap { while started.elapsed() < cap {
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen { let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).is_scrolling(), Some(screen) => (screen.scroll)(rsc).is_scrolling(),
None => false, None => false,
}) })
.await; .await;
+17 -9
View File
@@ -113,14 +113,22 @@ fn build_message_list(
rsc: &mut BenchRsc, rsc: &mut BenchRsc,
n: usize, n: usize,
image_every: usize, image_every: usize,
) -> (WeakWidget<LazySpan>, StrongWidget) { ) -> (WeakWidget<LazySpan>, WeakWidget<Scroll>, StrongWidget) {
let mut list = LazySpan::new(Dir::DOWN, true); let mut list = LazySpan::new(Dir::DOWN, true);
for i in 0..n { for i in 0..n {
let row = build_row(rsc, i, image_every); let row = build_row(rsc, i, image_every);
list.push_back(LazyItem::new(i as u64, row)); list.push_back(LazyItem::new(i as u64, row));
} }
let list = rsc.ui.widgets.add_strong(list); let list = rsc.ui.widgets.add_strong(list);
(list.weak(), list.any()) let list_weak = list.weak();
// Scrolled through a `Scroll`, like every other scroll area in iris
// since the position moved out of the list: what this measures has to
// be the path the app actually takes.
let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(list.any(), Axis::Y, true));
(list_weak, scroll.weak(), scroll.any())
} }
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) { fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
@@ -135,7 +143,7 @@ fn bench_first_frame(n: usize) {
let mut rsc = BenchRsc { let mut rsc = BenchRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let (_list, root) = build_message_list(&mut rsc, n, 20); let (_list, _scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new(); let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0)); render.resize((1080.0, 2000.0));
@@ -160,11 +168,11 @@ fn bench_scroll(n: usize, ticks: usize) {
let mut rsc = BenchRsc { let mut rsc = BenchRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let (list, root) = build_message_list(&mut rsc, n, 20); let (_list, scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new(); let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0)); render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
render.take_counters(); render.take_counters();
@@ -173,7 +181,7 @@ fn bench_scroll(n: usize, ticks: usize) {
let mut total_rewrites = 0u64; let mut total_rewrites = 0u64;
let mut total_moves = 0u64; let mut total_moves = 0u64;
for _ in 0..ticks { for _ in 0..ticks {
rsc.ui.widgets.get_mut(&list).unwrap().scroll(-8.0); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0);
let start = Instant::now(); let start = Instant::now();
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
total += start.elapsed(); total += start.elapsed();
@@ -207,7 +215,7 @@ fn bench_input_grows(n: usize, lines: usize) {
let mut rsc = BenchRsc { let mut rsc = BenchRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let (list, list_root) = build_message_list(&mut rsc, n, 20); let (_list, scroll, list_root) = build_message_list(&mut rsc, n, 20);
let list_area = rsc.ui.widgets.add_strong(Sized { let list_area = rsc.ui.widgets.add_strong(Sized {
inner: list_root, inner: list_root,
x: None, x: None,
@@ -231,7 +239,7 @@ fn bench_input_grows(n: usize, lines: usize) {
let mut render = UiRenderState::new(); let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0)); render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
render.take_counters(); render.take_counters();
@@ -277,7 +285,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
let mut rsc = BenchRsc { let mut rsc = BenchRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let (list, root) = build_message_list(&mut rsc, n, 20); let (list, _scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new(); let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0)); render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
+37
View File
@@ -166,6 +166,43 @@ impl<'a> Painter<'a> {
self.mask = self.own_mask; self.mask = self.own_mask;
} }
/// Ask a child whether it positions its own content
/// ([`Widget::scrolls_itself`]) -- read through `get_dyn`, which does
/// **not** mark it dirty, which is the whole reason this is a separate
/// question from [`Self::apply_scroll`] rather than something that
/// falls out of calling it. `false` for a child that has gone.
pub fn scrolls_itself<W: ?Sized>(&self, id: &StrongWidget<W>) -> bool {
self.rsc
.widgets()
.get_dyn(id.id())
.is_some_and(|w| w.scrolls_itself())
}
/// Hand a child a scroll delta to take what it can of
/// ([`Widget::apply_scroll`]), leaving the rest in `delta`.
///
/// Reaching the child mutably is what marks it for a real redraw
/// (`Widgets::get_dyn_mut`), so a caller that follows this with
/// another `widget_within` at the same region gets a genuine draw
/// rather than `draw_inner`'s unchanged-region skip -- which is
/// exactly the measure-then-place shape `Scroll` uses, and why there
/// is no "mark this for another frame" call in this type.
pub fn apply_scroll<W: ?Sized>(&mut self, id: &StrongWidget<W>, delta: &mut f32) {
if let Some(w) = self.rsc.widgets_mut().get_dyn_mut(id.id()) {
w.apply_scroll(delta);
}
}
/// Read a self-positioning child's accumulated content movement
/// ([`Widget::scroll_offset`]). Through `get_dyn`, so it does not mark
/// the child dirty. `0.0` for a child that has gone.
pub fn scroll_offset<W: ?Sized>(&self, id: &StrongWidget<W>) -> f32 {
self.rsc
.widgets()
.get_dyn(id.id())
.map_or(0.0, |w| w.scroll_offset())
}
/// Whether anything is clipping what this widget draws -- its own /// Whether anything is clipping what this widget draws -- its own
/// [`Self::set_mask`], or one an ancestor set that it inherited. What /// [`Self::set_mask`], or one an ancestor set that it inherited. What
/// a widget whose contents may legitimately extend past its own box /// a widget whose contents may legitimately extend past its own box
+66
View File
@@ -60,6 +60,72 @@ pub trait Widget: Any {
fn tick(&mut self, now: std::time::Instant) -> bool { fn tick(&mut self, now: std::time::Instant) -> bool {
false false
} }
/// Whether this widget positions its own content and should be handed
/// scroll deltas ([`Self::apply_scroll`]) instead of being moved by
/// the `Scroll` around it. Default `false`: an ordinary child is a
/// fixed lump its parent slides about, which is what makes a scroll
/// tick an O(1) move of one subtree rather than a redraw.
///
/// A lazy layout has to answer `true`, because the two halves of a
/// scroll are not separable for it: which children exist at all is a
/// function of where it is scrolled to, so it cannot be a lump, and it
/// cannot report a content length for the parent to clamp against
/// either -- it has never measured the rows it has not drawn.
///
/// **`&self` on purpose.** Reaching a widget through
/// `Widgets::get_dyn_mut` marks it dirty, so asking the question
/// through [`Self::apply_scroll`] would dirty every ordinary child on
/// every scroll tick and cost exactly the redraw the move path exists
/// to avoid. This is read through `get_dyn`, which does not mark.
fn scrolls_itself(&self) -> bool {
false
}
/// Take as much of `delta` as this widget can actually move, and leave
/// the rest in it. Only called on a widget whose
/// [`Self::scrolls_itself`] is `true`.
///
/// The sign is `Scroll::scroll`'s, which is the finger's: a positive
/// delta moves the content in the positive direction of the axis, and
/// so brings *earlier* content into view.
///
/// What is left behind is how the caller learns it reached a wall --
/// asked for 300, got 50 back means the content ran out 250 short --
/// which is all a fling needs to know to stop, and all a pin needs to
/// know to re-pin. There is deliberately nothing here reporting an
/// absolute position: a lazy layout's origin moves when content is
/// loaded above it, so any such number would be a fiction.
///
/// Called between the two draws of `Scroll::draw`, so the walls this
/// answers against were measured by the first of them.
#[allow(unused_variables)]
fn apply_scroll(&mut self, delta: &mut f32) {}
/// How far this widget has moved its own content in total, in
/// [`Self::apply_scroll`]'s direction convention -- for a parent
/// keeping an account of where a self-positioning child has got to.
///
/// **Why this exists and `apply_scroll`'s remainder is not enough.**
/// A lazy layout usually cannot say where its content ends until it
/// has walked there, so `apply_scroll` takes a delta in full whenever
/// the wall is not already in view, and the wall is found by the walk
/// that follows -- which gives some of it back. The remainder is
/// therefore right only when the wall was already visible, and a
/// parent adding remainders up would over-count by every overshoot
/// and never correct. Read after the child has been placed, this is
/// what actually happened.
///
/// `&self`, so asking does not mark the child dirty
/// ([`Self::scrolls_itself`] has the reasoning).
///
/// Counts scrolling only: a jump straight to an item is not travel
/// across the content and does not appear here, because for a layout
/// whose origin moves as content is paged in there is no distance
/// between the two positions to report.
fn scroll_offset(&self) -> f32 {
0.0
}
} }
impl Widget for () { impl Widget for () {
+5 -4
View File
@@ -104,11 +104,12 @@ impl DefaultAppState for State {
list.push_back(LazyItem::new(i as u64, row)); list.push_back(LazyItem::new(i as u64, row));
} }
// `.scrollable_to_end`, like anything else that scrolls: the
// wheel and the drag are the `Scroll`'s, and the list only lays
// out. Masked outside it, since a `LazySpan` draws the row
// straddling each edge in full and asserts something clips it.
let root = list let root = list
.on(CursorSense::Scroll, |ctx, rsc| { .scrollable_to_end(Axis::Y)
let delta = ctx.data.scroll_delta.y * 50.0;
ctx.widget(rsc).scroll(delta);
})
.masked() .masked()
.background(rect(Color::WHITE)) .background(rect(Color::WHITE))
.add_strong(rsc); .add_strong(rsc);
+3 -3
View File
@@ -142,7 +142,7 @@ impl TouchScript {
/// Counts the frames something asked for without drawing any -- the /// Counts the frames something asked for without drawing any -- the
/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for /// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
/// the next frame through this (`LazySpan::set_redraw_handle`), so a test can /// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can
/// tell "nothing moved" from "nothing was even asked to move". /// tell "nothing moved" from "nothing was even asked to move".
#[derive(Default)] #[derive(Default)]
pub struct RedrawCounter(AtomicUsize); pub struct RedrawCounter(AtomicUsize);
@@ -335,7 +335,7 @@ impl Harness {
} }
/// The `Instant` this harness means by `t_ms`. Public because a /// The `Instant` this harness means by `t_ms`. Public because a
/// caller driving `LazySpan::tick_fling` or `DragGesture` by hand needs /// caller driving `Scroll::tick` or `DragGesture` by hand needs
/// to date those calls on the same clock the touch samples use. /// to date those calls on the same clock the touch samples use.
pub fn at(&self, t_ms: u64) -> Instant { pub fn at(&self, t_ms: u64) -> Instant {
self.base + Duration::from_millis(t_ms) self.base + Duration::from_millis(t_ms)
@@ -371,7 +371,7 @@ impl Harness {
/// Frames every `step_ms` up to and including `end_ms` -- what a /// Frames every `step_ms` up to and including `end_ms` -- what a
/// fling needs, since it moves only while something ticks it /// fling needs, since it moves only while something ticks it
/// (`LazySpan::fling`'s doc). Returns the time of the last frame run. /// (`Scroll::fling`'s doc). Returns the time of the last frame run.
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 { pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
debug_assert!(step_ms > 0, "a frame loop with no step never ends"); debug_assert!(step_ms > 0, "a frame loop with no step never ends");
let mut t = from_ms; let mut t = from_ms;
+14 -14
View File
@@ -924,7 +924,7 @@ pub struct PressState {
/// instead of waiting for a long-press. /// instead of waiting for a long-press.
pub already_selected: bool, pub already_selected: bool,
/// Whether the target was already moving under its own momentum (a /// Whether the target was already moving under its own momentum (a
/// `LazySpan` with a fling in flight, `LazySpan::is_scrolling`). See /// `LazySpan` with a fling in flight, `Scroll::is_scrolling`). See
/// [`DragArbiter::press_start`]: a press on moving content is a catch, /// [`DragArbiter::press_start`]: a press on moving content is a catch,
/// and catches skip the slop entirely. /// and catches skip the slop entirely.
pub scrolling: bool, pub scrolling: bool,
@@ -1125,7 +1125,7 @@ impl DragArbiter {
/// Whether the arbiter's current gesture (if any) has committed to /// Whether the arbiter's current gesture (if any) has committed to
/// panning -- what a caller checks at release time to decide whether /// panning -- what a caller checks at release time to decide whether
/// to hand the tracked velocity to [`crate::widget::LazySpan::fling`], per /// to hand the tracked velocity to [`crate::widget::Scroll::fling`], per
/// IRIS_TODO.md's "swiping has no momentum": a fling must only follow /// IRIS_TODO.md's "swiping has no momentum": a fling must only follow
/// a pan, never a text selection that happened to end with the finger /// a pan, never a text selection that happened to end with the finger
/// still moving. /// still moving.
@@ -1158,7 +1158,7 @@ impl DragArbiter {
pub enum GestureOutcome { pub enum GestureOutcome {
Undecided, Undecided,
/// Same units and sign as [`DragOutcome::Pan`] -- the caller's own /// Same units and sign as [`DragOutcome::Pan`] -- the caller's own
/// convention (`LazySpan::scroll`'s, for a transcript) to apply. /// convention (`Scroll::scroll`'s, for a transcript) to apply.
Pan(f32), Pan(f32),
SelectStart, SelectStart,
SelectExtend, SelectExtend,
@@ -1173,7 +1173,7 @@ pub enum GestureOutcome {
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`. /// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
/// `Some(velocity)` only if the gesture had committed to panning /// `Some(velocity)` only if the gesture had committed to panning
/// (never a tap, a long-press selection, or one still `Undecided`); /// (never a tap, a long-press selection, or one still `Undecided`);
/// same units as `Pan`, so a caller hands it to `LazySpan::fling` with /// same units as `Pan`, so a caller hands it to `Scroll::fling` with
/// whatever sign flip it already applies to `Pan`. /// whatever sign flip it already applies to `Pan`.
Released(Option<f32>), Released(Option<f32>),
/// Another widget took the pointer (`CursorSense::Cancel`), so this /// Another widget took the pointer (`CursorSense::Cancel`), so this
@@ -1478,9 +1478,9 @@ const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1;
/// (`DragGestureNode.sendDragStopped` passes /// (`DragGestureNode.sendDragStopped` passes
/// `LocalViewConfiguration.maximumFlingVelocity` into /// `LocalViewConfiguration.maximumFlingVelocity` into
/// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it /// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it
/// in [`crate::widget::LazySpan::fling`] instead, because that is the only /// in [`crate::widget::Scroll::fling`] instead, because that is the only
/// place that knows the density this has to be multiplied by. There is /// place that knows the density this has to be multiplied by. There is
/// deliberately **no** matching minimum: see `LazySpan::fling`. /// deliberately **no** matching minimum: see `Scroll::fling`.
pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0; pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0;
/// Estimates a drag's speed along one axis the way Compose's touch /// Estimates a drag's speed along one axis the way Compose's touch
@@ -1608,7 +1608,7 @@ impl VelocityTracker {
/// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then /// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then
/// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum /// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum
/// itself is applied by the caller that knows the density /// itself is applied by the caller that knows the density
/// ([`crate::widget::LazySpan::fling`]). /// ([`crate::widget::Scroll::fling`]).
/// ///
/// `0.0` with fewer than [`MIN_SAMPLE_SIZE`] usable samples, which is /// `0.0` with fewer than [`MIN_SAMPLE_SIZE`] usable samples, which is
/// Compose's answer too: a press and a single move carry no curve to /// Compose's answer too: a press and a single move carry no curve to
@@ -1655,7 +1655,7 @@ impl VelocityTracker {
// `calculateVelocity(maximumVelocity)`'s first branch, kept as the // `calculateVelocity(maximumVelocity)`'s first branch, kept as the
// outer guard even though the degenerate case is now detected // outer guard even though the degenerate case is now detected
// rather than clamped: a fit can still overflow on inputs nothing // rather than clamped: a fit can still overflow on inputs nothing
// here has produced, and `LazySpan::fling` asserts finiteness. // here has produced, and `Scroll::fling` asserts finiteness.
if velocity.is_finite() { velocity } else { 0.0 } if velocity.is_finite() { velocity } else { 0.0 }
} }
} }
@@ -1876,7 +1876,7 @@ const FLING_FRICTION: f32 = 0.015;
/// small, which put an `ln` of a 56x-too-large ratio through /// small, which put an `ln` of a 56x-too-large ratio through
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds** /// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
/// instead of 1.6. Nothing could see it while a finger fling never /// instead of 1.6. Nothing could see it while a finger fling never
/// animated at all (`LazySpan::fling`'s doc), which is why two defects had to /// animated at all (`Scroll::fling`'s doc), which is why two defects had to
/// be fixed before either was visible. /// be fixed before either was visible.
const FLING_TUNING: f32 = 0.84; const FLING_TUNING: f32 = 0.84;
fn deceleration_rate() -> f32 { fn deceleration_rate() -> f32 {
@@ -1896,7 +1896,7 @@ const GRAVITY_EARTH: f32 = 9.80665;
/// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the /// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the
/// wrong density changes how long a fling lasts exponentially rather than /// wrong density changes how long a fling lasts exponentially rather than
/// scaling it. An earlier version of this comment claimed the opposite and /// scaling it. An earlier version of this comment claimed the opposite and
/// `LazySpan::fling` passed `1.0`; on a 2.75-density screen that gave a /// `Scroll::fling` passed `1.0`; on a 2.75-density screen that gave a
/// one-second flick a 45-second coast (measured 2026-09-07). `LazySpan` reads /// one-second flick a 45-second coast (measured 2026-09-07). `LazySpan` reads
/// its density from the painter now. /// its density from the painter now.
pub struct FlingCalculator { pub struct FlingCalculator {
@@ -1919,7 +1919,7 @@ impl FlingCalculator {
/// Total signed distance the fling travels before settling, in the /// Total signed distance the fling travels before settling, in the
/// same pixel units `velocity` was given in. /// same pixel units `velocity` was given in.
pub fn distance(&self, velocity: f32) -> f32 { pub fn distance(&self, velocity: f32) -> f32 {
// See `LazySpan::fling`'s matching assertion -- a non-finite velocity // See `Scroll::fling`'s matching assertion -- a non-finite velocity
// here silently produces a NaN distance rather than surfacing the // here silently produces a NaN distance rather than surfacing the
// bug that produced it (docs/REVIEW-2026-09-06.md finding 3). // bug that produced it (docs/REVIEW-2026-09-06.md finding 3).
debug_assert!(velocity.is_finite()); debug_assert!(velocity.is_finite());
@@ -1946,7 +1946,7 @@ impl FlingCalculator {
} }
/// The signed distance covered by `elapsed` into a fling of this /// The signed distance covered by `elapsed` into a fling of this
/// `velocity` -- what a per-frame ticker (`LazySpan::tick_fling`) calls to /// `velocity` -- what a per-frame ticker (`Scroll::tick`) calls to
/// find how far to have scrolled by now. Clamped to the full /// find how far to have scrolled by now. Clamped to the full
/// `distance()` once `elapsed` reaches `duration()`, so a caller need /// `distance()` once `elapsed` reaches `duration()`, so a caller need
/// not special-case "past the end." /// not special-case "past the end."
@@ -1964,7 +1964,7 @@ impl FlingCalculator {
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start /// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
/// to zero at `duration()`, which is the whole difference between a /// to zero at `duration()`, which is the whole difference between a
/// fling and a constant-speed slide, so it is what /// fling and a constant-speed slide, so it is what
/// `LazySpan::tick_fling`'s debug line reports: successive frames printing /// `Scroll::tick`'s debug line reports: successive frames printing
/// a shrinking number is the evidence that the curve is being followed /// a shrinking number is the evidence that the curve is being followed
/// at all. /// at all.
pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 { pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 {
@@ -2346,7 +2346,7 @@ mod fling_calculator_tests {
/// Summing the spline's own per-frame position deltas across the /// Summing the spline's own per-frame position deltas across the
/// whole fling has to land within 1% of the closed-form `distance()` /// whole fling has to land within 1% of the closed-form `distance()`
/// -- this is the guarantee that `LazySpan::tick_fling`'s per-frame reads /// -- this is the guarantee that `Scroll::tick`'s per-frame reads
/// of `position_at` actually add up to the total the fling promised, /// of `position_at` actually add up to the total the fling promised,
/// not merely that the two formulas look plausible independently. /// not merely that the two formulas look plausible independently.
#[test] #[test]
+333 -428
View File
@@ -105,18 +105,31 @@
//! that "whatever lies between" stays empty however far the list is //! that "whatever lies between" stays empty however far the list is
//! panned. //! panned.
//! //!
//! **Overscroll is taken back within the frame, never rubber-banded.** A //! **This widget does not scroll; it is scrolled.** There is no fling
//! `scroll()` or a fling past the first or last row leaves a gap; //! here, no gesture, no scroll position and no redraw handle -- a
//! `overscroll_gap` measures it from the ends the walk already placed, and //! `Scroll` around it owns all of that, hands it deltas through
//! `draw` moves the anchor by it and walks a second time before the frame //! [`Widget::apply_scroll`], and reads back what it managed to move
//! ends -- layout is a pure function of the state, not of how many frames //! ([`Widget::scroll_offset`]). What is left here is the layout: an
//! have been drawn (Iris, 2026-09-08), the same rule `Scroll::draw` //! anchor, a walk outward from it, and an honest answer about how far it
//! follows. A list shorter than its viewport is not overscrolled and is //! can go. So `.scrollable_to_end()` is how a `LazySpan` is scrolled,
//! left alone, still bottom-anchored. //! which is how everything else in iris is scrolled too.
//!
//! **Overscroll cannot be entered by scrolling, and is taken back within
//! the frame when something else causes it.** `apply_scroll` only ever
//! takes as much as the walk says is there, so a delta that runs off the
//! end simply comes back short. 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 by it and walks a second time
//! **before the frame ends** -- layout is a pure function of the state,
//! not of how many frames have been drawn (Iris, 2026-09-08), the same
//! rule `Scroll::draw` follows. A span shorter than its viewport is not
//! overscrolled and is left alone, still pinned to the end it was built
//! with.
use crate::prelude::*; use crate::prelude::*;
use iris_core::util::HashMap; use iris_core::util::HashMap;
use std::{collections::VecDeque, sync::Arc, time::Instant}; use std::collections::VecDeque;
/// A stable identifier for a loaded row, reused across pages so that a row /// A stable identifier for a loaded row, reused across pages so that a row
/// already measured and drawn is not treated as new when data is inserted /// already measured and drawn is not treated as new when data is inserted
@@ -254,42 +267,41 @@ pub struct LazySpan {
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past /// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
/// however many rows are currently loaded. /// however many rows are currently loaded.
heights: HashMap<RowKey, f32>, heights: HashMap<RowKey, f32>,
/// The list's fling, shared with every other scrolling widget in the /// Whether the last walk found no more content before the leading
/// crate -- see `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's /// edge *and* nothing left to give back there -- what
/// "swiping has no momentum." /// [`Self::overscroll_gap`] reads. `false` by default, matching
fling: Flinger, /// "assume there is more content until a walk proves otherwise."
/// What `tick_fling` re-arms every frame a fling is still running, so
/// the list keeps animating without needing a caller to poll it --
/// set once via `set_redraw_handle` by whoever owns the surface this
/// list draws into (the same handle `iris::task::Tasks::redraw_handle`
/// hands out elsewhere). `None` for a list that never flings
/// (headless tests, a caller driving `tick_fling` by hand as
/// `bench_client.rs`'s scripted phases do).
redraw: Option<Arc<dyn RequestRedraw>>,
/// Physical pixels per `dp`, copied from the painter on every `draw`
/// -- what [`Self::fling`] hands `FlingCalculator`. 1.0 until this
/// list has been drawn once, which is also the only state in which a
/// fling is impossible (`fling` needs an anchor, and an anchor comes
/// from a draw).
///
/// It has to be the real one: the deceleration constant is
/// `GRAVITY * 39.37 * density * 160 * friction`, and the velocity fed
/// in is in the same physical pixels the touch events arrive in, so a
/// hardcoded 1.0 against a 2.75-density screen does not cancel out --
/// it makes the fling last exponentially too long. Measured on this
/// checkout's emulator, 2026-09-07, once flings could animate at all:
/// a flick that should coast for about a second ran for **45
/// seconds**.
density: f32,
/// Whether the last `draw` found no more content above the topmost
/// visible row (its top edge at or past the viewport's own top, with
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
/// the start against. Stale (from whatever the last draw found) on a
/// list that hasn't drawn yet; `false` by default, matching "assume
/// there is more content until a draw proves otherwise."
at_start: bool, at_start: bool,
/// The mirror of `at_start` for the newest end. /// The mirror of `at_start` for the trailing end.
at_end: bool, at_end: bool,
/// The extreme edges the last walk reached, in the walk's own
/// direction-relative pixels, kept so [`Widget::apply_scroll`] can say
/// **exactly** how much of a delta this span is able to take rather
/// than only whether it is against a wall: with no more content past
/// an edge, the travel left in that direction is the distance from
/// that edge to the viewport's. `Scroll` adds what was taken to its
/// own `amt`, so an estimate here would leave that number drifting
/// from what is on screen by every overshoot into a wall.
///
/// Measured by the *first* of `Scroll::draw`'s two draws of this
/// widget, which is the whole reason that draw exists -- see
/// `Widget::apply_scroll`.
content_lead: f32,
content_trail: f32,
/// Whether the last walk ran out of items before its leading /
/// trailing edge -- the structural half of `at_start`/`at_end`, and
/// what says whether `content_lead`/`content_trail` bound a scroll at
/// all. With more content past an edge there is no bound to give.
no_more_before: bool,
no_more_after: bool,
/// Accumulated content movement in [`Widget::apply_scroll`]'s
/// direction convention -- every `scroll` this span makes, including
/// the ones `overscroll_gap` gives back, so a `Scroll` above reads
/// what actually happened rather than adding up what it asked for.
/// See [`Widget::scroll_offset`] for why the remainder alone will not
/// do. Jumps (`jump_to_end`/`jump_to_start`) are deliberately not
/// counted: they are not travel across the content.
moved: f32,
} }
impl LazySpan { impl LazySpan {
@@ -308,11 +320,13 @@ impl LazySpan {
snap_end: at_end, snap_end: at_end,
viewport_len: 0.0, viewport_len: 0.0,
last_viewport_len: 0.0, last_viewport_len: 0.0,
fling: Flinger::new(),
redraw: None,
density: 1.0,
at_start: false, at_start: false,
at_end: false, at_end: false,
content_lead: 0.0,
content_trail: 0.0,
no_more_before: false,
no_more_after: false,
moved: 0.0,
pending_tap: None, pending_tap: None,
extents: HashMap::default(), extents: HashMap::default(),
heights: HashMap::default(), heights: HashMap::default(),
@@ -438,135 +452,30 @@ impl LazySpan {
self.extents.clear(); self.extents.clear();
} }
/// Move the anchor's edge by `amt` pixels. Positive moves later /// Move the anchor's edge by `amt` pixels, where positive brings
/// content into view (mirrors `Scroll::scroll`'s sign convention). /// **later** content into view.
/// Unclamped here, on purpose: it is one write, and there is nothing ///
/// at this point that knows where the content ends. The `draw` that /// Private, and the opposite sign to [`Widget::apply_scroll`]'s
/// follows gives back whatever this moved past, in that same frame /// `delta`, which is the finger's direction (`Scroll::scroll`'s): the
/// ([`Self::overscroll_gap`]). /// anchor's offset says where the pinned edge *sits*, so moving the
pub fn scroll(&mut self, amt: f32) { /// content forward moves that number down. `apply_scroll` is the one
/// place that negates, so there is exactly one public convention for
/// a scroll delta in this crate rather than two that read alike and
/// mean opposite things -- which is what these two were before
/// `Scroll` took the position over.
///
/// Unclamped here, on purpose: it is one write. `apply_scroll` does
/// the clamping, against walls the walk measured.
fn scroll(&mut self, amt: f32) {
if let Some(a) = &mut self.anchor { if let Some(a) = &mut self.anchor {
a.offset -= amt; a.offset -= amt;
// Negated into `apply_scroll`'s convention, which is the
// finger's -- this is the one place the two directions meet,
// and `moved` is what `Widget::scroll_offset` hands upward.
self.moved -= amt;
} }
} }
/// Give this list a way to ask for another frame on its own, so a
/// fling keeps animating without a caller polling it every tick --
/// see the `redraw` field's doc. Pass the same handle
/// `iris::task::Tasks::redraw_handle` hands a `spawn`ed task; a list
/// that never calls this can still `fling`, but has to be driven by a
/// caller-owned loop instead (`bench_client.rs`'s scripted phases do
/// exactly that, since they need to await settling rather than let it
/// run in the background).
pub fn set_redraw_handle(&mut self, handle: Arc<dyn RequestRedraw>) {
self.redraw = Some(handle);
}
/// Start a fling at `velocity_px_per_s` (this widget's own pixel
/// space, same sign convention as `scroll`'s `amt`: positive continues
/// moving later content into view). Cancels any fling already in
/// progress. A caller with a live touch/press must cancel this on the
/// next touch-down (`cancel_fling`) -- `AndroidFlingSpline`'s curve
/// has no idea a finger came back down, and Android's own `Scroller`
/// relies on the view calling `abortAnimation` for the same reason.
///
/// The density handed to `FlingCalculator` is this list's own
/// (`self.density`, taken from the painter in `draw`), not `1.0`: it
/// does **not** cancel out of the spline -- see `FlingCalculator`'s
/// doc, which used to claim the opposite, and the 45-second coast that
/// claim produced.
///
/// **Sets the fling; it does not drive it.** A fling moves only while
/// something calls [`Self::tick_fling`] once per frame, and what does
/// that in a running app is `UiData::tick_animations`, over the ids
/// `UiData::animate` was given. So a caller starting a fling from a
/// gesture registers the list in the same breath:
///
/// ```ignore
/// list(ui).fling(-velocity);
/// let id = list.id();
/// ui.ui_mut().animate(id);
/// ```
///
/// Split that way because the two halves have different owners: the
/// velocity is the list's business, and whether anything animates at
/// all is the frame loop's. Missing the second call is what a finger
/// fling did on Iris's phone for two builds -- the velocity was right
/// and nothing ever advanced it, which looks exactly like a list that
/// stops dead under the finger. A caller driving frames itself
/// (`bench_client.rs`'s fling phase, the headless tests) calls
/// `tick_fling` directly instead and does not register.
pub fn fling(&mut self, velocity_px_per_s: f32) {
// No anchor means this list has never drawn, so there is nothing
// to move: `Flinger` cannot know that and this does.
if self.anchor.is_none() {
self.fling.stop();
return;
}
self.fling.start(velocity_px_per_s, self.density);
}
/// Whether a fling is currently animating. What a caller's own
/// per-frame loop polls to know when to stop driving `tick_fling`
/// (`bench_client.rs`'s fling phase) or to decide whether the list is
/// "moving on its own" for any other purpose.
pub fn is_scrolling(&self) -> bool {
self.fling.is_flinging()
}
/// The velocity a fling in progress is coasting at, in this list's
/// own pixel space -- `None` when nothing is flinging. What a
/// release's decision looks like from the outside: a
/// `GestureOutcome::Released(Some(v))` is the only thing that puts a
/// value here, so a test (or a diagnostic) can read what the gesture
/// measured at the place it landed, rather than re-timing the
/// gesture itself.
pub fn fling_velocity(&self) -> Option<f32> {
self.fling.velocity()
}
/// Cancel any fling in progress with no further movement -- the next
/// touch-down's job, per `fling`'s own doc.
pub fn cancel_fling(&mut self) {
self.fling.stop();
}
/// Advance an in-flight fling to `now`, applying this call's share of
/// its total travel via `scroll` and re-arming this list's own redraw
/// handle (if it has one) for another frame. Returns whether the
/// fling is still going after this call -- `false` either because it
/// settled on its own spline-decided schedule or because it reached
/// `at_start`/`at_end` (the module doc's clamp: a fling must not carry
/// the list past content that does not exist, unlike an ordinary
/// touch-pan, which this widget already leaves unclamped by design).
///
/// Safe to call even with no fling active (a no-op returning `false`),
/// so a caller does not need to check `is_scrolling` first.
pub fn tick_fling(&mut self, now: Instant) -> bool {
let Some(velocity) = self.fling.velocity() else {
return false;
};
let delta = self.fling.tick(now);
self.scroll(delta);
// Clamp: a fling moving toward the start that has already reached
// it (or one moving toward the end that has already reached that)
// stops rather than continuing to spend its remaining distance on
// a part of the list that will never scroll further. `at_start`/
// `at_end` are what the last draw found, which is the only thing
// here that knows where the content ends.
if (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end) {
self.fling.stop();
}
if !self.fling.is_flinging() {
return false;
}
if let Some(redraw) = &self.redraw {
redraw.request_redraw();
}
true
}
/// The anchor's own row index and pixel offset, formatted the same /// The anchor's own row index and pixel offset, formatted the same
/// shape Compose's `firstVisibleItemIndex`/`firstVisibleItemScrollOffset` /// shape Compose's `firstVisibleItemIndex`/`firstVisibleItemScrollOffset`
/// report (`idx=N/off=Mpx`) -- what RUST.md's "Benchmark v2" fling /// report (`idx=N/off=Mpx`) -- what RUST.md's "Benchmark v2" fling
@@ -956,6 +865,15 @@ impl LazySpan {
// content" means everywhere else in this widget. // content" means everywhere else in this widget.
self.at_start = self.prev_slot(idx_lead).is_none() && lead >= 0.0; self.at_start = self.prev_slot(idx_lead).is_none() && lead >= 0.0;
self.at_end = self.next_slot(idx_trail).is_none() && trail <= self.viewport_len; self.at_end = self.next_slot(idx_trail).is_none() && trail <= self.viewport_len;
// The structural half of the same two questions, kept apart from
// `at_start`/`at_end` because they mean different things:
// "there is nothing loaded past this edge" is what bounds a
// scroll, while `at_start`/`at_end` add "and there is a gap to
// give back", which is what the overscroll clamp acts on.
self.no_more_before = self.prev_slot(idx_lead).is_none();
self.no_more_after = self.next_slot(idx_trail).is_none();
self.content_lead = lead;
self.content_trail = trail;
// Both halves of `intersects_viewport`'s rule, checked where they // Both halves of `intersects_viewport`'s rule, checked where they
// are cheap to check: what this pass put on screen is exactly what // are cheap to check: what this pass put on screen is exactly what
@@ -1188,20 +1106,56 @@ impl LazySpan {
const GENEROUS_PADDING: f32 = 100_000.0; const GENEROUS_PADDING: f32 = 100_000.0;
impl Widget for LazySpan { impl Widget for LazySpan {
/// A `LazySpan` animates exactly one thing, a fling /// A lazy layout cannot be slid about as a lump: which rows exist at
/// ([`Self::tick_fling`]). The registration that makes this run is /// all is a function of where it is scrolled to, and it has no content
/// `UiData::animate` beside the `fling` call -- see `fling`'s own doc. /// length to hand its parent to clamp against, since it has never
fn tick(&mut self, now: Instant) -> bool { /// measured the rows it has not drawn. So it takes the delta itself.
self.tick_fling(now) /// See [`Widget::scrolls_itself`].
fn scrolls_itself(&self) -> bool {
true
}
fn scroll_offset(&self) -> f32 {
self.moved
}
/// Take what the loaded content allows and leave the rest, so the
/// `Scroll` above learns it hit a wall from what comes back.
///
/// `delta` arrives in the finger's direction (positive brings
/// *earlier* content into view) and the anchor's offset runs the other
/// way, which is the one negation in this widget -- see
/// [`Self::scroll`].
///
/// **The bound is exact, not a "we are at the wall" flag.** With no
/// more content past an edge, the travel left in that direction is the
/// distance from the edge the last walk placed to the viewport's own,
/// so a delta that runs 250px past the end gives 250 back rather than
/// everything or nothing. That is what lets `Scroll::amt` stay equal
/// to what is actually on screen instead of drifting by every
/// overshoot. The walls come from the walk `Scroll::draw` runs
/// immediately before this.
fn apply_scroll(&mut self, delta: &mut f32) {
// `f32::INFINITY` rather than a big number or an `Option`: with
// content past this edge there is genuinely no bound to apply, and
// `clamp` says exactly that with no branch of its own.
let max_forward = if self.no_more_after {
(self.content_trail - self.viewport_len).max(0.0)
} else {
f32::INFINITY
};
let max_backward = if self.no_more_before {
(-self.content_lead).max(0.0)
} else {
f32::INFINITY
};
let taken = (-*delta).clamp(-max_backward, max_forward);
self.scroll(taken);
*delta += taken;
} }
fn draw(&mut self, painter: &mut Painter) -> Size { fn draw(&mut self, painter: &mut Painter) -> Size {
let axis = self.dir.axis; let axis = self.dir.axis;
// Learned from the frame rather than passed in: a fling's
// deceleration is a physical quantity and needs the real display
// density, and `draw` is where this widget meets the only thing
// that knows it. See `fling`.
self.density = painter.density();
// A row that straddles either edge is drawn in full // A row that straddles either edge is drawn in full
// (`intersects_viewport`), so the part of it outside this list's // (`intersects_viewport`), so the part of it outside this list's
// box is on screen unless something clips it -- and with nothing // box is on screen unless something clips it -- and with nothing
@@ -1277,6 +1231,27 @@ impl Widget for LazySpan {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::time::Instant;
/// Every row in `build_flingable_list` is this tall, which is what
/// makes `scroll_position` exact.
const FLING_ROW_H: f32 = 20.0;
/// How far a `build_flingable_list` span has scrolled from its very
/// first row, in pixels: read off the leading row on screen, whose
/// content position is exactly `slot * FLING_ROW_H` because every row
/// there is that tall. Measures where the content actually sits rather
/// than any bookkeeping about it, and unlike a single row's extent it
/// stays defined however far the list travels -- `extents` holds only
/// what is on screen (`LazySpan::intersects_viewport`).
fn scroll_position(list: &LazySpan) -> f32 {
let first = list
.extents
.values()
.min_by(|a, b| a.lead.total_cmp(&b.lead))
.expect("something is on screen");
first.slot as f32 * FLING_ROW_H - first.lead
}
struct TestRsc { struct TestRsc {
ui: UiData, ui: UiData,
@@ -2070,273 +2045,247 @@ mod tests {
} }
/// Enough rows, tall enough, that a fling toward the start has real /// Enough rows, tall enough, that a fling toward the start has real
/// room to travel before `at_start` clamps it -- shared by the fling /// room to travel before the walls stop it -- shared by the tests
/// tests below. /// below.
/// How far a `build_flingable_list` list has scrolled from its very ///
/// first row, in pixels: read off the topmost row on screen, whose /// Built the way a real caller does since the scroll position moved
/// content position is exactly `slot * ROW_H` because every row there /// out of this widget: `Masked(Scroll(LazySpan))`. The `Scroll`
/// is that tall. Measures the list's own accumulated movement (the /// contributes the gesture, the fling and `amt`; the `LazySpan` lays
/// thing `scroll`/`tick_fling` write) rather than the spline's /// out and says how far it can actually go
/// bookkeeping, and unlike a single row's extent it stays defined /// (`Widget::apply_scroll`). The mask is outside the `Scroll` because
/// however far the list travels -- `extents` holds only what is /// what needs clipping is the row straddling an edge, and masks are
/// on screen (`LazySpan::intersects_viewport`). /// inherited down the chain.
fn scroll_position(list: &LazySpan) -> f32 {
let top = list
.extents
.values()
.min_by(|a, b| a.lead.total_cmp(&b.lead))
.expect("something is on screen");
top.slot as f32 * FLING_ROW_H - top.lead
}
const FLING_ROW_H: f32 = 20.0;
fn build_flingable_list( fn build_flingable_list(
rsc: &mut TestRsc, rsc: &mut TestRsc,
) -> (WeakWidget<LazySpan>, StrongWidget, UiRenderState) { ) -> (
WeakWidget<LazySpan>,
WeakWidget<Scroll>,
StrongWidget,
UiRenderState,
) {
let mut list = LazySpan::new(Dir::DOWN, true); let mut list = LazySpan::new(Dir::DOWN, true);
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), FLING_ROW_H); push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), FLING_ROW_H);
let (list_weak, root) = add_list(rsc, list); let list = rsc.ui.widgets.add_strong(list);
let list_weak = list.weak();
let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(list.any(), Axis::Y, true));
let scroll_weak = scroll.weak();
let root = rsc.ui.widgets.add_strong(Masked {
shape: None,
inner: scroll.any(),
});
let root = root.any();
let mut render = UiRenderState::new(); let mut render = UiRenderState::new();
render.resize((100.0, 600.0)); render.resize((100.0, 600.0));
render.update(&root, rsc); render.update(&root, rsc);
(list_weak, root, render) (list_weak, scroll_weak, root, render)
}
/// Drive one frame of a fling: tick the `Scroll` the way
/// `UiData::tick_animations` does, then draw. Answers whether the
/// fling is still going.
fn fling_frame(
rsc: &mut TestRsc,
scroll: &WeakWidget<Scroll>,
root: &StrongWidget,
render: &mut UiRenderState,
now: Instant,
) -> bool {
let still = rsc.ui.widgets.get_mut(scroll).unwrap().tick(now);
render.update(root, rsc);
still
} }
#[test] #[test]
fn fling_moves_the_list_and_then_settles() { fn a_fling_moves_the_list_and_then_settles() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let (list_weak, root, mut render) = build_flingable_list(&mut rsc); let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
// A fling toward the start: negative velocity, matching `scroll`'s // Toward the start: **positive**, which is the finger's direction
// sign convention (`Selection::drag` calls `scroll(-dy)` for a // and `Scroll::scroll`'s convention -- the one convention a delta
// downward finger motion revealing older content). // has anywhere in the crate now. It used to be negative here,
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0); // because a `LazySpan`'s anchor offset ran the opposite way to a
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling()); // `Scroll`'s `amt` while both were public and both claimed to
// mirror the other.
assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0));
assert!(rsc.ui.widgets.get(&scroll).unwrap().is_scrolling());
let start = Instant::now(); let start = Instant::now();
let mut last_still_scrolling = true; let mut still = true;
for step in 0..600 { for step in 0..600 {
let now = start + std::time::Duration::from_millis(step * 16); let now = start + std::time::Duration::from_millis(step * 16);
last_still_scrolling = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now); still = fling_frame(&mut rsc, &scroll, &root, &mut render, now);
render.update(&root, &mut rsc); if !still {
if !last_still_scrolling {
break; break;
} }
} }
assert!(!still, "fling never settled within 600 steps");
assert!(!rsc.ui.widgets.get(&scroll).unwrap().is_scrolling());
assert!( assert!(
!last_still_scrolling, scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()) < 200.0 * FLING_ROW_H,
"fling never settled within 600 steps" "a fling toward the start should have moved the list back through its rows"
); );
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
} }
/// The half `fling` itself does not do: a registered list is advanced /// The registration half, which is the frame loop's rather than the
/// by the frame loop's own driver, and unregisters itself when the /// widget's: a fling that nothing registers never moves, however right
/// fling settles. Written against `UiData::tick_animations` rather /// its velocity is -- which is exactly what a finger fling did on
/// than `tick_fling` because the defect it pins is exactly the gap /// Iris's phone for two builds.
/// between the two -- a fling with a correct velocity that nothing
/// ever advanced, which is what a finger fling did on the phone.
#[test] #[test]
fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() { fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let (list_weak, root, mut render) = build_flingable_list(&mut rsc); let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
let before = rsc let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
.ui
.widgets
.get(&list_weak)
.unwrap()
.anchor_position_display();
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0); if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) {
rsc.ui.animate(list_weak.id()); let id = scroll.id();
rsc.ui.animate(id);
}
let start = Instant::now(); let start = Instant::now();
let mut animating = true;
let mut steps = 0; let mut steps = 0;
let mut animating = true;
while animating && steps < 600 { while animating && steps < 600 {
animating = rsc let now = start + std::time::Duration::from_millis(steps * 16);
.ui animating = rsc.ui.tick_animations(now);
.tick_animations(start + std::time::Duration::from_millis(steps * 16));
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
steps += 1; steps += 1;
} }
assert!(!animating, "the driver never stopped within 600 frames"); assert!(!animating, "the driver never stopped within 600 frames");
assert!(steps > 1, "the fling settled without ever moving"); assert!(steps > 1, "the fling settled without ever moving");
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
assert_ne!( assert_ne!(
scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()),
before, before,
rsc.ui "the fling was registered but never applied"
.widgets
.get(&list_weak)
.unwrap()
.anchor_position_display(),
"the list is where it started -- the fling was registered but never applied"
); );
// Nothing left registered, so the next frame costs nothing: the
// path out of `animate` is the `false` answer, not a caller // Nothing left registered, so the next frame costs nothing.
// remembering to remove it. let now = start + std::time::Duration::from_millis(steps * 16);
assert!(!rsc.ui.tick_animations(start)); assert!(!rsc.ui.tick_animations(now));
} }
/// The sign, pinned across the whole handoff: gesture -> `Scroll` ->
/// `apply_scroll` -> anchor. A negative delta is the finger moving the
/// negative way along the axis, which pulls **later** content up into
/// view. Getting this wrong anywhere in that chain scrolls the list
/// backwards, which no type can catch.
#[test] #[test]
fn fling_distance_is_positive_toward_the_end() { fn a_negative_delta_moves_toward_the_end() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let (list_weak, root, mut render) = build_flingable_list(&mut rsc); let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
// Start scrolled away from the newest end so there is room for an // Start well back from the end so there is room to move forward.
// end-ward fling to actually move. rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(2000.0);
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0]; let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-500.0);
let start = Instant::now();
for step in 0..600 {
let now = start + std::time::Duration::from_millis(step * 16);
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
if !still { let after = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
assert!(
after > before,
"a negative delta should move toward the end: {before} -> {after}"
);
assert!(
(after - before - 500.0).abs() < 0.5,
"and by exactly what was asked for, away from a wall: {before} -> {after}"
);
}
/// `Scroll::amt` is the accumulated movement the child actually made,
/// so it stays equal to what is on screen even when a delta runs off
/// the end of the content. This is the whole reason `apply_scroll`
/// hands back a remainder rather than a "hit a wall" flag: an
/// all-or-nothing answer would leave `amt` over-counted by every
/// overshoot, and nothing would ever correct it.
#[test]
fn amt_counts_only_what_the_child_could_take() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (_list, scroll, root, mut render) = build_flingable_list(&mut rsc);
// A short move away from the end, all of which is available.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100.0);
render.update(&root, &mut rsc);
assert!(
(rsc.ui.widgets.get(&scroll).unwrap().amt() + 100.0).abs() < 0.5,
"amt counts forward through the content, so 100px back is -100: {}",
rsc.ui.widgets.get(&scroll).unwrap().amt()
);
// 200 rows of 20px in a 600px viewport: 3400px of travel in all,
// so this asks for far more than is left and must be given only
// what there was.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0);
render.update(&root, &mut rsc);
let amt = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!(
(amt + 3400.0).abs() < 0.5,
"amt should equal the content's real travel, not what was asked for: {amt}"
);
}
/// A fling stops when the child says it could not take the delta,
/// rather than spending its remaining distance on content that is not
/// there. Before the clamp existed, a hard fling to the top of the
/// bench fixture left the first row 1398px below a 600px viewport --
/// the whole screen blank -- and it stayed there.
#[test]
fn a_fling_stops_at_the_first_row() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
// An enormous velocity that would travel far past all 200 rows if
// unclamped.
rsc.ui.widgets.get_mut(&scroll).unwrap().fling(50_000.0);
let start = Instant::now();
for step in 0..2000 {
let now = start + std::time::Duration::from_millis(step * 16);
if !fling_frame(&mut rsc, &scroll, &root, &mut render, now) {
break; break;
} }
} }
// No settling frame: the draw that runs out of content gives the
// pixels back inside that same frame, so the last frame the loop
// drew is already flush with the top.
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
// Row 0 either scrolled out of the loaded extents (flung well past assert!(list_ref.at_start, "the fling should have reached the start");
// it) or moved upward (smaller top) -- either way, real motion // Both edges, so neither an overshoot past the top nor one left
// happened toward the end rather than staying put. // uncorrected can pass. `extents` used to hold every row the walk
if let Some(after) = list_ref.extents.get(&0) { // placed, on screen or not, so this read was once satisfied by a
// first row sitting 1398px *below* the viewport with the whole
// screen blank.
let first = list_ref.extents[&0];
assert!( assert!(
after.lead < before.lead, first.lead.abs() < 0.5,
"fling toward the end did not move content up" "a fling stopped at the start must leave the first row flush with the top, not {}px \
from it",
first.lead
); );
} }
}
/// `fling_moves_the_list_and_then_settles`/ /// Asking for more travel than the content has leaves it *on* its
/// `fling_distance_is_positive_toward_the_end` only check that a fling /// first row rather than beyond it, in the frame that asked. Since the
/// started, moved the right way and eventually stopped -- both /// position moved into `Scroll`, this is prevented at the source --
/// unaffected by *how* the interior ticks split up the total travel /// `apply_scroll` only takes what the walk says is there -- rather
/// (docs/REVIEW-2026-09-06.md finding 9). A regression that made /// than corrected afterwards, and `overscroll_gap` is left for the
/// `tick_fling` apply the whole spline distance every tick instead of /// case a scroll cannot cause: content or a viewport that changed
/// just this tick's incremental slice would still pass both, while /// under a settled anchor.
/// being wildly wrong every intermediate frame -- this pins the
/// per-tick delta to a decelerating curve (`FlingCalculator::
/// position_at`'s own monotonic-and-clamped property, one level
/// down, already covers the calculator alone; this is the same
/// property through `LazySpan::tick_fling`'s `scroll`/`extents`
/// accumulation).
#[test] #[test]
fn tick_fling_applies_shrinking_incremental_deltas() { fn scrolling_past_the_start_lands_on_it_in_the_same_frame() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let (list_weak, root, mut render) = build_flingable_list(&mut rsc); let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start(); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0);
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
let start = Instant::now();
let mut prev = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
let mut deltas = Vec::new();
for step in 1..600 {
let now = start + std::time::Duration::from_millis(step * 16);
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
let at = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
deltas.push((at - prev).abs());
prev = at;
if !still {
break;
}
}
assert!(
deltas.len() >= 3,
"fling settled before collecting enough samples"
);
// Skip the first tick (the slop-transition jump the arbiter
// applies is a `LazySpan::fling`-adjacent concern, not this curve,
// but the very first frame can still carry rounding noise from
// `jump_to_start`'s own layout settling).
for w in deltas[1..].windows(2) {
assert!(
w[1] <= w[0] + 0.01,
"fling's per-tick delta grew instead of decelerating: {:?} then {:?}",
w[0],
w[1]
);
}
// Non-increasing is not deceleration: a fling that coasts at a
// constant speed and then stops dead satisfies every `<=` above,
// and that is exactly what iris shipped until 2026-09-07
// (`android_fling_spline`'s doc). Over the samples collected here
// -- the earliest part of the curve, since row 0 leaves the loaded
// extents soon after -- AOSP's spline has already lost more than
// a fifth of its speed.
let (first, last) = (deltas[1], *deltas.last().unwrap());
assert!(
last < first * 0.8,
"fling barely slowed across {} ticks: {first} -> {last}",
deltas.len()
);
}
#[test]
fn cancel_fling_stops_it_with_no_further_movement() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
let start = Instant::now();
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.tick_fling(start + std::time::Duration::from_millis(16));
render.update(&root, &mut rsc);
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
rsc.ui.widgets.get_mut(&list_weak).unwrap().cancel_fling();
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
// A tick after cancelling must be a no-op -- this is what a fresh
// touch-down relies on to stop a fling in its tracks.
let still = rsc
.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.tick_fling(start + std::time::Duration::from_millis(200));
render.update(&root, &mut rsc);
assert!(!still);
let after = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
assert_eq!((before.lead, before.trail), (after.lead, after.trail));
}
/// The clamp on the frame that discovers it, for an ordinary
/// `scroll` rather than a fling: one `update` after moving 100,000px
/// past the first row, and the list is already flush with the top.
/// The old code left that frame drawn with the whole screen blank and
/// snapped back on the next one, so this fails on it -- it draws no
/// settling frame on purpose (the same shape as
/// `phone_screen.rs`'s composer test).
#[test]
fn scrolling_past_the_start_is_given_back_in_the_same_frame() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.scroll(-100_000.0);
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
let first = list_ref.extents[&0]; let first = list_ref.extents[&0];
@@ -2347,50 +2296,6 @@ mod tests {
); );
} }
#[test]
fn fling_toward_the_start_stops_at_the_first_row() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
// An enormous velocity that would travel far past all 200 rows if
// unclamped -- this is exactly what IRIS_TODO.md's "way faster...
// better for stress testing" fling asks for.
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-50_000.0);
let start = Instant::now();
for step in 0..2000 {
let now = start + std::time::Duration::from_millis(step * 16);
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
if !still {
break;
}
}
// No settling frame: the draw that discovers the fling ran past
// the first row gives those pixels back inside that same frame
// (`overscroll_gap`), so the last frame the loop above drew is
// already flush with the top. Drawing one more here would hide a
// regression to the old next-frame correction.
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(
list_ref.at_start,
"fling should have clamped at the first row"
);
// Not `>= -0.5`: `extents` used to hold every row the walk placed,
// on screen or not, so that read was satisfied by a first row
// sitting *1398px below* a 600px viewport with the whole screen
// blank -- the assertion could not fail in the direction the bug
// actually went. Both edges, so neither an overshoot past the top
// nor one left uncorrected can pass.
let first = list_ref.extents[&0];
assert!(
first.lead.abs() < 0.5,
"a fling stopped at the start must leave the first row flush with the top, not {}px \
from it",
first.lead
);
}
#[test] #[test]
fn anchor_position_display_before_any_draw_is_none() { fn anchor_position_display_before_any_draw_is_none() {
let list = LazySpan::new(Dir::DOWN, true); let list = LazySpan::new(Dir::DOWN, true);
@@ -2402,7 +2307,7 @@ mod tests {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let (list_weak, root, mut render) = build_flingable_list(&mut rsc); let (list_weak, _scroll, root, mut render) = build_flingable_list(&mut rsc);
let _ = (&root, &mut render); let _ = (&root, &mut render);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(list_ref.anchor_position_display().starts_with("idx=")); assert!(list_ref.anchor_position_display().starts_with("idx="));
+224 -79
View File
@@ -20,6 +20,27 @@ pub struct Scroll {
/// therefore opened at the end of its longest line, mid-word /// therefore opened at the end of its longest line, mid-word
/// (`iris/run-headless.sh phone`, 2026-09-08). /// (`iris/run-headless.sh phone`, 2026-09-08).
content_len: Option<f32>, content_len: Option<f32>,
/// A scroll delta this area has been handed but not yet passed to a
/// child that positions itself ([`Widget::scrolls_itself`]) -- how
/// far a wheel, a drag or a fling asked to move since the last draw.
/// Applied and cleared in `draw`, which is the only place the child's
/// walls are known.
///
/// Nothing accumulates here for an ordinary child, whose position is
/// this widget's own `amt` and is written the moment a delta arrives.
pending: f32,
/// Whether the last draw handed a self-positioning child more than it
/// could take -- the only way this widget learns where that child's
/// content ends, since it has no length to ask for. Reset every time a
/// delta is fully consumed.
hit_wall: bool,
/// Whether the child positions itself, from its own
/// [`Widget::scrolls_itself`], re-read every draw. Cached because it
/// is asked once per scroll delta as well as once per draw, and it is
/// read through `get_dyn` -- taking `&mut` to ask would mark every
/// ordinary child dirty on every tick and cost exactly the O(1) move
/// this widget exists for.
child_scrolls: bool,
/// Touch panning, from the same `DragGesture` `LazySpan` is driven by /// Touch panning, from the same `DragGesture` `LazySpan` is driven by
/// (`transcript-ui::Selection::drag`) rather than a second copy of its /// (`transcript-ui::Selection::drag`) rather than a second copy of its
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in /// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
@@ -50,42 +71,32 @@ impl Widget for Scroll {
let delta = self.fling.tick(now); let delta = self.fling.tick(now);
self.scroll(delta); self.scroll(delta);
// A fling must not keep spending its distance on content that is // A fling must not keep spending its distance on content that is
// not there. Unlike `LazySpan`, this widget knows exactly where its // not there. With an ordinary child this widget knows exactly
// content ends -- `update_amt` has just clamped `amt` into it -- // where the content ends -- `update_amt` has just clamped `amt`
// so the wall is read after the move rather than from what the // into it -- so the wall is read straight off `amt`. With a child
// last draw found. // that positions itself there is no content length to read, and
if self.amt <= 0.0 || self.amt >= self.scroll_range() { // the wall arrives instead as the part of a delta the child could
// not take (`hit_wall`, set in `draw`); it is one frame old, which
// costs a fling one extra tick and nothing on screen, since the
// child clamped its own position within the frame that found it.
let at_wall = if self.child_scrolls {
self.hit_wall
} else {
self.amt <= 0.0 || self.amt >= self.scroll_range()
};
if at_wall {
self.fling.stop(); self.fling.stop();
} }
self.fling.is_flinging() self.fling.is_flinging()
} }
fn draw(&mut self, painter: &mut Painter) -> Size { fn draw(&mut self, painter: &mut Painter) -> Size {
// The region offered to the child is sized using *last* frame's
// content length, not a fresh measurement -- deliberately, so that
// an ordinary scroll tick (`amt` changes, content does not) offers
// the child the exact same size it was last drawn with, only
// shifted. That is what lets `draw_inner` dispatch this as an O(1)
// move (LAYOUT.md section 2) instead of a redraw: sizing the region
// to a *fresh* measurement would require drawing the child first to
// learn it, and a provisional draw almost never matches the
// previously active size, forcing a real redraw on every tick. A
// genuine content-size change (not just a scroll) therefore lags
// one frame before the container's clamp reflects it; the content
// length itself (read below from what was actually drawn) is never
// stale, so this self-corrects the next frame and never leaves the
// scroll range wrong for long. See LAYOUT.md section 4.
//
// Every length here is resolved against the box this widget was // Every length here is resolved against the box this widget was
// **offered** (`px_size`), never `output_size`: a `Scroll` is // **offered** (`px_size`), never `output_size`: a `Scroll` is
// routinely smaller than the window -- the composer's field is // routinely smaller than the window -- the composer's field is
// capped at six lines by a `MaxSize` around it -- and measuring // capped at six lines by a `MaxSize` around it -- and measuring
// the window instead would make the pan range, and so where the // the window instead would make the pan range, and so where the
// content sits, a function of the screen rather than of the box. // content sits, a function of the screen rather than of the box.
// (What the previous arithmetic here computed came to the same
// number by a longer route, through a `within_len` against a
// window-relative scalar; it read as if the window were the
// container and cost a session working out that it was not.)
let axis = self.axis; let axis = self.axis;
let container_len = painter.px_size().axis(axis); let container_len = painter.px_size().axis(axis);
self.container_len = container_len; self.container_len = container_len;
@@ -94,45 +105,56 @@ impl Widget for Scroll {
// density, and `draw` is where this widget meets the only thing // density, and `draw` is where this widget meets the only thing
// that knows it. // that knows it.
self.density = painter.density(); self.density = painter.density();
let density = self.density; let was_known = self.child_scrolls;
self.child_scrolls = painter.scrolls_itself(&self.inner);
// A delta that arrived before this widget had ever drawn went to
// `amt` (the ordinary child's path), because what kind of child
// this is cannot be asked until there is a painter to ask through.
// Hand it on rather than dropping it: `amt` means nothing to a
// self-positioning child, so the delta would simply never happen.
if self.child_scrolls && !was_known && self.amt != 0.0 {
self.pending -= self.amt;
self.amt = 0.0;
}
if self.child_scrolls {
self.draw_self_scrolling_child(painter)
} else {
self.draw_moved_child(painter, container_len)
}
}
}
// **The child's box along this axis is its own length**, not the impl Scroll {
// container's -- that is what a scroll viewport means, and it is /// The ordinary case: the child is a fixed lump this widget slides
// why the offset is the only thing that moves. A length is not /// about, and its position is `amt`.
// knowable without drawing (LAYOUT.md section 5), so this draws ///
// the child once to measure it and once to place it, the same /// **The child is drawn twice, and only the second decides anything.**
// measure-then-place idiom `Span::draw` and `LazySpan::place` /// The first is handed last frame's length as a *hint* -- nothing
// already use. Written out rather than through /// about where the child ends up depends on it, and it exists only so
// `Painter::draw_twice`, which cannot take a closure needing /// that the usual case, where the content's length did not change,
// `&mut self` while `self.inner` is borrowed. /// offers the same region twice: `draw_inner` then makes the first
// /// call an O(1) `mov` and returns at the first line of the second. A
// **Both draws are in this frame, and only the second one /// frame on which the content did grow or shrink pays one real extra
// decides anything.** The first is handed last frame's length as /// draw, and that is a frame on which the content was being redrawn
// a *hint* -- nothing about where the child ends up depends on /// anyway.
// it, and it exists only so that the usual case, where the ///
// content's length did not change, offers the same region twice: /// The alternative -- place against the hint and let the next frame
// `draw_inner` then makes the first call an O(1) `mov` and /// fix it -- is what Iris found on her phone (2026-09-08): every
// returns at the first line of the second. A frame on which the /// newline typed into the composer drew the field in a box one line
// content did grow or shrink pays one real extra draw, and that /// short of its text, and since that text is centred in its box it
// is a frame on which the content was being redrawn anyway. /// hung half a line past each end. There was no next frame: nothing
// /// dirtied that subtree again, so the stale placement was the last one
// The alternative -- place against the hint and let the next /// drawn, until the keyboard closed and its inset rewrite forced a
// frame fix it -- is what Iris found on her phone (2026-09-08): /// redraw ("it fixes itself"). **Layout is a pure function of the
// every newline typed into the composer drew the field in a box /// state, not of how many frames have been drawn** (Iris, 2026-09-08)
// one line short of its text, and since that text is centred in /// -- a correction that needs a second frame is a frame drawn wrong.
// its box it hung half a line past each end, putting the caret a ///
// whole 12dp below the bar's inside edge and flush with its /// The container's own length stands in as the hint until anything has
// bottom. There was no next frame: nothing dirtied that subtree /// been measured: a zero-length region on the first frame would place
// again, so the stale placement was the last one drawn, until /// the child's primitives against a box of no size.
// the keyboard closed and its inset rewrite forced a redraw ("it fn draw_moved_child(&mut self, painter: &mut Painter, container_len: f32) -> Size {
// fixes itself"). **Layout is a pure function of the state, not let axis = self.axis;
// of how many frames have been drawn** (Iris, 2026-09-08) -- a let density = self.density;
// correction that needs a second frame is a frame drawn wrong.
//
// The container's own length stands in as the hint until
// anything has been measured: a zero-length region on the first
// frame would place the child's primitives against a box of no
// size.
let hint = self.content_len.unwrap_or(container_len); let hint = self.content_len.unwrap_or(container_len);
let used = painter.widget_within(&self.inner, self.child_region(hint)); let used = painter.widget_within(&self.inner, self.child_region(hint));
@@ -153,21 +175,82 @@ impl Widget for Scroll {
self.amt = measured - container_len; self.amt = measured - container_len;
} }
self.update_amt(); self.update_amt();
let used = painter.widget_within(&self.inner, self.child_region(measured));
// The **content's** size, not the container's. A parent that can // The **content's** size, not the container's. A parent that can
// grow (the composer's bar) should hug the text until its own cap // grow (the composer's bar) should hug the text until its own cap
// stops it, and reporting the container instead would make this // stops it, and reporting the container instead would make this
// widget's answer a function of the answer -- the bar is sized // widget's answer a function of the answer -- the bar is sized
// from what is reported here, so it collapses to nothing and // from what is reported here, so it collapses to nothing and never
// never recovers. What keeps the content inside the offered box // recovers. What keeps the content inside the offered box is the
// is the mask a caller puts around it (`.scrollable().masked()`), // mask a caller puts around it (`.scrollable().masked()`), not
// not this number. // this number.
painter.widget_within(&self.inner, self.child_region(measured))
}
/// The lazy case: the child positions its own content
/// ([`Widget::scrolls_itself`]), so this widget contributes the
/// gesture, the fling and the accounting, and the child contributes
/// the placement.
///
/// Measure, apply, place -- the same measure-then-place idiom
/// [`Self::draw_moved_child`] and `LazySpan::place` use, for the same
/// reason: the child cannot say how much of a delta it can take until
/// it has laid out, and a correction that waits for the next frame is
/// a frame drawn wrong.
///
/// **The measuring draw is free in the common case.** It offers the
/// child the same box as last frame, so with nothing dirty
/// `draw_inner` returns immediately and the walls the child answers
/// against are the ones it last measured -- 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 marks the child by hand.** Reaching it through
/// `get_dyn_mut` to hand it the delta is itself what dirties it
/// (`Widgets::get_dyn_mut`), so the placing draw below really draws
/// rather than taking `draw_inner`'s unchanged-region skip.
fn draw_self_scrolling_child(&mut self, painter: &mut Painter) -> Size {
painter.widget_within(&self.inner, UiRegion::FULL);
let requested = std::mem::take(&mut self.pending);
let mut left = requested;
if requested != 0.0 {
painter.apply_scroll(&self.inner, &mut left);
}
let used = painter.widget_within(&self.inner, UiRegion::FULL);
// Read *after* the child has been placed, because that is the
// draw that finds the end of the content: a child that could not
// see its wall yet took the whole delta above and gave part of it
// back while walking. `amt` is therefore the movement that
// actually happened, not the movement that was asked for -- see
// `Widget::scroll_offset`.
//
// Deliberately **not** a distance from the start of the content:
// paging rows in above moves that origin and the child cannot say
// by how much, never having measured them. It is honest for what
// it is used for -- "how far has this moved", a fling's wall --
// and anything wanting an absolute position (a scrollbar) needs a
// real content length first.
// Negated: `scroll_offset` is in the finger's direction while
// `amt` counts *forward through the content*, so that `amt` means
// the same thing and moves the same way whichever kind of child
// this is. What differs, unavoidably, is the origin -- an ordinary
// child's `amt` is measured from the start of its content, and a
// lazy child has no start to measure from, so its `amt` is
// movement from wherever it happened to be when this area was
// built. Direction is comparable; absolute value is not.
let moved = -painter.scroll_offset(&self.inner);
// A delta that came back short, or one the child took and then
// gave part of back, is this widget's only way of hearing that the
// content ran out.
self.hit_wall = requested != 0.0 && (moved - self.amt + requested).abs() > 0.5;
self.amt = moved;
used used
}
} }
impl Scroll {
/// `at_end` starts the area pinned to the end of its content and /// `at_end` starts the area pinned to the end of its content and
/// keeps it there while the content grows -- see /// keeps it there while the content grows -- see
/// `WidgetLike::scrollable_to_end`. `false` starts at the beginning, /// `WidgetLike::scrollable_to_end`. `false` starts at the beginning,
@@ -180,6 +263,9 @@ impl Scroll {
snap_end: at_end, snap_end: at_end,
container_len: 0.0, container_len: 0.0,
content_len: None, content_len: None,
pending: 0.0,
hit_wall: false,
child_scrolls: false,
gesture: DragGesture::on(axis), gesture: DragGesture::on(axis),
fling: Flinger::new(), fling: Flinger::new(),
density: 1.0, density: 1.0,
@@ -235,14 +321,13 @@ impl Scroll {
.gesture .gesture
.handle(pointer, id, sense, pos_window, now, press) .handle(pointer, id, sense, pos_window, now, press)
{ {
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes // The content follows the finger: `dy` straight through, and
// `-dy` to `LazySpan::scroll` because a `LazySpan`'s anchor offset and // the same `dy` a transcript's own arbiter (`Selection::drag`)
// this widget's `amt` run in *opposite* directions (offset is // hands to this same method. There used to be two conventions
// where the anchored edge sits; `amt` is how far the content // here -- this one and a `LazySpan::scroll` whose anchor offset
// has been pulled up past the top), even though `LazySpan::scroll`'s // ran the opposite way while its doc claimed to mirror this
// own doc claims to mirror this one's convention. The rule that // one -- so every call site had to remember which it was
// holds for both, and the one to check a sign against, is that // talking to. There is one now.
// the content follows the finger.
GestureOutcome::Pan(dy) => self.scroll(dy), GestureOutcome::Pan(dy) => self.scroll(dy),
// Same sign as `Pan`, since `tick` applies it through the // Same sign as `Pan`, since `tick` applies it through the
// same `scroll`. // same `scroll`.
@@ -288,7 +373,12 @@ impl Scroll {
/// question that cannot be answered yet -- answering it anyway is /// question that cannot be answered yet -- answering it anyway is
/// what `content_len`'s doc describes. /// what `content_len`'s doc describes.
pub fn update_amt(&mut self) { pub fn update_amt(&mut self) {
if self.content_len.is_none() { // A self-positioning child has no content length and no clamp of
// its own here: it does its own clamping in `apply_scroll`, and
// `amt` is a record of what it did rather than a position to
// correct. Clamping it against a `scroll_range` of zero (which is
// what no `content_len` computes to) would peg it at 0 forever.
if self.child_scrolls || self.content_len.is_none() {
return; return;
} }
let len = self.scroll_range(); let len = self.scroll_range();
@@ -304,7 +394,7 @@ impl Scroll {
} }
/// Whether a fling is coasting here right now -- the same question /// Whether a fling is coasting here right now -- the same question
/// `LazySpan::is_scrolling` answers for the other scrolling widget, under /// `Scroll::is_scrolling` answers for the other scrolling widget, under
/// the same name so there is one word for it. What a caller polls to /// the same name so there is one word for it. What a caller polls to
/// know whether this area is moving on its own (a test, and /// know whether this area is moving on its own (a test, and
/// [`PressState::scrolling`]'s own condition). /// [`PressState::scrolling`]'s own condition).
@@ -312,6 +402,46 @@ impl Scroll {
self.fling.is_flinging() self.fling.is_flinging()
} }
/// Start a fling at `velocity`, in the same direction convention as
/// [`Self::scroll`]. Answers whether one actually started, which is
/// the caller's cue to register this widget for frames
/// (`UiData::animate`) -- see [`Widget::tick`]. Cancels any fling
/// already in progress.
///
/// **Sets the fling; it does not drive it.** A fling moves only while
/// something calls `tick` once per frame, and what does that in a
/// running app is `UiData::tick_animations`, over the ids
/// `UiData::animate` was given. Split that way because the two halves
/// have different owners: the velocity is this widget's business and
/// whether anything animates at all is the frame loop's. Missing the
/// second call is what a finger fling did on Iris's phone for two
/// builds -- the velocity was right and nothing ever advanced it,
/// which looks exactly like a list that stops dead under the finger.
///
/// The density handed to `FlingCalculator` is this area's own, taken
/// from the painter in `draw`, not `1.0`: it does **not** cancel out
/// of the spline, and a hardcoded 1.0 against a 2.75-density screen
/// made a flick that should coast for about a second run for 45.
pub fn fling(&mut self, velocity: f32) -> bool {
self.fling.start(velocity, self.density)
}
/// Cancel any fling in progress with no further movement -- the next
/// touch-down's job, since `AndroidFlingSpline`'s curve has no idea a
/// finger came back down and Android's own `Scroller` relies on the
/// view calling `abortAnimation` for the same reason.
pub fn cancel_fling(&mut self) {
self.fling.stop();
}
/// The velocity a fling in progress is coasting at, `None` when
/// nothing is flinging. What a release's decision looks like from the
/// outside, so a test can read what the gesture measured rather than
/// re-timing the gesture itself.
pub fn fling_velocity(&self) -> Option<f32> {
self.fling.velocity()
}
/// Which way this area pans. For a caller that found the widget /// Which way this area pans. For a caller that found the widget
/// rather than built it -- a test walking what is drawn, a scroll /// rather than built it -- a test walking what is drawn, a scroll
/// indicator asking which edge to sit on. /// indicator asking which edge to sit on.
@@ -319,7 +449,22 @@ impl Scroll {
self.axis self.axis
} }
/// Pan by `amt`, in the finger's direction: positive moves the
/// content the positive way along the axis, which brings **earlier**
/// content into view. One convention, and the one
/// [`Widget::apply_scroll`] carries, so that a delta means the same
/// thing wherever it is handed on.
///
/// A child that positions itself cannot be moved by writing `amt`
/// here -- where it can actually go is a question only its own layout
/// can answer -- so the delta is banked until `draw`, which is where
/// that answer exists. `amt` is then written from what the child
/// really took.
pub fn scroll(&mut self, amt: f32) { pub fn scroll(&mut self, amt: f32) {
if self.child_scrolls {
self.pending += amt;
return;
}
self.amt -= amt; self.amt -= amt;
self.update_amt(); self.update_amt();
} }
@@ -125,11 +125,6 @@ impl DefaultAppState for Client {
if let Some(inset) = ime_argv() { if let Some(inset) = ime_argv() {
opened.screen.composer.set_bottom_inset(rsc, inset); opened.screen.composer.set_bottom_inset(rsc, inset);
} }
// A fling coasts only while something asks for the next
// frame; on the desktop that is the window's own redraw
// request (`LazySpan::fling`'s doc).
let handle = rsc.tasks.redraw_handle();
(opened.screen.list)(rsc).set_redraw_handle(handle);
Some(opened.screen) Some(opened.screen)
} }
// On screen rather than a panic: this window exists to be // On screen rather than a panic: this window exists to be
@@ -124,7 +124,7 @@ fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
PHONE_FRAME_MS, PHONE_FRAME_MS,
); );
assert!( assert!(
(screen.list)(&mut h.rsc).is_scrolling(), (screen.scroll)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing" "the fling must still be running 150ms in, or this test catches nothing"
); );
@@ -151,7 +151,7 @@ fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
PHONE_FRAME_MS, PHONE_FRAME_MS,
); );
assert!( assert!(
(screen.list)(&mut h.rsc).is_scrolling(), (screen.scroll)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing" "the fling must still be running 150ms in, or this test catches nothing"
); );
@@ -161,7 +161,7 @@ fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8); h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
assert_eq!( assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(), (screen.scroll)(&mut h.rsc).fling_velocity(),
None, None,
"a press that stopped a fling and moved nothing must not start another" "a press that stopped a fling and moved nothing must not start another"
); );
@@ -195,7 +195,7 @@ fn the_same_small_drag_on_a_settled_list_moves_nothing() {
PHONE_FRAME_MS, PHONE_FRAME_MS,
); );
assert!( assert!(
!(screen.list)(&mut h.rsc).is_scrolling(), !(screen.scroll)(&mut h.rsc).is_scrolling(),
"the fling must have stopped, or this is the same case as the test above" "the fling must have stopped, or this is the same case as the test above"
); );
@@ -60,7 +60,7 @@ fn a_cancelled_flick_does_not_fling() {
h.replay(&flick); h.replay(&flick);
assert_eq!( assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(), (screen.scroll)(&mut h.rsc).fling_velocity(),
None, None,
"a gesture the platform took away must not fling" "a gesture the platform took away must not fling"
); );
@@ -110,7 +110,7 @@ fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
after - before after - before
); );
assert_eq!( assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(), (screen.scroll)(&mut h.rsc).fling_velocity(),
None, None,
"and it must not have flung either" "and it must not have flung either"
); );
+14 -9
View File
@@ -34,7 +34,7 @@ fn offset(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> String {
/// (a) and (b) together, because the second is only meaningful if the /// (a) and (b) together, because the second is only meaningful if the
/// first happened: the recorded flick must release with a real velocity /// first happened: the recorded flick must release with a real velocity
/// (`GestureOutcome::Released(Some(v))`, which is the only thing that /// (`GestureOutcome::Released(Some(v))`, which is the only thing that
/// puts a value in `LazySpan::fling_velocity`), and the list must then /// puts a value in `Scroll::fling_velocity`), and the list must then
/// actually travel and stop on the spline's own schedule. /// actually travel and stop on the spline's own schedule.
#[test] #[test]
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() { fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
@@ -44,18 +44,23 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch")); let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
h.replay(&flick); h.replay(&flick);
let velocity = (screen.list)(&mut h.rsc) let velocity = (screen.scroll)(&mut h.rsc)
.fling_velocity() .fling_velocity()
.expect("the flick must release as a pan with a velocity, not a tap"); .expect("the flick must release as a pan with a velocity, not a tap");
// Compose's own answer for this recording's five samples, printed by // Compose's own answer for this recording's five samples, printed by
// `iris/benches/velocity_reference.py` -- not a number read off this // `iris/benches/velocity_reference.py` -- not a number read off this
// code. Negative because the flick runs *down* the screen and // code. **Positive** because the flick runs *down* the screen and a
// `Selection::drag` flings the list by `-v` (see its `Released` arm). // delta now carries the finger's own direction the whole way, from the
// gesture through `Selection::drag` (which passes it straight to
// `Scroll::fling`) to the anchor. It read -15250 while the transcript
// negated the velocity on its way into a `LazySpan` whose anchor
// offset ran the other way; the magnitude is the number that came from
// `velocity_reference.py` and it has not changed.
// The 2026-09-07 before/after: the old average estimator read // The 2026-09-07 before/after: the old average estimator read
// -12250px/s here, which is the fling Iris reported as too slow. // 12250px/s here, which is the fling Iris reported as too slow.
assert!( assert!(
(velocity + 15_250.0).abs() < 20.0, (velocity - 15_250.0).abs() < 20.0,
"expected ~-15250px/s from velocity_reference.py, got {velocity}" "expected ~15250px/s from velocity_reference.py, got {velocity}"
); );
// `iris/benches/fling_spline_reference.py`'s own line for this exact // `iris/benches/fling_spline_reference.py`'s own line for this exact
@@ -93,7 +98,7 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
.key_at(middle) .key_at(middle)
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))), .and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
}; };
if settled_at.is_none() && !list.is_scrolling() { if settled_at.is_none() && !(screen.scroll)(&mut h.rsc).is_scrolling() {
settled_at = Some(t); settled_at = Some(t);
} }
t += PHONE_FRAME_MS; t += PHONE_FRAME_MS;
@@ -137,7 +142,7 @@ fn a_tap_on_a_row_moves_nothing() {
h.replay(&script("tap", include_str!("../touch/tap.touch"))); h.replay(&script("tap", include_str!("../touch/tap.touch")));
assert_eq!( assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(), (screen.scroll)(&mut h.rsc).fling_velocity(),
None, None,
"a tap must not fling" "a tap must not fling"
); );
+15 -10
View File
@@ -62,10 +62,15 @@ fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32
rows rows
} }
/// Scrolls `amount` (negative walks back through older rows) and runs the /// Scrolls `amount` and runs the frame it asks for, returning the time of
/// frame it asks for, returning the time of the next one. /// the next one. **Positive walks back through older rows** -- the
/// finger's own direction, and `Scroll::scroll`'s, which is the one
/// convention a delta has anywhere in iris since the transcript's scroll
/// position moved out of the `LazySpan` and into the `Scroll` around it.
/// It used to be the opposite here, because a `LazySpan`'s anchor offset
/// ran the other way.
fn scrolled(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, amount: f32, t: u64) -> u64 { fn scrolled(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
(screen.list)(&mut h.rsc).scroll(amount); (screen.scroll)(&mut h.rsc).scroll(amount);
h.frame(t); h.frame(t);
t + PHONE_FRAME_MS t + PHONE_FRAME_MS
} }
@@ -84,7 +89,7 @@ fn the_row_across_the_top_edge_is_drawn() {
// 40px a frame, the shape a finger pan arrives in, through a straddle // 40px a frame, the shape a finger pan arrives in, through a straddle
// and out the other side of it many times over. // and out the other side of it many times over.
for _ in 0..60 { for _ in 0..60 {
t = scrolled(&mut h, &screen, -40.0, t); t = scrolled(&mut h, &screen, 40.0, t);
let rows = drawn_rows(&h, &screen); let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("something is on screen"); let first = *rows.first().expect("something is on screen");
assert!( assert!(
@@ -231,17 +236,17 @@ fn rows_that_have_left_the_viewport_are_not_drawn() {
}; };
for step in 0..40 { for step in 0..40 {
t = scrolled(&mut h, &screen, -400.0, t); t = scrolled(&mut h, &screen, 400.0, t);
bounded(&drawn_rows(&h, &screen), "measuring", step); bounded(&drawn_rows(&h, &screen), "measuring", step);
} }
for step in 0..40 { for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t); t = scrolled(&mut h, &screen, -400.0, t);
let rows = drawn_rows(&h, &screen); let rows = drawn_rows(&h, &screen);
bounded(&rows, "forward", step); bounded(&rows, "forward", step);
inside(&rows, "forward", step); inside(&rows, "forward", step);
} }
for step in 0..40 { for step in 0..40 {
t = scrolled(&mut h, &screen, -400.0, t); t = scrolled(&mut h, &screen, 400.0, t);
let rows = drawn_rows(&h, &screen); let rows = drawn_rows(&h, &screen);
bounded(&rows, "back", step); bounded(&rows, "back", step);
inside(&rows, "back", step); inside(&rows, "back", step);
@@ -259,7 +264,7 @@ fn the_row_across_the_bottom_edge_is_drawn() {
let mut t = PHONE_FRAME_MS * 2; let mut t = PHONE_FRAME_MS * 2;
for _ in 0..40 { for _ in 0..40 {
t = scrolled(&mut h, &screen, -37.0, t); t = scrolled(&mut h, &screen, 37.0, t);
let rows = drawn_rows(&h, &screen); let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("something is on screen"); let last = *rows.last().expect("something is on screen");
assert!( assert!(
@@ -290,7 +295,7 @@ fn scrolling_past_the_first_row_settles_on_it() {
let mut t = PHONE_FRAME_MS * 2; let mut t = PHONE_FRAME_MS * 2;
for _ in 0..60 { for _ in 0..60 {
t = scrolled(&mut h, &screen, -100_000.0, t); t = scrolled(&mut h, &screen, 100_000.0, t);
} }
// No settling frame on purpose: the draw that discovers the gap gives // No settling frame on purpose: the draw that discovers the gap gives
// it back inside that same frame (`LazySpan::overscroll_gap`), so the last // it back inside that same frame (`LazySpan::overscroll_gap`), so the last
@@ -316,7 +321,7 @@ fn scrolling_past_the_last_row_settles_on_it() {
let mut t = PHONE_FRAME_MS * 2; let mut t = PHONE_FRAME_MS * 2;
for _ in 0..20 { for _ in 0..20 {
t = scrolled(&mut h, &screen, 100_000.0, t); t = scrolled(&mut h, &screen, -100_000.0, t);
} }
let rows = drawn_rows(&h, &screen); let rows = drawn_rows(&h, &screen);
+37 -11
View File
@@ -59,6 +59,11 @@ pub struct TranscriptScreen {
/// `.extent()`/call `.jump_to_end()` etc. directly for anything this /// `.extent()`/call `.jump_to_end()` etc. directly for anything this
/// crate does not already wrap. /// crate does not already wrap.
pub list: WeakWidget<LazySpan>, pub list: WeakWidget<LazySpan>,
/// The scroll area around it, which owns the position, the fling and
/// the pin -- what a caller drives to scroll the transcript, or reads
/// to ask whether it is coasting. The `LazySpan` above lays out; it
/// does not scroll.
pub scroll: WeakWidget<Scroll>,
pub composer: composer::Composer, pub composer: composer::Composer,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
/// How many times [`Self::apply`] has fallen back to a full rebuild -- /// How many times [`Self::apply`] has fallen back to a full rebuild --
@@ -375,16 +380,6 @@ where
tail = kept.map(|t| (key, t)); tail = kept.map(|t| (key, t));
} }
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
// `scrollable()` uses for `Scroll`, applied directly to `LazySpan` since
// `LazySpan` already does its own placement and needs no `Scroll` wrapper.
// Real touch-drag panning is the known gap in this module's doc.
list.on(CursorSense::Scroll, |ctx, rsc| {
let delta = ctx.data.scroll_delta.y * 50.0;
ctx.widget(rsc).scroll(delta);
})
.add(rsc);
// The continuation of a row-started drag once it has committed and // The continuation of a row-started drag once it has committed and
// taken pointer capture on `list`'s own id (`row.rs`'s registration is // taken pointer capture on `list`'s own id (`row.rs`'s registration is
// only ever the gesture's first frame) -- registered once here, not // only ever the gesture's first frame) -- registered once here, not
@@ -420,6 +415,36 @@ where
.add(rsc); .add(rsc);
} }
// The scroll area. Everything about *scrolling* is here rather than
// in the list: the wheel, the fling, `amt`, and the clamp against the
// ends. The `LazySpan` inside answers `Widget::scrolls_itself`, so
// this hands it deltas to apply itself (`apply_scroll`) instead of
// sliding it about as a lump -- which it cannot be, since which rows
// exist at all is a function of where it is scrolled to.
//
// Built out rather than through `.scrollable_to_end()`, and this is
// the reason: that helper registers a finger drag on the `Scroll`,
// driving `Scroll`'s own `DragGesture`, and the transcript already has
// an arbiter of its own -- `Selection`, which has to decide between
// panning and selecting text and so cannot let a second `DragGesture`
// see the same frames. `DragGesture`'s doc says it: one gesture, one
// arbiter, each frame delivered exactly once. So the wheel is
// registered here (identical to the helper's) and the finger arrives
// through `Selection::drag`, which hands committed pans and releases
// to this same `Scroll`.
//
// `at_end: true` -- a transcript opens at its newest message and stays
// there while replies arrive. That is the *pin*, and it is a different
// question from `Dir::DOWN` above, which says the oldest message is
// item 0 and sits at the top.
let scroll = Scroll::new(list.add_strong(rsc).any(), Axis::Y, true)
.on(CursorSense::Scroll, |ctx, rsc| {
let delta = ctx.data.scroll_delta.y * 50.0;
ctx.widget(rsc).scroll(delta);
})
.add(rsc);
selection.borrow_mut().set_scroll_area(scroll);
let (composer, composer_bar) = composer::build_composer(rsc); let (composer, composer_bar) = composer::build_composer(rsc);
// `.masked()`: the list draws the row straddling each of its edges in // `.masked()`: the list draws the row straddling each of its edges in
@@ -428,13 +453,14 @@ where
// there, which on the phone is the header bar (docs/IRIS_TODO.md, // there, which on the phone is the header bar (docs/IRIS_TODO.md,
// 2026-09-07: "code and a paragraph visible behind Run benchmark"). // 2026-09-07: "code and a paragraph visible behind Run benchmark").
// The same clip is what `LazySpan::draw` asserts it has. // The same clip is what `LazySpan::draw` asserts it has.
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar) let tree = (scroll.width(rest(1)).height(rest(1)).masked(), composer_bar)
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
( (
TranscriptScreen { TranscriptScreen {
scroll,
tail: RefCell::new(tail), tail: RefCell::new(tail),
session_working: std::cell::Cell::new(false), session_working: std::cell::Cell::new(false),
list, list,
+54 -14
View File
@@ -53,6 +53,15 @@ pub struct Selection {
/// in iris's default input layer, with only the pan-vs-select /// in iris's default input layer, with only the pan-vs-select
/// *decision* staying here. /// *decision* staying here.
gesture: DragGesture, gesture: DragGesture,
/// The scroll area around the transcript's `LazySpan`, which owns the
/// position, the fling and `amt` -- what a committed pan and a release
/// are handed to. Set by `build_tree` the moment it exists, which is
/// after this (rows need a `Selection` to be built, and the `Scroll`
/// needs the rows), hence the `Option` rather than a constructor
/// argument. A `Selection` without one still selects text and still
/// reports taps; it simply cannot pan, which is what the `debug_assert`
/// in `drag` is there to catch in the tests that build one by hand.
scroll: Option<WeakWidget<Scroll>>,
} }
impl Default for Selection { impl Default for Selection {
@@ -67,9 +76,18 @@ impl Selection {
rows: BTreeMap::new(), rows: BTreeMap::new(),
anchor: None, anchor: None,
gesture: DragGesture::new(), gesture: DragGesture::new(),
scroll: None,
} }
} }
/// Hand this the scroll area that wraps the transcript, once it
/// exists -- see the `scroll` field. Called by `build_tree`; every
/// pan and fling this arbitrates goes there rather than to the
/// `LazySpan`, which no longer owns a position.
pub fn set_scroll_area(&mut self, scroll: WeakWidget<Scroll>) {
self.scroll = Some(scroll);
}
/// A row's selectable text became visible/known. Every addition here /// A row's selectable text became visible/known. Every addition here
/// needs its removal (`unregister`, or `clear` for all of them at /// needs its removal (`unregister`, or `clear` for all of them at
/// once) -- called when `LazySpan` evicts the row (`pop_front`/ /// once) -- called when `LazySpan` evicts the row (`pop_front`/
@@ -260,7 +278,7 @@ impl Selection {
pointer: &PointerRequests, pointer: &PointerRequests,
) -> GestureOutcome { ) -> GestureOutcome {
// A fresh touch-down cancels any fling still coasting from the // A fresh touch-down cancels any fling still coasting from the
// previous gesture -- `LazySpan::fling`'s own doc, and Android's // previous gesture -- `Scroll::fling`'s own doc, and Android's
// `Scroller::abortAnimation` for the same reason -- and, since // `Scroller::abortAnimation` for the same reason -- and, since
// 2026-09-07, *tells the gesture there was one*. A press that // 2026-09-07, *tells the gesture there was one*. A press that
// caught moving content is a catch: it pans from this very sample // caught moving content is a catch: it pans from this very sample
@@ -270,10 +288,17 @@ impl Selection {
// finger is"). See `DragArbiter::press_start` for Compose's own // finger is"). See `DragArbiter::press_start` for Compose's own
// mechanism. `starts_press` rather than a `PressStart` test of our // mechanism. `starts_press` rather than a `PressStart` test of our
// own, so this fires on the recovered-press frames too. // own, so this fires on the recovered-press frames too.
debug_assert!(
self.scroll.is_some(),
"Selection::drag with no scroll area: a committed pan would be silently dropped -- \
call `set_scroll_area` after building the `Scroll` around the transcript"
);
let mut press = PressState::default(); let mut press = PressState::default();
if self.gesture.starts_press(sense) { if self.gesture.starts_press(sense) {
press.scrolling = list(ui).is_scrolling(); press.scrolling = self.scroll.is_some_and(|s| s(ui).is_scrolling());
list(ui).cancel_fling(); if let Some(scroll) = self.scroll {
scroll(ui).cancel_fling();
}
} }
press.already_selected = self.has_selection(ui); press.already_selected = self.has_selection(ui);
let outcome = self let outcome = self
@@ -286,7 +311,15 @@ impl Selection {
// to undo either -- the point is that no tap, fling or // to undo either -- the point is that no tap, fling or
// selection follows from a gesture that was never ours. // selection follows from a gesture that was never ours.
GestureOutcome::Cancelled | GestureOutcome::Undecided => {} GestureOutcome::Cancelled | GestureOutcome::Undecided => {}
GestureOutcome::Pan(dy) => list(ui).scroll(-dy), // `scroll(dy)`, not `scroll(-dy)`: since the position moved
// into `Scroll` there is one convention for a scroll delta in
// the crate -- the finger's -- rather than a `LazySpan` whose
// anchor offset ran the other way while claiming to mirror it.
GestureOutcome::Pan(dy) => {
if let Some(scroll) = self.scroll {
scroll(ui).scroll(dy);
}
}
GestureOutcome::SelectStart => { GestureOutcome::SelectStart => {
if let Some((key, pos_row, size)) = row { if let Some((key, pos_row, size)) = row {
// Grep-able on "iris selection" the way the frame // Grep-able on "iris selection" the way the frame
@@ -310,18 +343,19 @@ impl Selection {
// tap/long-press that never left `Undecided` -- exactly what // tap/long-press that never left `Undecided` -- exactly what
// `DragGesture`'s `Some(v)` already encodes. // `DragGesture`'s `Some(v)` already encodes.
GestureOutcome::Released(Some(v)) => { GestureOutcome::Released(Some(v)) => {
list(ui).fling(-v);
// The half that actually makes it move -- see // The half that actually makes it move -- see
// `LazySpan::fling`'s doc. Without it the velocity is // `Scroll::fling`'s doc. Without it the velocity is
// computed, stored, and never advanced by anything. // computed, stored, and never advanced by anything.
// //
// Only when `fling` actually took it: below Compose's // Only when `fling` actually took it: below Compose's
// `|v| <= 1.0`, or with no anchor, there is nothing to // `|v| <= 1.0` there is nothing to tick, and registering
// tick, and registering an animation for a widget that is // an animation for a widget that is not animating asks the
// not animating asks the next frame to find that out // next frame to find that out (docs/REVIEW-2026-09-07.md's
// (docs/REVIEW-2026-09-07.md's second nit). // second nit).
if list(ui).is_scrolling() { if let Some(scroll) = self.scroll
let id = list.id(); && scroll(ui).fling(v)
{
let id = scroll.id();
ui.ui_mut().animate(id); ui.ui_mut().animate(id);
} }
} }
@@ -427,13 +461,19 @@ mod tests {
EditMode::MultiLine, EditMode::MultiLine,
)) ))
.weak(); .weak();
let list = rsc let list = rsc.ui.widgets.add_strong(LazySpan::new(Dir::DOWN, true));
let list_weak = list.weak();
// The scroll area the real screen puts around it: a committed pan
// goes there, and `Selection` asserts it was told about one.
let scroll = rsc
.ui .ui
.widgets .widgets
.add_strong(LazySpan::new(Dir::DOWN, true)) .add_strong(Scroll::new(list.any(), Axis::Y, true))
.weak(); .weak();
let list = list_weak;
let mut sel = Selection::new(); let mut sel = Selection::new();
sel.set_scroll_area(scroll);
sel.register((1, 0), field); sel.register((1, 0), field);
assert!(sel.gesture.is_idle()); assert!(sel.gesture.is_idle());