iris: one ScrollController, a Scrollable trait, and Pin

Iris's three points on docs/SCROLL.md, in the shape she proposed: a
controller both scrolling widgets *contain*, rather than a protocol
between them. "I don't like adding methods to widget, it seems like we
can structure things better instead."

`Scroll` becomes `ScrollArea`, because it only scrolls a predefined area.
`ScrollController` holds everything that is not a particular widget's
layout -- the position, the pending delta, the travel left each way, the
pin, the DragGesture and the Flinger -- and `Scrollable` is the trait over
it, one required pair of methods with the rest defaulted.

`Widget` loses `scrolls_itself`, `apply_scroll` and `scroll_offset`. They
existed only so a `Scroll` could drive a `LazySpan` it had no business
wrapping; the span owns its own controller now, so the wrapper, the
measure/apply/place dance between two widgets and `amt`'s two meanings all
go with them. The transcript's tree loses a node: `list` is the layout and
the position.

`.scrollable(axis, pin)` replaces `scrollable`/`scrollable_on`/
`scrollable_to_end` -- one mechanism whose arguments had been hidden in
three names. `LazySpan` has an inherent `scrollable()` that shadows it,
since Rust resolves inherent methods before trait ones: the same word at
the call site, and the wrapping version cannot reach the one widget that
must not be wrapped.

`Pin` says which end either way round: `Start`/`End` are content-relative
and `Neg`/`Pos` axis-absolute, so a caller can say "the bottom" and mean
it whichever way the content runs. They differ only for a reversed span,
which is the whole reason both exist.

One behaviour changes: a delta is applied by the next draw rather than
where it arrives, since the layout is the only thing that knows where the
content ends. Nothing on screen differs -- input is followed by a frame --
but `amt` no longer moves between draws, which several tests were reading.
This also closes SCROLL.md's open question about the pin living in two
places.

Verified: cargo test --workspace (all green, including the layer-1
transcript-fixture fling/selection/top-edge tests), clippy --all-targets
clean, fmt clean, `cargo ndk` check of android-app, and
`run-headless.sh phone --phone --replay flick-120hz.touch`, whose
before/after screenshots show the recorded flick carrying the transcript
back from turn 270 to turn 258 on the Vulkan adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 21:51:53 -04:00
1 parent bf8658c404
commit 4fdabc39d0
33 files changed
+1721 -1431

No files matched your search

