iris: scrolling belongs to Scroll, and a LazySpan only lays out
Steps 2 and 3 of the plan in docs/IRIS_TODO.md, together because
deleting the fling before `Scroll` could drive it would leave the app
unable to scroll at all. IRIS.md has the account and the measurements.
`LazySpan` loses its `Flinger`, its `density`, its
`Arc<dyn RequestRedraw>` -- which had no business existing in a
single-threaded frame loop -- its `tick`, and the whole
`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity`
surface. `Scroll` was the only other `Flinger` user, so there is now one
implementation of the physics rather than two, and a transcript is
`list.scrollable_to_end()` like anything else.
Three new `Widget` methods carry the handoff:
fn scrolls_itself(&self) -> bool { false }
fn apply_scroll(&mut self, delta: &mut f32) {}
fn scroll_offset(&self) -> f32 { 0.0 }
`Scroll` asks the first, and a child that says yes is handed deltas
instead of being slid about as a lump -- which a lazy layout cannot be,
since which rows exist at all is a function of where it is scrolled to,
and it has no content length to be clamped against. `scrolls_itself` is
`&self` deliberately: `Widgets::get_dyn_mut` marks a widget dirty, so
asking through `apply_scroll` would dirty every ordinary child on every
tick and cost exactly the O(1) move the scheme exists for.
`Scroll::draw` is measure, apply, place -- the idiom it already used for
its own content length. The measuring draw is free in the common case
(unchanged region, nothing dirty, `draw_inner` returns immediately and
the child's stored walls are still correct) and really walks exactly
when the content changed. Nothing is marked by hand: reaching the child
to hand it the delta is what dirties it, which is why `draw_again` could
stay deleted.
`scroll_offset` was not in the plan and is needed. A lazy span usually
cannot say where its content ends until it has walked there, so it takes
a delta in full whenever the wall is not already in view and the walk
gives part of it back; the remainder is exact only when the wall was
already visible, and `Scroll` adding remainders up would over-count by
every overshoot and never correct. It reads the child's accumulated
movement after the placing draw instead, so `amt` equals what is on
screen. `amt_counts_only_what_the_child_could_take` is the test.
One convention for a scroll delta, the finger's. `Scroll::scroll(+)`
moved toward the start while `LazySpan::scroll(+)` moved toward the end,
with the latter's doc claiming to mirror the former -- so every call site
had to know which it was talking to. `LazySpan::scroll` is private now
and the single negation is inside its `apply_scroll`; call sites that
passed `-dy`/`-v` pass them through, and `phone_screen.rs`'s recorded
velocity flips sign with its magnitude unchanged.
`a_negative_delta_moves_toward_the_end` pins the sign across the whole
handoff, since nothing else can catch a list scrolling backwards.
The transcript builds its `Scroll` by hand rather than through
`.scrollable_to_end()`: that helper registers a finger drag, and
`Selection` is already the arbiter for those frames -- two `DragGesture`s
seeing one gesture is what its own doc rules out. Caught by
`a_long_press_and_drag_selects_text`, which failed when both were live.
Deferred, in DECISIONS.md and IRIS_TODO.md: the *pin* is still each
widget's own. Applying one happens when a row is appended, between
frames with no painter in hand, so moving it to `Scroll` needs a fourth
`Widget` method or a parameter on `apply_scroll`; nothing external edits
a pin today.
Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace green (21 suites), the arm64 release APK builds,
and the phone-shaped headless window replaying flick-120hz.touch scrolls
back through the transcript in the direction it did before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8e5928cc6a
commit
b7474f61b0
19 files changed
+1016
-611
No files matched your search
@@ -5,6 +5,34 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
||||
for iris API changes); this file is only the summary. Newest first. Items
|
||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||
|
||||
## 2026-09-08 (last: scrolling moves out of the list)
|
||||
|
||||
Agreed with Iris in the exchange that followed, so most of this is her
|
||||
call rather than mine. IRIS.md has the account. What I decided along the
|
||||
way, and would flag for reversal:
|
||||
|
||||
- **A third `Widget` method, `scroll_offset`**, beyond the two we agreed.
|
||||
`apply_scroll`'s remainder is exact only when the wall was already in
|
||||
view, and a lazy span usually cannot see its wall until it has walked
|
||||
there -- so `Scroll` reads the child's accumulated movement after the
|
||||
placing draw instead of adding remainders up, which would drift.
|
||||
- **One scroll-delta convention, the finger's.** The two widgets had
|
||||
opposite ones under the same name; `LazySpan::scroll` is now private and
|
||||
the single negation lives in its `apply_scroll`. Call sites that passed
|
||||
`-dy`/`-v` pass them straight through, and one fixture's expected
|
||||
velocity flipped sign with its magnitude unchanged.
|
||||
- **The transcript builds its `Scroll` by hand rather than through
|
||||
`.scrollable_to_end()`**, because that helper registers a finger drag
|
||||
and `Selection` is already the arbiter for those frames -- two
|
||||
`DragGesture`s seeing one gesture is what `DragGesture`'s own doc rules
|
||||
out. The wheel is registered identically; only the drag differs.
|
||||
- **DEFERRED: the pin stays in each widget.** Iris asked for `amt` and
|
||||
the at-end control to live in `Scroll`; `amt` does, the pin does not,
|
||||
because applying a pin happens when a row is appended -- between frames,
|
||||
with no painter -- so moving it needs a fourth `Widget` method or a
|
||||
parameter on `apply_scroll`. Nothing external edits a pin today.
|
||||
docs/IRIS_TODO.md carries it.
|
||||
|
||||
## 2026-09-08 (later still: the list's overscroll clamp, in frame)
|
||||
|
||||
Finishes the item the previous entry deferred. IRIS.md has the account.
|
||||
|
||||
+105
@@ -12,6 +12,111 @@ things still stay out.
|
||||
An entry gives the date, what changed, why, and a short before/after where
|
||||
it helps judge the change without the session that made it. Newest first.
|
||||
|
||||
## 2026-09-08 (last): `List` is `LazySpan`, and scrolling belongs to `Scroll`
|
||||
|
||||
From the design exchange after the overscroll fix, where you asked
|
||||
whether `List` could just be `Span::scrollable()`. It cannot -- a lazy
|
||||
layout is a real thing a `Span` is not, for reasons measured below -- but
|
||||
almost everything you named as out of place was, and it has all moved.
|
||||
|
||||
**`List` -> `LazySpan`** (`ListRow` -> `LazyItem`, `RowKey` unchanged),
|
||||
living beside `Span` under `widget/position/`. It is what `Span` is, laid
|
||||
out lazily from an anchor rather than eagerly from the start, and the name
|
||||
says so. It also stops colliding with `BlockKind::List` in the markdown
|
||||
code.
|
||||
|
||||
**It takes a `Dir` instead of an `Axis`**, meaning what it means in `Span`:
|
||||
which end item 0 sits at. That is a **different question from which end
|
||||
the view is pinned to**, and conflating them would stand a transcript on
|
||||
its head -- its oldest message is item 0 and sits at the top (`Dir::DOWN`)
|
||||
while the view clings to the bottom. So the pin is its own argument:
|
||||
`LazySpan::new(dir, at_end)`, spelled like `Scroll::new`'s. `Dir::UP` is
|
||||
real rather than nominal: the walk works in direction-relative pixels from
|
||||
the leading edge, with `abs_region` flipping the box and `flip_pos`
|
||||
converting the screen-space positions the hit-testing helpers speak in.
|
||||
|
||||
**Everything about scrolling left the list.** Its `Flinger`, its
|
||||
`density`, its `Arc<dyn RequestRedraw>` (which had no business existing in
|
||||
a single-threaded frame loop), its `tick`, and the whole
|
||||
`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity`
|
||||
surface are gone. `Scroll` was the only other `Flinger` user, so there is
|
||||
now exactly one implementation of the physics and `sense.rs` keeps the
|
||||
parts both ever shared. A transcript is `list.scrollable_to_end()` like
|
||||
anything else.
|
||||
|
||||
### The new public surface: three `Widget` methods
|
||||
|
||||
```rust
|
||||
fn scrolls_itself(&self) -> bool { false }
|
||||
fn apply_scroll(&mut self, delta: &mut f32) {}
|
||||
fn scroll_offset(&self) -> f32 { 0.0 }
|
||||
```
|
||||
|
||||
`Scroll` asks the first, and if the child says yes it stops sliding the
|
||||
child about as a lump and starts handing it deltas. Each method is `&self`
|
||||
or `&mut self` for a reason worth keeping: reaching a widget through
|
||||
`Widgets::get_dyn_mut` *marks it dirty*, so asking the capability question
|
||||
through `apply_scroll` would dirty every ordinary child on every scroll
|
||||
tick and cost exactly the O(1) move the whole scheme exists for.
|
||||
|
||||
`Scroll::draw` is then measure, apply, place -- the same measure-then-place
|
||||
idiom it already used for its own content length. The measuring draw is
|
||||
free in the common case (unchanged region, nothing dirty, so `draw_inner`
|
||||
returns immediately and the child's stored walls are still correct) and
|
||||
really walks exactly when the content changed, which is when they need
|
||||
re-reading. **Nothing is marked by hand**: reaching the child to hand it
|
||||
the delta is itself what dirties it, so the placing draw really draws.
|
||||
That is why `Painter::draw_again` could stay deleted.
|
||||
|
||||
### Why `scroll_offset` exists
|
||||
|
||||
`apply_scroll` leaving a remainder was meant to be the whole story, and it
|
||||
is not quite. A lazy span usually **cannot say where its content ends
|
||||
until it has walked there**, so it takes a delta in full whenever the wall
|
||||
is not already in view, and the walk that follows gives part of it back.
|
||||
The remainder is exact only when the wall was already visible. `Scroll`
|
||||
adding remainders up would over-count by every overshoot and never
|
||||
correct, so it reads the child's accumulated movement after the placing
|
||||
draw instead, and `amt` is set from that. `amt` therefore always equals
|
||||
what is on screen.
|
||||
|
||||
For a self-positioning child `amt` is **movement, not position**: paging
|
||||
rows in above moves the origin and the child cannot say by how much,
|
||||
never having measured them. The direction is the same as an ordinary
|
||||
child's; the absolute value is not comparable, and a scrollbar would need
|
||||
a real content length before it could use either.
|
||||
|
||||
### One convention for a scroll delta
|
||||
|
||||
There were two, and they read alike: `Scroll::scroll(+)` moved toward the
|
||||
*start* while `LazySpan::scroll(+)` moved toward the *end*, with the
|
||||
latter's doc claiming to mirror the former. Every call site had to
|
||||
remember which it was talking to, and `Selection::drag` negated on the way
|
||||
in. There is one now -- the finger's, which is `Scroll`'s -- and
|
||||
`LazySpan::scroll` is private with the single negation inside
|
||||
`apply_scroll`. `a_negative_delta_moves_toward_the_end` pins it across the
|
||||
whole handoff, since no type can catch a scroll running backwards.
|
||||
|
||||
### What the measurements said, for the record
|
||||
|
||||
- A `Span` is skipped entirely in the steady state (`(0,0,0)` counters),
|
||||
but **when it is redrawn it costs two draws per child** -- 21 draws for
|
||||
10 children -- because phase 1 offers each child the ambient region to
|
||||
learn its length and phase 2 offers it its real share. Any mutation of a
|
||||
`Span` therefore redraws all of it: 24 draws for 11 children after one
|
||||
prepend. That is why a transcript cannot be one.
|
||||
- A settled scroll tick of the lazy span with 31 rows on screen is
|
||||
**1 real draw and 31 move-slot writes**, no primitive rewrites and no
|
||||
text reshaped; an idle frame is `(0,0,0,0)`. That is the number against
|
||||
which "store the edges and only recompute what changed" would be
|
||||
judged, and it is why the walk was left alone.
|
||||
- The framework's own `ActiveData::size` cannot serve as the row-height
|
||||
cache: `remove_rec` frees it the moment a row is virtualised away,
|
||||
which is exactly when the walk needs it. The cache stays in the
|
||||
container, keyed by `RowKey` -- which is also right for the reason you
|
||||
gave, that a widget may one day render in two places and a size keyed
|
||||
by `WidgetId` would break.
|
||||
|
||||
## 2026-09-08 (later still): a `List` clamps its overscroll in the same frame, and `draw_again` is gone
|
||||
|
||||
The last place in iris that corrected itself on a later frame. `List`'s
|
||||
|
||||
+39
-1
@@ -52,7 +52,45 @@ order and what "done" looks like. Tick and date them in place.
|
||||
honest for every current use and must be written at the field so
|
||||
nobody builds a scrollbar on it.
|
||||
|
||||
Step 1 is done. Steps 2 and 3 are not.
|
||||
Done 2026-09-08, in two commits (the rename, then steps 2 and 3
|
||||
together -- deleting the fling before `Scroll` could drive it would
|
||||
have left the app unable to scroll at all).
|
||||
|
||||
**Two things the plan did not anticipate, both settled in the code:**
|
||||
|
||||
- **`apply_scroll`'s remainder is not enough on its own, so `Widget`
|
||||
gained a third method, `scroll_offset`.** A lazy span usually cannot
|
||||
say where its content ends until it has walked there, so it takes a
|
||||
delta in full whenever the wall is not already in view, and the walk
|
||||
that follows gives part of it back. The remainder is therefore right
|
||||
only when the wall was already visible, and `Scroll` adding
|
||||
remainders up would over-count by every overshoot and never correct.
|
||||
`scroll_offset` is the child's accumulated movement, read `&self`
|
||||
after the placing draw, and `Scroll::amt` is set from it -- so `amt`
|
||||
equals what is on screen rather than what was asked for. There is a
|
||||
test, `amt_counts_only_what_the_child_could_take`.
|
||||
- **There were two opposite scroll-delta conventions**, and the
|
||||
handoff made keeping both impossible. `Scroll::scroll(+)` moved
|
||||
toward the *start* while `LazySpan::scroll(+)` moved toward the
|
||||
*end*, and `LazySpan::scroll`'s own doc claimed to mirror `Scroll`'s.
|
||||
There is one now -- the finger's, which is `Scroll`'s -- and
|
||||
`LazySpan::scroll` is private, with the single negation inside
|
||||
`apply_scroll`. Call sites that used to pass `-dy`/`-v` pass them
|
||||
through, and the fixture recordings' expected velocity flipped sign
|
||||
with its magnitude unchanged.
|
||||
|
||||
**Still open, and the one thing to decide:** the *pin* ("stay at the
|
||||
end as rows are appended") is still each widget's own -- `Scroll` has
|
||||
`snap_end` for an ordinary child, `LazySpan` has one for itself, and
|
||||
the constructor argument sets each. Iris asked for `amt` and "other
|
||||
controls (iirc only at end for now)" to live in `Scroll` so a caller
|
||||
always edits the `Scroll`; that half is done for `amt` and not for the
|
||||
pin, because a pin has to be *applied* when a row is appended --
|
||||
between frames, with no painter in hand -- so moving it needs either a
|
||||
fourth `Widget` method or a parameter on `apply_scroll`. Nothing
|
||||
external edits a pin today (the transcript sets it once at
|
||||
construction and calls `jump_to_end` on the span for the rest), so
|
||||
this is a design question rather than a missing capability.
|
||||
|
||||
|
||||
- [x] **`List::clamp_to_content` still corrects on the next frame
|
||||
|
||||
@@ -42,7 +42,7 @@ const STREAM_SECONDS: u64 = 20;
|
||||
/// swipe with these any more.
|
||||
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
|
||||
/// 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.
|
||||
@@ -908,14 +908,15 @@ where
|
||||
}
|
||||
|
||||
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
|
||||
/// from it (toward older messages) through `LazySpan::fling`, then
|
||||
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
|
||||
/// convention (`LazySpan::scroll`'s own doc: positive moves *later* content
|
||||
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
|
||||
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
|
||||
/// the other way around; the two apps' *travel* is still directly
|
||||
/// comparable because both report it as a row index + pixel offset, not a
|
||||
/// signed distance.
|
||||
/// from it (toward older messages) through `Scroll::fling`, then
|
||||
/// `FLING_COUNT` back. Outward is *positive* in `Scroll::scroll`'s
|
||||
/// convention, which is the finger's: a finger dragged down the screen
|
||||
/// brings earlier content into view. It was negative here until
|
||||
/// 2026-09-08, when the transcript's scroll position moved out of the
|
||||
/// `LazySpan` -- whose anchor offset ran the other way -- and into the
|
||||
/// `Scroll` around it. The two apps' *travel* is directly comparable
|
||||
/// 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(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
@@ -937,8 +938,8 @@ async fn run_fling_phase(
|
||||
for _ in 0..FLING_COUNT {
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||
animate_list(screen.list, rsc);
|
||||
(screen.scroll)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||
animate_scroll(screen.scroll, rsc);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
@@ -950,8 +951,8 @@ async fn run_fling_phase(
|
||||
for _ in 0..FLING_COUNT {
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||
animate_list(screen.list, rsc);
|
||||
(screen.scroll)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||
animate_scroll(screen.scroll, rsc);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
@@ -977,11 +978,11 @@ async fn read_anchor_position(
|
||||
.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) --
|
||||
/// `LazySpan::fling` sets a velocity and drives nothing by itself.
|
||||
fn animate_list(list: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
|
||||
let id = list.id();
|
||||
/// `Scroll::fling` sets a velocity and drives nothing by itself.
|
||||
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::Scroll>, rsc: &mut Rsc) {
|
||||
let id = scroll.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.
|
||||
///
|
||||
/// **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
|
||||
/// redrew the list at a position it had already drawn, and the benchmark
|
||||
/// 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();
|
||||
while started.elapsed() < cap {
|
||||
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,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -113,14 +113,22 @@ fn build_message_list(
|
||||
rsc: &mut BenchRsc,
|
||||
n: usize,
|
||||
image_every: usize,
|
||||
) -> (WeakWidget<LazySpan>, StrongWidget) {
|
||||
) -> (WeakWidget<LazySpan>, WeakWidget<Scroll>, StrongWidget) {
|
||||
let mut list = LazySpan::new(Dir::DOWN, true);
|
||||
for i in 0..n {
|
||||
let row = build_row(rsc, i, image_every);
|
||||
list.push_back(LazyItem::new(i as u64, row));
|
||||
}
|
||||
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) {
|
||||
@@ -135,7 +143,7 @@ fn bench_first_frame(n: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
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();
|
||||
render.resize((1080.0, 2000.0));
|
||||
|
||||
@@ -160,11 +168,11 @@ fn bench_scroll(n: usize, ticks: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
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();
|
||||
render.resize((1080.0, 2000.0));
|
||||
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.take_counters();
|
||||
|
||||
@@ -173,7 +181,7 @@ fn bench_scroll(n: usize, ticks: usize) {
|
||||
let mut total_rewrites = 0u64;
|
||||
let mut total_moves = 0u64;
|
||||
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();
|
||||
render.update(&root, &mut rsc);
|
||||
total += start.elapsed();
|
||||
@@ -207,7 +215,7 @@ fn bench_input_grows(n: usize, lines: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
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 {
|
||||
inner: list_root,
|
||||
x: None,
|
||||
@@ -231,7 +239,7 @@ fn bench_input_grows(n: usize, lines: usize) {
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 2000.0));
|
||||
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.take_counters();
|
||||
|
||||
@@ -277,7 +285,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
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();
|
||||
render.resize((1080.0, 2000.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
@@ -166,6 +166,43 @@ impl<'a> Painter<'a> {
|
||||
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
|
||||
/// [`Self::set_mask`], or one an ancestor set that it inherited. What
|
||||
/// a widget whose contents may legitimately extend past its own box
|
||||
|
||||
@@ -60,6 +60,72 @@ pub trait Widget: Any {
|
||||
fn tick(&mut self, now: std::time::Instant) -> bool {
|
||||
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 () {
|
||||
|
||||
@@ -104,11 +104,12 @@ impl DefaultAppState for State {
|
||||
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
|
||||
.on(CursorSense::Scroll, |ctx, rsc| {
|
||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
||||
ctx.widget(rsc).scroll(delta);
|
||||
})
|
||||
.scrollable_to_end(Axis::Y)
|
||||
.masked()
|
||||
.background(rect(Color::WHITE))
|
||||
.add_strong(rsc);
|
||||
|
||||
+3
-3
@@ -142,7 +142,7 @@ impl TouchScript {
|
||||
|
||||
/// Counts the frames something asked for without drawing any -- the
|
||||
/// 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".
|
||||
#[derive(Default)]
|
||||
pub struct RedrawCounter(AtomicUsize);
|
||||
@@ -335,7 +335,7 @@ impl Harness {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn at(&self, t_ms: u64) -> Instant {
|
||||
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
|
||||
/// 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 {
|
||||
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
||||
let mut t = from_ms;
|
||||
|
||||
+14
-14
@@ -924,7 +924,7 @@ pub struct PressState {
|
||||
/// instead of waiting for a long-press.
|
||||
pub already_selected: bool,
|
||||
/// 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,
|
||||
/// and catches skip the slop entirely.
|
||||
pub scrolling: bool,
|
||||
@@ -1125,7 +1125,7 @@ impl DragArbiter {
|
||||
|
||||
/// Whether the arbiter's current gesture (if any) has committed to
|
||||
/// 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
|
||||
/// a pan, never a text selection that happened to end with the finger
|
||||
/// still moving.
|
||||
@@ -1158,7 +1158,7 @@ impl DragArbiter {
|
||||
pub enum GestureOutcome {
|
||||
Undecided,
|
||||
/// 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),
|
||||
SelectStart,
|
||||
SelectExtend,
|
||||
@@ -1173,7 +1173,7 @@ pub enum GestureOutcome {
|
||||
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
|
||||
/// `Some(velocity)` only if the gesture had committed to panning
|
||||
/// (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`.
|
||||
Released(Option<f32>),
|
||||
/// Another widget took the pointer (`CursorSense::Cancel`), so this
|
||||
@@ -1478,9 +1478,9 @@ const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1;
|
||||
/// (`DragGestureNode.sendDragStopped` passes
|
||||
/// `LocalViewConfiguration.maximumFlingVelocity` into
|
||||
/// `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
|
||||
/// deliberately **no** matching minimum: see `LazySpan::fling`.
|
||||
/// deliberately **no** matching minimum: see `Scroll::fling`.
|
||||
pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0;
|
||||
|
||||
/// 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
|
||||
/// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum
|
||||
/// 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
|
||||
/// 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
|
||||
// outer guard even though the degenerate case is now detected
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
@@ -1876,7 +1876,7 @@ const FLING_FRICTION: f32 = 0.015;
|
||||
/// small, which put an `ln` of a 56x-too-large ratio through
|
||||
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
|
||||
/// 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.
|
||||
const FLING_TUNING: f32 = 0.84;
|
||||
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
|
||||
/// wrong density changes how long a fling lasts exponentially rather than
|
||||
/// 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
|
||||
/// its density from the painter now.
|
||||
pub struct FlingCalculator {
|
||||
@@ -1919,7 +1919,7 @@ impl FlingCalculator {
|
||||
/// Total signed distance the fling travels before settling, in the
|
||||
/// same pixel units `velocity` was given in.
|
||||
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
|
||||
// bug that produced it (docs/REVIEW-2026-09-06.md finding 3).
|
||||
debug_assert!(velocity.is_finite());
|
||||
@@ -1946,7 +1946,7 @@ impl FlingCalculator {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
|
||||
/// not special-case "past the end."
|
||||
@@ -1964,7 +1964,7 @@ impl FlingCalculator {
|
||||
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
|
||||
/// to zero at `duration()`, which is the whole difference between a
|
||||
/// 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
|
||||
/// at all.
|
||||
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
|
||||
/// 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,
|
||||
/// not merely that the two formulas look plausible independently.
|
||||
#[test]
|
||||
|
||||
@@ -105,18 +105,31 @@
|
||||
//! that "whatever lies between" stays empty however far the list is
|
||||
//! panned.
|
||||
//!
|
||||
//! **Overscroll is taken back within the frame, never rubber-banded.** A
|
||||
//! `scroll()` or a fling past the first or last row leaves a gap;
|
||||
//! `overscroll_gap` measures it 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 list shorter than its viewport is not overscrolled and is
|
||||
//! left alone, still bottom-anchored.
|
||||
//! **This widget does not scroll; it is scrolled.** There is no fling
|
||||
//! here, no gesture, no scroll position and no redraw handle -- a
|
||||
//! `Scroll` around it owns all of that, hands it deltas through
|
||||
//! [`Widget::apply_scroll`], and reads back what it managed to move
|
||||
//! ([`Widget::scroll_offset`]). What is left here is the layout: an
|
||||
//! anchor, a walk outward from it, and an honest answer about how far it
|
||||
//! can go. So `.scrollable_to_end()` is how a `LazySpan` is scrolled,
|
||||
//! 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 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
|
||||
/// 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
|
||||
/// however many rows are currently loaded.
|
||||
heights: HashMap<RowKey, f32>,
|
||||
/// The list's fling, shared with every other scrolling widget in the
|
||||
/// crate -- see `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's
|
||||
/// "swiping has no momentum."
|
||||
fling: Flinger,
|
||||
/// 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."
|
||||
/// Whether the last walk found no more content before the leading
|
||||
/// edge *and* nothing left to give back there -- what
|
||||
/// [`Self::overscroll_gap`] reads. `false` by default, matching
|
||||
/// "assume there is more content until a walk proves otherwise."
|
||||
at_start: bool,
|
||||
/// The mirror of `at_start` for the newest end.
|
||||
/// The mirror of `at_start` for the trailing end.
|
||||
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 {
|
||||
@@ -308,11 +320,13 @@ impl LazySpan {
|
||||
snap_end: at_end,
|
||||
viewport_len: 0.0,
|
||||
last_viewport_len: 0.0,
|
||||
fling: Flinger::new(),
|
||||
redraw: None,
|
||||
density: 1.0,
|
||||
at_start: 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,
|
||||
extents: HashMap::default(),
|
||||
heights: HashMap::default(),
|
||||
@@ -438,135 +452,30 @@ impl LazySpan {
|
||||
self.extents.clear();
|
||||
}
|
||||
|
||||
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
||||
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
||||
/// Unclamped here, on purpose: it is one write, and there is nothing
|
||||
/// at this point that knows where the content ends. The `draw` that
|
||||
/// follows gives back whatever this moved past, in that same frame
|
||||
/// ([`Self::overscroll_gap`]).
|
||||
pub fn scroll(&mut self, amt: f32) {
|
||||
/// Move the anchor's edge by `amt` pixels, where positive brings
|
||||
/// **later** content into view.
|
||||
///
|
||||
/// Private, and the opposite sign to [`Widget::apply_scroll`]'s
|
||||
/// `delta`, which is the finger's direction (`Scroll::scroll`'s): the
|
||||
/// anchor's offset says where the pinned edge *sits*, so moving the
|
||||
/// 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 {
|
||||
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
|
||||
/// shape Compose's `firstVisibleItemIndex`/`firstVisibleItemScrollOffset`
|
||||
/// 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.
|
||||
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;
|
||||
// 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
|
||||
// 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;
|
||||
|
||||
impl Widget for LazySpan {
|
||||
/// A `LazySpan` animates exactly one thing, a fling
|
||||
/// ([`Self::tick_fling`]). The registration that makes this run is
|
||||
/// `UiData::animate` beside the `fling` call -- see `fling`'s own doc.
|
||||
fn tick(&mut self, now: Instant) -> bool {
|
||||
self.tick_fling(now)
|
||||
/// A lazy layout cannot be slid about as a lump: which rows exist at
|
||||
/// all is a function of where it is scrolled to, and it has no content
|
||||
/// length to hand its parent to clamp against, since it has never
|
||||
/// measured the rows it has not drawn. So it takes the delta itself.
|
||||
/// 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 {
|
||||
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
|
||||
// (`intersects_viewport`), so the part of it outside this list's
|
||||
// box is on screen unless something clips it -- and with nothing
|
||||
@@ -1277,6 +1231,27 @@ impl Widget for LazySpan {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 {
|
||||
ui: UiData,
|
||||
@@ -2070,273 +2045,247 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Enough rows, tall enough, that a fling toward the start has real
|
||||
/// room to travel before `at_start` clamps it -- shared by the fling
|
||||
/// tests 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
|
||||
/// content position is exactly `slot * ROW_H` because every row there
|
||||
/// is that tall. Measures the list's own accumulated movement (the
|
||||
/// thing `scroll`/`tick_fling` write) rather than the spline's
|
||||
/// bookkeeping, 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 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;
|
||||
|
||||
/// room to travel before the walls stop it -- shared by the tests
|
||||
/// below.
|
||||
///
|
||||
/// Built the way a real caller does since the scroll position moved
|
||||
/// out of this widget: `Masked(Scroll(LazySpan))`. The `Scroll`
|
||||
/// contributes the gesture, the fling and `amt`; the `LazySpan` lays
|
||||
/// out and says how far it can actually go
|
||||
/// (`Widget::apply_scroll`). The mask is outside the `Scroll` because
|
||||
/// what needs clipping is the row straddling an edge, and masks are
|
||||
/// inherited down the chain.
|
||||
fn build_flingable_list(
|
||||
rsc: &mut TestRsc,
|
||||
) -> (WeakWidget<LazySpan>, StrongWidget, UiRenderState) {
|
||||
) -> (
|
||||
WeakWidget<LazySpan>,
|
||||
WeakWidget<Scroll>,
|
||||
StrongWidget,
|
||||
UiRenderState,
|
||||
) {
|
||||
let mut list = LazySpan::new(Dir::DOWN, true);
|
||||
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();
|
||||
render.resize((100.0, 600.0));
|
||||
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]
|
||||
fn fling_moves_the_list_and_then_settles() {
|
||||
fn a_fling_moves_the_list_and_then_settles() {
|
||||
let mut rsc = TestRsc {
|
||||
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
|
||||
// sign convention (`Selection::drag` calls `scroll(-dy)` for a
|
||||
// downward finger motion revealing older content).
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
// Toward the start: **positive**, which is the finger's direction
|
||||
// and `Scroll::scroll`'s convention -- the one convention a delta
|
||||
// has anywhere in the crate now. It used to be negative here,
|
||||
// because a `LazySpan`'s anchor offset ran the opposite way to a
|
||||
// `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 mut last_still_scrolling = true;
|
||||
let mut still = true;
|
||||
for step in 0..600 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
last_still_scrolling = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
if !last_still_scrolling {
|
||||
still = fling_frame(&mut rsc, &scroll, &root, &mut render, now);
|
||||
if !still {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(!still, "fling never settled within 600 steps");
|
||||
assert!(!rsc.ui.widgets.get(&scroll).unwrap().is_scrolling());
|
||||
assert!(
|
||||
!last_still_scrolling,
|
||||
"fling never settled within 600 steps"
|
||||
scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()) < 200.0 * FLING_ROW_H,
|
||||
"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
|
||||
/// by the frame loop's own driver, and unregisters itself when the
|
||||
/// fling settles. Written against `UiData::tick_animations` rather
|
||||
/// than `tick_fling` because the defect it pins is exactly the gap
|
||||
/// between the two -- a fling with a correct velocity that nothing
|
||||
/// ever advanced, which is what a finger fling did on the phone.
|
||||
/// The registration half, which is the frame loop's rather than the
|
||||
/// widget's: a fling that nothing registers never moves, however right
|
||||
/// its velocity is -- which is exactly what a finger fling did on
|
||||
/// Iris's phone for two builds.
|
||||
#[test]
|
||||
fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
let before = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get(&list_weak)
|
||||
.unwrap()
|
||||
.anchor_position_display();
|
||||
let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
|
||||
let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||
rsc.ui.animate(list_weak.id());
|
||||
if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) {
|
||||
let id = scroll.id();
|
||||
rsc.ui.animate(id);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let mut animating = true;
|
||||
let mut steps = 0;
|
||||
let mut animating = true;
|
||||
while animating && steps < 600 {
|
||||
animating = rsc
|
||||
.ui
|
||||
.tick_animations(start + std::time::Duration::from_millis(steps * 16));
|
||||
let now = start + std::time::Duration::from_millis(steps * 16);
|
||||
animating = rsc.ui.tick_animations(now);
|
||||
render.update(&root, &mut rsc);
|
||||
steps += 1;
|
||||
}
|
||||
assert!(!animating, "the driver never stopped within 600 frames");
|
||||
assert!(steps > 1, "the fling settled without ever moving");
|
||||
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
assert_ne!(
|
||||
scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()),
|
||||
before,
|
||||
rsc.ui
|
||||
.widgets
|
||||
.get(&list_weak)
|
||||
.unwrap()
|
||||
.anchor_position_display(),
|
||||
"the list is where it started -- the fling was registered but never applied"
|
||||
"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
|
||||
// remembering to remove it.
|
||||
assert!(!rsc.ui.tick_animations(start));
|
||||
|
||||
// Nothing left registered, so the next frame costs nothing.
|
||||
let now = start + std::time::Duration::from_millis(steps * 16);
|
||||
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]
|
||||
fn fling_distance_is_positive_toward_the_end() {
|
||||
fn a_negative_delta_moves_toward_the_end() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
// Start scrolled away from the newest end so there is room for an
|
||||
// end-ward fling to actually move.
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
|
||||
let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
|
||||
// Start well back from the end so there is room to move forward.
|
||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(2000.0);
|
||||
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);
|
||||
render.update(&root, &mut rsc);
|
||||
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..600 {
|
||||
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 {
|
||||
if !fling_frame(&mut rsc, &scroll, &root, &mut render, now) {
|
||||
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();
|
||||
// Row 0 either scrolled out of the loaded extents (flung well past
|
||||
// it) or moved upward (smaller top) -- either way, real motion
|
||||
// happened toward the end rather than staying put.
|
||||
if let Some(after) = list_ref.extents.get(&0) {
|
||||
assert!(
|
||||
after.lead < before.lead,
|
||||
"fling toward the end did not move content up"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `fling_moves_the_list_and_then_settles`/
|
||||
/// `fling_distance_is_positive_toward_the_end` only check that a fling
|
||||
/// started, moved the right way and eventually stopped -- both
|
||||
/// unaffected by *how* the interior ticks split up the total travel
|
||||
/// (docs/REVIEW-2026-09-06.md finding 9). A regression that made
|
||||
/// `tick_fling` apply the whole spline distance every tick instead of
|
||||
/// just this tick's incremental slice would still pass both, while
|
||||
/// 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]
|
||||
fn tick_fling_applies_shrinking_incremental_deltas() {
|
||||
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().jump_to_start();
|
||||
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!(list_ref.at_start, "the fling should have reached the start");
|
||||
// Both edges, so neither an overshoot past the top nor one left
|
||||
// uncorrected can pass. `extents` used to hold every row the walk
|
||||
// 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!(
|
||||
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()
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/// Asking for more travel than the content has leaves it *on* its
|
||||
/// first row rather than beyond it, in the frame that asked. Since the
|
||||
/// position moved into `Scroll`, this is prevented at the source --
|
||||
/// `apply_scroll` only takes what the walk says is there -- rather
|
||||
/// than corrected afterwards, and `overscroll_gap` is left for the
|
||||
/// case a scroll cannot cause: content or a viewport that changed
|
||||
/// under a settled anchor.
|
||||
#[test]
|
||||
fn cancel_fling_stops_it_with_no_further_movement() {
|
||||
fn scrolling_past_the_start_lands_on_it_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().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);
|
||||
let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
|
||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0);
|
||||
render.update(&root, &mut rsc);
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
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]
|
||||
fn anchor_position_display_before_any_draw_is_none() {
|
||||
let list = LazySpan::new(Dir::DOWN, true);
|
||||
@@ -2402,7 +2307,7 @@ mod tests {
|
||||
let mut rsc = TestRsc {
|
||||
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 list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!(list_ref.anchor_position_display().starts_with("idx="));
|
||||
|
||||
@@ -20,6 +20,27 @@ pub struct Scroll {
|
||||
/// therefore opened at the end of its longest line, mid-word
|
||||
/// (`iris/run-headless.sh phone`, 2026-09-08).
|
||||
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
|
||||
/// (`transcript-ui::Selection::drag`) rather than a second copy of its
|
||||
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
|
||||
@@ -50,42 +71,32 @@ impl Widget for Scroll {
|
||||
let delta = self.fling.tick(now);
|
||||
self.scroll(delta);
|
||||
// A fling must not keep spending its distance on content that is
|
||||
// not there. Unlike `LazySpan`, this widget knows exactly where its
|
||||
// content ends -- `update_amt` has just clamped `amt` into it --
|
||||
// so the wall is read after the move rather than from what the
|
||||
// last draw found.
|
||||
if self.amt <= 0.0 || self.amt >= self.scroll_range() {
|
||||
// not there. With an ordinary child this widget knows exactly
|
||||
// where the content ends -- `update_amt` has just clamped `amt`
|
||||
// into it -- so the wall is read straight off `amt`. With a child
|
||||
// that positions itself there is no content length to read, and
|
||||
// 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.is_flinging()
|
||||
}
|
||||
|
||||
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
|
||||
// **offered** (`px_size`), never `output_size`: a `Scroll` is
|
||||
// routinely smaller than the window -- the composer's field is
|
||||
// capped at six lines by a `MaxSize` around it -- and measuring
|
||||
// the window instead would make the pan range, and so where the
|
||||
// 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 container_len = painter.px_size().axis(axis);
|
||||
self.container_len = container_len;
|
||||
@@ -94,45 +105,56 @@ impl Widget for Scroll {
|
||||
// density, and `draw` is where this widget meets the only thing
|
||||
// that knows it.
|
||||
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
|
||||
// container's -- that is what a scroll viewport means, and it is
|
||||
// why the offset is the only thing that moves. A length is not
|
||||
// knowable without drawing (LAYOUT.md section 5), so this draws
|
||||
// the child once to measure it and once to place it, the same
|
||||
// measure-then-place idiom `Span::draw` and `LazySpan::place`
|
||||
// already use. Written out rather than through
|
||||
// `Painter::draw_twice`, which cannot take a closure needing
|
||||
// `&mut self` while `self.inner` is borrowed.
|
||||
//
|
||||
// **Both draws are in this frame, and only the second one
|
||||
// decides anything.** The first is handed last frame's length as
|
||||
// a *hint* -- nothing about where the child ends up depends on
|
||||
// it, and it exists only so that the usual case, where the
|
||||
// content's length did not change, 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 frame on which the
|
||||
// content did grow or shrink pays one real extra draw, and that
|
||||
// is a frame on which the content was being redrawn anyway.
|
||||
//
|
||||
// The alternative -- place against the hint and let the next
|
||||
// frame fix it -- is what Iris found on her phone (2026-09-08):
|
||||
// every newline typed into the composer drew the field in a box
|
||||
// one line short of its text, and since that text is centred in
|
||||
// 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
|
||||
// bottom. There was no next frame: nothing dirtied that subtree
|
||||
// again, so the stale placement was the last one drawn, until
|
||||
// the keyboard closed and its inset rewrite forced a redraw ("it
|
||||
// fixes itself"). **Layout is a pure function of the state, not
|
||||
// of how many frames have been drawn** (Iris, 2026-09-08) -- a
|
||||
// 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.
|
||||
impl Scroll {
|
||||
/// The ordinary case: the child is a fixed lump this widget slides
|
||||
/// about, and its position is `amt`.
|
||||
///
|
||||
/// **The child is drawn twice, and only the second decides anything.**
|
||||
/// The first is handed last frame's length as a *hint* -- nothing
|
||||
/// about where the child ends up depends on it, and it exists only so
|
||||
/// that the usual case, where the content's length did not change,
|
||||
/// 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
|
||||
/// frame on which the content did grow or shrink pays one real extra
|
||||
/// draw, and that is a frame on which the content was being redrawn
|
||||
/// anyway.
|
||||
///
|
||||
/// The alternative -- place against the hint and let the next frame
|
||||
/// fix it -- is what Iris found on her phone (2026-09-08): every
|
||||
/// newline typed into the composer drew the field in a box one line
|
||||
/// short of its text, and since that text is centred in its box it
|
||||
/// 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
|
||||
/// drawn, until the keyboard closed and its inset rewrite forced a
|
||||
/// redraw ("it fixes itself"). **Layout is a pure function of the
|
||||
/// state, not of how many frames have been drawn** (Iris, 2026-09-08)
|
||||
/// -- a 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.
|
||||
fn draw_moved_child(&mut self, painter: &mut Painter, container_len: f32) -> Size {
|
||||
let axis = self.axis;
|
||||
let density = self.density;
|
||||
let hint = self.content_len.unwrap_or(container_len);
|
||||
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.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
|
||||
// grow (the composer's bar) should hug the text until its own cap
|
||||
// stops it, and reporting the container instead would make this
|
||||
// widget's answer a function of the answer -- the bar is sized
|
||||
// from what is reported here, so it collapses to nothing and
|
||||
// never recovers. What keeps the content inside the offered box
|
||||
// is the mask a caller puts around it (`.scrollable().masked()`),
|
||||
// not this number.
|
||||
// from what is reported here, so it collapses to nothing and never
|
||||
// recovers. What keeps the content inside the offered box is the
|
||||
// mask a caller puts around it (`.scrollable().masked()`), not
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
impl Scroll {
|
||||
/// `at_end` starts the area pinned to the end of its content and
|
||||
/// keeps it there while the content grows -- see
|
||||
/// `WidgetLike::scrollable_to_end`. `false` starts at the beginning,
|
||||
@@ -180,6 +263,9 @@ impl Scroll {
|
||||
snap_end: at_end,
|
||||
container_len: 0.0,
|
||||
content_len: None,
|
||||
pending: 0.0,
|
||||
hit_wall: false,
|
||||
child_scrolls: false,
|
||||
gesture: DragGesture::on(axis),
|
||||
fling: Flinger::new(),
|
||||
density: 1.0,
|
||||
@@ -235,14 +321,13 @@ impl Scroll {
|
||||
.gesture
|
||||
.handle(pointer, id, sense, pos_window, now, press)
|
||||
{
|
||||
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
|
||||
// `-dy` to `LazySpan::scroll` because a `LazySpan`'s anchor offset and
|
||||
// this widget's `amt` run in *opposite* directions (offset is
|
||||
// where the anchored edge sits; `amt` is how far the content
|
||||
// has been pulled up past the top), even though `LazySpan::scroll`'s
|
||||
// own doc claims to mirror this one's convention. The rule that
|
||||
// holds for both, and the one to check a sign against, is that
|
||||
// the content follows the finger.
|
||||
// The content follows the finger: `dy` straight through, and
|
||||
// the same `dy` a transcript's own arbiter (`Selection::drag`)
|
||||
// hands to this same method. There used to be two conventions
|
||||
// here -- this one and a `LazySpan::scroll` whose anchor offset
|
||||
// ran the opposite way while its doc claimed to mirror this
|
||||
// one -- so every call site had to remember which it was
|
||||
// talking to. There is one now.
|
||||
GestureOutcome::Pan(dy) => self.scroll(dy),
|
||||
// Same sign as `Pan`, since `tick` applies it through the
|
||||
// same `scroll`.
|
||||
@@ -288,7 +373,12 @@ impl Scroll {
|
||||
/// question that cannot be answered yet -- answering it anyway is
|
||||
/// what `content_len`'s doc describes.
|
||||
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;
|
||||
}
|
||||
let len = self.scroll_range();
|
||||
@@ -304,7 +394,7 @@ impl Scroll {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// know whether this area is moving on its own (a test, and
|
||||
/// [`PressState::scrolling`]'s own condition).
|
||||
@@ -312,6 +402,46 @@ impl Scroll {
|
||||
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
|
||||
/// rather than built it -- a test walking what is drawn, a scroll
|
||||
/// indicator asking which edge to sit on.
|
||||
@@ -319,7 +449,22 @@ impl Scroll {
|
||||
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) {
|
||||
if self.child_scrolls {
|
||||
self.pending += amt;
|
||||
return;
|
||||
}
|
||||
self.amt -= amt;
|
||||
self.update_amt();
|
||||
}
|
||||
|
||||
@@ -125,11 +125,6 @@ impl DefaultAppState for Client {
|
||||
if let Some(inset) = ime_argv() {
|
||||
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)
|
||||
}
|
||||
// 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,
|
||||
);
|
||||
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"
|
||||
);
|
||||
|
||||
@@ -151,7 +151,7 @@ fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
|
||||
PHONE_FRAME_MS,
|
||||
);
|
||||
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"
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
assert_eq!(
|
||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
||||
None,
|
||||
"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,
|
||||
);
|
||||
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"
|
||||
);
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ fn a_cancelled_flick_does_not_fling() {
|
||||
h.replay(&flick);
|
||||
|
||||
assert_eq!(
|
||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
||||
None,
|
||||
"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
|
||||
);
|
||||
assert_eq!(
|
||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
||||
None,
|
||||
"and it must not have flung either"
|
||||
);
|
||||
|
||||
@@ -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
|
||||
/// first happened: the recorded flick must release with a real velocity
|
||||
/// (`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.
|
||||
#[test]
|
||||
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"));
|
||||
h.replay(&flick);
|
||||
|
||||
let velocity = (screen.list)(&mut h.rsc)
|
||||
let velocity = (screen.scroll)(&mut h.rsc)
|
||||
.fling_velocity()
|
||||
.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
|
||||
// `iris/benches/velocity_reference.py` -- not a number read off this
|
||||
// code. Negative because the flick runs *down* the screen and
|
||||
// `Selection::drag` flings the list by `-v` (see its `Released` arm).
|
||||
// code. **Positive** because the flick runs *down* the screen and a
|
||||
// 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
|
||||
// -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!(
|
||||
(velocity + 15_250.0).abs() < 20.0,
|
||||
"expected ~-15250px/s from velocity_reference.py, got {velocity}"
|
||||
(velocity - 15_250.0).abs() < 20.0,
|
||||
"expected ~15250px/s from velocity_reference.py, got {velocity}"
|
||||
);
|
||||
|
||||
// `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)
|
||||
.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);
|
||||
}
|
||||
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")));
|
||||
|
||||
assert_eq!(
|
||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
||||
None,
|
||||
"a tap must not fling"
|
||||
);
|
||||
|
||||
@@ -62,10 +62,15 @@ fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32
|
||||
rows
|
||||
}
|
||||
|
||||
/// Scrolls `amount` (negative walks back through older rows) and runs the
|
||||
/// frame it asks for, returning the time of the next one.
|
||||
/// Scrolls `amount` and runs the frame it asks for, returning the time of
|
||||
/// 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 {
|
||||
(screen.list)(&mut h.rsc).scroll(amount);
|
||||
(screen.scroll)(&mut h.rsc).scroll(amount);
|
||||
h.frame(t);
|
||||
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
|
||||
// and out the other side of it many times over.
|
||||
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 first = *rows.first().expect("something is on screen");
|
||||
assert!(
|
||||
@@ -231,17 +236,17 @@ fn rows_that_have_left_the_viewport_are_not_drawn() {
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
bounded(&rows, "forward", step);
|
||||
inside(&rows, "forward", step);
|
||||
}
|
||||
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);
|
||||
bounded(&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;
|
||||
|
||||
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 last = *rows.last().expect("something is on screen");
|
||||
assert!(
|
||||
@@ -290,7 +295,7 @@ fn scrolling_past_the_first_row_settles_on_it() {
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
|
||||
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
|
||||
// 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;
|
||||
|
||||
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);
|
||||
|
||||
@@ -59,6 +59,11 @@ pub struct TranscriptScreen {
|
||||
/// `.extent()`/call `.jump_to_end()` etc. directly for anything this
|
||||
/// crate does not already wrap.
|
||||
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,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
/// How many times [`Self::apply`] has fallen back to a full rebuild --
|
||||
@@ -375,16 +380,6 @@ where
|
||||
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
|
||||
// taken pointer capture on `list`'s own id (`row.rs`'s registration is
|
||||
// only ever the gesture's first frame) -- registered once here, not
|
||||
@@ -420,6 +415,36 @@ where
|
||||
.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);
|
||||
|
||||
// `.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,
|
||||
// 2026-09-07: "code and a paragraph visible behind Run benchmark").
|
||||
// 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)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
|
||||
(
|
||||
TranscriptScreen {
|
||||
scroll,
|
||||
tail: RefCell::new(tail),
|
||||
session_working: std::cell::Cell::new(false),
|
||||
list,
|
||||
|
||||
@@ -53,6 +53,15 @@ pub struct Selection {
|
||||
/// in iris's default input layer, with only the pan-vs-select
|
||||
/// *decision* staying here.
|
||||
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 {
|
||||
@@ -67,9 +76,18 @@ impl Selection {
|
||||
rows: BTreeMap::new(),
|
||||
anchor: None,
|
||||
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
|
||||
/// needs its removal (`unregister`, or `clear` for all of them at
|
||||
/// once) -- called when `LazySpan` evicts the row (`pop_front`/
|
||||
@@ -260,7 +278,7 @@ impl Selection {
|
||||
pointer: &PointerRequests,
|
||||
) -> GestureOutcome {
|
||||
// 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
|
||||
// 2026-09-07, *tells the gesture there was one*. A press that
|
||||
// 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
|
||||
// mechanism. `starts_press` rather than a `PressStart` test of our
|
||||
// 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();
|
||||
if self.gesture.starts_press(sense) {
|
||||
press.scrolling = list(ui).is_scrolling();
|
||||
list(ui).cancel_fling();
|
||||
press.scrolling = self.scroll.is_some_and(|s| s(ui).is_scrolling());
|
||||
if let Some(scroll) = self.scroll {
|
||||
scroll(ui).cancel_fling();
|
||||
}
|
||||
}
|
||||
press.already_selected = self.has_selection(ui);
|
||||
let outcome = self
|
||||
@@ -286,7 +311,15 @@ impl Selection {
|
||||
// to undo either -- the point is that no tap, fling or
|
||||
// selection follows from a gesture that was never ours.
|
||||
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 => {
|
||||
if let Some((key, pos_row, size)) = row {
|
||||
// Grep-able on "iris selection" the way the frame
|
||||
@@ -310,18 +343,19 @@ impl Selection {
|
||||
// tap/long-press that never left `Undecided` -- exactly what
|
||||
// `DragGesture`'s `Some(v)` already encodes.
|
||||
GestureOutcome::Released(Some(v)) => {
|
||||
list(ui).fling(-v);
|
||||
// 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.
|
||||
//
|
||||
// Only when `fling` actually took it: below Compose's
|
||||
// `|v| <= 1.0`, or with no anchor, there is nothing to
|
||||
// tick, and registering an animation for a widget that is
|
||||
// not animating asks the next frame to find that out
|
||||
// (docs/REVIEW-2026-09-07.md's second nit).
|
||||
if list(ui).is_scrolling() {
|
||||
let id = list.id();
|
||||
// `|v| <= 1.0` there is nothing to tick, and registering
|
||||
// an animation for a widget that is not animating asks the
|
||||
// next frame to find that out (docs/REVIEW-2026-09-07.md's
|
||||
// second nit).
|
||||
if let Some(scroll) = self.scroll
|
||||
&& scroll(ui).fling(v)
|
||||
{
|
||||
let id = scroll.id();
|
||||
ui.ui_mut().animate(id);
|
||||
}
|
||||
}
|
||||
@@ -427,13 +461,19 @@ mod tests {
|
||||
EditMode::MultiLine,
|
||||
))
|
||||
.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
|
||||
.widgets
|
||||
.add_strong(LazySpan::new(Dir::DOWN, true))
|
||||
.add_strong(Scroll::new(list.any(), Axis::Y, true))
|
||||
.weak();
|
||||
let list = list_weak;
|
||||
|
||||
let mut sel = Selection::new();
|
||||
sel.set_scroll_area(scroll);
|
||||
sel.register((1, 0), field);
|
||||
assert!(sel.gesture.is_idle());
|
||||
|
||||
|
||||
Reference in new issue
Block a user