+6 -5
View File
@@ -76,11 +76,12 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
public API** -- Iris, 2026-09-08), working list, decisions log,
layout/render design, and texture-atlas design, and the client-core
crate's design, respectively.
- `docs/SCROLL.md` — how anything in iris scrolls: `Scroll` owns the
position, the gesture and the fling; a child either gets moved or
answers `Widget::scrolls_itself` and is handed deltas. Read it before
touching `scroll.rs`, `lazy_span.rs`, or anything that pans, flings
or lays out a long list.
- `docs/SCROLL.md` — how anything in iris scrolls: one
`ScrollController` holds the position, the gesture, the fling and the
pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own
one each through the `Scrollable` trait. Read it before touching
`scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that
pans, flings or lays out a long list.
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
`service: Managed(…)`, supervised by Dev Updater's own implementation
rather than a script kept here) and the APK, in parallel. It points at
+62 -1
View File
@@ -12,7 +12,68 @@ 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`
## 2026-09-08 (newest): one `ScrollController`, a `Scrollable` trait, and `Pin`
Your three points on `docs/SCROLL.md`, in one change. The shape is the one
you proposed: **a controller both scrolling widgets contain**, rather than
a protocol between them.
**`Scroll` is `ScrollArea`**, because it only scrolls a predefined area --
your word for it. **`ScrollController`** (`widget/position/scrollable.rs`)
holds everything that is not a particular widget's layout: the position,
the pending delta, the travel left each way, the pin, the `DragGesture`
and the `Flinger`. **`Scrollable`** is the trait over it -- one required
pair of methods handing the controller back, and `scroll`, `fling`, `drag`,
`amt`, `is_scrolling`, `cancel_fling`, `tick_fling` and the pin as
defaults.
**The three scrolling methods are off `Widget`.** `scrolls_itself`,
`apply_scroll` and `scroll_offset` existed only so a `Scroll` could drive a
`LazySpan` it had no business wrapping. A `LazySpan` owns its own
controller now, so there is no wrapper, no measure/apply/place dance
between two widgets, and no `amt` with two meanings depending on which kind
of child it had. The transcript's tree lost a node with it: `list` is the
layout *and* the position.
before Masked(Scroll(LazySpan)) .scrollable_to_end(Axis::Y)
after Masked(LazySpan) .scrollable()
**`.scrollable(axis, pin)`** is the only one now -- `scrollable`,
`scrollable_on` and `scrollable_to_end` were one mechanism with the
arguments hidden in the names. A `LazySpan` has an **inherent**
`scrollable()` that shadows it, since Rust resolves inherent methods
first: same word at the call site, and the wrapping version cannot reach a
widget that must not be wrapped. The axis and pin are already its own.
**`Pin` says which end either way round.** `Start`/`End` are
content-relative, `Neg`/`Pos` axis-absolute -- your ask, so a caller can
say "the bottom" and mean it whichever way the content runs. They coincide
for everything except a reversed `LazySpan`, where they are opposites.
**A delta's sign is now a screen direction**, positive scrolling up or
left. It was "positive brings earlier content into view", which points the
opposite way for a `Dir::UP` span -- a real defect, latent only because
nothing builds one yet, and invisible to the existing test because that
test asserts in the same space the bug lives in.
**Why overscroll exists at all**, since you asked: a lazy span cannot see
the wall until it has walked to it, so with rows loaded past an edge it
honestly reports infinite travel, takes the whole delta, and the walk
finds the content ran out 200px ago. It is given back inside the same
frame. The rows past the edge have never been measured, and measuring them
is the work virtualisation exists to skip.
**One behaviour changed**: a delta is applied by the next `draw` rather
than the moment it arrives, since the layout is the only thing that knows
where the content ends. Nothing on screen differs -- input is followed by
a frame -- but `amt` no longer moves between draws, which several tests
were reading.
The pin question `SCROLL.md` had open ("the pin lives in each widget, not
in `Scroll`") is closed by this: it is one field on the controller, and a
caller edits one place.
## 2026-09-08 (earlier): `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
+140 -134
View File
@@ -4,22 +4,38 @@ How anything in iris scrolls, as of 2026-09-08. This is the current
design, not a history — `docs/IRIS.md`'s dated entries have the account of
how it got here, and `docs/IRIS_TODO.md` has what is still open.
Read this before touching `iris/src/widget/position/scroll.rs`,
`iris/src/widget/position/lazy_span.rs`, or anything that pans, flings or
lays out a long list.
Read this before touching `iris/src/widget/position/scrollable.rs`,
`scroll_area.rs`, `lazy_span.rs`, or anything that pans, flings or lays
out a long list.
## The one rule
**`Scroll` owns the position, the gesture and the fling. Nothing else
does.** A widget inside it either gets moved by it or is handed deltas to
apply itself, and either way the scroll state lives in the `Scroll`. There
is exactly one `Flinger` in the crate's widgets and one `DragGesture`
implementation, both in `iris/src/sense.rs`.
**Everything a scroll position is made of lives in one `ScrollController`,
and the widget that scrolls owns one.** The position, the pending delta,
the travel left, the pin, the `DragGesture` and the `Flinger` are all in
that struct (`scrollable.rs`); there is exactly one `Flinger` and one
`DragGesture` implementation in the crate's widgets. A widget with one
implements `Scrollable`, whose one required pair of methods hands the
controller back, and gets `scroll`, `fling`, `drag`, `amt`,
`is_scrolling`, `tick_fling` and the pin as default methods.
So: `.scrollable_on(axis)` / `.scrollable_to_end(axis)` is how anything
becomes scrollable, including a `LazySpan`. Do not give a widget its own
fling, its own scroll amount, or a `RequestRedraw` handle — that is what
was just removed.
Two widgets have one, and they differ only in how they spend a delta:
- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, measured whole and
then slid about as a lump, which is what makes a scroll tick an O(1)
move of one subtree. `.scrollable(axis, pin)` wraps anything in one.
- **`LazySpan`** (`lazy_span.rs`) — lays its own rows out from an anchor,
so it cannot be a lump and is not wrapped in anything. Its own
`.scrollable()` registers the same two senses against the controller it
already has.
Do not give a widget its own fling, its own scroll amount, or a
`RequestRedraw` handle. And do not add a scrolling method to the `Widget`
trait: the three that used to be there (`scrolls_itself`, `apply_scroll`,
`scroll_offset`) existed only so a `Scroll` could drive a `LazySpan` it
had no business wrapping, and they are gone (Iris, 2026-09-08: "I don't
like adding methods to widget, it seems like we can structure things
better instead").
## One convention for a delta
@@ -49,101 +65,84 @@ way_on_screen` checks the two `dir`s against **where rows were drawn** —
an assertion written in the walk's own space passes with the flip
deleted, because it checks the bookkeeping against itself.
## The `Widget` handoff
## The contract between a controller and its owner
Three default methods on `Widget` (`iris/core/src/widget/mod.rs`):
Two calls, both inside the owner's `draw`, because a `draw` is the only
place that knows where the content ends:
```rust
fn scrolls_itself(&self) -> bool { false }
fn apply_scroll(&mut self, delta: &mut f32) {}
fn scroll_offset(&self) -> f32 { 0.0 }
```
1. **`take_delta()`** — everything a wheel, a drag or a fling asked for
since the last layout, in one number, already clamped to the travel
that layout reported. Clipping it stops a fling.
2. **`set_travel(Travel)`** at the end, plus whichever of **`moved_by`**
(movement) or **`set_amt`** (an absolute position) fits how that owner
knows where it ended up.
- **`scrolls_itself`** — "I position my own content; hand me deltas rather
than sliding me about." Default `false`: an ordinary child is a lump its
parent moves, which is what makes a scroll tick an O(1) move of one
subtree instead of a redraw.
- **`apply_scroll`** — take as much of `delta` as you can actually move,
leave the rest. What comes back short is how the parent learns the
content ran out.
- **`scroll_offset`** — accumulated content movement, so the parent can
keep an honest account. See "why the remainder is not enough" below.
`Travel` is `{ back, fwd }` in the same screen-space units as a delta:
`back` bounds a positive one, `fwd` a negative one, and `f32::INFINITY`
means "the end is not in sight". That last is not a placeholder — a lazy
layout genuinely cannot say how far its content runs without walking
there, and `clamp` takes the answer with no branch of its own.
**`scrolls_itself` and `scroll_offset` are `&self` on purpose.** Reaching a
widget through `Widgets::get_dyn_mut` *marks it dirty*
(`iris/core/src/widget/widgets.rs`). Asking either question through a
`&mut` path would dirty every ordinary child on every scroll tick and cost
exactly the O(1) move the scheme exists for. `Painter::scrolls_itself` and
`Painter::scroll_offset` go through `get_dyn`; only
`Painter::apply_scroll` takes `&mut`.
**Why the delta is banked rather than applied where it arrives.** A wheel
event, a drag frame and a fling tick all land between draws, and none of
them can know whether there is content to move into. Applying them at the
layout that follows is also what keeps layout a pure function of the state
(Iris, 2026-09-08). The visible consequence, and the thing that catches a
test out: **`amt` does not move until the next draw.**
### Why the remainder is not enough on its own
### Why a remainder was not enough
`apply_scroll` leaving a remainder was meant to be the whole story. It is
not, because **a lazy layout usually cannot say where its content ends
until it has walked there.** With the wall out of view it takes the delta
in full, and the walk that follows gives part of it back. So the remainder
is exact only when the wall was already visible, and a parent adding
remainders up would over-count by every overshoot and never correct.
The `apply_scroll(&mut delta)` this replaced left the part it could not
take in the caller's variable, and that was meant to be the whole story.
It is not, because **a lazy layout usually cannot say where its content
ends until it has walked there.** With the wall out of view it takes the
delta in full, and the walk that follows gives part of it back. So the
remainder is exact only when the wall was already visible, and a parent
adding remainders up would over-count by every overshoot and never
correct. Now the owner reports what it *did* (`moved_by`, from the one
place its anchor moves) as well as what it *can* do, and
`amt_counts_only_what_the_child_could_take` is the test.
`Scroll` therefore reads `scroll_offset` *after* the placing draw and sets
`amt` from it. `amt_counts_only_what_the_child_could_take` is the test;
it fails if you try to go back to remainders alone.
## What `amt` means
## `Scroll::draw` — two paths
The same direction for both owners, and a different origin:
`Scroll` reads `scrolls_itself` every draw and branches once, on the
capability rather than on any concrete type.
**Ordinary child (`draw_moved_child`)** — unchanged from before: offer the
child a box as long as last frame's content length to measure it, apply
the end-pin and the clamp against the measured length, then place it at
the length just measured. Two draws, the second free unless the content
changed. `amt` is a distance from the start of the content.
**Self-positioning child (`draw_self_scrolling_child`)** — measure, apply,
place:
1. `painter.widget_within(child, UiRegion::FULL)` — the measuring draw.
2. `painter.apply_scroll(child, &mut delta)` — the child takes what it can.
3. `painter.widget_within(child, UiRegion::FULL)` — the placing draw.
4. `amt = -painter.scroll_offset(child)`.
Two properties make this work, and both are easy to break:
- **The measuring draw is free in the common case.** It offers the same
box as last frame, so with nothing dirty `draw_inner` returns
immediately and the child's stored walls from its last walk are still
correct — because nothing changed. When the content *did* change the
child is dirty, really walks, and the walls are fresh, which is exactly
when they need to be.
- **Nothing is marked dirty by hand.** Reaching the child through
`get_dyn_mut` in step 2 is itself what dirties it, so step 3 really
draws rather than taking `draw_inner`'s unchanged-region skip. This is
why there is no `Painter::draw_again` and why one should not come back:
a mechanism for "give me a corrective frame later" is the thing this
shape replaces.
### What `amt` means
- Ordinary child: distance from the start of the content, clamped into the
- `ScrollArea`: distance from the start of the content, clamped into the
scroll range. An absolute position.
- Self-positioning child: **movement, not position.** Paging rows in above
moves the origin and the child cannot say by how much, never having
measured them. The direction is the same as an ordinary child's; the
absolute value is not comparable between the two.
- `LazySpan`: **movement, not position.** Paging rows in above moves the
origin and the span cannot say by how much, never having measured them.
A scrollbar needs a real content length before it can use either, and a
lazy child has none. Do not invent one.
lazy span has none. Do not invent one.
## `ScrollArea::draw` — measure, then place
1. `take_delta`, and move to where it asks.
2. Draw the child in a box as long as **last frame's** length, to measure
it. This is free in the common case: the same region as last frame
means `draw_inner` returns immediately.
3. Apply the pin and clamp against the length just measured.
4. Draw the child again, at that length and position.
Only the second draw decides anything, and a frame on which the content
did change pays one real extra draw — a frame on which it was being
redrawn anyway. Placing against the hint and letting the next frame fix it
is what hung the composer's text half a line outside its box on Iris's
phone: **layout is a pure function of the state, not of how many frames
have been drawn**, and there may be no next frame.
The pin only re-pins on a frame with **no delta of its own**: the pin
means "stay flush with the end as the content grows", and a reader who has
just scrolled away has said otherwise.
## `LazySpan`
`iris/src/widget/position/lazy_span.rs`. A virtualised sequence of
variable-height rows, laid out from an anchor. It is what `Span` is, done
lazily. It **does not scroll** — it lays out and answers honestly about
how far it can go.
lazily, and it drives its own controller: the walk is the only thing that
can say how far it may go, so nothing above it is in a position to.
### Why it is not `Span::scrollable()`
### Why it is not a `Span` inside a `ScrollArea`
Measured 2026-09-08, and worth not re-deriving:
@@ -152,27 +151,35 @@ Measured 2026-09-08, and worth not re-deriving:
phase 1 offers each child the ambient region to learn its length,
phase 2 offers it its real share. So any mutation of a `Span` redraws
all of it — 24 draws for 11 children after one prepend.
- A `Scroll`'s efficiency and virtualisation pull opposite ways: a scroll
tick offers a same-size moved region, `draw_inner` takes the `mov` path,
and the child's `draw` never runs. A virtualising child inside a plain
`Scroll` would never update which rows it shows. That is what
`scrolls_itself` resolves.
- A lazy child cannot report a content length, so `Scroll`'s clamp,
end-pin and any future scrollbar have nothing to work against. Walls
are *discovered* by the walk instead.
- A `ScrollArea`'s efficiency and virtualisation pull opposite ways: a
scroll tick offers a same-size moved region, `draw_inner` takes the
`mov` path, and the child's `draw` never runs. A virtualising child
inside one would never update which rows it shows. That is why a
`LazySpan` owns its controller instead of being wrapped in one.
- A lazy child cannot report a content length, so an area's clamp,
end-pin and any future scrollbar would have nothing to work against.
Walls are *discovered* by the walk instead.
### Direction and pin are separate questions
`LazySpan::new(dir, at_end)`.
`LazySpan::new(dir, pin)`, and `ScrollArea::new(inner, axis, pin)`.
- **`dir`** means what it means in `Span`: which end of the box item 0
sits at, and which way the sequence grows.
- **`at_end`** is the pin: which end the view clings to as rows arrive.
- **`pin`** is which end the view clings to as rows arrive.
A transcript is `Dir::DOWN` (oldest message is item 0, at the top) with
the pin at the end (the view sits at the bottom). Conflating the two would
stand it on its head. `Scroll::new`'s third argument is the same flag for
the ordinary case.
`Pin::End` (the view sits at the bottom). Conflating the two would stand
it on its head.
**`Pin` says it either way round**, because there are two questions and
they are not the same one (Iris, 2026-09-08). `Start`/`End` are
content-relative — the first row or the newest one, wherever the layout
puts it — and `Neg`/`Pos` are axis-absolute: the top/left edge and the
bottom/right one, whichever end of the content is there. They coincide for
everything except a reversed `LazySpan`, where they are exact opposites,
which is the whole reason both exist. The one question a scrollable acts
on is `pinned_to_end`, and `dir` is what resolves a `Pin` into it.
### Two coordinate spaces, two conversion points
@@ -211,13 +218,22 @@ framework:
Virtualisation *means* traversing rows without drawing them, and a size
you can only get by drawing is no use for deciding not to draw.
### Overscroll
### Overscroll, and why it happens at all
`apply_scroll` only takes what the walk says is there, so **scrolling
cannot enter overscroll.** What it cannot prevent is the content or the
viewport changing under a settled anchor, and for that `overscroll_gap`
measures the gap from the ends the walk already placed and `draw` moves
the anchor and walks a **second time inside the same frame**.
**Because the span cannot see the wall until it has walked to it.** With
rows loaded past an edge it reports `INFINITY` of travel that way, takes
the whole delta, and the walk that follows discovers the content ran out
200px ago. Nothing else could be reported: the rows past the edge have
never been measured, and measuring them is exactly the work
virtualisation exists to skip. The other source is the content or the
viewport changing under a settled anchor — a row that grew, a page
dropped, the keyboard opening — where nothing scrolled at all.
So `overscroll_gap` measures the gap from the ends the walk already
placed, and `draw` moves the anchor by it and walks a **second time
inside the same frame**. `moved_by` counts that correction along with the
move that caused it, which is why `amt` stays equal to what is on screen
rather than drifting by every overshoot.
Layout is a pure function of the state, not of how many frames have been
drawn (Iris, 2026-09-08). A correction that lands next frame is a frame
@@ -228,20 +244,21 @@ asking for one.
`iris/transcript-ui/src/lib.rs`, `build_tree`.
The transcript builds its `Scroll` **by hand rather than through
`.scrollable_to_end()`**, and this is not an oversight. That helper
registers a finger drag driving `Scroll`'s own `DragGesture`, and the
The transcript registers the wheel **by hand rather than calling
`LazySpan::scrollable()`**, and this is not an oversight. That helper also
registers a finger drag driving the span's own `DragGesture`, and the
transcript already has an arbiter — `Selection`, which must decide between
panning and selecting text and so cannot let a second `DragGesture` see
the same frames. `DragGesture`'s doc states the rule: one gesture, one
arbiter, each frame delivered exactly once. The wheel handler registered
here is identical to the helper's; only the drag differs.
`Selection` is given the scroll area by `set_scroll_area` after the
`Scroll` exists (rows need a `Selection`, and the `Scroll` needs the
rows), and hands it committed pans and releases. `TranscriptScreen` exposes
both `list` (layout, `extent`/`key_at`/`jump_to_end`) and `scroll`
(position, fling, `amt`).
`Selection` is given the span by `set_scroll_area` after it exists (rows
need a `Selection`, and the span needs the rows), and hands it committed
pans and releases through `Scrollable::scroll`/`fling`. There is no
wrapper widget: `TranscriptScreen::list` is the layout (`extent`,
`key_at`, `jump_to_end`) *and* the position (`amt`, `fling`,
`is_scrolling`).
A `Selection` with no scroll area still selects and still reports taps but
cannot pan; there is a `debug_assert` in `drag` naming that.
@@ -259,33 +276,22 @@ cannot pan; there is a `debug_assert` in `drag` naming that.
write is moving the content as a unit, which needs a content length —
which a lazy layout cannot supply.
## Still open
**The pin lives in each widget, not in `Scroll`.** Iris asked for `amt`
and the at-end control both to live in `Scroll` so a caller always edits
the `Scroll`. `amt` does; the pin does not, because applying a pin happens
when a row is appended — between frames, with no painter in hand — so it
cannot arrive through `apply_scroll` as it stands. Moving it needs either
a fourth `Widget` method or a parameter on `apply_scroll`
(`apply_scroll(&mut self, delta: &mut f32, pinned_to_end: bool)` reads
best: one method, and the signature says "here is the state you need from
me"). Nothing external edits a pin today — the transcript sets it once at
construction and calls `jump_to_end` on the span for the rest — so this is
a design question rather than a missing capability. **Ask Iris which she
wants before building it.**
## Tests that pin the behaviour
In `lazy_span.rs`, all of these fail if the corresponding piece is undone:
- `a_negative_delta_moves_toward_the_end` — the sign, end to end.
- `amt_counts_only_what_the_child_could_take``scroll_offset`'s reason
for existing.
- `a_delta_moves_both_directions_the_same_way_on_screen` — the sign is a
screen direction, checked against where rows were *drawn*.
- `amt_counts_only_what_the_child_could_take` — why the owner reports what
it did rather than the caller adding up what it asked for.
- `a_fling_stops_at_the_first_row`,
`scrolling_past_the_start_lands_on_it_in_the_same_frame` — the walls,
with no settling frame drawn on purpose.
- `a_dir_up_span_grows_upward_from_item_zero`,
`a_reversed_span_hit_tests_in_screen_space` — the two conversions.
`a_reversed_span_hit_tests_in_screen_space` — the position conversions
(`flip_pos`), as `a_delta_moves_both_directions_the_same_way_on_screen`
is the delta one (`flip_delta`).
- `a_registered_fling_is_driven_by_tick_animations_and_then_unregisters`
a fling that nothing registers never moves, whatever its velocity.
+12 -11
View File
@@ -914,7 +914,7 @@ where
/// 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
/// `ScrollArea` 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(
@@ -938,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.scroll)(rsc).fling(FLING_VELOCITY_PX_S);
animate_scroll(screen.scroll, rsc);
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
animate_scroll(screen.list, rsc);
}
});
redraw.request_redraw();
@@ -951,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.scroll)(rsc).fling(-FLING_VELOCITY_PX_S);
animate_scroll(screen.scroll, rsc);
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
animate_scroll(screen.list, rsc);
}
});
redraw.request_redraw();
@@ -978,10 +978,11 @@ async fn read_anchor_position(
.await
}
/// Register the scroll area with the frame loop, exactly as a finger's own
/// release does (`transcript_ui::Selection::drag`'s `Released` arm) --
/// `Scroll::fling` sets a velocity and drives nothing by itself.
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::Scroll>, rsc: &mut Rsc) {
/// Register the scrolling widget with the frame loop, exactly as a
/// finger's own release does (`transcript_ui::Selection::drag`'s
/// `Released` arm) -- `Scrollable::fling` sets a velocity and drives
/// nothing by itself.
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
let id = scroll.id();
rsc.ui_mut().animate(id);
}
@@ -992,7 +993,7 @@ fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::Scroll>, rsc:
/// spline-decided `duration()` already caps how long it can run.
///
/// **It observes; it does not drive.** Until 2026-09-08 this loop called
/// `Scroll::tick` itself every `POLL_MS`, which advanced the
/// `Scrollable::tick_fling` 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.
@@ -1011,7 +1012,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.scroll)(rsc).is_scrolling(),
Some(screen) => (screen.list)(rsc).is_scrolling(),
None => false,
})
.await;
+14 -19
View File
@@ -15,8 +15,8 @@
//! Per the code rules, the plain option is also the one shorter to explain.
//!
//! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a
//! `Scroll` over a `Span` of pre-built rows.** Earlier versions of this
//! file built their own giant `Span` and wrapped it in `Scroll`, which
//! `ScrollArea` over a `Span` of pre-built rows.** Earlier versions of this
//! file built their own giant `Span` and wrapped it in `ScrollArea`, which
//! meant (a)/(b)/(c) below were measuring "move one big child," never the
//! virtualised widget the app's transcript screen actually needs. `LazySpan`
//! still needs every row's *widget* built up front by the caller (its
@@ -113,22 +113,17 @@ fn build_message_list(
rsc: &mut BenchRsc,
n: usize,
image_every: usize,
) -> (WeakWidget<LazySpan>, WeakWidget<Scroll>, StrongWidget) {
let mut list = LazySpan::new(Dir::DOWN, true);
) -> (WeakWidget<LazySpan>, StrongWidget) {
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
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);
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())
// Driven through the span's own `ScrollController`, like every other
// scroll area in iris: what this measures has to be the path the app
// actually takes.
(list.weak(), list.any())
}
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
@@ -143,7 +138,7 @@ fn bench_first_frame(n: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (_list, _scroll, root) = build_message_list(&mut rsc, n, 20);
let (_list, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
@@ -161,14 +156,14 @@ fn bench_first_frame(n: usize) {
}
/// (b) Per-frame cost of scrolling an already-laid-out list of N rows.
/// Warms up (one no-op tick, matching `Scroll`'s own need for it before an
/// Warms up (one no-op tick, matching `ScrollArea`'s own need for it before an
/// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move
/// rather than a resize), then times a run of individual scroll ticks.
fn bench_scroll(n: usize, ticks: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (_list, scroll, root) = build_message_list(&mut rsc, n, 20);
let (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);
@@ -215,7 +210,7 @@ fn bench_input_grows(n: usize, lines: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (_list, scroll, list_root) = build_message_list(&mut rsc, n, 20);
let (scroll, list_root) = build_message_list(&mut rsc, n, 20);
let list_area = rsc.ui.widgets.add_strong(Sized {
inner: list_root,
x: None,
@@ -285,7 +280,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (list, _scroll, root) = build_message_list(&mut rsc, n, 20);
let (list, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
@@ -342,7 +337,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
// Near the end (not the very last row) so it is already on screen
// under the list's default bottom-anchored placement, for every N --
// no scrolling needed to bring it into view before measuring.
+1 -1
View File
@@ -101,7 +101,7 @@ pub struct Mask {
/// One widget's cumulative on-screen translation, and the slot of the
/// ancestor to add on top of it. `parent == u32::MAX` ends the chain. A
/// pure abs-pixel delta, not a general `UiRegion` remap -- sufficient for
/// every call site that moves a widget (`Scroll`, `Offset`) since both are
/// every call site that moves a widget (`ScrollArea`, `Offset`) since both are
/// translations of an already-drawn subtree. See LAYOUT.md section 2.
///
/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is
+1 -1
View File
@@ -9,7 +9,7 @@
//! The tree itself is deliberately flat -- one synthetic `Role::Window`
//! root with every named widget as a direct child, in no particular order.
//! iris's actual widget nesting (a label three `Span`s deep inside a
//! `Scroll`) carries no accessibility meaning of its own here: nothing
//! `ScrollArea`) carries no accessibility meaning of its own here: nothing
//! upstream of a named leaf needs a node, since a screen reader's own
//! traversal (and uiautomator's tap-by-name, the pass condition this was
//! built for) works from each node's on-screen bounds rather than from
-37
View File
@@ -166,43 +166,6 @@ 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
-70
View File
@@ -60,76 +60,6 @@ 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, and it is a **screen** direction
/// rather than a logical one: positive scrolls the reader up or left,
/// negative down or right, whichever way this widget's own content
/// happens to be laid out (Iris, 2026-09-08). A convention phrased as
/// "positive brings earlier content into view" points the opposite
/// way for a widget laid out backwards, so the same delta would pan
/// one list up and another down.
///
/// 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 () {
+1 -1
View File
@@ -54,7 +54,7 @@ impl DefaultAppState for State {
let root = rsc
.ui
.widgets
.add_strong(Scroll::new(span.any(), Axis::Y, true));
.add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::End));
ui_state.set_root(root.any());
Self {
ui_state,
+8 -6
View File
@@ -98,18 +98,20 @@ impl DefaultAppState for State {
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
for i in 0..ROWS {
let row = build_row(rsc, i);
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.
// `.scrollable()`, like anything else that scrolls -- here the
// span's own inherent one, which registers the wheel and the drag
// against the controller it already owns rather than wrapping it
// in a `ScrollArea`. Masked outside it, since a `LazySpan` draws
// the row straddling each edge in full and asserts something clips
// it.
let root = list
.scrollable_to_end(Axis::Y)
.scrollable()
.masked()
.background(rect(Color::WHITE))
.add_strong(rsc);
+1 -1
View File
@@ -146,7 +146,7 @@ fn on_press(
// vertical drag still is not a selection. Android's own `EditText`
// scrolls its overflowed text on a vertical drag and starts a
// selection only from a long press; a scroll area wrapping this
// field (`Scroll::drag`) is what actually pans, and it needs the
// field (`ScrollController::drag`) is what actually pans, and it needs the
// first frames of the gesture not to have selected anything behind
// it before it crosses `DRAG_SLOP` and takes pointer capture.
// `press_origin` carries the same meaning here as in the unfocused
+2 -2
View File
@@ -335,7 +335,7 @@ impl Harness {
}
/// The `Instant` this harness means by `t_ms`. Public because a
/// caller driving `Scroll::tick` or `DragGesture` by hand needs
/// caller driving `ScrollController::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
/// (`Scroll::fling`'s doc). Returns the time of the last frame run.
/// (`ScrollController::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;
+20 -17
View File
@@ -23,7 +23,7 @@ impl UiRsc for TestRsc {
}
}
/// A `Scroll` over a `Span` of `n` fixed-height rects -- N primitives large
/// A `ScrollArea` over a `Span` of `n` fixed-height rects -- N primitives large
/// enough that an O(N) regression in the move path would show up as a
/// non-trivial counter rather than being lost in noise (LAYOUT.md section
/// 8, condition 3, using rects rather than glyphs to avoid pulling the font
@@ -33,7 +33,7 @@ impl UiRsc for TestRsc {
fn scrolled_rects(
rsc: &mut TestRsc,
n: usize,
) -> (WeakWidget<Scroll>, StrongWidget, Vec<WeakWidget<Rect>>) {
) -> (WeakWidget<ScrollArea>, StrongWidget, Vec<WeakWidget<Rect>>) {
let mut span = Span::empty(Dir::DOWN);
let mut rects = Vec::with_capacity(n);
for _ in 0..n {
@@ -57,11 +57,11 @@ fn scrolled_rects(
// the top and states its sign convention against that. An
// end-anchored area now sits at its end from its first drawn
// frame (`Scroll::draw` measures and places in the same frame),
// so `at_end: true` here would mean scrolling down from a
// so `Pin::End` here would mean scrolling down from a
// position that is already the bottom -- a clamped no-op, which
// reads as "the move path is broken" rather than as the test
// starting somewhere it did not mean to.
.add_strong(Scroll::new(span.any(), Axis::Y, false));
.add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::Start));
let weak = scroll.weak();
(weak, scroll.any(), rects)
}
@@ -76,7 +76,7 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() {
render.resize((800.0, 20000.0));
render.update(&root, &mut rsc);
// Two, not one: the first offers `Scroll`'s content the container's
// Two, not one: the first offers `ScrollArea`'s content the container's
// own length as a placeholder (nothing has been measured yet) and
// `Scroll::draw` asks to be drawn again once it knows the real one,
// which the second update is. Only after that is the tree settled --
@@ -99,12 +99,12 @@ fn scrolling_moves_in_o1_without_a_redraw() {
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
// The first draw offers `Scroll`'s content a zero-height region
// The first draw offers `ScrollArea`'s content a zero-height region
// (nothing has been measured yet) and learns the real content length
// from what comes back; `update()` only redraws widgets actually
// marked dirty, so that corrected length is not reflected in the
// content's own *active* region until something -- here a no-op
// scroll tick -- actually asks `Scroll` to redraw again. Only after
// scroll tick -- actually asks `ScrollArea` to redraw again. Only after
// that warm-up does the content's offered size stop changing between
// draws, which is what makes a further, real scroll tick a same-size
// move instead of a resize. See scroll.rs.
@@ -123,7 +123,7 @@ fn scrolling_moves_in_o1_without_a_redraw() {
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
// 1 move_offsets write, independent of how many rects are in the
// scrolled subtree. `draws` here is exactly 1: `Scroll` itself is
// scrolled subtree. `draws` here is exactly 1: `ScrollArea` itself is
// marked dirty by `scroll()` and its own body is cheap arithmetic with
// no primitives of its own, so it is the one real `Widget::draw` this
// counts -- the 500 rects underneath move via the O(1) chain and are
@@ -324,7 +324,7 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
);
}
/// `Scroll` used to be documented as resolving its own lengths against
/// `ScrollArea` used to be documented as resolving its own lengths against
/// `Painter::output_size` -- the window -- which read as if a scroll area
/// smaller than the screen could not work, and cost a session's
/// investigation before the composer was wired up (docs/RUST.md,
@@ -347,7 +347,7 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
.widgets
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
// go -- see `scrolled_rects`' note on the same choice.
.add_strong(Scroll::new(tall.any(), Axis::Y, false));
.add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start));
let scroll_w = scroll.weak();
let scroll_id = scroll.id();
let capped = rsc.ui.widgets.add_strong(MaxSize {
@@ -382,8 +382,11 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
);
// Panning is bounded by content minus *container*: 900, not the 400
// a 600px window would give.
// a 600px window would give. The draw is what spends the delta -- a
// controller banks it until the layout that knows where the content
// ends (`ScrollController::take_delta`).
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0);
render.update(&root, &mut rsc);
assert!(
(rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01,
"amt={}",
@@ -392,7 +395,7 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
}
/// The half `hit_testing_follows_a_scrolled_widget` could not see: it
/// checks a *descendant* of the widget `Scroll` actually moves, whose own
/// checks a *descendant* of the widget `ScrollArea` actually moves, whose own
/// `region` is stale and is corrected entirely by the move chain. The
/// moved widget itself had its `region` updated *and* the chain delta
/// added on top, so its hit box sat at twice the pan -- which is why a
@@ -415,7 +418,7 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() {
.widgets
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
// go -- see `scrolled_rects`' note on the same choice.
.add_strong(Scroll::new(tall.any(), Axis::Y, false));
.add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start));
let scroll_w = scroll.weak();
let root = scroll.any();
@@ -500,7 +503,7 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
/// places a child using the `abs`/`rel` of the length it reported, so a
/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's
/// bar a slot of **zero** the moment its content grew past six lines --
/// and the `Scroll` inside then measured its container at -63px (the
/// and the `ScrollArea` inside then measured its container at -63px (the
/// padding, subtracted from nothing) and panned the whole message out of
/// view. Measured on this checkout's emulator, 2026-09-06:
/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`.
@@ -948,7 +951,7 @@ fn a_plain_mask_still_clips_to_a_square_box() {
/// middle of a word (`iris/run-headless.sh phone`, 2026-09-08).
#[test]
fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
for (name, at_end, want) in [("read", false, 0.0), ("written", true, 4900.0)] {
for (name, pin, want) in [("read", Pin::Start, 0.0), ("written", Pin::End, 4900.0)] {
let mut rsc = TestRsc {
ui: UiData::default(),
};
@@ -961,7 +964,7 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(tall.any(), Axis::Y, at_end));
.add_strong(ScrollArea::new(tall.any(), Axis::Y, pin));
let weak = scroll.weak();
let root = scroll.any();
@@ -1041,7 +1044,7 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
dir: Dir::DOWN,
gap: Len::ZERO,
});
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
list.push_back(LazyItem::new(0, outer.any()));
let list = rsc.ui.widgets.add_strong(list);
let root = rsc
+17 -17
View File
@@ -100,7 +100,7 @@ impl CursorSense {
/// and [`Self::Cancel`] if somebody else captured it first.
///
/// One function rather than a set spelled out per call site, because
/// the two terminal senses are exactly what gets forgotten: a `Scroll`
/// the two terminal senses are exactly what gets forgotten: a `ScrollArea`
/// registered `click_or_drag | unclick` and so never saw the end of
/// any gesture it had captured, which left its arbiter panning from a
/// stale position and made the *next* drag jump by the distance
@@ -651,7 +651,7 @@ pub fn should_run(
// sideways is left `hover == On` (the capture branch above returns
// before the loop that would have updated it), so the *next* touch
// down anywhere on the screen decayed it to `End`, ran the fence's
// `Scroll::drag` with a `PressStart`, and -- the press being a catch
// `ScrollController::drag` with a `PressStart`, and -- the press being a catch
// of its own fling, which commits with no slop -- captured the whole
// gesture 500px away from the fence. The list under the finger moved
// by nothing at all.
@@ -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, `Scroll::is_scrolling`). See
/// `LazySpan` with a fling in flight, `Scrollable::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::Scroll::fling`], per
/// to hand the tracked velocity to [`crate::widget::ScrollController::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 (`Scroll::scroll`'s, for a transcript) to apply.
/// convention (`ScrollController::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 `Scroll::fling` with
/// same units as `Pan`, so a caller hands it to `ScrollController::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::Scroll::fling`] instead, because that is the only
/// in [`crate::widget::ScrollController::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 `Scroll::fling`.
/// deliberately **no** matching minimum: see `ScrollController::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::Scroll::fling`]).
/// ([`crate::widget::ScrollController::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 `Scroll::fling` asserts finiteness.
// here has produced, and `ScrollController::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 (`Scroll::fling`'s doc), which is why two defects had to
/// animated at all (`ScrollController::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
/// `Scroll::fling` passed `1.0`; on a 2.75-density screen that gave a
/// `ScrollController::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 `Scroll::fling`'s matching assertion -- a non-finite velocity
// See `ScrollController::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 (`Scroll::tick`) calls to
/// `velocity` -- what a per-frame ticker (`ScrollController::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
/// `Scroll::tick`'s debug line reports: successive frames printing
/// `ScrollController::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 {
@@ -1985,7 +1985,7 @@ impl FlingCalculator {
/// It owns the curve and the clock and nothing else. Which way a positive
/// delta moves the content, and whether the content has anywhere left to
/// go, are the caller's -- a `LazySpan` scrolls its anchor one way and a
/// `Scroll` moves its `amt` the other, and a `Flinger` that tried to know
/// `ScrollArea` moves its `amt` the other, and a `Flinger` that tried to know
/// which would have to be told, which is the same thing as not knowing.
/// So a caller applies [`Self::tick`]'s delta in its own convention and
/// calls [`Self::stop`] when it runs out of content.
@@ -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 `Scroll::tick`'s per-frame reads
/// -- this is the guarantee that `ScrollController::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]
+38 -22
View File
@@ -1,5 +1,5 @@
//! IRIS_TODO.md's "Input does not fall through by input type": a widget
//! that only registered `click()` used to also block a `Scroll` meant for
//! that only registered `click()` used to also block a `ScrollArea` meant for
//! whatever is behind it, because `run_sensors` decided "consumed, stop
//! looking at lower layers" from mere hover, not from anything actually
//! matching. Exercised as a plain unit test for the same reason
@@ -104,6 +104,7 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
let mut scroll_cursor = cursor_at((50.0, 50.0).into());
scroll_cursor.scroll_delta = (0.0, 10.0).into();
render.run_sensors(&mut rsc, &mut state, scroll_cursor, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert!(
scrolled.get(),
@@ -117,6 +118,7 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
let mut click_cursor = cursor_at((50.0, 50.0).into());
click_cursor.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, click_cursor, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert!(
clicked.get(),
@@ -174,6 +176,7 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
let mut press = cursor_at((5.0, 5.0).into());
press.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
render.update(&draggable, &mut rsc);
assert_eq!(
pointer_input(&mut rsc).holder(),
Some(draggable.id()),
@@ -185,6 +188,7 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
let mut release = cursor_at((95.0, 95.0).into());
release.buttons.left = ActivationState::End;
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
render.update(&draggable, &mut rsc);
assert!(
dropped.get(),
@@ -241,6 +245,7 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
let mut state = ();
let cursor = cursor_at((50.0, 50.0).into());
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert!(
!b_hovered.get(),
@@ -248,11 +253,11 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
);
}
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `Scroll` only
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `ScrollArea` only
/// answered a wheel, so a finger drag over overflowed text did nothing.
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
/// `run_sensors`' dispatch, `Scroll::drag`, `DragGesture`'s arbitration and
/// pointer capture -- rather than only `Scroll::drag`'s own unit tests in
/// `run_sensors`' dispatch, `ScrollController::drag`, `DragGesture`'s arbitration and
/// pointer capture -- rather than only `ScrollController::drag`'s own unit tests in
/// `scroll.rs`, because the registration is exactly the half those cannot
/// see.
#[test]
@@ -265,7 +270,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
// 1000px of content in a 100px window: room to pan.
let scroll_strong = rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable()
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
let scroll = scroll_strong.weak();
let root = scroll_strong.any();
@@ -273,7 +278,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
// `Scroll` reads its content length back from the draw it just did, so
// `ScrollArea` reads its content length back from the draw it just did, so
// the frame after is the first one that knows there is anything to pan
// -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is
// how `layout_tests.rs` asks for that second frame, and it also drops
@@ -286,6 +291,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
let mut down = cursor_at((50.0, 80.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert_eq!(
rsc.ui.widgets.get(&scroll).unwrap().amt(),
0.0,
@@ -296,6 +302,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
nudge.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert_eq!(
rsc.ui.widgets.get(&scroll).unwrap().amt(),
0.0,
@@ -307,6 +314,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
drag.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
render.update(&root, &mut rsc);
let after = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!(
(after - 40.0).abs() < 0.01,
@@ -368,8 +376,8 @@ fn the_clock_orders_samples_across_events() {
/// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to
/// some position when horizontally scrolling."
///
/// A `Scroll` that has committed to a pan holds the pointer, so the
/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable_on`
/// A `ScrollArea` that has committed to a pan holds the pointer, so the
/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable`
/// used to register `click_or_drag | unclick` only, which `should_run`
/// never matches a `Drop` against. So the widget never learned its own
/// gesture had ended: its `DragArbiter` stayed `Panning` at the position
@@ -385,7 +393,7 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
};
let scroll_strong = rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable()
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
let scroll = scroll_strong.weak();
let root = scroll_strong.any();
@@ -398,24 +406,25 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
let mut state = ();
let win = Vec2::new(100.0, 100.0);
let mut send = |render: &UiRenderState, rsc: &mut SenseRsc, y: f32, button| {
let mut send = |render: &mut UiRenderState, rsc: &mut SenseRsc, y: f32, button| {
let mut c = cursor_at((50.0, y).into());
c.buttons.left = button;
render.run_sensors(rsc, &mut state, c, win);
render.update(&root, rsc);
};
// One pan of 40px past the slop, then a release well outside the
// widget -- the ordinary shape of a flick.
send(&render, &mut rsc, 80.0, ActivationState::Start);
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
send(
&render,
&mut render,
&mut rsc,
80.0 - (DRAG_SLOP + 40.0),
ActivationState::On,
);
let after_first = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!((after_first - 40.0).abs() < 0.01, "amt={after_first}");
send(&render, &mut rsc, 400.0, ActivationState::End);
send(&mut render, &mut rsc, 400.0, ActivationState::End);
assert_eq!(
pointer_input(&mut rsc).holder(),
None,
@@ -425,7 +434,7 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
// A second gesture, starting where the first one did. If the arbiter
// were still panning from the release position, this first frame
// would apply the whole distance between the two at once.
send(&render, &mut rsc, 80.0, ActivationState::Start);
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!(
(after_second - after_first).abs() < 0.01,
@@ -503,11 +512,13 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
let mut down = cursor_at((50.0, 50.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, win);
render.update(&root, &mut rsc);
assert_eq!(cancelled.get(), 0, "nothing has captured yet");
let mut moved = cursor_at((50.0, 20.0).into());
moved.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, moved, win);
render.update(&root, &mut rsc);
assert!(capturer_saw.get() > 0, "the capturer never saw the press");
assert_eq!(
pointer_input(&mut rsc).holder(),
@@ -525,9 +536,11 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
let mut more = cursor_at((50.0, 10.0).into());
more.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, more, win);
render.update(&root, &mut rsc);
let mut up = cursor_at((50.0, 10.0).into());
up.buttons.left = ActivationState::End;
render.run_sensors(&mut rsc, &mut state, up, win);
render.update(&root, &mut rsc);
assert_eq!(cancelled.get(), 1, "cancelled more than once");
assert_eq!(
ended.get(),
@@ -569,15 +582,15 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
let outer_strong = rect(UiColor::WHITE)
.width(Len::abs(1000.0))
.height(Len::abs(1000.0))
.scrollable_on(Axis::X)
.scrollable(Axis::X, Pin::Start)
// The inner area's own handle, taken as the chain is built --
// the whole point is to exercise `scrollable_on`'s real
// the whole point is to exercise `scrollable`'s real
// registration on both, so neither is assembled by hand.
.with_id(move |_rsc, id| {
record.set(Some(id));
id
})
.scrollable()
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
let inner = seen.get().unwrap();
let outer = outer_strong.weak();
@@ -599,9 +612,11 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
let mut down = cursor_at((50.0, 80.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, win);
render.update(&root, &mut rsc);
let mut drag = cursor_at(to);
drag.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, drag, win);
render.update(&root, &mut rsc);
let moved = rsc.ui.widgets.get(&areas[pans]).unwrap().amt();
let unmoved = rsc.ui.widgets.get(&areas[still]).unwrap().amt();
@@ -628,7 +643,7 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is
/// not `Off`) -- and `should_run` derived a press from the button alone,
/// so that farewell frame also carried a `PressStart`. A widget nowhere
/// near the finger therefore opened a gesture, and a `Scroll` catching
/// near the finger therefore opened a gesture, and a `ScrollArea` catching
/// its own fling commits with no slop, so it captured the pointer and the
/// whole gesture went to it.
///
@@ -646,13 +661,13 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
// is the first, the bottom half the second. Each area's own handle is
// taken as its chain is built (`with_id`, the same way the nested-axes
// test above does it), since what is under test is `scrollable()`'s
// real registration rather than a `Scroll` assembled by hand.
let seen: [Rc<Cell<Option<WeakWidget<Scroll>>>>; 2] = Default::default();
let half = |slot: &Rc<Cell<Option<WeakWidget<Scroll>>>>| {
// real registration rather than a `ScrollArea` assembled by hand.
let seen: [Rc<Cell<Option<WeakWidget<ScrollArea>>>>; 2] = Default::default();
let half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| {
let record = slot.clone();
rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable()
.scrollable(Axis::Y, Pin::Start)
.with_id(move |_rsc, id| {
record.set(Some(id));
id
@@ -694,6 +709,7 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
c.buttons.left = button;
c.time = base + std::time::Duration::from_millis(at_ms);
render.run_sensors(rsc, state, c, win);
render.update(&root, rsc);
};
sample(
&mut render,
+219 -163
View File
@@ -1,7 +1,7 @@
//! `LazySpan`: a virtualised span of variable-height rows, laid out from
//! an anchor rather than eagerly like `Span`.
//!
//! **`docs/SCROLL.md` is the overview** -- how this widget and `Scroll`
//! **`docs/SCROLL.md` is the overview** -- how this widget and `ScrollArea`
//! divide the work, the one sign convention, the `Widget` handoff, and
//! what is still open. Read it first; this file is the detail.
//! RUST.md's I3. Read LAYOUT.md first -- this widget is built entirely out
@@ -109,31 +109,39 @@
//! that "whatever lies between" stays empty however far the list is
//! panned.
//!
//! **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.
//! **This widget scrolls itself, and everything that is not its layout
//! lives in a [`ScrollController`] it owns** -- the position, the gesture,
//! the fling and the pin, the same struct a `ScrollArea` holds
//! (`docs/SCROLL.md`). It is not wrapped in one of those and must not be:
//! a scroll tick offers a moved region of the same size, `draw_inner`
//! takes the `mov` path, and a virtualising child inside it would never
//! update which rows it shows. `.scrollable()` here is the span's own
//! inherent one, registering the wheel and the drag against that
//! controller.
//!
//! What is left in this file is the layout: an anchor, a walk outward
//! from it, and an honest answer about how far it can go
//! ([`Self::travel`]).
//!
//! **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
//! the frame when something else causes it.** The controller clamps a
//! delta to the travel the last walk reported, so a delta that runs off a
//! wall already in view is simply cut short. What that cannot cover is a
//! wall this span has not walked to yet -- with rows loaded past an edge
//! there is no bound to report -- or the content or viewport changing
//! under a settled anchor, and for both
//! `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
//! rule `ScrollArea::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;
use std::time::Instant;
/// 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
@@ -246,11 +254,13 @@ pub struct LazySpan {
more_before: Option<StrongWidget>,
more_after: Option<StrongWidget>,
anchor: Option<Anchor>,
/// Whether the anchor currently sits flush against the true end of the
/// content (last item or `more_after`, bottom edge at the viewport's
/// own bottom) -- if so, appending a new row keeps it pinned there,
/// mirroring `Scroll::snap_end`.
snap_end: bool,
/// The position, the gesture, the fling and the pin -- everything
/// about scrolling that is not this widget's own layout, in the same
/// struct a `ScrollArea` holds rather than a protocol between the two
/// (`docs/SCROLL.md`). This widget's `draw` is one instance of the
/// contract in [`ScrollController`]'s module doc: take the delta,
/// lay out, report what it did and how far it can still go.
ctl: ScrollController,
viewport_len: f32,
/// `viewport_len` as of the *previous* draw -- what `repair_anchor`
/// compares against to tell "the container was actually resized" from
@@ -279,17 +289,12 @@ pub struct LazySpan {
/// 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
/// direction-relative pixels, kept so [`Self::travel`] 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`.
/// that edge to the viewport's. An estimate here would leave `amt`
/// drifting from what is on screen by every overshoot into a wall.
content_lead: f32,
content_trail: f32,
/// Whether the last walk ran out of items before its leading /
@@ -298,30 +303,22 @@ pub struct LazySpan {
/// 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 {
/// `at_end` starts the span pinned to the end of its content and keeps
/// it there while rows are appended -- the transcript's case, and the
/// same flag under the same name as [`Scroll::new`]'s. `false` starts
/// at the beginning, which is what anything read from the top wants.
/// Independent of `dir`, which says where item 0 is; see the field.
pub fn new(dir: Dir, at_end: bool) -> Self {
/// `pin` says which end of its content this span opens at and clings
/// to as rows arrive -- [`Pin::End`] for a transcript, and independent
/// of `dir`, which says where item 0 is (see the field). `dir` is also
/// what resolves [`Pin::Pos`]/[`Pin::Neg`], the axis-absolute way of
/// asking the same question.
pub fn new(dir: Dir, pin: Pin) -> Self {
Self {
dir,
ctl: ScrollController::new(dir, pin),
items: VecDeque::new(),
more_before: None,
more_after: None,
anchor: None,
snap_end: at_end,
viewport_len: 0.0,
last_viewport_len: 0.0,
at_start: false,
@@ -330,7 +327,6 @@ impl LazySpan {
content_trail: 0.0,
no_more_before: false,
no_more_after: false,
moved: 0.0,
pending_tap: None,
extents: HashMap::default(),
heights: HashMap::default(),
@@ -361,12 +357,11 @@ impl LazySpan {
}
/// O(1). If the list is currently flush with its own end
/// (`snap_end`), the new row becomes the anchor so a live list stays
/// pinned to its newest content -- the same policy `Scroll` applies
/// via `snap_end`.
/// ([`ScrollController::pinned_to_end`]), the new row becomes the
/// anchor so a live list stays pinned to its newest content.
pub fn push_back(&mut self, row: LazyItem) {
self.items.push_back(row);
if self.snap_end {
if self.ctl.pinned_to_end() {
self.anchor = Some(Anchor {
slot: self.items.len() as isize - 1,
edge: Edge::Trailing,
@@ -451,7 +446,7 @@ impl LazySpan {
pub fn clear(&mut self) {
self.items.clear();
self.anchor = None;
self.snap_end = true;
self.ctl.set_pinned_to_end(true);
self.heights.clear();
self.extents.clear();
}
@@ -459,6 +454,11 @@ impl LazySpan {
/// Move the anchor's edge by `amt` pixels, where positive brings
/// **later** content into view.
///
/// Named apart from [`Scrollable::scroll`] rather than shadowing it:
/// the two run in different spaces and an inherent method silently
/// wins over a trait one, so a caller reaching for the public
/// convention would have got this instead.
///
/// Private, and in the direction-relative space the walk works in
/// rather than the screen space every public delta speaks in: the
/// anchor's offset says where the pinned edge *sits*, so moving the
@@ -467,16 +467,24 @@ impl LazySpan {
/// [`Self::flip_delta`] is the one conversion, exactly as
/// [`Self::flip_pos`] is for positions.
///
/// 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;
// Converted into the screen-space convention every caller
// outside this widget uses, since `moved` is what
// `Widget::scroll_offset` hands upward.
self.moved += self.flip_delta(amt);
/// Unclamped here, on purpose: it is one write. The controller does
/// the clamping, against the walls [`Self::travel`] published from the
/// last walk, and `overscroll_gap` gives back whatever that could not
/// know about.
fn move_anchor(&mut self, amt: f32) {
if self.anchor.is_none() {
return;
}
self.anchor.as_mut().unwrap().offset -= amt;
// Converted into the screen-space convention the controller and
// every caller outside this widget speak in. Every move this span
// makes goes through here, including the ones `overscroll_gap`
// gives back, so `amt` is what actually happened rather than what
// was asked for -- see `ScrollController::moved_by`. Jumps
// (`jump_to_end`/`jump_to_start`) deliberately do not: they are
// not travel across the content.
let moved = self.flip_delta(amt);
self.ctl.moved_by(moved);
}
/// The anchor's own row index and pixel offset, formatted the same
@@ -701,7 +709,7 @@ impl LazySpan {
if let Some(a) = self.anchor
&& self.slot_exists(a.slot)
{
if self.snap_end && self.viewport_len != self.last_viewport_len {
if self.ctl.pinned_to_end() && self.viewport_len != self.last_viewport_len {
self.anchor.as_mut().unwrap().offset = self.viewport_len;
}
self.last_viewport_len = self.viewport_len;
@@ -925,7 +933,7 @@ impl LazySpan {
}
fn update_snap_end(&mut self) {
self.snap_end = match self.anchor {
let pinned = match self.anchor {
Some(a) => {
self.next_slot(a.slot).is_none()
&& a.edge == Edge::Trailing
@@ -933,6 +941,7 @@ impl LazySpan {
}
None => false,
};
self.ctl.set_pinned_to_end(pinned);
}
/// **The one rule for what this list draws**: a row is on screen if
@@ -1133,53 +1142,89 @@ impl LazySpan {
/// on for an O(1) move.
const GENEROUS_PADDING: f32 = 100_000.0;
impl Widget for LazySpan {
/// 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
impl LazySpan {
/// Make this span scrollable: the wheel and a finger drag, registered
/// on the span itself.
///
/// **Inherent, and it shadows `WidgetLike::scrollable` on purpose.**
/// That one wraps its widget in a `ScrollArea`, which is exactly what
/// must not happen here -- a lump slid about by a parent would never
/// update which rows it shows -- and this span already owns the
/// controller such an area would have brought. Rust resolves an
/// inherent method before a trait one, so `list.scrollable()` finds
/// this, and it needs neither of the other's arguments: the axis is
/// `dir`'s and the pin was chosen at construction.
///
/// A span reached through a builder (already wrapped, already behind a
/// closure) gets the trait method instead, correctly -- by then it is
/// a different widget.
///
/// A caller with a drag arbiter of its own registers the wheel and
/// leaves the drag out rather than calling this: one gesture, one
/// arbiter (`transcript_ui`'s `Selection`, and `DragGesture`'s doc).
pub fn scrollable<Rsc: HasEvents>(self) -> impl WidgetIdFn<Rsc, LazySpan> {
let axis = self.dir.axis;
scroll_senses(self, axis)
}
}
impl Scrollable for LazySpan {
fn controller(&self) -> &ScrollController {
&self.ctl
}
fn scroll_offset(&self) -> f32 {
self.moved
fn controller_mut(&mut self) -> &mut ScrollController {
&mut self.ctl
}
}
/// Take what the loaded content allows and leave the rest, so the
/// `Scroll` above learns it hit a wall from what comes back.
impl LazySpan {
/// How far this span can still travel each way, from the edges the
/// last walk actually placed -- what the controller clamps the next
/// delta against, so that a delta running 250px past the end gives
/// 250 back rather than everything or nothing.
///
/// `delta` arrives in screen space -- positive scrolls the reader up
/// or left -- and the walk works in direction-relative pixels, so
/// [`Self::flip_delta`] is the one conversion, on the way in and on
/// the remainder's way back out.
///
/// **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 {
/// **The bound is exact where there is one, and `INFINITY` where there
/// is not.** With content still loaded past an edge this span
/// genuinely cannot say how far it goes without walking there, and
/// saying so is what lets the walk find the wall and `overscroll_gap`
/// hand the overshoot back inside the same frame.
fn travel(&self) -> Travel {
let 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 {
let backward = if self.no_more_before {
(-self.content_lead).max(0.0)
} else {
f32::INFINITY
};
let taken = self.flip_delta(*delta).clamp(-max_backward, max_forward);
self.scroll(taken);
*delta -= self.flip_delta(taken);
// `forward`/`backward` are the walk's own directions -- toward
// later content and toward earlier -- and `Travel` is in screen
// space, so which is which depends on `dir` exactly as
// `flip_delta` does. A `Dir::UP` span's later content is *above*
// it, so scrolling back down the screen is what runs out first.
match self.dir.sign {
Sign::Pos => Travel {
back: backward,
fwd: forward,
},
Sign::Neg => Travel {
back: forward,
fwd: backward,
},
}
}
}
impl Widget for LazySpan {
/// A lazy span animates exactly one thing, its fling -- and it drives
/// its own rather than being handed deltas by a `ScrollArea` around
/// it, since which rows exist at all is a function of where it is
/// scrolled to and a moved lump would never update them.
fn tick(&mut self, now: Instant) -> bool {
self.tick_fling(now)
}
fn draw(&mut self, painter: &mut Painter) -> Size {
@@ -1207,12 +1252,29 @@ impl Widget for LazySpan {
let output_len = painter.output_size().axis(axis);
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
self.ctl.set_density(painter.density());
self.repair_anchor();
if self.anchor.is_none() {
self.extents.clear();
return Size::REST;
}
// What a wheel, a drag or a fling asked for since the last frame,
// already clamped to the travel that frame reported -- the walls
// it placed are the freshest answer available, and where they are
// stale (the content changed under a settled anchor) the walk
// below finds the real ones and `overscroll_gap` gives back the
// difference before this frame ends.
//
// Deliberately after the early return above: a delta that arrives
// while there is nothing to scroll stays banked rather than being
// silently spent on an empty list.
let delta = self.ctl.take_delta();
if delta != 0.0 {
let amt = self.flip_delta(delta);
self.move_anchor(amt);
}
// `reanchor_at_tap` reads `extents` as they stood after the
// *previous* frame's layout -- the last on-screen box for each
// visible row -- so the clear that starts rebuilding it for this
@@ -1245,13 +1307,14 @@ impl Widget for LazySpan {
// box at a new offset, which `draw_inner` dispatches as an O(1)
// move.
if let Some(gap) = self.overscroll_gap(lead, trail) {
self.scroll(gap);
self.move_anchor(gap);
self.extents.clear();
self.lay_out(painter);
}
self.rehome_anchor();
self.update_snap_end();
self.ctl.set_travel(self.travel());
Size::REST
}
}
@@ -1355,7 +1418,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -1405,7 +1468,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::UP, true);
let mut list = LazySpan::new(Dir::UP, Pin::End);
let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -1440,7 +1503,7 @@ mod tests {
/// span is laid out (Iris, 2026-09-08 -- "positive should always
/// scroll up / left, and negative down / right"). A `Dir::UP` span
/// used to pan the opposite way for the same number, because
/// `apply_scroll` handed the delta to the walk without the flip its
/// the delta reached the walk without the flip its
/// positions already went through.
///
/// Asserted on where rows were **drawn**, for the reason
@@ -1458,7 +1521,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(dir, true);
let mut list = LazySpan::new(dir, Pin::End);
let keys: Vec<RowKey> = (0..10).collect();
let rows = push_rows(&mut rsc, &mut list, &keys, 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -1467,14 +1530,15 @@ mod tests {
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let push = |rsc: &mut TestRsc, render: &mut UiRenderState, mut delta: f32| {
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.apply_scroll(&mut delta);
assert_eq!(delta, 0.0, "there was content to take the whole delta");
let push = |rsc: &mut TestRsc, render: &mut UiRenderState, delta: f32| {
let before = rsc.ui.widgets.get(&list_weak).unwrap().amt();
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(delta);
render.update(&root, rsc);
let moved = before - rsc.ui.widgets.get(&list_weak).unwrap().amt();
assert!(
(moved - delta).abs() < 0.01,
"there was content to take the whole delta: asked {delta}, moved {moved}",
);
};
// Away from the pinned end, in whichever screen direction
// that is for this `dir`.
@@ -1520,7 +1584,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::UP, true);
let mut list = LazySpan::new(Dir::UP, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -1547,7 +1611,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -1619,7 +1683,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
let mut rows = Vec::new();
for key in 0..5u64 {
let (bg_id, fg, row) = resizable_background_row(&mut rsc, 20.0);
@@ -1663,7 +1727,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
let mut bg_ids = Vec::new();
for key in 0..5u64 {
let (bg_id, row) = background_styled_row(&mut rsc, 20.0);
@@ -1693,7 +1757,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[10, 11, 12], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -1743,7 +1807,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
// Five rows of 20px; with a 100px viewport all are visible,
// anchored at the bottom by default (row 4's bottom at 100).
let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
@@ -1800,7 +1864,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -1839,7 +1903,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
let keys: Vec<RowKey> = (0..n as u64).collect();
push_rows(&mut rsc, &mut list, &keys, 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -1852,12 +1916,11 @@ mod tests {
render.take_counters();
// Backwards, into content that exists: a list opens flush with
// its newest end, so scrolling *forward* from there is
// its newest end, so a *negative* delta from there is
// overscroll, and the clamp lays out a second time within the
// frame to give it back -- a correct extra pass, but not the
// ordinary
// scroll tick whose cost this test is about.
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(-5.0);
// ordinary scroll tick whose cost this test is about.
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
render.update(&root, &mut rsc);
let (draws, _rewrites, moves, _shapes) = render.take_counters();
@@ -1882,7 +1945,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -1936,7 +1999,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -1979,7 +2042,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
@@ -2049,7 +2112,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
for key in 0..5u64 {
let (_bg_id, row) = background_styled_row(&mut rsc, 20.0);
list.push_back(LazyItem::new(key, row));
@@ -2109,7 +2172,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, true);
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
// Rows that own a primitive *at their own id* (a background rect),
// not only through a child: an orphan is a widget's own primitive
// outliving its own redraw, so a row whose top-level widget paints
@@ -2151,47 +2214,35 @@ mod tests {
/// 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.
/// Built the way a real caller does: `Masked(LazySpan)`, with the span
/// driving its own `ScrollController` -- the gesture, the fling and
/// `amt` are its own, and so is the walk that says how far it can
/// actually go. The mask is outside 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>,
WeakWidget<Scroll>,
StrongWidget,
UiRenderState,
) {
let mut list = LazySpan::new(Dir::DOWN, true);
) -> (WeakWidget<LazySpan>, StrongWidget, UiRenderState) {
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), FLING_ROW_H);
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(),
inner: list.any(),
});
let root = root.any();
let mut render = UiRenderState::new();
render.resize((100.0, 600.0));
render.update(&root, rsc);
(list_weak, scroll_weak, root, render)
(list_weak, root, render)
}
/// Drive one frame of a fling: tick the `Scroll` the way
/// Drive one frame of a fling: tick the span the way
/// `UiData::tick_animations` does, then draw. Answers whether the
/// fling is still going.
fn fling_frame(
rsc: &mut TestRsc,
scroll: &WeakWidget<Scroll>,
scroll: &WeakWidget<LazySpan>,
root: &StrongWidget,
render: &mut UiRenderState,
now: Instant,
@@ -2206,13 +2257,14 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
// Toward the start: **positive**, which is the finger's direction
// and `Scroll::scroll`'s convention -- the one convention a delta
// and `ScrollController::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
// `ScrollArea`'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());
@@ -2243,7 +2295,8 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) {
@@ -2273,8 +2326,8 @@ mod tests {
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
/// The sign, pinned across the whole handoff: gesture -> `ScrollArea` ->
/// controller -> 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.
@@ -2283,7 +2336,8 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
// 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);
@@ -2304,16 +2358,16 @@ mod tests {
/// `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.
/// the end of the content. This is the whole reason the span reports
/// what it *moved* rather than the caller adding up what it asked
/// for: 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);
let (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);
@@ -2345,7 +2399,8 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
// An enormous velocity that would travel far past all 200 rows if
// unclamped.
rsc.ui.widgets.get_mut(&scroll).unwrap().fling(50_000.0);
@@ -2377,8 +2432,8 @@ mod tests {
/// 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
/// position moved into `ScrollArea`, this is prevented at the source --
/// the clamp only allows 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.
@@ -2387,7 +2442,8 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc);
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
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();
@@ -2401,7 +2457,7 @@ mod tests {
#[test]
fn anchor_position_display_before_any_draw_is_none() {
let list = LazySpan::new(Dir::DOWN, true);
let list = LazySpan::new(Dir::DOWN, Pin::End);
assert_eq!(list.anchor_position_display(), "idx=none");
}
@@ -2410,7 +2466,7 @@ mod tests {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, _scroll, root, mut render) = build_flingable_list(&mut rsc);
let (list_weak, 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="));
+4 -2
View File
@@ -4,7 +4,8 @@ mod lazy_span;
mod max_size;
mod offset;
mod pad;
mod scroll;
mod scroll_area;
mod scrollable;
mod sized;
mod span;
mod stack;
@@ -15,7 +16,8 @@ pub use lazy_span::*;
pub use max_size::*;
pub use offset::*;
pub use pad::*;
pub use scroll::*;
pub use scroll_area::*;
pub use scrollable::*;
pub use sized::*;
pub use span::*;
pub use stack::*;
-794
View File
@@ -1,794 +0,0 @@
//! `Scroll`: the one place a scroll position, a gesture and a fling live.
//!
//! **`docs/SCROLL.md` is the overview** -- the two kinds of child (moved,
//! or handed deltas through `Widget::apply_scroll`), the one sign
//! convention, what `amt` means for each, and what is still open. Read it
//! first; this file is the detail.
use crate::prelude::*;
use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState};
use std::time::Instant;
pub struct Scroll {
inner: StrongWidget,
axis: Axis,
amt: f32,
snap_end: bool,
container_len: f32,
/// How long the content is along `axis`, as of the last draw --
/// `None` until this widget has drawn once.
///
/// An `Option` rather than a `0.0` that stands in for both, because
/// the two answers led somewhere different and the code could not
/// tell them apart: on the first frame the clamp in `update_amt`
/// computed a scroll range of zero, concluded from `amt == len` that
/// the area was sitting at its end, and set `snap_end` -- so the next
/// frame, now knowing the real length, jumped to it. A code fence
/// 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
/// `sense.rs` and only what a committed pan *means* is decided here.
/// See [`Self::drag`].
gesture: DragGesture,
/// The momentum a release leaves behind, the same [`Flinger`] a
/// `LazySpan` coasts on. Every scroll area flings, on either axis and
/// with nothing to opt into -- Compose's `scrollable` attaches
/// `ScrollableDefaults.flingBehavior()` on every axis it is given,
/// and Iris asked for the same (2026-09-08: "flinging should be
/// enabled by default in all scroll areas on android to match
/// composes behavior").
fling: Flinger,
/// Physical pixels per dp, copied from the painter on every draw --
/// what a fling's deceleration is computed against. 1.0 until this
/// widget has drawn once, which is also the only state in which
/// nothing can be flung, since there is no content length yet.
density: f32,
}
impl Widget for Scroll {
/// A `Scroll` animates exactly one thing, its fling. The registration
/// that makes this run is `UiData::animate`, which
/// `WidgetLike::scroll_area`'s own drag handler calls the frame a
/// release starts one.
fn tick(&mut self, now: Instant) -> bool {
let delta = self.fling.tick(now);
self.scroll(delta);
// A fling must not keep spending its distance on content that is
// 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 {
// 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.
let axis = self.axis;
let container_len = painter.px_size().axis(axis);
self.container_len = container_len;
// 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.
self.density = painter.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)
}
}
}
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));
// A child reporting `rel` means "this fraction of what I was
// offered", and what it was offered is this scroll area -- so the
// container, again, is what that resolves against.
let measured = used.axis(axis).apply_rest(density).to_abs(container_len);
self.content_len = Some(measured);
// Everything that decides the placement, against the length just
// measured: the end-pin, then the clamp `update_amt` shares with
// `scroll` and `drag`. Deliberately not also run before the
// measuring draw above -- clamping against the hint would let a
// stale length reduce `amt` in a way this pass cannot undo, and
// then where the content sits would depend on the previous frame
// after all.
if self.snap_end {
self.amt = measured - container_len;
}
self.update_amt();
// 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.
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
}
/// `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,
/// which is what anything being *read* wants.
pub fn new(inner: StrongWidget, axis: Axis, at_end: bool) -> Self {
Self {
inner,
axis,
amt: 0.0,
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,
}
}
/// Feed one frame of a touch gesture over this scroll area through.
/// Wired by `WidgetLike::scrollable`; a caller building a `Scroll` by
/// hand registers the same senses and calls this.
///
/// `id` is this widget's own id, which `DragGesture` takes pointer
/// capture on once the gesture commits -- so the rest of the drag
/// reaches here even after the finger has left this area, and, just as
/// importantly, stops reaching whatever is *inside* it. That is what
/// resolves a vertical drag over a focused text field: the field sees
/// the first few frames, iris::attr's `on_press` gives up its pending
/// selection the moment they pass `DRAG_SLOP` vertically, and this
/// takes the gesture over. Android's own `EditText` behaves the same
/// way -- a vertical drag scrolls, and only a long press selects.
///
/// Answers whether this frame *started a fling*, which is the
/// caller's cue to register the widget for frames
/// (`UiData::animate`) -- see [`Widget::tick`]. 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, and `drag` has no `Rsc` to reach the loop through.
pub fn drag(
&mut self,
pointer: &PointerRequests,
id: WidgetId,
sense: CursorSense,
pos_window: Vec2,
now: Instant,
) -> bool {
// A scroll area has no selection of its own to extend, so a drag
// across the axis stays `Undecided` and one along it past the
// slop pans, which is the whole contract here. A caller that
// *does* own a selection (the transcript's `Selection`) drives
// `DragGesture` itself instead.
//
// `scrolling` is the other half: a finger put down on content
// that is still coasting means "stop it here", and commits to a
// pan on that very sample with no slop to wait out
// (`DragArbiter::press_start`). The fling is cancelled in the
// same breath, since the curve has no idea a finger came back
// down.
let mut press = PressState::default();
if self.gesture.starts_press(sense) {
press.scrolling = self.fling.is_flinging();
self.fling.stop();
}
match self
.gesture
.handle(pointer, id, sense, pos_window, now, press)
{
// 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`.
GestureOutcome::Released(Some(v)) => {
return self.fling.start(v, self.density);
}
GestureOutcome::Undecided
| GestureOutcome::Tapped
| GestureOutcome::SelectStart
| GestureOutcome::SelectExtend
| GestureOutcome::Cancelled
| GestureOutcome::Released(None) => {}
}
false
}
/// How far this area can be panned: the content's length past the
/// container's, or zero when it all fits. The one arithmetic
/// `update_amt`'s clamp and `tick`'s wall both ask for, stated once.
fn scroll_range(&self) -> f32 {
match self.content_len {
Some(len) => (len - self.container_len).max(0.0),
None => 0.0,
}
}
/// Where the child sits for a given content length: a box that long
/// along the scroll axis, pulled back by `amt`. Taken as a parameter
/// rather than read from `content_len`, because `draw` places twice
/// -- once against last frame's length and once against the one it
/// has just measured -- and the two must be the same arithmetic.
fn child_region(&self, content_len: f32) -> UiRegion {
let mut region = UiRegion::FULL;
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(content_len);
region.offset(Vec2::from_axis(self.axis, -self.amt, 0.0))
}
/// Clamp `amt` into the range the content allows, and re-read whether
/// this area is sitting at its end.
///
/// Both are skipped until the content has been measured: with no
/// length there is no range to clamp into, and "at the end" is a
/// question that cannot be answered yet -- answering it anyway is
/// what `content_len`'s doc describes.
pub fn update_amt(&mut self) {
// 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();
self.amt = self.amt.clamp(0.0, len);
self.snap_end = self.amt == len;
}
/// How far the content has been pulled past the container's leading
/// edge, in pixels -- 0 at the start of the content. Read-only, for a
/// caller that needs to observe a pan (a test, a scroll indicator).
pub fn amt(&self) -> f32 {
self.amt
}
/// Whether a fling is coasting here right now -- the same question
/// `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).
pub fn is_scrolling(&self) -> bool {
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.
pub fn axis(&self) -> Axis {
self.axis
}
/// Pan by `amt`, in the finger's direction: **positive scrolls up or
/// left**, moving the content the positive way along the axis. One
/// convention, and the one [`Widget::apply_scroll`] carries, so that
/// a delta means the same thing wherever it is handed on -- and a
/// screen direction rather than a logical one, so that it means the
/// same thing to a widget laid out backwards too.
///
/// 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();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sense::{CursorButton, DRAG_SLOP};
use iris_core::UiData;
use std::time::Duration;
/// A scroll area with 1000px of content in a 100px box, already
/// settled somewhere in the middle so a drag has room in both
/// directions.
fn area() -> (UiData, Scroll, WidgetId) {
let mut ui = UiData::default();
let inner = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let id = inner.id();
let mut s = Scroll::new(inner, Axis::Y, true);
s.content_len = Some(1000.0);
s.container_len = 100.0;
s.amt = 400.0;
s.snap_end = false;
(ui, s, id)
}
fn press(
s: &mut Scroll,
render: &PointerRequests,
id: WidgetId,
sense: CursorSense,
y: f32,
t: Instant,
) {
s.drag(render, id, sense, Vec2::new(0.0, y), t);
}
#[test]
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
let (_ui, mut s, id) = area();
let render = PointerRequests::default();
let t = Instant::now();
press(
&mut s,
&render,
id,
CursorSense::PressStart(CursorButton::Left),
0.0,
t,
);
// Finger down by well past the slop: the content follows it down,
// which for this widget means *less* `amt`.
press(
&mut s,
&render,
id,
CursorSense::Pressing(CursorButton::Left),
DRAG_SLOP + 30.0,
t + Duration::from_millis(20),
);
assert!(
(s.amt - 370.0).abs() < 0.01,
"expected the 30px past the slop to be applied downward, got amt={}",
s.amt
);
// ...and the next frame's motion is a plain per-frame delta.
press(
&mut s,
&render,
id,
CursorSense::Pressing(CursorButton::Left),
DRAG_SLOP + 50.0,
t + Duration::from_millis(40),
);
assert!((s.amt - 350.0).abs() < 0.01, "amt={}", s.amt);
}
/// The half the change had no reason to touch: a press that never
/// leaves the slop is a tap, and must move nothing at all -- otherwise
/// every tap on a scrollable field nudges its text.
#[test]
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
let (_ui, mut s, id) = area();
let render = PointerRequests::default();
let t = Instant::now();
press(
&mut s,
&render,
id,
CursorSense::PressStart(CursorButton::Left),
0.0,
t,
);
for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() {
press(
&mut s,
&render,
id,
CursorSense::Pressing(CursorButton::Left),
y,
t + Duration::from_millis(10 * (i as u64 + 1)),
);
}
press(
&mut s,
&render,
id,
CursorSense::PressEnd(CursorButton::Left),
DRAG_SLOP - 0.5,
t + Duration::from_millis(50),
);
assert!(
(s.amt - 400.0).abs() < 0.01,
"a tap scrolled: amt={}",
s.amt
);
}
/// A horizontal drag is not this widget's gesture: it must stay put
/// rather than pick up the vertical noise in a sideways swipe.
#[test]
fn a_horizontal_drag_does_not_scroll() {
let (_ui, mut s, id) = area();
let render = PointerRequests::default();
let t = Instant::now();
s.drag(
&render,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
t,
);
s.drag(
&render,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(120.0, 3.0),
t + Duration::from_millis(20),
);
assert!((s.amt - 400.0).abs() < 0.01, "amt={}", s.amt);
}
/// Panning stops at the ends of the content rather than running off,
/// which is `update_amt`'s clamp -- checked through `drag` so the two
/// cannot drift apart.
#[test]
fn a_pan_past_the_end_clamps_instead_of_running_off() {
let (_ui, mut s, id) = area();
let render = PointerRequests::default();
let t = Instant::now();
s.drag(
&render,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
t,
);
s.drag(
&render,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 5000.0),
t + Duration::from_millis(20),
);
assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt);
}
/// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll
/// areas. Flinging should be enabled by default in all scroll areas
/// on android to match composes behavior." A release with real
/// velocity coasts, decelerating, and settles on its own.
#[test]
fn a_released_pan_flings_and_settles() {
for axis in [Axis::X, Axis::Y] {
let (_ui, mut s, id) = area();
s.axis = axis;
s.gesture = DragGesture::on(axis);
let render = PointerRequests::default();
let t = Instant::now();
let at = |d: f32| Vec2::from_axis(axis, d, 0.0);
s.drag(
&render,
id,
CursorSense::PressStart(CursorButton::Left),
at(0.0),
t,
);
// Four samples 8ms apart, accelerating away from the start --
// three is the fewest `VelocityTracker`'s quadratic fit can
// use, so this is a gesture that genuinely has a velocity.
for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() {
s.drag(
&render,
id,
CursorSense::Pressing(CursorButton::Left),
at(d),
t + Duration::from_millis(8 * (i as u64 + 1)),
);
}
let at_release = s.amt;
s.drag(
&render,
id,
CursorSense::PressEnd(CursorButton::Left),
at(-280.0),
t + Duration::from_millis(32),
);
assert!(
s.fling.is_flinging(),
"{axis:?}: a released pan with velocity must fling"
);
// Frames at 8ms until it stops, with each step no longer than
// the one before it -- a coast that does not decelerate is
// the linear-spline bug this crate has had once already.
let mut last_step = f32::INFINITY;
let mut ticks = 0;
let mut now = t + Duration::from_millis(32);
while s.tick(now) {
let before = s.amt;
now += Duration::from_millis(8);
s.tick(now);
let step = (s.amt - before).abs();
assert!(
step <= last_step + 0.01,
"{axis:?}: the fling sped up: {last_step} then {step}"
);
last_step = step;
ticks += 1;
assert!(ticks < 10_000, "{axis:?}: the fling never settled");
}
assert!(
s.amt > at_release,
"{axis:?}: the fling moved the content the wrong way: {at_release} -> {}",
s.amt
);
}
}
/// The wall: a fling must not spend its remaining distance on content
/// that is not there. Released hard toward the start, it settles
/// exactly on it.
#[test]
fn a_fling_stops_at_the_end_of_the_content() {
// Both walls. A positive delta is applied as `amt -= delta`, so a
// positive velocity runs toward the start of the content and a
// negative one toward its end; 1000px of content in a 100px box
// leaves `amt` in 0..=900.
for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] {
let (_ui, mut s, _id) = area();
s.fling.start(velocity, 1.0);
let t = Instant::now();
let mut now = t;
for _ in 0..1_000 {
if !s.tick(now) {
break;
}
now += Duration::from_millis(8);
}
assert!(
!s.fling.is_flinging(),
"the fling toward {wall} ran past the content"
);
assert!(
(s.amt - wall).abs() < 0.01,
"it should have settled on {wall}, got amt={}",
s.amt
);
}
}
/// A finger on coasting content stops it there, from the first
/// sample, with no `DRAG_SLOP` to wait out -- the catch
/// `DragArbiter::press_start` describes, which a scroll area needs
/// for the same reason a list does now that it can coast at all.
#[test]
fn a_press_on_a_coasting_area_catches_it() {
let (_ui, mut s, id) = area();
s.fling.start(-4_000.0, 1.0);
let t = Instant::now();
s.tick(t);
s.tick(t + Duration::from_millis(8));
let caught_at = s.amt;
assert!(s.fling.is_flinging(), "the fixture must still be moving");
let render = PointerRequests::default();
let down = t + Duration::from_millis(16);
s.drag(
&render,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
down,
);
assert!(!s.fling.is_flinging(), "a touch-down must end the fling");
assert!(
(s.amt - caught_at).abs() < 0.01,
"the down itself must not move the content, only stop it"
);
// A move well under `DRAG_SLOP` still tracks the finger, because
// this press caught something that was moving.
s.drag(
&render,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 2.0),
down + Duration::from_millis(8),
);
assert!(
(s.amt - (caught_at - 2.0)).abs() < 0.01,
"a caught press must pan from its first sample: {} -> {}",
caught_at,
s.amt
);
}
}
+565
View File
@@ -0,0 +1,565 @@
//! `ScrollArea`: a fixed child, slid about by a [`ScrollController`].
//!
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
//! `amt` means, and how this differs from a `LazySpan`, which scrolls
//! itself. Read it first; this file is the detail.
use crate::prelude::*;
use std::time::Instant;
/// A scrolling view over a child that is a fixed lump: it is measured
/// whole and then moved, which is what makes a scroll tick an O(1) move of
/// one subtree rather than a redraw.
///
/// **"Area" because it only scrolls a predefined one** (Iris, 2026-09-08):
/// a child that lays out lazily cannot be measured whole or moved as a
/// lump, and virtualising it inside one of these would never update which
/// rows it shows, since a scroll tick offers a same-size moved region and
/// `draw_inner` never re-enters the child. That case is `LazySpan`, which
/// owns a controller of its own instead of being wrapped in one of these.
pub struct ScrollArea {
inner: StrongWidget,
/// The position, the gesture, the fling and the pin -- everything
/// about scrolling that is not this widget's own layout, shared with
/// `LazySpan` rather than reimplemented beside it.
ctl: ScrollController,
container_len: f32,
/// How long the content is along the axis, as of the last draw --
/// `None` until this widget has drawn once.
///
/// An `Option` rather than a `0.0` that stands in for both, because
/// the two answers led somewhere different and the code could not tell
/// them apart: on the first frame the clamp computed a scroll range of
/// zero, concluded from `amt == range` that the area was sitting at
/// its end, and pinned it -- so the next frame, now knowing the real
/// length, jumped to it. A code fence therefore opened at the end of
/// its longest line, mid-word (`iris/run-headless.sh phone`,
/// 2026-09-08).
content_len: Option<f32>,
}
impl Scrollable for ScrollArea {
fn controller(&self) -> &ScrollController {
&self.ctl
}
fn controller_mut(&mut self) -> &mut ScrollController {
&mut self.ctl
}
}
impl Widget for ScrollArea {
/// A scroll area animates exactly one thing, its fling. The
/// registration that makes this run is `UiData::animate`, which
/// `WidgetLike::scrollable`'s own drag handler calls the frame a
/// release starts one.
fn tick(&mut self, now: Instant) -> bool {
self.tick_fling(now)
}
/// Measure, then place -- the same idiom `LazySpan` uses, for the same
/// reason: nothing drawn may depend on a length measured last frame.
///
/// **The child is drawn twice, and only the second decides anything.**
/// The first is handed last frame's length as a *hint*, 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.
fn draw(&mut self, painter: &mut Painter) -> Size {
// Every length here is resolved against the box this widget was
// **offered** (`px_size`), never `output_size`: a scroll area 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.
let axis = self.ctl.axis();
let container_len = painter.px_size().axis(axis);
self.container_len = container_len;
// 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.
self.ctl.set_density(painter.density());
// Where the delta asked for since the last frame puts the content.
// Already inside the range the previous frame published, so it is
// the position to *measure* against; the clamp below is what the
// length just measured has to say about it.
let delta = self.ctl.take_delta();
let travelled = self.ctl.amt() - delta;
self.ctl.set_amt(travelled);
// The container's own length stands in as the hint until anything
// has been measured: a zero-length region on the first frame would
// place the child's primitives against a box of no size.
let hint = self.content_len.unwrap_or(container_len);
let used = painter.widget_within(&self.inner, self.child_region(hint));
// A child reporting `rel` means "this fraction of what I was
// offered", and what it was offered is this scroll area -- so the
// container, again, is what that resolves against.
let measured = used
.axis(axis)
.apply_rest(painter.density())
.to_abs(container_len);
self.content_len = Some(measured);
let range = (measured - container_len).max(0.0);
// The end-pin, and then the clamp, against the length just
// measured. Deliberately not also run before the measuring draw
// above -- clamping against the hint would let a stale length
// reduce `amt` in a way this pass cannot undo, and then where the
// content sits would depend on the previous frame after all.
//
// Only a frame with no delta of its own re-pins: the pin means
// "stay flush with the end as the content grows", and a reader who
// just scrolled away from that end has said otherwise. (A delta
// cannot be moving *toward* the end here -- the travel published
// below is zero that way while pinned, so `take_delta` has already
// clipped it.)
let amt = if self.ctl.pinned_to_end() && delta == 0.0 {
range
} else {
travelled.clamp(0.0, range)
};
self.ctl.set_amt(amt);
self.ctl.set_pinned_to_end(amt >= range);
self.ctl.set_travel(Travel {
back: amt,
fwd: range - amt,
});
// 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.
painter.widget_within(&self.inner, self.child_region(measured))
}
}
impl ScrollArea {
/// `pin` says which end this area opens at and clings to -- see
/// [`Pin`], and `WidgetLike::scrollable`, which is how one of these is
/// normally built.
pub fn new(inner: StrongWidget, axis: Axis, pin: Pin) -> Self {
Self {
inner,
// A fixed child is laid out from the box's negative edge
// onward, always, so the end of its content is the positive
// one -- which is what makes `Pin::End` and `Pin::Pos` the
// same pin here and different ones in a reversed `LazySpan`.
ctl: ScrollController::new(Dir::new(axis, Sign::Pos), pin),
container_len: 0.0,
content_len: None,
}
}
/// Where the child sits for a given content length: a box that long
/// along the scroll axis, pulled back by `amt`. The length is taken as
/// a parameter rather than read from `content_len`, because `draw`
/// places twice -- once against last frame's length and once against
/// the one it has just measured -- and the two must be the same
/// arithmetic.
fn child_region(&self, content_len: f32) -> UiRegion {
let axis = self.ctl.axis();
let mut region = UiRegion::FULL;
region.axis_mut(axis).end = region.axis(axis).start.offset(content_len);
region.offset(Vec2::from_axis(axis, -self.ctl.amt(), 0.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout_tests::TestRsc;
use crate::sense::{CursorButton, DRAG_SLOP, PointerRequests};
use iris_core::UiData;
use std::time::Duration;
/// A scroll area with 1000px of content in a 100px box, drawn once and
/// settled somewhere in the middle so a drag has room in both
/// directions.
///
/// Built and rendered for real rather than assembled field by field,
/// because a delta is spent in `draw` now (the controller banks it, and
/// only a layout knows where the content ends) -- so a test that never
/// draws would watch `amt` never move and read that as a broken
/// gesture.
fn area() -> (Fixture, WidgetId) {
area_on(Axis::Y)
}
/// The same fixture on either axis -- a code fence pans sideways
/// through one of these exactly as a field pans down, and the pair of
/// them is what caught a fling that only worked vertically.
fn area_on(axis: Axis) -> (Fixture, WidgetId) {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let id = fill.id();
let long = Some(Len::abs(1000.0));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: fill,
x: (axis == Axis::X).then_some(long).flatten(),
y: (axis == Axis::Y).then_some(long).flatten(),
});
let area = rsc
.ui
.widgets
.add_strong(ScrollArea::new(tall.any(), axis, Pin::Start));
let weak = area.weak();
let root = area.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let mut fixture = Fixture {
rsc,
area: weak,
root,
render,
};
// 400px in, which is the middle of the 900px of travel this
// content has.
fixture.get().scroll(-400.0);
fixture.draw();
assert!((fixture.amt() - 400.0).abs() < 0.01);
(fixture, id)
}
/// The area under test with everything needed to draw it -- the drag
/// tests all do the same three things (reach the widget, draw, read
/// `amt`) and each of the three is a line of arena plumbing.
struct Fixture {
rsc: TestRsc,
area: WeakWidget<ScrollArea>,
root: StrongWidget,
render: UiRenderState,
}
impl Fixture {
fn get(&mut self) -> &mut ScrollArea {
self.rsc.ui.widgets.get_mut(&self.area).unwrap()
}
fn draw(&mut self) {
self.render.update(&self.root, &mut self.rsc);
}
fn amt(&self) -> f32 {
self.rsc.ui.widgets.get(&self.area).unwrap().amt()
}
/// One frame of a fling, the way `UiData::tick_animations` drives
/// it: tick, then draw. Answers whether it is still going.
fn fling_frame(&mut self, now: Instant) -> bool {
let still = self.get().tick(now);
self.draw();
still
}
}
/// One frame of a touch gesture, followed by the draw that spends it.
fn press(f: &mut Fixture, id: WidgetId, sense: CursorSense, y: f32, t: Instant) {
drag(f, id, sense, Vec2::new(0.0, y), t);
}
/// The same, for a gesture whose position is not on the Y axis.
fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) {
let pointer = PointerRequests::default();
let flung = f.get().drag(&pointer, id, sense, pos, t);
// What `WidgetLike::scrollable`'s own handler does with the
// answer, and the half a fling does not move without.
if flung {
let id = f.area.id();
f.rsc.ui.animate(id);
}
f.draw();
}
#[test]
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
let (mut f, id) = area();
let t = Instant::now();
press(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
0.0,
t,
);
// Finger down by well past the slop: the content follows it down,
// which for this widget means *less* `amt`.
press(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
DRAG_SLOP + 30.0,
t + Duration::from_millis(20),
);
assert!(
(f.amt() - 370.0).abs() < 0.01,
"expected the 30px past the slop to be applied downward, got amt={}",
f.amt()
);
// ...and the next frame's motion is a plain per-frame delta.
press(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
DRAG_SLOP + 50.0,
t + Duration::from_millis(40),
);
assert!((f.amt() - 350.0).abs() < 0.01, "amt={}", f.amt());
}
/// The half the change had no reason to touch: a press that never
/// leaves the slop is a tap, and must move nothing at all -- otherwise
/// every tap on a scrollable field nudges its text.
#[test]
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
let (mut f, id) = area();
let t = Instant::now();
press(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
0.0,
t,
);
for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() {
press(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
y,
t + Duration::from_millis(10 * (i as u64 + 1)),
);
}
press(
&mut f,
id,
CursorSense::PressEnd(CursorButton::Left),
DRAG_SLOP - 0.5,
t + Duration::from_millis(50),
);
assert!(
(f.amt() - 400.0).abs() < 0.01,
"a tap scrolled: amt={}",
f.amt()
);
}
/// A horizontal drag is not this widget's gesture: it must stay put
/// rather than pick up the vertical noise in a sideways swipe.
#[test]
fn a_horizontal_drag_does_not_scroll() {
let (mut f, id) = area();
let t = Instant::now();
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
t,
);
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(120.0, 3.0),
t + Duration::from_millis(20),
);
assert!((f.amt() - 400.0).abs() < 0.01, "amt={}", f.amt());
}
/// Panning stops at the ends of the content rather than running off,
/// which is `update_amt`'s clamp -- checked through `drag` so the two
/// cannot drift apart.
#[test]
fn a_pan_past_the_end_clamps_instead_of_running_off() {
let (mut f, id) = area();
let t = Instant::now();
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
t,
);
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 5000.0),
t + Duration::from_millis(20),
);
assert!((f.amt() - 0.0).abs() < 0.01, "amt={}", f.amt());
}
/// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll
/// areas. Flinging should be enabled by default in all scroll areas
/// on android to match composes behavior." A release with real
/// velocity coasts, decelerating, and settles on its own.
#[test]
fn a_released_pan_flings_and_settles() {
for axis in [Axis::X, Axis::Y] {
let (mut f, id) = area_on(axis);
let t = Instant::now();
let at = |d: f32| Vec2::from_axis(axis, d, 0.0);
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
at(0.0),
t,
);
// Four samples 8ms apart, accelerating away from the start --
// three is the fewest `VelocityTracker`'s quadratic fit can
// use, so this is a gesture that genuinely has a velocity.
for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() {
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
at(d),
t + Duration::from_millis(8 * (i as u64 + 1)),
);
}
let at_release = f.amt();
drag(
&mut f,
id,
CursorSense::PressEnd(CursorButton::Left),
at(-280.0),
t + Duration::from_millis(32),
);
assert!(
f.get().is_scrolling(),
"{axis:?}: a released pan with velocity must fling"
);
// Frames at 8ms until it stops, with each step no longer than
// the one before it -- a coast that does not decelerate is
// the linear-spline bug this crate has had once already.
let mut last_step = f32::INFINITY;
let mut ticks = 0;
let mut now = t + Duration::from_millis(32);
while f.fling_frame(now) {
let before = f.amt();
now += Duration::from_millis(8);
f.fling_frame(now);
let step = (f.amt() - before).abs();
assert!(
step <= last_step + 0.01,
"{axis:?}: the fling sped up: {last_step} then {step}"
);
last_step = step;
ticks += 1;
assert!(ticks < 10_000, "{axis:?}: the fling never settled");
}
assert!(
f.amt() > at_release,
"{axis:?}: the fling moved the content the wrong way: {at_release} -> {}",
f.amt()
);
}
}
/// The wall: a fling must not spend its remaining distance on content
/// that is not there. Released hard toward the start, it settles
/// exactly on it.
#[test]
fn a_fling_stops_at_the_end_of_the_content() {
// Both walls. A positive delta is applied as `amt -= delta`, so a
// positive velocity runs toward the start of the content and a
// negative one toward its end; 1000px of content in a 100px box
// leaves `amt` in 0..=900.
for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] {
let (mut f, _id) = area();
f.get().fling(velocity);
let t = Instant::now();
let mut now = t;
for _ in 0..1_000 {
if !f.fling_frame(now) {
break;
}
now += Duration::from_millis(8);
}
assert!(
!f.get().is_scrolling(),
"the fling toward {wall} ran past the content"
);
assert!(
(f.amt() - wall).abs() < 0.01,
"it should have settled on {wall}, got amt={}",
f.amt()
);
}
}
/// A finger on coasting content stops it there, from the first
/// sample, with no `DRAG_SLOP` to wait out -- the catch
/// `DragArbiter::press_start` describes, which a scroll area needs
/// for the same reason a list does now that it can coast at all.
#[test]
fn a_press_on_a_coasting_area_catches_it() {
let (mut f, id) = area();
f.get().fling(-4_000.0);
let t = Instant::now();
f.fling_frame(t);
f.fling_frame(t + Duration::from_millis(8));
let caught_at = f.amt();
assert!(f.get().is_scrolling(), "the fixture must still be moving");
let down = t + Duration::from_millis(16);
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
down,
);
assert!(!f.get().is_scrolling(), "a touch-down must end the fling");
assert!(
(f.amt() - caught_at).abs() < 0.01,
"the down itself must not move the content, only stop it"
);
// A move well under `DRAG_SLOP` still tracks the finger, because
// this press caught something that was moving.
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 2.0),
down + Duration::from_millis(8),
);
assert!(
(f.amt() - (caught_at - 2.0)).abs() < 0.01,
"a caught press must pan from its first sample: {} -> {}",
caught_at,
f.amt()
);
}
}
+524
View File
@@ -0,0 +1,524 @@
//! The scrolling capability: one `ScrollController` holding everything a
//! scroll position is made of, and a `Scrollable` trait for the widgets
//! that own one.
//!
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
//! `amt` means, and which widgets scroll. Read it first; this file is the
//! detail.
//!
//! Two widgets scroll in iris and they scroll differently: a
//! [`ScrollArea`](super::ScrollArea) slides a fixed child about as a lump,
//! and a [`LazySpan`](super::LazySpan) lays its own rows out from an
//! anchor and cannot be slid at all. What they share is everything that is
//! *not* the layout -- the gesture, the fling, the pin, the position and
//! the account of how far it can still go -- so that lives here, in a
//! plain struct each of them contains, rather than in a protocol between
//! them (Iris, 2026-09-08: "what about adding a scroll controller that
//! both scroll and lazy span contain").
//!
//! The contract with the owner is two calls, both in its `draw`:
//!
//! 1. [`ScrollController::take_delta`] -- what a wheel, a drag or a fling
//! asked for since the last layout, already clamped to the travel the
//! owner last reported.
//! 2. [`ScrollController::set_travel`], plus whichever of
//! [`ScrollController::moved_by`] or [`ScrollController::set_amt`] fits
//! how that owner knows where it ended up -- movement for a layout with
//! no fixed origin, an absolute position for one that has.
//!
//! Everything between the two is the owner's own layout, and everything
//! outside them is the same for both.
use crate::prelude::*;
use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState};
use std::time::Instant;
/// Which end of its content a scroll area clings to as that content
/// grows, said either way round -- an enum rather than the `at_end: bool`
/// this used to be, because the flag sat at the end of two constructors
/// and `scrollable(axis, true)` says nothing at the call site about which
/// end `true` is.
///
/// **Two pairs, because there are two questions and they are not the same
/// one** (Iris, 2026-09-08: "that way you can select the pin based on the
/// axis's sign rather than the direction, so for example you can assure
/// it's always pinned to the bottom"):
///
/// - [`Pin::Start`] / [`Pin::End`] are **content-relative**: the first row
/// or the newest one, wherever the layout happens to put it. A
/// transcript wants `End` -- the newest message -- and does not care
/// which edge of the screen that is.
/// - [`Pin::Neg`] / [`Pin::Pos`] are **axis-absolute**: the top or left
/// edge, and the bottom or right one, whichever end of the content sits
/// there. What to reach for when the *screen* position is the
/// requirement.
///
/// The two coincide for content laid out along the positive axis, which is
/// everything except a reversed `LazySpan` (`Dir::UP`, `Dir::LEFT`) --
/// where they are exact opposites, which is the whole reason both exist.
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
pub enum Pin {
/// The start of the content: item 0, wherever it is drawn.
Start,
/// The end of the content: the newest item, wherever it is drawn.
End,
/// The top or left edge of the box, whichever end of the content is
/// there.
Neg,
/// The bottom or right edge of the box, whichever end of the content
/// is there.
Pos,
}
impl Pin {
/// Resolve to the one question a scrollable actually acts on: does
/// content appended to the end bring the view with it? `dir` is the
/// way this owner's content runs, which is the only thing that tells
/// the axis-absolute pair from the content-relative one.
fn pinned_to_end(self, dir: Dir) -> bool {
match self {
Pin::Start => false,
Pin::End => true,
Pin::Neg => dir.sign == Sign::Neg,
Pin::Pos => dir.sign == Sign::Pos,
}
}
}
/// How far a scrollable can still travel from where it is, as of its last
/// layout, in the same screen-space units a delta is in.
///
/// `f32::INFINITY` where the end is not in sight: a lazy layout genuinely
/// does not know how much content lies past the rows it has walked, and
/// saying "infinity" is the honest answer that `clamp` also happens to
/// take with no branch. The wall is then found by the walk, which is why
/// the owner reports what it *did* as well as what it can do.
#[derive(Clone, Copy, Debug)]
pub struct Travel {
/// The bound on a **positive** delta -- scrolling up or left, back
/// toward the start of the content.
pub back: f32,
/// The bound on a **negative** delta -- scrolling down or right,
/// onward toward the end of the content. Positive itself: it is a
/// distance, and the sign it bounds is the caller's.
pub fwd: f32,
}
impl Travel {
/// Nothing known yet, so nothing is bounded -- what a scrollable
/// starts with and what it reports for an axis whose content it has
/// not measured.
pub const UNBOUNDED: Self = Self {
back: f32::INFINITY,
fwd: f32::INFINITY,
};
/// The bound on a delta of this sign, as a positive distance.
fn toward(&self, delta: f32) -> f32 {
if delta >= 0.0 { self.back } else { self.fwd }
}
}
/// The state a scroll position is made of, owned by the widget that
/// scrolls: where it is, what it was asked to do next, how far it can go,
/// which end it clings to, and the gesture and fling that drive it.
///
/// See the module doc for the two-call contract with its owner, and
/// [`Scrollable`] for the trait that reaches one.
pub struct ScrollController {
/// Which way this area's content runs: the axis it pans along, and the
/// sign the content grows in. A plain `ScrollArea` always grows the
/// positive way; a `LazySpan` passes its own `dir`, which is what
/// tells [`Pin::Pos`]/[`Pin::Neg`] from [`Pin::Start`]/[`Pin::End`].
dir: Dir,
/// Where this area has got to, counting **forward through the
/// content**: 0 at the start, growing as the reader moves on. The
/// opposite sign to a delta, which counts the way the finger moves.
///
/// For a `ScrollArea` it is a position, clamped into the content's
/// real length. For a `LazySpan` it is **movement, not position** --
/// paging rows in above moves the origin and the span cannot say by
/// how much, never having measured them -- so the direction is
/// comparable between the two and the absolute value is not.
amt: f32,
/// Asked for but not yet laid out: how far a wheel, a drag or a fling
/// has moved this area since the last draw. Taken and cleared by
/// [`Self::take_delta`], which is the only place it is spent, because
/// the owner's `draw` is the only place the walls are known.
pending: f32,
/// What the owner's last layout said was left, and what `take_delta`
/// clamps against.
travel: Travel,
/// Whether this area is currently flush against the end of its
/// content, so that content appended to it should bring the view
/// along. Set from [`Pin`] at construction and recomputed by the owner
/// at the end of every layout -- it is live state, not a preference: a
/// reader who scrolls away from the end stops being pinned to it, and
/// scrolling back re-pins.
pinned_to_end: bool,
/// Touch panning. Arbitration, `DRAG_SLOP` and pointer capture all
/// live in `sense.rs`; only what a committed pan *means* is decided
/// here. See [`Self::drag`].
gesture: DragGesture,
/// The momentum a release leaves behind. Every scroll area flings, on
/// either axis and with nothing to opt into -- Compose's `scrollable`
/// attaches `ScrollableDefaults.flingBehavior()` on every axis it is
/// given, and Iris asked for the same (2026-09-08: "flinging should be
/// enabled by default in all scroll areas on android to match composes
/// behavior").
fling: Flinger,
/// Physical pixels per dp, copied from the painter on every draw --
/// what a fling's deceleration is computed against. 1.0 until the
/// owner has drawn once, which is also the only state in which nothing
/// can be flung, since there is no content measured yet.
density: f32,
}
impl ScrollController {
pub fn new(dir: Dir, pin: Pin) -> Self {
Self {
dir,
amt: 0.0,
pending: 0.0,
travel: Travel::UNBOUNDED,
pinned_to_end: pin.pinned_to_end(dir),
gesture: DragGesture::on(dir.axis),
fling: Flinger::new(),
density: 1.0,
}
}
/// Which way this area pans.
pub fn axis(&self) -> Axis {
self.dir.axis
}
/// Which way this area's content runs -- the axis it pans along and
/// the sign it grows in. What resolves a [`Pin`].
pub fn dir(&self) -> Dir {
self.dir
}
/// How far the content has been pulled past the container's leading
/// edge -- see the field for what that means for each kind of owner.
pub fn amt(&self) -> f32 {
self.amt
}
/// Pan by `amt`, in the finger's direction: **positive scrolls up or
/// left**, moving the content the positive way along the axis. One
/// convention, everywhere, and a screen direction rather than a
/// logical one so that it means the same thing to a widget laid out
/// backwards (Iris, 2026-09-08).
///
/// Banked rather than applied: where this area can actually go is a
/// question only its owner's layout can answer, and the owner's `draw`
/// is where that answer exists.
pub fn scroll(&mut self, amt: f32) {
self.pending += amt;
}
/// What has been asked for since the last layout, clamped to the
/// travel that layout reported. Called once at the top of the owner's
/// `draw`.
///
/// **Clipping it stops a fling**, because a fling that keeps spending
/// its distance on content that is not there is what left a hard flick
/// parked a whole screen past the first row of the bench fixture
/// (docs/IRIS_TODO.md, 2026-09-07). This catches the wall the owner
/// could already see; [`Self::set_travel`] catches the one it finds by
/// walking.
pub fn take_delta(&mut self) -> f32 {
let asked = std::mem::take(&mut self.pending);
let limit = self.travel.toward(asked);
let taken = asked.clamp(-limit, limit);
if taken != asked {
self.fling.stop();
}
taken
}
/// Record content this area really moved, and by how much, in a
/// delta's own sign. For an owner that cannot state an absolute
/// position -- a lazy layout, whose origin moves as rows are paged in
/// above it.
pub fn moved_by(&mut self, delta: f32) {
self.amt -= delta;
}
/// Set where this area is outright, for an owner that knows: a
/// `ScrollArea` has measured its content and clamps against its real
/// length, and a jump to an end is a position rather than travel.
pub fn set_amt(&mut self, amt: f32) {
self.amt = amt;
}
/// Publish how far this area can still go, from the layout that just
/// ran. Stops a fling with nothing left in the direction it is
/// travelling -- the wall a lazy layout only finds by walking to it,
/// reported in the same frame that found it.
pub fn set_travel(&mut self, travel: Travel) {
self.travel = travel;
if let Some(v) = self.fling.velocity()
&& travel.toward(v) <= 0.0
{
self.fling.stop();
}
}
/// What the last layout said was left. Read by an owner that has to
/// reconcile its own walls with what it was allowed to take.
pub fn travel(&self) -> Travel {
self.travel
}
/// Whether this area is flush against the end of its content, so that
/// an appended row should bring the view with it. The owner recomputes
/// this at the end of every layout; a caller may set it to re-pin (a
/// "jump to latest" button) or to let go.
pub fn pinned_to_end(&self) -> bool {
self.pinned_to_end
}
pub fn set_pinned_to_end(&mut self, pinned: bool) {
self.pinned_to_end = pinned;
}
/// Physical pixels per dp, which a fling's deceleration is computed
/// against. Learned from the frame rather than passed in: it is a
/// physical quantity, and the owner's `draw` is where it meets the
/// only thing that knows it.
pub fn set_density(&mut self, density: f32) {
self.density = density;
}
/// Start a fling at `velocity`, in [`Self::scroll`]'s direction
/// convention. Answers whether one actually started, which is the
/// caller's cue to register the widget for frames (`UiData::animate`).
/// Cancels any fling already in progress.
///
/// **Sets the fling; it does not drive it.** A fling moves only while
/// something calls [`Self::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 area'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 on is this area's own, taken from the painter,
/// 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();
}
/// Whether a fling is coasting here right now. What a caller polls to
/// know whether this area is moving on its own (a test, and
/// [`PressState::scrolling`]'s own condition).
pub fn is_scrolling(&self) -> bool {
self.fling.is_flinging()
}
/// 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()
}
/// Advance a fling by one frame, banking the distance it covered.
/// Answers whether it is still going, which is what
/// `UiData::tick_animations` reads to decide whether to keep the
/// widget registered -- so an owner's `Widget::tick` is this one line.
///
/// Stopping at a wall is [`Self::take_delta`]'s and
/// [`Self::set_travel`]'s, not this method's: both know where the
/// content ends and this one does not.
pub fn tick(&mut self, now: Instant) -> bool {
let delta = self.fling.tick(now);
self.scroll(delta);
self.fling.is_flinging()
}
/// Feed one frame of a touch gesture over this area through.
/// Registered by `WidgetLike::scrollable`; a caller with an arbiter of
/// its own drives `DragGesture` itself and hands the committed pans
/// here instead (`transcript_ui::Selection`).
///
/// `id` is the owning widget's id, which `DragGesture` takes pointer
/// capture on once the gesture commits -- so the rest of the drag
/// reaches here even after the finger has left the area, and, just as
/// importantly, stops reaching whatever is *inside* it. That is what
/// resolves a vertical drag over a focused text field: the field sees
/// the first few frames, `iris::attr`'s `on_press` gives up its
/// pending selection the moment they pass `DRAG_SLOP` vertically, and
/// this takes the gesture over. Android's own `EditText` behaves the
/// same way -- a vertical drag scrolls, and only a long press selects.
///
/// Answers whether this frame *started a fling*, which is the caller's
/// cue to register the widget for frames (`UiData::animate`) -- see
/// [`Self::fling`].
pub fn drag(
&mut self,
pointer: &PointerRequests,
id: WidgetId,
sense: CursorSense,
pos_window: Vec2,
now: Instant,
) -> bool {
// A scroll area has no selection of its own to extend, so a drag
// across the axis stays `Undecided` and one along it past the slop
// pans, which is the whole contract here.
//
// `scrolling` is the other half: a finger put down on content that
// is still coasting means "stop it here", and commits to a pan on
// that very sample with no slop to wait out
// (`DragArbiter::press_start`). The fling is cancelled in the same
// breath, since the curve has no idea a finger came back down.
let mut press = PressState::default();
if self.gesture.starts_press(sense) {
press.scrolling = self.fling.is_flinging();
self.fling.stop();
}
match self
.gesture
.handle(pointer, id, sense, pos_window, now, press)
{
// The content follows the finger, and the same `dy` an
// arbiter of the caller's own (`Selection::drag`) hands
// straight to `scroll`.
GestureOutcome::Pan(dy) => self.scroll(dy),
// Same sign as `Pan`, since `tick` applies it through the same
// `scroll`.
GestureOutcome::Released(Some(v)) => return self.fling(v),
GestureOutcome::Undecided
| GestureOutcome::Tapped
| GestureOutcome::SelectStart
| GestureOutcome::SelectExtend
| GestureOutcome::Cancelled
| GestureOutcome::Released(None) => {}
}
false
}
}
/// A widget that scrolls its own content. Implementors hand back the
/// [`ScrollController`] they own and get everything a caller does with a
/// scroll position for free.
///
/// The two implementors are [`ScrollArea`](super::ScrollArea) and
/// [`LazySpan`](super::LazySpan). What distinguishes them is only *how*
/// they spend a delta, which is their `draw`'s business -- so a caller
/// that pans, flings, reads `amt` or re-pins works through this trait and
/// never has to know which it is holding.
pub trait Scrollable {
fn controller(&self) -> &ScrollController;
fn controller_mut(&mut self) -> &mut ScrollController;
/// Pan by `amt` -- positive scrolls up or left. See
/// [`ScrollController::scroll`].
fn scroll(&mut self, amt: f32) {
self.controller_mut().scroll(amt);
}
/// See [`ScrollController::fling`], including why starting one is not
/// the same as driving it.
fn fling(&mut self, velocity: f32) -> bool {
self.controller_mut().fling(velocity)
}
fn cancel_fling(&mut self) {
self.controller_mut().cancel_fling();
}
/// See [`ScrollController::drag`].
fn drag(
&mut self,
pointer: &PointerRequests,
id: WidgetId,
sense: CursorSense,
pos_window: Vec2,
now: Instant,
) -> bool {
self.controller_mut()
.drag(pointer, id, sense, pos_window, now)
}
/// See [`ScrollController::amt`] for what this counts, which differs
/// between the two implementors in origin though not in direction.
fn amt(&self) -> f32 {
self.controller().amt()
}
fn axis(&self) -> Axis {
self.controller().axis()
}
fn is_scrolling(&self) -> bool {
self.controller().is_scrolling()
}
fn fling_velocity(&self) -> Option<f32> {
self.controller().fling_velocity()
}
fn pinned_to_end(&self) -> bool {
self.controller().pinned_to_end()
}
fn set_pinned_to_end(&mut self, pinned: bool) {
self.controller_mut().set_pinned_to_end(pinned);
}
/// Advance a fling by one frame -- an implementor's `Widget::tick` is
/// this, and nothing else animates in a scroll area.
fn tick_fling(&mut self, now: Instant) -> bool {
self.controller_mut().tick(now)
}
}
/// Register the two inputs of a scroll -- the wheel and a finger drag --
/// on a widget that owns a [`ScrollController`], and hand back the id.
///
/// The one place either is wired, shared by `WidgetLike::scrollable` and
/// `LazySpan::scrollable`: what differs between those two is only whether
/// there is a `ScrollArea` in the way, and a drag registered twice is a
/// gesture arbitrated twice.
pub fn scroll_senses<Rsc, Tag, W, WL>(w: WL, axis: Axis) -> impl WidgetIdFn<Rsc, W>
where
Rsc: HasEvents,
W: Widget + Scrollable,
WL: WidgetLike<Rsc, Tag, Widget = W>,
{
w.on(CursorSense::Scroll, move |ctx, rsc| {
let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
ctx.widget(rsc).scroll(delta);
})
.on(CursorSense::drag_senses(), |ctx, rsc: &mut Rsc| {
let id = ctx.widget.id();
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
let flung = ctx
.widget(rsc)
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
// The half that actually makes it move -- a fling is set by the
// widget and driven by the frame loop, and only this side can
// reach the loop. Only when one actually started: registering a
// widget that is not animating asks the next frame to find that
// out.
if flung {
rsc.ui_mut().animate(id);
}
})
}
+24 -55
View File
@@ -83,63 +83,32 @@ widget_trait! {
}
}
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
self.scrollable_on(Axis::Y)
}
/// `scrollable_on`, but starting pinned to the **end** of its content
/// and staying there while the content grows -- what a composer wants,
/// where the newest line is the one being written.
/// Wrap this widget in a [`ScrollArea`] that pans along `axis`, with
/// the wheel and a finger drag both registered -- how anything with a
/// fixed layout becomes scrollable.
///
/// Explicit, because the other kind is not a variation on it: a code
/// fence opened at the end of its longest line, which is the middle
/// of a word (seen in `iris/run-headless.sh phone`, 2026-09-08). The
/// two behaviours are one mechanism with the starting edge passed in,
/// and both names say which they are rather than one of them being a
/// default nobody reads.
fn scrollable_to_end(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
self.scroll_area(axis, true)
}
/// `scrollable` along `axis`. A code fence pans across its own long
/// lines exactly the way a transcript pans down its rows, so the two
/// are one function with the axis passed in rather than a second copy
/// -- `DragArbiter::on` is the other half.
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
self.scroll_area(axis, false)
}
/// The one implementation behind [`Self::scrollable_on`] and
/// [`Self::scrollable_to_end`] -- see the latter for what `at_end`
/// decides.
fn scroll_area(self, axis: Axis, at_end: bool) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
/// `pin` says which end the area opens at and clings to as its content
/// grows, and it is spelled out rather than defaulted because the two
/// cases are not variations on each other: a composer wants the end,
/// where what is being typed is, and a code fence opened at the end of
/// its longest line, which is the middle of a word (seen in
/// `iris/run-headless.sh phone`, 2026-09-08).
///
/// One method with the axis and the pin passed in, rather than the
/// three named variants this used to be (Iris, 2026-09-08: "can we
/// make both scroll methods become `.scrollable`, and it takes an axis
/// and a pin instead of having two?"). A code fence pans across its
/// own long lines exactly the way a transcript pans down its rows, so
/// the two are one mechanism with the direction passed in --
/// `DragArbiter::on` is the other half.
///
/// A [`LazySpan`] has an inherent `scrollable` of its own that this
/// does not reach: it owns a controller already and must not be
/// wrapped in an area that would slide it about as a lump.
fn scrollable(self, axis: Axis, pin: Pin) -> impl WidgetIdFn<Rsc, ScrollArea> where Rsc: HasEvents {
move |state| {
Scroll::new(self.add_strong(state), axis, at_end)
.on(CursorSense::Scroll, move |ctx, rsc| {
let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
ctx.widget(rsc).scroll(delta);
})
// A finger drag, through the same `DragGesture` the
// transcript's `LazySpan` is panned by -- `Scroll::drag`'s doc
// has the arbitration and why there is no fling. The wheel
// above and this are the two inputs of one scroll, so they
// are registered together rather than left to each caller.
.on(CursorSense::drag_senses(), |ctx, rsc: &mut Rsc| {
let id = ctx.widget.id();
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
let flung =
ctx.widget(rsc)
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
// The half that actually makes it move -- a fling is
// set by the widget and driven by the frame loop, and
// only this side can reach the loop. Only when one
// actually started: registering a widget that is not
// animating asks the next frame to find that out.
if flung {
rsc.ui_mut().animate(id);
}
})
.add(state)
let area = ScrollArea::new(self.add_strong(state), axis, pin);
scroll_senses(area, axis)(state)
}
}
+4 -1
View File
@@ -101,7 +101,10 @@ where
.add(rsc);
let texts = Span::empty(Dir::DOWN).gap(10).add(rsc);
let msg_area = texts.scrollable().masked().background(rect(Color::SKY));
let msg_area = texts
.scrollable(Axis::Y, Pin::Start)
.masked()
.background(rect(Color::SKY));
let add_text = wtext("add")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
@@ -124,7 +124,7 @@ fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
PHONE_FRAME_MS,
);
assert!(
(screen.scroll)(&mut h.rsc).is_scrolling(),
(screen.list)(&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.scroll)(&mut h.rsc).is_scrolling(),
(screen.list)(&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.scroll)(&mut h.rsc).fling_velocity(),
(screen.list)(&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.scroll)(&mut h.rsc).is_scrolling(),
!(screen.list)(&mut h.rsc).is_scrolling(),
"the fling must have stopped, or this is the same case as the test above"
);
+5 -5
View File
@@ -3,7 +3,7 @@
//! flicked sideways, has to keep moving after the finger leaves.
//!
//! The fence is pushed here rather than hunted for in the bench fixture,
//! so the test knows which row it is pressing and where. The `Scroll` it
//! so the test knows which row it is pressing and where. The `ScrollArea` it
//! asserts on is found by walking what is actually drawn -- there is no
//! handle to it from the outside, and a coordinate would only prove that
//! *something* moved.
@@ -15,7 +15,7 @@ use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// The horizontal scroll area drawn inside `top..bottom`, with the box
/// it was drawn at -- a fence is the only thing in a transcript that pans
/// sideways. Found by walking what is actually drawn, because there is no
/// handle to a fence's own `Scroll` from the outside and a bare
/// handle to a fence's own `ScrollArea` from the outside and a bare
/// coordinate would only prove that *something* moved.
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
h.render
@@ -27,7 +27,7 @@ fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, Pixe
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
.is_some_and(|s| s.axis() == Axis::X)
})
.find_map(|id| {
@@ -41,7 +41,7 @@ fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
.expect("the fence's scroll area is still drawn")
.is_scrolling()
}
@@ -51,7 +51,7 @@ fn amt(h: &Harness, id: WidgetId) -> f32 {
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
.expect("the fence's scroll area is still drawn")
.amt()
}
@@ -60,7 +60,7 @@ fn a_cancelled_flick_does_not_fling() {
h.replay(&flick);
assert_eq!(
(screen.scroll)(&mut h.rsc).fling_velocity(),
(screen.list)(&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.scroll)(&mut h.rsc).fling_velocity(),
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"and it must not have flung either"
);
@@ -119,7 +119,7 @@ fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
/// The report itself: "if I scroll in a horizontal area and then tap in a
/// vertical area, it seems to snap."
///
/// A markdown fence pans sideways through its own `Scroll`, which takes
/// A markdown fence pans sideways through its own `ScrollArea`, which takes
/// pointer capture the moment it commits. Everything else that was handed
/// a frame of that press is told so with `CursorSense::Cancel` -- and the
/// widget the press actually landed on is the fence's own text block,
@@ -44,7 +44,7 @@ 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.scroll)(&mut h.rsc)
let velocity = (screen.list)(&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
@@ -98,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() && !(screen.scroll)(&mut h.rsc).is_scrolling() {
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
settled_at = Some(t);
}
t += PHONE_FRAME_MS;
@@ -142,7 +142,7 @@ fn a_tap_on_a_row_moves_nothing() {
h.replay(&script("tap", include_str!("../touch/tap.touch")));
assert_eq!(
(screen.scroll)(&mut h.rsc).fling_velocity(),
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a tap must not fling"
);
+2 -2
View File
@@ -66,11 +66,11 @@ fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32
/// 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.
/// position lives in the `LazySpan`'s own `ScrollController`.
/// 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.scroll)(&mut h.rsc).scroll(amount);
(screen.list)(&mut h.rsc).scroll(amount);
h.frame(t);
t + PHONE_FRAME_MS
}
+1 -1
View File
@@ -113,7 +113,7 @@ where
let content = field
// `scrollable_to_end`: what is being typed is at the end, so a
// message longer than the six lines shown holds that end.
.scrollable_to_end(Axis::Y)
.scrollable(Axis::Y, Pin::End)
.pad(dp(FIELD_PAD_DP))
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
.width(rest(1))
+20 -36
View File
@@ -55,15 +55,13 @@ use selection::Selection;
use std::{cell::RefCell, rc::Rc};
pub struct TranscriptScreen {
/// The transcript's own `LazySpan` -- exposed so a caller can read
/// `.extent()`/call `.jump_to_end()` etc. directly for anything this
/// crate does not already wrap.
/// The transcript's own `LazySpan` -- the layout *and* the scroll
/// position, since a lazy span owns a `ScrollController` of its own
/// rather than being wrapped in a `ScrollArea` (`docs/SCROLL.md`).
/// Exposed so a caller can read `.extent()`, drive it through
/// `Scrollable` (`.scroll()`, `.fling()`, `.amt()`) or call
/// `.jump_to_end()` directly.
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 --
@@ -360,7 +358,7 @@ where
Rsc::State: FocusHost + OpenUrl,
{
let selection = Rc::new(RefCell::new(Selection::new()));
let list = LazySpan::new(Dir::DOWN, true).add(rsc);
let list = LazySpan::new(Dir::DOWN, Pin::End).add(rsc);
// The last row's block widgets are kept for the same reason
// `push_row` keeps them: a reply that is *already* streaming when the
@@ -415,35 +413,22 @@ 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| {
// The wheel, registered by hand rather than through
// `LazySpan::scrollable()`, and this is the reason: that helper also
// registers a finger drag driving the span's own `DragGesture`, and
// the transcript already has an arbiter -- `Selection`, which has to
// decide between panning and selecting text and so cannot let a second
// `DragGesture` see the same frames. `DragGesture`'s doc states the
// rule: one gesture, one arbiter, each frame delivered exactly once.
// The wheel handler here is identical to the helper's; only the drag
// differs, and it arrives through `Selection::drag`, which hands
// committed pans and releases to this same span.
list.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);
selection.borrow_mut().set_scroll_area(list);
let (composer, composer_bar) = composer::build_composer(rsc);
@@ -453,14 +438,13 @@ 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 = (scroll.width(rest(1)).height(rest(1)).masked(), composer_bar)
let tree = (list.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,
+1 -1
View File
@@ -277,7 +277,7 @@ where
// curve instead of leaving square pixels in the corners
// (Iris, 2026-09-07).
BlockFrame::Verbatim { fill } => field
.scrollable_on(Axis::X)
.scrollable(Axis::X, Pin::Start)
.pad(dp(FRAME_PAD_DP))
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
.width(rest(1))
+17 -14
View File
@@ -53,15 +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
/// The transcript's `LazySpan`, whose `ScrollController` 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`
/// after this (rows need a `Selection` to be built, and the span
/// 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>>,
scroll: Option<WeakWidget<LazySpan>>,
}
impl Default for Selection {
@@ -80,11 +80,11 @@ impl Selection {
}
}
/// 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>) {
/// Hand this the transcript's `LazySpan` once it exists -- see the
/// `scroll` field. Called by `build_tree`; every pan and fling this
/// arbitrates goes to that span's own `ScrollController`
/// (`Scrollable::scroll`/`fling`), which is where the position lives.
pub fn set_scroll_area(&mut self, scroll: WeakWidget<LazySpan>) {
self.scroll = Some(scroll);
}
@@ -278,7 +278,7 @@ impl Selection {
pointer: &PointerRequests,
) -> GestureOutcome {
// A fresh touch-down cancels any fling still coasting from the
// previous gesture -- `Scroll::fling`'s own doc, and Android's
// previous gesture -- `ScrollController::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
@@ -291,7 +291,7 @@ impl Selection {
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"
call `set_scroll_area` after building the transcript's `LazySpan`"
);
let mut press = PressState::default();
if self.gesture.starts_press(sense) {
@@ -312,7 +312,7 @@ impl Selection {
// selection follows from a gesture that was never ours.
GestureOutcome::Cancelled | GestureOutcome::Undecided => {}
// `scroll(dy)`, not `scroll(-dy)`: since the position moved
// into `Scroll` there is one convention for a scroll delta in
// into the controller 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) => {
@@ -344,7 +344,7 @@ impl Selection {
// `DragGesture`'s `Some(v)` already encodes.
GestureOutcome::Released(Some(v)) => {
// The half that actually makes it move -- see
// `Scroll::fling`'s doc. Without it the velocity is
// `ScrollController::fling`'s doc. Without it the velocity is
// computed, stored, and never advanced by anything.
//
// Only when `fling` actually took it: below Compose's
@@ -461,14 +461,17 @@ mod tests {
EditMode::MultiLine,
))
.weak();
let list = rsc.ui.widgets.add_strong(LazySpan::new(Dir::DOWN, true));
let list = rsc
.ui
.widgets
.add_strong(LazySpan::new(Dir::DOWN, Pin::End));
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(Scroll::new(list.any(), Axis::Y, true))
.add_strong(LazySpan::new(Dir::DOWN, Pin::End))
.weak();
let list = list_weak;
+2 -2
View File
@@ -256,7 +256,7 @@ fn disclosure<Rsc>(glyph: &'static str) -> TextBuilder<Rsc> {
/// where its arguments end, and the long one is the one being read
/// closely. A long line is **clipped** here rather than pannable, which a
/// markdown fence (`row.rs`'s `BlockFrame::Verbatim`) is not: adding
/// `.scrollable_on(Axis::X)` to this non-editable `Text` made it draw
/// `.scrollable(Axis::X, Pin::Start)` to this non-editable `Text` made it draw
/// nothing at all -- an empty panel where the command should be, seen on
/// 2026-09-06 in `docs/bench/p1b-2026-09-06/` and bisected to that one
/// call (the fence, which does the same thing to a `TextEdit`, is fine).
@@ -272,7 +272,7 @@ where
.wrap(false)
.add(rsc);
field
.scrollable_on(Axis::X)
.scrollable(Axis::X, Pin::Start)
.pad(dp(RAW_PAD_DP))
.masked_by(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP)))
.width(rest(1))