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:
1 parent
bf8658c404
commit
4fdabc39d0
33 files changed
+1721
-1431
No files matched your search
@@ -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,
|
public API** -- Iris, 2026-09-08), working list, decisions log,
|
||||||
layout/render design, and texture-atlas design, and the client-core
|
layout/render design, and texture-atlas design, and the client-core
|
||||||
crate's design, respectively.
|
crate's design, respectively.
|
||||||
- `docs/SCROLL.md` — how anything in iris scrolls: `Scroll` owns the
|
- `docs/SCROLL.md` — how anything in iris scrolls: one
|
||||||
position, the gesture and the fling; a child either gets moved or
|
`ScrollController` holds the position, the gesture, the fling and the
|
||||||
answers `Widget::scrolls_itself` and is handed deltas. Read it before
|
pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own
|
||||||
touching `scroll.rs`, `lazy_span.rs`, or anything that pans, flings
|
one each through the `Scrollable` trait. Read it before touching
|
||||||
or lays out a long list.
|
`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
|
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||||||
rather than a script kept here) and the APK, in parallel. It points at
|
rather than a script kept here) and the APK, in parallel. It points at
|
||||||
|
|||||||
+62
-1
@@ -12,7 +12,68 @@ things still stay out.
|
|||||||
An entry gives the date, what changed, why, and a short before/after where
|
An entry gives the date, what changed, why, and a short before/after where
|
||||||
it helps judge the change without the session that made it. Newest first.
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
## 2026-09-08 (last): `List` is `LazySpan`, and scrolling belongs to `Scroll`
|
## 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
|
From the design exchange after the overscroll fix, where you asked
|
||||||
whether `List` could just be `Span::scrollable()`. It cannot -- a lazy
|
whether `List` could just be `Span::scrollable()`. It cannot -- a lazy
|
||||||
|
|||||||
+140
-134
@@ -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
|
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.
|
how it got here, and `docs/IRIS_TODO.md` has what is still open.
|
||||||
|
|
||||||
Read this before touching `iris/src/widget/position/scroll.rs`,
|
Read this before touching `iris/src/widget/position/scrollable.rs`,
|
||||||
`iris/src/widget/position/lazy_span.rs`, or anything that pans, flings or
|
`scroll_area.rs`, `lazy_span.rs`, or anything that pans, flings or lays
|
||||||
lays out a long list.
|
out a long list.
|
||||||
|
|
||||||
## The one rule
|
## The one rule
|
||||||
|
|
||||||
**`Scroll` owns the position, the gesture and the fling. Nothing else
|
**Everything a scroll position is made of lives in one `ScrollController`,
|
||||||
does.** A widget inside it either gets moved by it or is handed deltas to
|
and the widget that scrolls owns one.** The position, the pending delta,
|
||||||
apply itself, and either way the scroll state lives in the `Scroll`. There
|
the travel left, the pin, the `DragGesture` and the `Flinger` are all in
|
||||||
is exactly one `Flinger` in the crate's widgets and one `DragGesture`
|
that struct (`scrollable.rs`); there is exactly one `Flinger` and one
|
||||||
implementation, both in `iris/src/sense.rs`.
|
`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
|
Two widgets have one, and they differ only in how they spend a delta:
|
||||||
becomes scrollable, including a `LazySpan`. Do not give a widget its own
|
|
||||||
fling, its own scroll amount, or a `RequestRedraw` handle — that is what
|
- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, measured whole and
|
||||||
was just removed.
|
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
|
## 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
|
an assertion written in the walk's own space passes with the flip
|
||||||
deleted, because it checks the bookkeeping against itself.
|
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
|
1. **`take_delta()`** — everything a wheel, a drag or a fling asked for
|
||||||
fn scrolls_itself(&self) -> bool { false }
|
since the last layout, in one number, already clamped to the travel
|
||||||
fn apply_scroll(&mut self, delta: &mut f32) {}
|
that layout reported. Clipping it stops a fling.
|
||||||
fn scroll_offset(&self) -> f32 { 0.0 }
|
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
|
`Travel` is `{ back, fwd }` in the same screen-space units as a delta:
|
||||||
than sliding me about." Default `false`: an ordinary child is a lump its
|
`back` bounds a positive one, `fwd` a negative one, and `f32::INFINITY`
|
||||||
parent moves, which is what makes a scroll tick an O(1) move of one
|
means "the end is not in sight". That last is not a placeholder — a lazy
|
||||||
subtree instead of a redraw.
|
layout genuinely cannot say how far its content runs without walking
|
||||||
- **`apply_scroll`** — take as much of `delta` as you can actually move,
|
there, and `clamp` takes the answer with no branch of its own.
|
||||||
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.
|
|
||||||
|
|
||||||
**`scrolls_itself` and `scroll_offset` are `&self` on purpose.** Reaching a
|
**Why the delta is banked rather than applied where it arrives.** A wheel
|
||||||
widget through `Widgets::get_dyn_mut` *marks it dirty*
|
event, a drag frame and a fling tick all land between draws, and none of
|
||||||
(`iris/core/src/widget/widgets.rs`). Asking either question through a
|
them can know whether there is content to move into. Applying them at the
|
||||||
`&mut` path would dirty every ordinary child on every scroll tick and cost
|
layout that follows is also what keeps layout a pure function of the state
|
||||||
exactly the O(1) move the scheme exists for. `Painter::scrolls_itself` and
|
(Iris, 2026-09-08). The visible consequence, and the thing that catches a
|
||||||
`Painter::scroll_offset` go through `get_dyn`; only
|
test out: **`amt` does not move until the next draw.**
|
||||||
`Painter::apply_scroll` takes `&mut`.
|
|
||||||
|
|
||||||
### 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
|
The `apply_scroll(&mut delta)` this replaced left the part it could not
|
||||||
not, because **a lazy layout usually cannot say where its content ends
|
take in the caller's variable, and that was meant to be the whole story.
|
||||||
until it has walked there.** With the wall out of view it takes the delta
|
It is not, because **a lazy layout usually cannot say where its content
|
||||||
in full, and the walk that follows gives part of it back. So the remainder
|
ends until it has walked there.** With the wall out of view it takes the
|
||||||
is exact only when the wall was already visible, and a parent adding
|
delta in full, and the walk that follows gives part of it back. So the
|
||||||
remainders up would over-count by every overshoot and never correct.
|
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
|
## What `amt` means
|
||||||
`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.
|
|
||||||
|
|
||||||
## `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
|
- `ScrollArea`: distance from the start of the content, clamped into 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
|
|
||||||
scroll range. An absolute position.
|
scroll range. An absolute position.
|
||||||
- Self-positioning child: **movement, not position.** Paging rows in above
|
- `LazySpan`: **movement, not position.** Paging rows in above moves the
|
||||||
moves the origin and the child cannot say by how much, never having
|
origin and the span cannot say by how much, never having measured them.
|
||||||
measured them. The direction is the same as an ordinary child's; the
|
|
||||||
absolute value is not comparable between the two.
|
|
||||||
|
|
||||||
A scrollbar needs a real content length before it can use either, and a
|
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`
|
## `LazySpan`
|
||||||
|
|
||||||
`iris/src/widget/position/lazy_span.rs`. A virtualised sequence of
|
`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
|
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
|
lazily, and it drives its own controller: the walk is the only thing that
|
||||||
how far it can go.
|
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:
|
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 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
|
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.
|
all of it — 24 draws for 11 children after one prepend.
|
||||||
- A `Scroll`'s efficiency and virtualisation pull opposite ways: a scroll
|
- A `ScrollArea`'s efficiency and virtualisation pull opposite ways: a
|
||||||
tick offers a same-size moved region, `draw_inner` takes the `mov` path,
|
scroll tick offers a same-size moved region, `draw_inner` takes the
|
||||||
and the child's `draw` never runs. A virtualising child inside a plain
|
`mov` path, and the child's `draw` never runs. A virtualising child
|
||||||
`Scroll` would never update which rows it shows. That is what
|
inside one would never update which rows it shows. That is why a
|
||||||
`scrolls_itself` resolves.
|
`LazySpan` owns its controller instead of being wrapped in one.
|
||||||
- A lazy child cannot report a content length, so `Scroll`'s clamp,
|
- A lazy child cannot report a content length, so an area's clamp,
|
||||||
end-pin and any future scrollbar have nothing to work against. Walls
|
end-pin and any future scrollbar would have nothing to work against.
|
||||||
are *discovered* by the walk instead.
|
Walls are *discovered* by the walk instead.
|
||||||
|
|
||||||
### Direction and pin are separate questions
|
### 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
|
- **`dir`** means what it means in `Span`: which end of the box item 0
|
||||||
sits at, and which way the sequence grows.
|
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
|
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
|
`Pin::End` (the view sits at the bottom). Conflating the two would stand
|
||||||
stand it on its head. `Scroll::new`'s third argument is the same flag for
|
it on its head.
|
||||||
the ordinary case.
|
|
||||||
|
**`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
|
### Two coordinate spaces, two conversion points
|
||||||
|
|
||||||
@@ -211,13 +218,22 @@ framework:
|
|||||||
Virtualisation *means* traversing rows without drawing them, and a size
|
Virtualisation *means* traversing rows without drawing them, and a size
|
||||||
you can only get by drawing is no use for deciding not to draw.
|
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
|
**Because the span cannot see the wall until it has walked to it.** With
|
||||||
cannot enter overscroll.** What it cannot prevent is the content or the
|
rows loaded past an edge it reports `INFINITY` of travel that way, takes
|
||||||
viewport changing under a settled anchor, and for that `overscroll_gap`
|
the whole delta, and the walk that follows discovers the content ran out
|
||||||
measures the gap from the ends the walk already placed and `draw` moves
|
200px ago. Nothing else could be reported: the rows past the edge have
|
||||||
the anchor and walks a **second time inside the same frame**.
|
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
|
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
|
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`.
|
`iris/transcript-ui/src/lib.rs`, `build_tree`.
|
||||||
|
|
||||||
The transcript builds its `Scroll` **by hand rather than through
|
The transcript registers the wheel **by hand rather than calling
|
||||||
`.scrollable_to_end()`**, and this is not an oversight. That helper
|
`LazySpan::scrollable()`**, and this is not an oversight. That helper also
|
||||||
registers a finger drag driving `Scroll`'s own `DragGesture`, and the
|
registers a finger drag driving the span's own `DragGesture`, and the
|
||||||
transcript already has an arbiter — `Selection`, which must decide between
|
transcript already has an arbiter — `Selection`, which must decide between
|
||||||
panning and selecting text and so cannot let a second `DragGesture` see
|
panning and selecting text and so cannot let a second `DragGesture` see
|
||||||
the same frames. `DragGesture`'s doc states the rule: one gesture, one
|
the same frames. `DragGesture`'s doc states the rule: one gesture, one
|
||||||
arbiter, each frame delivered exactly once. The wheel handler registered
|
arbiter, each frame delivered exactly once. The wheel handler registered
|
||||||
here is identical to the helper's; only the drag differs.
|
here is identical to the helper's; only the drag differs.
|
||||||
|
|
||||||
`Selection` is given the scroll area by `set_scroll_area` after the
|
`Selection` is given the span by `set_scroll_area` after it exists (rows
|
||||||
`Scroll` exists (rows need a `Selection`, and the `Scroll` needs the
|
need a `Selection`, and the span needs the rows), and hands it committed
|
||||||
rows), and hands it committed pans and releases. `TranscriptScreen` exposes
|
pans and releases through `Scrollable::scroll`/`fling`. There is no
|
||||||
both `list` (layout, `extent`/`key_at`/`jump_to_end`) and `scroll`
|
wrapper widget: `TranscriptScreen::list` is the layout (`extent`,
|
||||||
(position, fling, `amt`).
|
`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
|
A `Selection` with no scroll area still selects and still reports taps but
|
||||||
cannot pan; there is a `debug_assert` in `drag` naming that.
|
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 —
|
write is moving the content as a unit, which needs a content length —
|
||||||
which a lazy layout cannot supply.
|
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
|
## Tests that pin the behaviour
|
||||||
|
|
||||||
In `lazy_span.rs`, all of these fail if the corresponding piece is undone:
|
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.
|
- `a_negative_delta_moves_toward_the_end` — the sign, end to end.
|
||||||
- `amt_counts_only_what_the_child_could_take` — `scroll_offset`'s reason
|
- `a_delta_moves_both_directions_the_same_way_on_screen` — the sign is a
|
||||||
for existing.
|
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`,
|
- `a_fling_stops_at_the_first_row`,
|
||||||
`scrolling_past_the_start_lands_on_it_in_the_same_frame` — the walls,
|
`scrolling_past_the_start_lands_on_it_in_the_same_frame` — the walls,
|
||||||
with no settling frame drawn on purpose.
|
with no settling frame drawn on purpose.
|
||||||
- `a_dir_up_span_grows_upward_from_item_zero`,
|
- `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_registered_fling_is_driven_by_tick_animations_and_then_unregisters` —
|
||||||
a fling that nothing registers never moves, whatever its velocity.
|
a fling that nothing registers never moves, whatever its velocity.
|
||||||
|
|
||||||
|
|||||||
@@ -914,7 +914,7 @@ where
|
|||||||
/// brings earlier content into view. It was negative here until
|
/// brings earlier content into view. It was negative here until
|
||||||
/// 2026-09-08, when the transcript's scroll position moved out of the
|
/// 2026-09-08, when the transcript's scroll position moved out of the
|
||||||
/// `LazySpan` -- whose anchor offset ran the other way -- and into 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
|
/// whichever way the signs run, because both report it as a row index plus
|
||||||
/// a pixel offset rather than a signed distance.
|
/// a pixel offset rather than a signed distance.
|
||||||
async fn run_fling_phase(
|
async fn run_fling_phase(
|
||||||
@@ -938,8 +938,8 @@ async fn run_fling_phase(
|
|||||||
for _ in 0..FLING_COUNT {
|
for _ in 0..FLING_COUNT {
|
||||||
ctx.update(|state: &mut BenchClient, rsc| {
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
if let Some(screen) = &state.screen {
|
if let Some(screen) = &state.screen {
|
||||||
(screen.scroll)(rsc).fling(FLING_VELOCITY_PX_S);
|
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||||
animate_scroll(screen.scroll, rsc);
|
animate_scroll(screen.list, rsc);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
@@ -951,8 +951,8 @@ async fn run_fling_phase(
|
|||||||
for _ in 0..FLING_COUNT {
|
for _ in 0..FLING_COUNT {
|
||||||
ctx.update(|state: &mut BenchClient, rsc| {
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
if let Some(screen) = &state.screen {
|
if let Some(screen) = &state.screen {
|
||||||
(screen.scroll)(rsc).fling(-FLING_VELOCITY_PX_S);
|
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||||
animate_scroll(screen.scroll, rsc);
|
animate_scroll(screen.list, rsc);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
@@ -978,10 +978,11 @@ async fn read_anchor_position(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register the scroll area with the frame loop, exactly as a finger's own
|
/// Register the scrolling widget with the frame loop, exactly as a
|
||||||
/// release does (`transcript_ui::Selection::drag`'s `Released` arm) --
|
/// finger's own release does (`transcript_ui::Selection::drag`'s
|
||||||
/// `Scroll::fling` sets a velocity and drives nothing by itself.
|
/// `Released` arm) -- `Scrollable::fling` sets a velocity and drives
|
||||||
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::Scroll>, rsc: &mut Rsc) {
|
/// nothing by itself.
|
||||||
|
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
|
||||||
let id = scroll.id();
|
let id = scroll.id();
|
||||||
rsc.ui_mut().animate(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.
|
/// spline-decided `duration()` already caps how long it can run.
|
||||||
///
|
///
|
||||||
/// **It observes; it does not drive.** Until 2026-09-08 this loop called
|
/// **It observes; it does not drive.** Until 2026-09-08 this loop called
|
||||||
/// `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
|
/// fling in 16ms steps -- so on Iris's 120Hz phone every second frame
|
||||||
/// redrew the list at a position it had already drawn, and the benchmark
|
/// redrew the list at a position it had already drawn, and the benchmark
|
||||||
/// looked distinctly less smooth than the same list under her finger.
|
/// looked distinctly less smooth than the same list under her finger.
|
||||||
@@ -1011,7 +1012,7 @@ async fn wait_for_fling_settle(
|
|||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
while started.elapsed() < cap {
|
while started.elapsed() < cap {
|
||||||
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||||
Some(screen) => (screen.scroll)(rsc).is_scrolling(),
|
Some(screen) => (screen.list)(rsc).is_scrolling(),
|
||||||
None => false,
|
None => false,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
//! Per the code rules, the plain option is also the one shorter to explain.
|
//! 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
|
//! **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
|
//! `ScrollArea` over a `Span` of pre-built rows.** Earlier versions of this
|
||||||
//! file built their own giant `Span` and wrapped it in `Scroll`, which
|
//! 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
|
//! meant (a)/(b)/(c) below were measuring "move one big child," never the
|
||||||
//! virtualised widget the app's transcript screen actually needs. `LazySpan`
|
//! virtualised widget the app's transcript screen actually needs. `LazySpan`
|
||||||
//! still needs every row's *widget* built up front by the caller (its
|
//! still needs every row's *widget* built up front by the caller (its
|
||||||
@@ -113,22 +113,17 @@ fn build_message_list(
|
|||||||
rsc: &mut BenchRsc,
|
rsc: &mut BenchRsc,
|
||||||
n: usize,
|
n: usize,
|
||||||
image_every: usize,
|
image_every: usize,
|
||||||
) -> (WeakWidget<LazySpan>, WeakWidget<Scroll>, StrongWidget) {
|
) -> (WeakWidget<LazySpan>, StrongWidget) {
|
||||||
let mut list = LazySpan::new(Dir::DOWN, true);
|
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let row = build_row(rsc, i, image_every);
|
let row = build_row(rsc, i, image_every);
|
||||||
list.push_back(LazyItem::new(i as u64, row));
|
list.push_back(LazyItem::new(i as u64, row));
|
||||||
}
|
}
|
||||||
let list = rsc.ui.widgets.add_strong(list);
|
let list = rsc.ui.widgets.add_strong(list);
|
||||||
let list_weak = list.weak();
|
// Driven through the span's own `ScrollController`, like every other
|
||||||
// Scrolled through a `Scroll`, like every other scroll area in iris
|
// scroll area in iris: what this measures has to be the path the app
|
||||||
// since the position moved out of the list: what this measures has to
|
// actually takes.
|
||||||
// be the path the app actually takes.
|
(list.weak(), list.any())
|
||||||
let scroll = rsc
|
|
||||||
.ui
|
|
||||||
.widgets
|
|
||||||
.add_strong(Scroll::new(list.any(), Axis::Y, true));
|
|
||||||
(list_weak, scroll.weak(), scroll.any())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
|
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
|
||||||
@@ -143,7 +138,7 @@ fn bench_first_frame(n: usize) {
|
|||||||
let mut rsc = BenchRsc {
|
let mut rsc = BenchRsc {
|
||||||
ui: UiData::default(),
|
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();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((1080.0, 2000.0));
|
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.
|
/// (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
|
/// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move
|
||||||
/// rather than a resize), then times a run of individual scroll ticks.
|
/// rather than a resize), then times a run of individual scroll ticks.
|
||||||
fn bench_scroll(n: usize, ticks: usize) {
|
fn bench_scroll(n: usize, ticks: usize) {
|
||||||
let mut rsc = BenchRsc {
|
let mut rsc = BenchRsc {
|
||||||
ui: UiData::default(),
|
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();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((1080.0, 2000.0));
|
render.resize((1080.0, 2000.0));
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
@@ -215,7 +210,7 @@ fn bench_input_grows(n: usize, lines: usize) {
|
|||||||
let mut rsc = BenchRsc {
|
let mut rsc = BenchRsc {
|
||||||
ui: UiData::default(),
|
ui: UiData::default(),
|
||||||
};
|
};
|
||||||
let (_list, 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 {
|
let list_area = rsc.ui.widgets.add_strong(Sized {
|
||||||
inner: list_root,
|
inner: list_root,
|
||||||
x: None,
|
x: None,
|
||||||
@@ -285,7 +280,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
|||||||
let mut rsc = BenchRsc {
|
let mut rsc = BenchRsc {
|
||||||
ui: UiData::default(),
|
ui: UiData::default(),
|
||||||
};
|
};
|
||||||
let (list, _scroll, root) = build_message_list(&mut rsc, n, 20);
|
let (list, root) = build_message_list(&mut rsc, n, 20);
|
||||||
let mut render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((1080.0, 2000.0));
|
render.resize((1080.0, 2000.0));
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
@@ -342,7 +337,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
|
|||||||
let mut rsc = BenchRsc {
|
let mut rsc = BenchRsc {
|
||||||
ui: UiData::default(),
|
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
|
// 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 --
|
// under the list's default bottom-anchored placement, for every N --
|
||||||
// no scrolling needed to bring it into view before measuring.
|
// no scrolling needed to bring it into view before measuring.
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ pub struct Mask {
|
|||||||
/// One widget's cumulative on-screen translation, and the slot of the
|
/// 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
|
/// 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
|
/// 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.
|
/// translations of an already-drawn subtree. See LAYOUT.md section 2.
|
||||||
///
|
///
|
||||||
/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is
|
/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//! The tree itself is deliberately flat -- one synthetic `Role::Window`
|
//! The tree itself is deliberately flat -- one synthetic `Role::Window`
|
||||||
//! root with every named widget as a direct child, in no particular order.
|
//! 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
|
//! 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
|
//! 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
|
//! 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
|
//! built for) works from each node's on-screen bounds rather than from
|
||||||
|
|||||||
@@ -166,43 +166,6 @@ impl<'a> Painter<'a> {
|
|||||||
self.mask = self.own_mask;
|
self.mask = self.own_mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask a child whether it positions its own content
|
|
||||||
/// ([`Widget::scrolls_itself`]) -- read through `get_dyn`, which does
|
|
||||||
/// **not** mark it dirty, which is the whole reason this is a separate
|
|
||||||
/// question from [`Self::apply_scroll`] rather than something that
|
|
||||||
/// falls out of calling it. `false` for a child that has gone.
|
|
||||||
pub fn scrolls_itself<W: ?Sized>(&self, id: &StrongWidget<W>) -> bool {
|
|
||||||
self.rsc
|
|
||||||
.widgets()
|
|
||||||
.get_dyn(id.id())
|
|
||||||
.is_some_and(|w| w.scrolls_itself())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hand a child a scroll delta to take what it can of
|
|
||||||
/// ([`Widget::apply_scroll`]), leaving the rest in `delta`.
|
|
||||||
///
|
|
||||||
/// Reaching the child mutably is what marks it for a real redraw
|
|
||||||
/// (`Widgets::get_dyn_mut`), so a caller that follows this with
|
|
||||||
/// another `widget_within` at the same region gets a genuine draw
|
|
||||||
/// rather than `draw_inner`'s unchanged-region skip -- which is
|
|
||||||
/// exactly the measure-then-place shape `Scroll` uses, and why there
|
|
||||||
/// is no "mark this for another frame" call in this type.
|
|
||||||
pub fn apply_scroll<W: ?Sized>(&mut self, id: &StrongWidget<W>, delta: &mut f32) {
|
|
||||||
if let Some(w) = self.rsc.widgets_mut().get_dyn_mut(id.id()) {
|
|
||||||
w.apply_scroll(delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a self-positioning child's accumulated content movement
|
|
||||||
/// ([`Widget::scroll_offset`]). Through `get_dyn`, so it does not mark
|
|
||||||
/// the child dirty. `0.0` for a child that has gone.
|
|
||||||
pub fn scroll_offset<W: ?Sized>(&self, id: &StrongWidget<W>) -> f32 {
|
|
||||||
self.rsc
|
|
||||||
.widgets()
|
|
||||||
.get_dyn(id.id())
|
|
||||||
.map_or(0.0, |w| w.scroll_offset())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether anything is clipping what this widget draws -- its own
|
/// Whether anything is clipping what this widget draws -- its own
|
||||||
/// [`Self::set_mask`], or one an ancestor set that it inherited. What
|
/// [`Self::set_mask`], or one an ancestor set that it inherited. What
|
||||||
/// a widget whose contents may legitimately extend past its own box
|
/// a widget whose contents may legitimately extend past its own box
|
||||||
|
|||||||
@@ -60,76 +60,6 @@ pub trait Widget: Any {
|
|||||||
fn tick(&mut self, now: std::time::Instant) -> bool {
|
fn tick(&mut self, now: std::time::Instant) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this widget positions its own content and should be handed
|
|
||||||
/// scroll deltas ([`Self::apply_scroll`]) instead of being moved by
|
|
||||||
/// the `Scroll` around it. Default `false`: an ordinary child is a
|
|
||||||
/// fixed lump its parent slides about, which is what makes a scroll
|
|
||||||
/// tick an O(1) move of one subtree rather than a redraw.
|
|
||||||
///
|
|
||||||
/// A lazy layout has to answer `true`, because the two halves of a
|
|
||||||
/// scroll are not separable for it: which children exist at all is a
|
|
||||||
/// function of where it is scrolled to, so it cannot be a lump, and it
|
|
||||||
/// cannot report a content length for the parent to clamp against
|
|
||||||
/// either -- it has never measured the rows it has not drawn.
|
|
||||||
///
|
|
||||||
/// **`&self` on purpose.** Reaching a widget through
|
|
||||||
/// `Widgets::get_dyn_mut` marks it dirty, so asking the question
|
|
||||||
/// through [`Self::apply_scroll`] would dirty every ordinary child on
|
|
||||||
/// every scroll tick and cost exactly the redraw the move path exists
|
|
||||||
/// to avoid. This is read through `get_dyn`, which does not mark.
|
|
||||||
fn scrolls_itself(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take as much of `delta` as this widget can actually move, and leave
|
|
||||||
/// the rest in it. Only called on a widget whose
|
|
||||||
/// [`Self::scrolls_itself`] is `true`.
|
|
||||||
///
|
|
||||||
/// The sign is `Scroll::scroll`'s, 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 () {
|
impl Widget for () {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ impl DefaultAppState for State {
|
|||||||
let root = rsc
|
let root = rsc
|
||||||
.ui
|
.ui
|
||||||
.widgets
|
.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());
|
ui_state.set_root(root.any());
|
||||||
Self {
|
Self {
|
||||||
ui_state,
|
ui_state,
|
||||||
|
|||||||
@@ -98,18 +98,20 @@ impl DefaultAppState for State {
|
|||||||
rsc: &mut DefaultRsc<Self>,
|
rsc: &mut DefaultRsc<Self>,
|
||||||
_: Proxy<Self::Event>,
|
_: Proxy<Self::Event>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut list = LazySpan::new(Dir::DOWN, true);
|
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
|
||||||
for i in 0..ROWS {
|
for i in 0..ROWS {
|
||||||
let row = build_row(rsc, i);
|
let row = build_row(rsc, i);
|
||||||
list.push_back(LazyItem::new(i as u64, row));
|
list.push_back(LazyItem::new(i as u64, row));
|
||||||
}
|
}
|
||||||
|
|
||||||
// `.scrollable_to_end`, like anything else that scrolls: the
|
// `.scrollable()`, like anything else that scrolls -- here the
|
||||||
// wheel and the drag are the `Scroll`'s, and the list only lays
|
// span's own inherent one, which registers the wheel and the drag
|
||||||
// out. Masked outside it, since a `LazySpan` draws the row
|
// against the controller it already owns rather than wrapping it
|
||||||
// straddling each edge in full and asserts something clips 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
|
let root = list
|
||||||
.scrollable_to_end(Axis::Y)
|
.scrollable()
|
||||||
.masked()
|
.masked()
|
||||||
.background(rect(Color::WHITE))
|
.background(rect(Color::WHITE))
|
||||||
.add_strong(rsc);
|
.add_strong(rsc);
|
||||||
|
|||||||
+1
-1
@@ -146,7 +146,7 @@ fn on_press(
|
|||||||
// vertical drag still is not a selection. Android's own `EditText`
|
// vertical drag still is not a selection. Android's own `EditText`
|
||||||
// scrolls its overflowed text on a vertical drag and starts a
|
// scrolls its overflowed text on a vertical drag and starts a
|
||||||
// selection only from a long press; a scroll area wrapping this
|
// 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
|
// first frames of the gesture not to have selected anything behind
|
||||||
// it before it crosses `DRAG_SLOP` and takes pointer capture.
|
// it before it crosses `DRAG_SLOP` and takes pointer capture.
|
||||||
// `press_origin` carries the same meaning here as in the unfocused
|
// `press_origin` carries the same meaning here as in the unfocused
|
||||||
|
|||||||
+2
-2
@@ -335,7 +335,7 @@ impl Harness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `Instant` this harness means by `t_ms`. Public because a
|
/// The `Instant` this harness means by `t_ms`. Public because a
|
||||||
/// caller driving `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.
|
/// to date those calls on the same clock the touch samples use.
|
||||||
pub fn at(&self, t_ms: u64) -> Instant {
|
pub fn at(&self, t_ms: u64) -> Instant {
|
||||||
self.base + Duration::from_millis(t_ms)
|
self.base + Duration::from_millis(t_ms)
|
||||||
@@ -371,7 +371,7 @@ impl Harness {
|
|||||||
|
|
||||||
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||||
/// fling needs, since it moves only while something ticks it
|
/// fling needs, since it moves only while something ticks it
|
||||||
/// (`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 {
|
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
|
||||||
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
||||||
let mut t = from_ms;
|
let mut t = from_ms;
|
||||||
|
|||||||
+20
-17
@@ -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
|
/// 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
|
/// 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
|
/// 8, condition 3, using rects rather than glyphs to avoid pulling the font
|
||||||
@@ -33,7 +33,7 @@ impl UiRsc for TestRsc {
|
|||||||
fn scrolled_rects(
|
fn scrolled_rects(
|
||||||
rsc: &mut TestRsc,
|
rsc: &mut TestRsc,
|
||||||
n: usize,
|
n: usize,
|
||||||
) -> (WeakWidget<Scroll>, StrongWidget, Vec<WeakWidget<Rect>>) {
|
) -> (WeakWidget<ScrollArea>, StrongWidget, Vec<WeakWidget<Rect>>) {
|
||||||
let mut span = Span::empty(Dir::DOWN);
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
let mut rects = Vec::with_capacity(n);
|
let mut rects = Vec::with_capacity(n);
|
||||||
for _ in 0..n {
|
for _ in 0..n {
|
||||||
@@ -57,11 +57,11 @@ fn scrolled_rects(
|
|||||||
// the top and states its sign convention against that. An
|
// the top and states its sign convention against that. An
|
||||||
// end-anchored area now sits at its end from its first drawn
|
// end-anchored area now sits at its end from its first drawn
|
||||||
// frame (`Scroll::draw` measures and places in the same frame),
|
// 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
|
// position that is already the bottom -- a clamped no-op, which
|
||||||
// reads as "the move path is broken" rather than as the test
|
// reads as "the move path is broken" rather than as the test
|
||||||
// starting somewhere it did not mean to.
|
// 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();
|
let weak = scroll.weak();
|
||||||
(weak, scroll.any(), rects)
|
(weak, scroll.any(), rects)
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() {
|
|||||||
render.resize((800.0, 20000.0));
|
render.resize((800.0, 20000.0));
|
||||||
|
|
||||||
render.update(&root, &mut rsc);
|
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
|
// own length as a placeholder (nothing has been measured yet) and
|
||||||
// `Scroll::draw` asks to be drawn again once it knows the real one,
|
// `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 --
|
// 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();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((800.0, 600.0));
|
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
|
// (nothing has been measured yet) and learns the real content length
|
||||||
// from what comes back; `update()` only redraws widgets actually
|
// from what comes back; `update()` only redraws widgets actually
|
||||||
// marked dirty, so that corrected length is not reflected in the
|
// marked dirty, so that corrected length is not reflected in the
|
||||||
// content's own *active* region until something -- here a no-op
|
// 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
|
// 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
|
// draws, which is what makes a further, real scroll tick a same-size
|
||||||
// move instead of a resize. See scroll.rs.
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
/// `Painter::output_size` -- the window -- which read as if a scroll area
|
||||||
/// smaller than the screen could not work, and cost a session's
|
/// smaller than the screen could not work, and cost a session's
|
||||||
/// investigation before the composer was wired up (docs/RUST.md,
|
/// 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
|
.widgets
|
||||||
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
|
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
|
||||||
// go -- see `scrolled_rects`' note on the same choice.
|
// 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_w = scroll.weak();
|
||||||
let scroll_id = scroll.id();
|
let scroll_id = scroll.id();
|
||||||
let capped = rsc.ui.widgets.add_strong(MaxSize {
|
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
|
// 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);
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
assert!(
|
assert!(
|
||||||
(rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01,
|
(rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01,
|
||||||
"amt={}",
|
"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
|
/// 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
|
/// `region` is stale and is corrected entirely by the move chain. The
|
||||||
/// moved widget itself had its `region` updated *and* the chain delta
|
/// 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
|
/// 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
|
.widgets
|
||||||
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
|
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
|
||||||
// go -- see `scrolled_rects`' note on the same choice.
|
// 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_w = scroll.weak();
|
||||||
let root = scroll.any();
|
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
|
/// 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
|
/// `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 --
|
/// 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
|
/// padding, subtracted from nothing) and panned the whole message out of
|
||||||
/// view. Measured on this checkout's emulator, 2026-09-06:
|
/// view. Measured on this checkout's emulator, 2026-09-06:
|
||||||
/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`.
|
/// `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).
|
/// middle of a word (`iris/run-headless.sh phone`, 2026-09-08).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
|
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 {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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
|
let scroll = rsc
|
||||||
.ui
|
.ui
|
||||||
.widgets
|
.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 weak = scroll.weak();
|
||||||
let root = scroll.any();
|
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,
|
dir: Dir::DOWN,
|
||||||
gap: Len::ZERO,
|
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()));
|
list.push_back(LazyItem::new(0, outer.any()));
|
||||||
let list = rsc.ui.widgets.add_strong(list);
|
let list = rsc.ui.widgets.add_strong(list);
|
||||||
let root = rsc
|
let root = rsc
|
||||||
|
|||||||
+17
-17
@@ -100,7 +100,7 @@ impl CursorSense {
|
|||||||
/// and [`Self::Cancel`] if somebody else captured it first.
|
/// and [`Self::Cancel`] if somebody else captured it first.
|
||||||
///
|
///
|
||||||
/// One function rather than a set spelled out per call site, because
|
/// 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
|
/// registered `click_or_drag | unclick` and so never saw the end of
|
||||||
/// any gesture it had captured, which left its arbiter panning from a
|
/// any gesture it had captured, which left its arbiter panning from a
|
||||||
/// stale position and made the *next* drag jump by the distance
|
/// 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
|
// sideways is left `hover == On` (the capture branch above returns
|
||||||
// before the loop that would have updated it), so the *next* touch
|
// 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
|
// 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
|
// of its own fling, which commits with no slop -- captured the whole
|
||||||
// gesture 500px away from the fence. The list under the finger moved
|
// gesture 500px away from the fence. The list under the finger moved
|
||||||
// by nothing at all.
|
// by nothing at all.
|
||||||
@@ -924,7 +924,7 @@ pub struct PressState {
|
|||||||
/// instead of waiting for a long-press.
|
/// instead of waiting for a long-press.
|
||||||
pub already_selected: bool,
|
pub already_selected: bool,
|
||||||
/// Whether the target was already moving under its own momentum (a
|
/// Whether the target was already moving under its own momentum (a
|
||||||
/// `LazySpan` with a fling in flight, `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,
|
/// [`DragArbiter::press_start`]: a press on moving content is a catch,
|
||||||
/// and catches skip the slop entirely.
|
/// and catches skip the slop entirely.
|
||||||
pub scrolling: bool,
|
pub scrolling: bool,
|
||||||
@@ -1125,7 +1125,7 @@ impl DragArbiter {
|
|||||||
|
|
||||||
/// Whether the arbiter's current gesture (if any) has committed to
|
/// Whether the arbiter's current gesture (if any) has committed to
|
||||||
/// panning -- what a caller checks at release time to decide whether
|
/// panning -- what a caller checks at release time to decide whether
|
||||||
/// to hand the tracked velocity to [`crate::widget::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
|
/// IRIS_TODO.md's "swiping has no momentum": a fling must only follow
|
||||||
/// a pan, never a text selection that happened to end with the finger
|
/// a pan, never a text selection that happened to end with the finger
|
||||||
/// still moving.
|
/// still moving.
|
||||||
@@ -1158,7 +1158,7 @@ impl DragArbiter {
|
|||||||
pub enum GestureOutcome {
|
pub enum GestureOutcome {
|
||||||
Undecided,
|
Undecided,
|
||||||
/// Same units and sign as [`DragOutcome::Pan`] -- the caller's own
|
/// Same units and sign as [`DragOutcome::Pan`] -- the caller's own
|
||||||
/// convention (`Scroll::scroll`'s, for a transcript) to apply.
|
/// convention (`ScrollController::scroll`'s, for a transcript) to apply.
|
||||||
Pan(f32),
|
Pan(f32),
|
||||||
SelectStart,
|
SelectStart,
|
||||||
SelectExtend,
|
SelectExtend,
|
||||||
@@ -1173,7 +1173,7 @@ pub enum GestureOutcome {
|
|||||||
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
|
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
|
||||||
/// `Some(velocity)` only if the gesture had committed to panning
|
/// `Some(velocity)` only if the gesture had committed to panning
|
||||||
/// (never a tap, a long-press selection, or one still `Undecided`);
|
/// (never a tap, a long-press selection, or one still `Undecided`);
|
||||||
/// same units as `Pan`, so a caller hands it to `Scroll::fling` with
|
/// same units as `Pan`, so a caller hands it to `ScrollController::fling` with
|
||||||
/// whatever sign flip it already applies to `Pan`.
|
/// whatever sign flip it already applies to `Pan`.
|
||||||
Released(Option<f32>),
|
Released(Option<f32>),
|
||||||
/// Another widget took the pointer (`CursorSense::Cancel`), so this
|
/// Another widget took the pointer (`CursorSense::Cancel`), so this
|
||||||
@@ -1478,9 +1478,9 @@ const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1;
|
|||||||
/// (`DragGestureNode.sendDragStopped` passes
|
/// (`DragGestureNode.sendDragStopped` passes
|
||||||
/// `LocalViewConfiguration.maximumFlingVelocity` into
|
/// `LocalViewConfiguration.maximumFlingVelocity` into
|
||||||
/// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it
|
/// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it
|
||||||
/// in [`crate::widget::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
|
/// 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;
|
pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0;
|
||||||
|
|
||||||
/// Estimates a drag's speed along one axis the way Compose's touch
|
/// Estimates a drag's speed along one axis the way Compose's touch
|
||||||
@@ -1608,7 +1608,7 @@ impl VelocityTracker {
|
|||||||
/// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then
|
/// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then
|
||||||
/// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum
|
/// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum
|
||||||
/// itself is applied by the caller that knows the density
|
/// itself is applied by the caller that knows the density
|
||||||
/// ([`crate::widget::Scroll::fling`]).
|
/// ([`crate::widget::ScrollController::fling`]).
|
||||||
///
|
///
|
||||||
/// `0.0` with fewer than [`MIN_SAMPLE_SIZE`] usable samples, which is
|
/// `0.0` with fewer than [`MIN_SAMPLE_SIZE`] usable samples, which is
|
||||||
/// Compose's answer too: a press and a single move carry no curve to
|
/// Compose's answer too: a press and a single move carry no curve to
|
||||||
@@ -1655,7 +1655,7 @@ impl VelocityTracker {
|
|||||||
// `calculateVelocity(maximumVelocity)`'s first branch, kept as the
|
// `calculateVelocity(maximumVelocity)`'s first branch, kept as the
|
||||||
// outer guard even though the degenerate case is now detected
|
// outer guard even though the degenerate case is now detected
|
||||||
// rather than clamped: a fit can still overflow on inputs nothing
|
// rather than clamped: a fit can still overflow on inputs nothing
|
||||||
// here has produced, and `Scroll::fling` asserts finiteness.
|
// here has produced, and `ScrollController::fling` asserts finiteness.
|
||||||
if velocity.is_finite() { velocity } else { 0.0 }
|
if velocity.is_finite() { velocity } else { 0.0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1876,7 +1876,7 @@ const FLING_FRICTION: f32 = 0.015;
|
|||||||
/// small, which put an `ln` of a 56x-too-large ratio through
|
/// small, which put an `ln` of a 56x-too-large ratio through
|
||||||
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
|
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
|
||||||
/// instead of 1.6. Nothing could see it while a finger fling never
|
/// instead of 1.6. Nothing could see it while a finger fling never
|
||||||
/// animated at all (`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.
|
/// be fixed before either was visible.
|
||||||
const FLING_TUNING: f32 = 0.84;
|
const FLING_TUNING: f32 = 0.84;
|
||||||
fn deceleration_rate() -> f32 {
|
fn deceleration_rate() -> f32 {
|
||||||
@@ -1896,7 +1896,7 @@ const GRAVITY_EARTH: f32 = 9.80665;
|
|||||||
/// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the
|
/// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the
|
||||||
/// wrong density changes how long a fling lasts exponentially rather than
|
/// wrong density changes how long a fling lasts exponentially rather than
|
||||||
/// scaling it. An earlier version of this comment claimed the opposite and
|
/// scaling it. An earlier version of this comment claimed the opposite and
|
||||||
/// `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
|
/// one-second flick a 45-second coast (measured 2026-09-07). `LazySpan` reads
|
||||||
/// its density from the painter now.
|
/// its density from the painter now.
|
||||||
pub struct FlingCalculator {
|
pub struct FlingCalculator {
|
||||||
@@ -1919,7 +1919,7 @@ impl FlingCalculator {
|
|||||||
/// Total signed distance the fling travels before settling, in the
|
/// Total signed distance the fling travels before settling, in the
|
||||||
/// same pixel units `velocity` was given in.
|
/// same pixel units `velocity` was given in.
|
||||||
pub fn distance(&self, velocity: f32) -> f32 {
|
pub fn distance(&self, velocity: f32) -> f32 {
|
||||||
// See `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
|
// here silently produces a NaN distance rather than surfacing the
|
||||||
// bug that produced it (docs/REVIEW-2026-09-06.md finding 3).
|
// bug that produced it (docs/REVIEW-2026-09-06.md finding 3).
|
||||||
debug_assert!(velocity.is_finite());
|
debug_assert!(velocity.is_finite());
|
||||||
@@ -1946,7 +1946,7 @@ impl FlingCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The signed distance covered by `elapsed` into a fling of this
|
/// The signed distance covered by `elapsed` into a fling of this
|
||||||
/// `velocity` -- what a per-frame ticker (`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
|
/// find how far to have scrolled by now. Clamped to the full
|
||||||
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
|
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
|
||||||
/// not special-case "past the end."
|
/// not special-case "past the end."
|
||||||
@@ -1964,7 +1964,7 @@ impl FlingCalculator {
|
|||||||
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
|
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
|
||||||
/// to zero at `duration()`, which is the whole difference between a
|
/// to zero at `duration()`, which is the whole difference between a
|
||||||
/// fling and a constant-speed slide, so it is what
|
/// fling and a constant-speed slide, so it is what
|
||||||
/// `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
|
/// a shrinking number is the evidence that the curve is being followed
|
||||||
/// at all.
|
/// at all.
|
||||||
pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||||
@@ -1985,7 +1985,7 @@ impl FlingCalculator {
|
|||||||
/// It owns the curve and the clock and nothing else. Which way a positive
|
/// 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
|
/// 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
|
/// 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.
|
/// 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
|
/// So a caller applies [`Self::tick`]'s delta in its own convention and
|
||||||
/// calls [`Self::stop`] when it runs out of content.
|
/// 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
|
/// Summing the spline's own per-frame position deltas across the
|
||||||
/// whole fling has to land within 1% of the closed-form `distance()`
|
/// whole fling has to land within 1% of the closed-form `distance()`
|
||||||
/// -- this is the guarantee that `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,
|
/// of `position_at` actually add up to the total the fling promised,
|
||||||
/// not merely that the two formulas look plausible independently.
|
/// not merely that the two formulas look plausible independently.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+38
-22
@@ -1,5 +1,5 @@
|
|||||||
//! IRIS_TODO.md's "Input does not fall through by input type": a widget
|
//! 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
|
//! whatever is behind it, because `run_sensors` decided "consumed, stop
|
||||||
//! looking at lower layers" from mere hover, not from anything actually
|
//! looking at lower layers" from mere hover, not from anything actually
|
||||||
//! matching. Exercised as a plain unit test for the same reason
|
//! 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());
|
let mut scroll_cursor = cursor_at((50.0, 50.0).into());
|
||||||
scroll_cursor.scroll_delta = (0.0, 10.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.run_sensors(&mut rsc, &mut state, scroll_cursor, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
scrolled.get(),
|
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());
|
let mut click_cursor = cursor_at((50.0, 50.0).into());
|
||||||
click_cursor.buttons.left = ActivationState::Start;
|
click_cursor.buttons.left = ActivationState::Start;
|
||||||
render.run_sensors(&mut rsc, &mut state, click_cursor, (100.0, 100.0).into());
|
render.run_sensors(&mut rsc, &mut state, click_cursor, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
clicked.get(),
|
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());
|
let mut press = cursor_at((5.0, 5.0).into());
|
||||||
press.buttons.left = ActivationState::Start;
|
press.buttons.left = ActivationState::Start;
|
||||||
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
|
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
|
||||||
|
render.update(&draggable, &mut rsc);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pointer_input(&mut rsc).holder(),
|
pointer_input(&mut rsc).holder(),
|
||||||
Some(draggable.id()),
|
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());
|
let mut release = cursor_at((95.0, 95.0).into());
|
||||||
release.buttons.left = ActivationState::End;
|
release.buttons.left = ActivationState::End;
|
||||||
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
|
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
|
||||||
|
render.update(&draggable, &mut rsc);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
dropped.get(),
|
dropped.get(),
|
||||||
@@ -241,6 +245,7 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
|
|||||||
let mut state = ();
|
let mut state = ();
|
||||||
let cursor = cursor_at((50.0, 50.0).into());
|
let cursor = cursor_at((50.0, 50.0).into());
|
||||||
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
|
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!b_hovered.get(),
|
!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.
|
/// answered a wheel, so a finger drag over overflowed text did nothing.
|
||||||
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
|
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
|
||||||
/// `run_sensors`' dispatch, `Scroll::drag`, `DragGesture`'s arbitration and
|
/// `run_sensors`' dispatch, `ScrollController::drag`, `DragGesture`'s arbitration and
|
||||||
/// pointer capture -- rather than only `Scroll::drag`'s own unit tests in
|
/// pointer capture -- rather than only `ScrollController::drag`'s own unit tests in
|
||||||
/// `scroll.rs`, because the registration is exactly the half those cannot
|
/// `scroll.rs`, because the registration is exactly the half those cannot
|
||||||
/// see.
|
/// see.
|
||||||
#[test]
|
#[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.
|
// 1000px of content in a 100px window: room to pan.
|
||||||
let scroll_strong = rect(UiColor::WHITE)
|
let scroll_strong = rect(UiColor::WHITE)
|
||||||
.height(Len::abs(1000.0))
|
.height(Len::abs(1000.0))
|
||||||
.scrollable()
|
.scrollable(Axis::Y, Pin::Start)
|
||||||
.add_strong(&mut rsc);
|
.add_strong(&mut rsc);
|
||||||
let scroll = scroll_strong.weak();
|
let scroll = scroll_strong.weak();
|
||||||
let root = scroll_strong.any();
|
let root = scroll_strong.any();
|
||||||
@@ -273,7 +278,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
|
|||||||
let mut render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((100.0, 100.0));
|
render.resize((100.0, 100.0));
|
||||||
render.update(&root, &mut rsc);
|
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 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
|
// -- 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
|
// 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());
|
let mut down = cursor_at((50.0, 80.0).into());
|
||||||
down.buttons.left = ActivationState::Start;
|
down.buttons.left = ActivationState::Start;
|
||||||
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
|
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||||
0.0,
|
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());
|
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
|
||||||
nudge.buttons.left = ActivationState::On;
|
nudge.buttons.left = ActivationState::On;
|
||||||
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
|
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||||
0.0,
|
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());
|
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
|
||||||
drag.buttons.left = ActivationState::On;
|
drag.buttons.left = ActivationState::On;
|
||||||
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
|
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();
|
let after = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||||
assert!(
|
assert!(
|
||||||
(after - 40.0).abs() < 0.01,
|
(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
|
/// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to
|
||||||
/// some position when horizontally scrolling."
|
/// some position when horizontally scrolling."
|
||||||
///
|
///
|
||||||
/// A `Scroll` that has committed to a pan holds the pointer, so the
|
/// A `ScrollArea` that has committed to a pan holds the pointer, so the
|
||||||
/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable_on`
|
/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable`
|
||||||
/// used to register `click_or_drag | unclick` only, which `should_run`
|
/// used to register `click_or_drag | unclick` only, which `should_run`
|
||||||
/// never matches a `Drop` against. So the widget never learned its own
|
/// never matches a `Drop` against. So the widget never learned its own
|
||||||
/// gesture had ended: its `DragArbiter` stayed `Panning` at the position
|
/// 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)
|
let scroll_strong = rect(UiColor::WHITE)
|
||||||
.height(Len::abs(1000.0))
|
.height(Len::abs(1000.0))
|
||||||
.scrollable()
|
.scrollable(Axis::Y, Pin::Start)
|
||||||
.add_strong(&mut rsc);
|
.add_strong(&mut rsc);
|
||||||
let scroll = scroll_strong.weak();
|
let scroll = scroll_strong.weak();
|
||||||
let root = scroll_strong.any();
|
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 mut state = ();
|
||||||
let win = Vec2::new(100.0, 100.0);
|
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());
|
let mut c = cursor_at((50.0, y).into());
|
||||||
c.buttons.left = button;
|
c.buttons.left = button;
|
||||||
render.run_sensors(rsc, &mut state, c, win);
|
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
|
// One pan of 40px past the slop, then a release well outside the
|
||||||
// widget -- the ordinary shape of a flick.
|
// 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(
|
send(
|
||||||
&render,
|
&mut render,
|
||||||
&mut rsc,
|
&mut rsc,
|
||||||
80.0 - (DRAG_SLOP + 40.0),
|
80.0 - (DRAG_SLOP + 40.0),
|
||||||
ActivationState::On,
|
ActivationState::On,
|
||||||
);
|
);
|
||||||
let after_first = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
let after_first = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||||
assert!((after_first - 40.0).abs() < 0.01, "amt={after_first}");
|
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!(
|
assert_eq!(
|
||||||
pointer_input(&mut rsc).holder(),
|
pointer_input(&mut rsc).holder(),
|
||||||
None,
|
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
|
// A second gesture, starting where the first one did. If the arbiter
|
||||||
// were still panning from the release position, this first frame
|
// were still panning from the release position, this first frame
|
||||||
// would apply the whole distance between the two at once.
|
// 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();
|
let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||||
assert!(
|
assert!(
|
||||||
(after_second - after_first).abs() < 0.01,
|
(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());
|
let mut down = cursor_at((50.0, 50.0).into());
|
||||||
down.buttons.left = ActivationState::Start;
|
down.buttons.left = ActivationState::Start;
|
||||||
render.run_sensors(&mut rsc, &mut state, down, win);
|
render.run_sensors(&mut rsc, &mut state, down, win);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
assert_eq!(cancelled.get(), 0, "nothing has captured yet");
|
assert_eq!(cancelled.get(), 0, "nothing has captured yet");
|
||||||
|
|
||||||
let mut moved = cursor_at((50.0, 20.0).into());
|
let mut moved = cursor_at((50.0, 20.0).into());
|
||||||
moved.buttons.left = ActivationState::On;
|
moved.buttons.left = ActivationState::On;
|
||||||
render.run_sensors(&mut rsc, &mut state, moved, win);
|
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!(capturer_saw.get() > 0, "the capturer never saw the press");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pointer_input(&mut rsc).holder(),
|
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());
|
let mut more = cursor_at((50.0, 10.0).into());
|
||||||
more.buttons.left = ActivationState::On;
|
more.buttons.left = ActivationState::On;
|
||||||
render.run_sensors(&mut rsc, &mut state, more, win);
|
render.run_sensors(&mut rsc, &mut state, more, win);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
let mut up = cursor_at((50.0, 10.0).into());
|
let mut up = cursor_at((50.0, 10.0).into());
|
||||||
up.buttons.left = ActivationState::End;
|
up.buttons.left = ActivationState::End;
|
||||||
render.run_sensors(&mut rsc, &mut state, up, win);
|
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!(cancelled.get(), 1, "cancelled more than once");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ended.get(),
|
ended.get(),
|
||||||
@@ -569,15 +582,15 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
|
|||||||
let outer_strong = rect(UiColor::WHITE)
|
let outer_strong = rect(UiColor::WHITE)
|
||||||
.width(Len::abs(1000.0))
|
.width(Len::abs(1000.0))
|
||||||
.height(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 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.
|
// registration on both, so neither is assembled by hand.
|
||||||
.with_id(move |_rsc, id| {
|
.with_id(move |_rsc, id| {
|
||||||
record.set(Some(id));
|
record.set(Some(id));
|
||||||
id
|
id
|
||||||
})
|
})
|
||||||
.scrollable()
|
.scrollable(Axis::Y, Pin::Start)
|
||||||
.add_strong(&mut rsc);
|
.add_strong(&mut rsc);
|
||||||
let inner = seen.get().unwrap();
|
let inner = seen.get().unwrap();
|
||||||
let outer = outer_strong.weak();
|
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());
|
let mut down = cursor_at((50.0, 80.0).into());
|
||||||
down.buttons.left = ActivationState::Start;
|
down.buttons.left = ActivationState::Start;
|
||||||
render.run_sensors(&mut rsc, &mut state, down, win);
|
render.run_sensors(&mut rsc, &mut state, down, win);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
let mut drag = cursor_at(to);
|
let mut drag = cursor_at(to);
|
||||||
drag.buttons.left = ActivationState::On;
|
drag.buttons.left = ActivationState::On;
|
||||||
render.run_sensors(&mut rsc, &mut state, drag, win);
|
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 moved = rsc.ui.widgets.get(&areas[pans]).unwrap().amt();
|
||||||
let unmoved = rsc.ui.widgets.get(&areas[still]).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
|
/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is
|
||||||
/// not `Off`) -- and `should_run` derived a press from the button alone,
|
/// not `Off`) -- and `should_run` derived a press from the button alone,
|
||||||
/// so that farewell frame also carried a `PressStart`. A widget nowhere
|
/// 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
|
/// its own fling commits with no slop, so it captured the pointer and the
|
||||||
/// whole gesture went to it.
|
/// 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
|
// 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
|
// 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
|
// test above does it), since what is under test is `scrollable()`'s
|
||||||
// real registration rather than a `Scroll` assembled by hand.
|
// real registration rather than a `ScrollArea` assembled by hand.
|
||||||
let seen: [Rc<Cell<Option<WeakWidget<Scroll>>>>; 2] = Default::default();
|
let seen: [Rc<Cell<Option<WeakWidget<ScrollArea>>>>; 2] = Default::default();
|
||||||
let half = |slot: &Rc<Cell<Option<WeakWidget<Scroll>>>>| {
|
let half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| {
|
||||||
let record = slot.clone();
|
let record = slot.clone();
|
||||||
rect(UiColor::WHITE)
|
rect(UiColor::WHITE)
|
||||||
.height(Len::abs(1000.0))
|
.height(Len::abs(1000.0))
|
||||||
.scrollable()
|
.scrollable(Axis::Y, Pin::Start)
|
||||||
.with_id(move |_rsc, id| {
|
.with_id(move |_rsc, id| {
|
||||||
record.set(Some(id));
|
record.set(Some(id));
|
||||||
id
|
id
|
||||||
@@ -694,6 +709,7 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
|
|||||||
c.buttons.left = button;
|
c.buttons.left = button;
|
||||||
c.time = base + std::time::Duration::from_millis(at_ms);
|
c.time = base + std::time::Duration::from_millis(at_ms);
|
||||||
render.run_sensors(rsc, state, c, win);
|
render.run_sensors(rsc, state, c, win);
|
||||||
|
render.update(&root, rsc);
|
||||||
};
|
};
|
||||||
sample(
|
sample(
|
||||||
&mut render,
|
&mut render,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! `LazySpan`: a virtualised span of variable-height rows, laid out from
|
//! `LazySpan`: a virtualised span of variable-height rows, laid out from
|
||||||
//! an anchor rather than eagerly like `Span`.
|
//! 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
|
//! divide the work, the one sign convention, the `Widget` handoff, and
|
||||||
//! what is still open. Read it first; this file is the detail.
|
//! 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
|
//! 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
|
//! that "whatever lies between" stays empty however far the list is
|
||||||
//! panned.
|
//! panned.
|
||||||
//!
|
//!
|
||||||
//! **This widget does not scroll; it is scrolled.** There is no fling
|
//! **This widget scrolls itself, and everything that is not its layout
|
||||||
//! here, no gesture, no scroll position and no redraw handle -- a
|
//! lives in a [`ScrollController`] it owns** -- the position, the gesture,
|
||||||
//! `Scroll` around it owns all of that, hands it deltas through
|
//! the fling and the pin, the same struct a `ScrollArea` holds
|
||||||
//! [`Widget::apply_scroll`], and reads back what it managed to move
|
//! (`docs/SCROLL.md`). It is not wrapped in one of those and must not be:
|
||||||
//! ([`Widget::scroll_offset`]). What is left here is the layout: an
|
//! a scroll tick offers a moved region of the same size, `draw_inner`
|
||||||
//! anchor, a walk outward from it, and an honest answer about how far it
|
//! takes the `mov` path, and a virtualising child inside it would never
|
||||||
//! can go. So `.scrollable_to_end()` is how a `LazySpan` is scrolled,
|
//! update which rows it shows. `.scrollable()` here is the span's own
|
||||||
//! which is how everything else in iris is scrolled too.
|
//! 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
|
//! **Overscroll cannot be entered by scrolling, and is taken back within
|
||||||
//! the frame when something else causes it.** `apply_scroll` only ever
|
//! the frame when something else causes it.** The controller clamps a
|
||||||
//! takes as much as the walk says is there, so a delta that runs off the
|
//! delta to the travel the last walk reported, so a delta that runs off a
|
||||||
//! end simply comes back short. What it cannot prevent is the content or
|
//! wall already in view is simply cut short. What that cannot cover is a
|
||||||
//! the viewport changing under a settled anchor, and for that
|
//! 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
|
//! `overscroll_gap` measures the gap from the ends the walk already
|
||||||
//! placed and `draw` moves the anchor by it and walks a second time
|
//! 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,
|
//! **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
|
//! 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
|
//! overscrolled and is left alone, still pinned to the end it was built
|
||||||
//! with.
|
//! with.
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use iris_core::util::HashMap;
|
use iris_core::util::HashMap;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
/// A stable identifier for a loaded row, reused across pages so that a row
|
/// A stable identifier for a loaded row, reused across pages so that a row
|
||||||
/// already measured and drawn is not treated as new when data is inserted
|
/// already measured and drawn is not treated as new when data is inserted
|
||||||
@@ -246,11 +254,13 @@ pub struct LazySpan {
|
|||||||
more_before: Option<StrongWidget>,
|
more_before: Option<StrongWidget>,
|
||||||
more_after: Option<StrongWidget>,
|
more_after: Option<StrongWidget>,
|
||||||
anchor: Option<Anchor>,
|
anchor: Option<Anchor>,
|
||||||
/// Whether the anchor currently sits flush against the true end of the
|
/// The position, the gesture, the fling and the pin -- everything
|
||||||
/// content (last item or `more_after`, bottom edge at the viewport's
|
/// about scrolling that is not this widget's own layout, in the same
|
||||||
/// own bottom) -- if so, appending a new row keeps it pinned there,
|
/// struct a `ScrollArea` holds rather than a protocol between the two
|
||||||
/// mirroring `Scroll::snap_end`.
|
/// (`docs/SCROLL.md`). This widget's `draw` is one instance of the
|
||||||
snap_end: bool,
|
/// 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: f32,
|
||||||
/// `viewport_len` as of the *previous* draw -- what `repair_anchor`
|
/// `viewport_len` as of the *previous* draw -- what `repair_anchor`
|
||||||
/// compares against to tell "the container was actually resized" from
|
/// 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.
|
/// The mirror of `at_start` for the trailing end.
|
||||||
at_end: bool,
|
at_end: bool,
|
||||||
/// The extreme edges the last walk reached, in the walk's own
|
/// 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
|
/// **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
|
/// 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
|
/// 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
|
/// that edge to the viewport's. An estimate here would leave `amt`
|
||||||
/// own `amt`, so an estimate here would leave that number drifting
|
/// drifting from what is on screen by every overshoot into a wall.
|
||||||
/// from what is on screen by every overshoot into a wall.
|
|
||||||
///
|
|
||||||
/// Measured by the *first* of `Scroll::draw`'s two draws of this
|
|
||||||
/// widget, which is the whole reason that draw exists -- see
|
|
||||||
/// `Widget::apply_scroll`.
|
|
||||||
content_lead: f32,
|
content_lead: f32,
|
||||||
content_trail: f32,
|
content_trail: f32,
|
||||||
/// Whether the last walk ran out of items before its leading /
|
/// 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.
|
/// all. With more content past an edge there is no bound to give.
|
||||||
no_more_before: bool,
|
no_more_before: bool,
|
||||||
no_more_after: bool,
|
no_more_after: bool,
|
||||||
/// Accumulated content movement in [`Widget::apply_scroll`]'s
|
|
||||||
/// direction convention -- every `scroll` this span makes, including
|
|
||||||
/// the ones `overscroll_gap` gives back, so a `Scroll` above reads
|
|
||||||
/// what actually happened rather than adding up what it asked for.
|
|
||||||
/// See [`Widget::scroll_offset`] for why the remainder alone will not
|
|
||||||
/// do. Jumps (`jump_to_end`/`jump_to_start`) are deliberately not
|
|
||||||
/// counted: they are not travel across the content.
|
|
||||||
moved: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LazySpan {
|
impl LazySpan {
|
||||||
/// `at_end` starts the span pinned to the end of its content and keeps
|
/// `pin` says which end of its content this span opens at and clings
|
||||||
/// it there while rows are appended -- the transcript's case, and the
|
/// to as rows arrive -- [`Pin::End`] for a transcript, and independent
|
||||||
/// same flag under the same name as [`Scroll::new`]'s. `false` starts
|
/// of `dir`, which says where item 0 is (see the field). `dir` is also
|
||||||
/// at the beginning, which is what anything read from the top wants.
|
/// what resolves [`Pin::Pos`]/[`Pin::Neg`], the axis-absolute way of
|
||||||
/// Independent of `dir`, which says where item 0 is; see the field.
|
/// asking the same question.
|
||||||
pub fn new(dir: Dir, at_end: bool) -> Self {
|
pub fn new(dir: Dir, pin: Pin) -> Self {
|
||||||
Self {
|
Self {
|
||||||
dir,
|
dir,
|
||||||
|
ctl: ScrollController::new(dir, pin),
|
||||||
items: VecDeque::new(),
|
items: VecDeque::new(),
|
||||||
more_before: None,
|
more_before: None,
|
||||||
more_after: None,
|
more_after: None,
|
||||||
anchor: None,
|
anchor: None,
|
||||||
snap_end: at_end,
|
|
||||||
viewport_len: 0.0,
|
viewport_len: 0.0,
|
||||||
last_viewport_len: 0.0,
|
last_viewport_len: 0.0,
|
||||||
at_start: false,
|
at_start: false,
|
||||||
@@ -330,7 +327,6 @@ impl LazySpan {
|
|||||||
content_trail: 0.0,
|
content_trail: 0.0,
|
||||||
no_more_before: false,
|
no_more_before: false,
|
||||||
no_more_after: false,
|
no_more_after: false,
|
||||||
moved: 0.0,
|
|
||||||
pending_tap: None,
|
pending_tap: None,
|
||||||
extents: HashMap::default(),
|
extents: HashMap::default(),
|
||||||
heights: HashMap::default(),
|
heights: HashMap::default(),
|
||||||
@@ -361,12 +357,11 @@ impl LazySpan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// O(1). If the list is currently flush with its own end
|
/// 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
|
/// ([`ScrollController::pinned_to_end`]), the new row becomes the
|
||||||
/// pinned to its newest content -- the same policy `Scroll` applies
|
/// anchor so a live list stays pinned to its newest content.
|
||||||
/// via `snap_end`.
|
|
||||||
pub fn push_back(&mut self, row: LazyItem) {
|
pub fn push_back(&mut self, row: LazyItem) {
|
||||||
self.items.push_back(row);
|
self.items.push_back(row);
|
||||||
if self.snap_end {
|
if self.ctl.pinned_to_end() {
|
||||||
self.anchor = Some(Anchor {
|
self.anchor = Some(Anchor {
|
||||||
slot: self.items.len() as isize - 1,
|
slot: self.items.len() as isize - 1,
|
||||||
edge: Edge::Trailing,
|
edge: Edge::Trailing,
|
||||||
@@ -451,7 +446,7 @@ impl LazySpan {
|
|||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
self.anchor = None;
|
self.anchor = None;
|
||||||
self.snap_end = true;
|
self.ctl.set_pinned_to_end(true);
|
||||||
self.heights.clear();
|
self.heights.clear();
|
||||||
self.extents.clear();
|
self.extents.clear();
|
||||||
}
|
}
|
||||||
@@ -459,6 +454,11 @@ impl LazySpan {
|
|||||||
/// Move the anchor's edge by `amt` pixels, where positive brings
|
/// Move the anchor's edge by `amt` pixels, where positive brings
|
||||||
/// **later** content into view.
|
/// **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
|
/// Private, and in the direction-relative space the walk works in
|
||||||
/// rather than the screen space every public delta speaks in: the
|
/// rather than the screen space every public delta speaks in: the
|
||||||
/// anchor's offset says where the pinned edge *sits*, so moving 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_delta`] is the one conversion, exactly as
|
||||||
/// [`Self::flip_pos`] is for positions.
|
/// [`Self::flip_pos`] is for positions.
|
||||||
///
|
///
|
||||||
/// Unclamped here, on purpose: it is one write. `apply_scroll` does
|
/// Unclamped here, on purpose: it is one write. The controller does
|
||||||
/// the clamping, against walls the walk measured.
|
/// the clamping, against the walls [`Self::travel`] published from the
|
||||||
fn scroll(&mut self, amt: f32) {
|
/// last walk, and `overscroll_gap` gives back whatever that could not
|
||||||
if let Some(a) = &mut self.anchor {
|
/// know about.
|
||||||
a.offset -= amt;
|
fn move_anchor(&mut self, amt: f32) {
|
||||||
// Converted into the screen-space convention every caller
|
if self.anchor.is_none() {
|
||||||
// outside this widget uses, since `moved` is what
|
return;
|
||||||
// `Widget::scroll_offset` hands upward.
|
|
||||||
self.moved += self.flip_delta(amt);
|
|
||||||
}
|
}
|
||||||
|
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
|
/// The anchor's own row index and pixel offset, formatted the same
|
||||||
@@ -701,7 +709,7 @@ impl LazySpan {
|
|||||||
if let Some(a) = self.anchor
|
if let Some(a) = self.anchor
|
||||||
&& self.slot_exists(a.slot)
|
&& 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.anchor.as_mut().unwrap().offset = self.viewport_len;
|
||||||
}
|
}
|
||||||
self.last_viewport_len = self.viewport_len;
|
self.last_viewport_len = self.viewport_len;
|
||||||
@@ -925,7 +933,7 @@ impl LazySpan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update_snap_end(&mut self) {
|
fn update_snap_end(&mut self) {
|
||||||
self.snap_end = match self.anchor {
|
let pinned = match self.anchor {
|
||||||
Some(a) => {
|
Some(a) => {
|
||||||
self.next_slot(a.slot).is_none()
|
self.next_slot(a.slot).is_none()
|
||||||
&& a.edge == Edge::Trailing
|
&& a.edge == Edge::Trailing
|
||||||
@@ -933,6 +941,7 @@ impl LazySpan {
|
|||||||
}
|
}
|
||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
|
self.ctl.set_pinned_to_end(pinned);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The one rule for what this list draws**: a row is on screen if
|
/// **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.
|
/// on for an O(1) move.
|
||||||
const GENEROUS_PADDING: f32 = 100_000.0;
|
const GENEROUS_PADDING: f32 = 100_000.0;
|
||||||
|
|
||||||
impl Widget for LazySpan {
|
impl LazySpan {
|
||||||
/// A lazy layout cannot be slid about as a lump: which rows exist at
|
/// Make this span scrollable: the wheel and a finger drag, registered
|
||||||
/// all is a function of where it is scrolled to, and it has no content
|
/// on the span itself.
|
||||||
/// 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.
|
/// **Inherent, and it shadows `WidgetLike::scrollable` on purpose.**
|
||||||
/// See [`Widget::scrolls_itself`].
|
/// That one wraps its widget in a `ScrollArea`, which is exactly what
|
||||||
fn scrolls_itself(&self) -> bool {
|
/// must not happen here -- a lump slid about by a parent would never
|
||||||
true
|
/// 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 {
|
fn controller_mut(&mut self) -> &mut ScrollController {
|
||||||
self.moved
|
&mut self.ctl
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Take what the loaded content allows and leave the rest, so the
|
impl LazySpan {
|
||||||
/// `Scroll` above learns it hit a wall from what comes back.
|
/// 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
|
/// **The bound is exact where there is one, and `INFINITY` where there
|
||||||
/// or left -- and the walk works in direction-relative pixels, so
|
/// is not.** With content still loaded past an edge this span
|
||||||
/// [`Self::flip_delta`] is the one conversion, on the way in and on
|
/// genuinely cannot say how far it goes without walking there, and
|
||||||
/// the remainder's way back out.
|
/// saying so is what lets the walk find the wall and `overscroll_gap`
|
||||||
///
|
/// hand the overshoot back inside the same frame.
|
||||||
/// **The bound is exact, not a "we are at the wall" flag.** With no
|
fn travel(&self) -> Travel {
|
||||||
/// more content past an edge, the travel left in that direction is the
|
let forward = if self.no_more_after {
|
||||||
/// distance from the edge the last walk placed to the viewport's own,
|
|
||||||
/// so a delta that runs 250px past the end gives 250 back rather than
|
|
||||||
/// everything or nothing. That is what lets `Scroll::amt` stay equal
|
|
||||||
/// to what is actually on screen instead of drifting by every
|
|
||||||
/// overshoot. The walls come from the walk `Scroll::draw` runs
|
|
||||||
/// immediately before this.
|
|
||||||
fn apply_scroll(&mut self, delta: &mut f32) {
|
|
||||||
// `f32::INFINITY` rather than a big number or an `Option`: with
|
|
||||||
// content past this edge there is genuinely no bound to apply, and
|
|
||||||
// `clamp` says exactly that with no branch of its own.
|
|
||||||
let max_forward = if self.no_more_after {
|
|
||||||
(self.content_trail - self.viewport_len).max(0.0)
|
(self.content_trail - self.viewport_len).max(0.0)
|
||||||
} else {
|
} else {
|
||||||
f32::INFINITY
|
f32::INFINITY
|
||||||
};
|
};
|
||||||
let max_backward = if self.no_more_before {
|
let backward = if self.no_more_before {
|
||||||
(-self.content_lead).max(0.0)
|
(-self.content_lead).max(0.0)
|
||||||
} else {
|
} else {
|
||||||
f32::INFINITY
|
f32::INFINITY
|
||||||
};
|
};
|
||||||
let taken = self.flip_delta(*delta).clamp(-max_backward, max_forward);
|
// `forward`/`backward` are the walk's own directions -- toward
|
||||||
self.scroll(taken);
|
// later content and toward earlier -- and `Travel` is in screen
|
||||||
*delta -= self.flip_delta(taken);
|
// 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 {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
@@ -1207,12 +1252,29 @@ impl Widget for LazySpan {
|
|||||||
let output_len = painter.output_size().axis(axis);
|
let output_len = painter.output_size().axis(axis);
|
||||||
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
||||||
|
|
||||||
|
self.ctl.set_density(painter.density());
|
||||||
self.repair_anchor();
|
self.repair_anchor();
|
||||||
if self.anchor.is_none() {
|
if self.anchor.is_none() {
|
||||||
self.extents.clear();
|
self.extents.clear();
|
||||||
return Size::REST;
|
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
|
// `reanchor_at_tap` reads `extents` as they stood after the
|
||||||
// *previous* frame's layout -- the last on-screen box for each
|
// *previous* frame's layout -- the last on-screen box for each
|
||||||
// visible row -- so the clear that starts rebuilding it for this
|
// 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)
|
// box at a new offset, which `draw_inner` dispatches as an O(1)
|
||||||
// move.
|
// move.
|
||||||
if let Some(gap) = self.overscroll_gap(lead, trail) {
|
if let Some(gap) = self.overscroll_gap(lead, trail) {
|
||||||
self.scroll(gap);
|
self.move_anchor(gap);
|
||||||
self.extents.clear();
|
self.extents.clear();
|
||||||
self.lay_out(painter);
|
self.lay_out(painter);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.rehome_anchor();
|
self.rehome_anchor();
|
||||||
self.update_snap_end();
|
self.update_snap_end();
|
||||||
|
self.ctl.set_travel(self.travel());
|
||||||
Size::REST
|
Size::REST
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1355,7 +1418,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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);
|
push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
@@ -1405,7 +1468,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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 rows = push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
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
|
/// span is laid out (Iris, 2026-09-08 -- "positive should always
|
||||||
/// scroll up / left, and negative down / right"). A `Dir::UP` span
|
/// scroll up / left, and negative down / right"). A `Dir::UP` span
|
||||||
/// used to pan the opposite way for the same number, because
|
/// 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.
|
/// positions already went through.
|
||||||
///
|
///
|
||||||
/// Asserted on where rows were **drawn**, for the reason
|
/// Asserted on where rows were **drawn**, for the reason
|
||||||
@@ -1458,7 +1521,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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 keys: Vec<RowKey> = (0..10).collect();
|
||||||
let rows = push_rows(&mut rsc, &mut list, &keys, 20.0);
|
let rows = push_rows(&mut rsc, &mut list, &keys, 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
@@ -1467,14 +1530,15 @@ mod tests {
|
|||||||
render.resize((100.0, 100.0));
|
render.resize((100.0, 100.0));
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
let push = |rsc: &mut TestRsc, render: &mut UiRenderState, mut delta: f32| {
|
let push = |rsc: &mut TestRsc, render: &mut UiRenderState, delta: f32| {
|
||||||
rsc.ui
|
let before = rsc.ui.widgets.get(&list_weak).unwrap().amt();
|
||||||
.widgets
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(delta);
|
||||||
.get_mut(&list_weak)
|
|
||||||
.unwrap()
|
|
||||||
.apply_scroll(&mut delta);
|
|
||||||
assert_eq!(delta, 0.0, "there was content to take the whole delta");
|
|
||||||
render.update(&root, rsc);
|
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
|
// Away from the pinned end, in whichever screen direction
|
||||||
// that is for this `dir`.
|
// that is for this `dir`.
|
||||||
@@ -1520,7 +1584,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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);
|
push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
@@ -1547,7 +1611,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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);
|
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
@@ -1619,7 +1683,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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();
|
let mut rows = Vec::new();
|
||||||
for key in 0..5u64 {
|
for key in 0..5u64 {
|
||||||
let (bg_id, fg, row) = resizable_background_row(&mut rsc, 20.0);
|
let (bg_id, fg, row) = resizable_background_row(&mut rsc, 20.0);
|
||||||
@@ -1663,7 +1727,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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();
|
let mut bg_ids = Vec::new();
|
||||||
for key in 0..5u64 {
|
for key in 0..5u64 {
|
||||||
let (bg_id, row) = background_styled_row(&mut rsc, 20.0);
|
let (bg_id, row) = background_styled_row(&mut rsc, 20.0);
|
||||||
@@ -1693,7 +1757,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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);
|
push_rows(&mut rsc, &mut list, &[10, 11, 12], 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
@@ -1743,7 +1807,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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,
|
// Five rows of 20px; with a 100px viewport all are visible,
|
||||||
// anchored at the bottom by default (row 4's bottom at 100).
|
// 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);
|
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 {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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 rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
@@ -1839,7 +1903,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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();
|
let keys: Vec<RowKey> = (0..n as u64).collect();
|
||||||
push_rows(&mut rsc, &mut list, &keys, 20.0);
|
push_rows(&mut rsc, &mut list, &keys, 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
@@ -1852,12 +1916,11 @@ mod tests {
|
|||||||
render.take_counters();
|
render.take_counters();
|
||||||
|
|
||||||
// Backwards, into content that exists: a list opens flush with
|
// 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
|
// overscroll, and the clamp lays out a second time within the
|
||||||
// frame to give it back -- a correct extra pass, but not the
|
// frame to give it back -- a correct extra pass, but not the
|
||||||
// ordinary
|
// ordinary scroll tick whose cost this test is about.
|
||||||
// scroll tick whose cost this test is about.
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
|
||||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(-5.0);
|
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
|
||||||
@@ -1882,7 +1945,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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);
|
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
@@ -1936,7 +1999,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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);
|
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
@@ -1979,7 +2042,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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);
|
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||||
let (list_weak, root) = add_list(&mut rsc, list);
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
@@ -2049,7 +2112,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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 {
|
for key in 0..5u64 {
|
||||||
let (_bg_id, row) = background_styled_row(&mut rsc, 20.0);
|
let (_bg_id, row) = background_styled_row(&mut rsc, 20.0);
|
||||||
list.push_back(LazyItem::new(key, row));
|
list.push_back(LazyItem::new(key, row));
|
||||||
@@ -2109,7 +2172,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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),
|
// 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
|
// 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
|
// 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
|
/// room to travel before the walls stop it -- shared by the tests
|
||||||
/// below.
|
/// below.
|
||||||
///
|
///
|
||||||
/// Built the way a real caller does since the scroll position moved
|
/// Built the way a real caller does: `Masked(LazySpan)`, with the span
|
||||||
/// out of this widget: `Masked(Scroll(LazySpan))`. The `Scroll`
|
/// driving its own `ScrollController` -- the gesture, the fling and
|
||||||
/// contributes the gesture, the fling and `amt`; the `LazySpan` lays
|
/// `amt` are its own, and so is the walk that says how far it can
|
||||||
/// out and says how far it can actually go
|
/// actually go. The mask is outside because what needs clipping is the
|
||||||
/// (`Widget::apply_scroll`). The mask is outside the `Scroll` because
|
/// row straddling an edge, and masks are inherited down the chain.
|
||||||
/// what needs clipping is the row straddling an edge, and masks are
|
|
||||||
/// inherited down the chain.
|
|
||||||
fn build_flingable_list(
|
fn build_flingable_list(
|
||||||
rsc: &mut TestRsc,
|
rsc: &mut TestRsc,
|
||||||
) -> (
|
) -> (WeakWidget<LazySpan>, StrongWidget, UiRenderState) {
|
||||||
WeakWidget<LazySpan>,
|
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
|
||||||
WeakWidget<Scroll>,
|
|
||||||
StrongWidget,
|
|
||||||
UiRenderState,
|
|
||||||
) {
|
|
||||||
let mut list = LazySpan::new(Dir::DOWN, true);
|
|
||||||
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), FLING_ROW_H);
|
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), FLING_ROW_H);
|
||||||
let list = rsc.ui.widgets.add_strong(list);
|
let list = rsc.ui.widgets.add_strong(list);
|
||||||
let list_weak = list.weak();
|
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 {
|
let root = rsc.ui.widgets.add_strong(Masked {
|
||||||
shape: None,
|
shape: None,
|
||||||
inner: scroll.any(),
|
inner: list.any(),
|
||||||
});
|
});
|
||||||
let root = root.any();
|
let root = root.any();
|
||||||
let mut render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((100.0, 600.0));
|
render.resize((100.0, 600.0));
|
||||||
render.update(&root, rsc);
|
render.update(&root, rsc);
|
||||||
(list_weak, 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
|
/// `UiData::tick_animations` does, then draw. Answers whether the
|
||||||
/// fling is still going.
|
/// fling is still going.
|
||||||
fn fling_frame(
|
fn fling_frame(
|
||||||
rsc: &mut TestRsc,
|
rsc: &mut TestRsc,
|
||||||
scroll: &WeakWidget<Scroll>,
|
scroll: &WeakWidget<LazySpan>,
|
||||||
root: &StrongWidget,
|
root: &StrongWidget,
|
||||||
render: &mut UiRenderState,
|
render: &mut UiRenderState,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
@@ -2206,13 +2257,14 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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
|
// 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,
|
// has anywhere in the crate now. It used to be negative here,
|
||||||
// because a `LazySpan`'s anchor offset ran the opposite way to a
|
// 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.
|
// mirror the other.
|
||||||
assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0));
|
assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0));
|
||||||
assert!(rsc.ui.widgets.get(&scroll).unwrap().is_scrolling());
|
assert!(rsc.ui.widgets.get(&scroll).unwrap().is_scrolling());
|
||||||
@@ -2243,7 +2295,8 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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());
|
let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
|
||||||
|
|
||||||
if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) {
|
if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) {
|
||||||
@@ -2273,8 +2326,8 @@ mod tests {
|
|||||||
assert!(!rsc.ui.tick_animations(now));
|
assert!(!rsc.ui.tick_animations(now));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sign, pinned across the whole handoff: gesture -> `Scroll` ->
|
/// The sign, pinned across the whole handoff: gesture -> `ScrollArea` ->
|
||||||
/// `apply_scroll` -> anchor. A negative delta is the finger moving the
|
/// controller -> anchor. A negative delta is the finger moving the
|
||||||
/// negative way along the axis, which pulls **later** content up into
|
/// negative way along the axis, which pulls **later** content up into
|
||||||
/// view. Getting this wrong anywhere in that chain scrolls the list
|
/// view. Getting this wrong anywhere in that chain scrolls the list
|
||||||
/// backwards, which no type can catch.
|
/// backwards, which no type can catch.
|
||||||
@@ -2283,7 +2336,8 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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.
|
// Start well back from the end so there is room to move forward.
|
||||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(2000.0);
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(2000.0);
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
@@ -2304,16 +2358,16 @@ mod tests {
|
|||||||
|
|
||||||
/// `Scroll::amt` is the accumulated movement the child actually made,
|
/// `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
|
/// 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`
|
/// the end of the content. This is the whole reason the span reports
|
||||||
/// hands back a remainder rather than a "hit a wall" flag: an
|
/// what it *moved* rather than the caller adding up what it asked
|
||||||
/// all-or-nothing answer would leave `amt` over-counted by every
|
/// for: an all-or-nothing answer would leave `amt` over-counted by
|
||||||
/// overshoot, and nothing would ever correct it.
|
/// every overshoot, and nothing would ever correct it.
|
||||||
#[test]
|
#[test]
|
||||||
fn amt_counts_only_what_the_child_could_take() {
|
fn amt_counts_only_what_the_child_could_take() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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.
|
// A short move away from the end, all of which is available.
|
||||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100.0);
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100.0);
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
@@ -2345,7 +2399,8 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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
|
// An enormous velocity that would travel far past all 200 rows if
|
||||||
// unclamped.
|
// unclamped.
|
||||||
rsc.ui.widgets.get_mut(&scroll).unwrap().fling(50_000.0);
|
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
|
/// 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
|
/// first row rather than beyond it, in the frame that asked. Since the
|
||||||
/// position moved into `Scroll`, this is prevented at the source --
|
/// position moved into `ScrollArea`, this is prevented at the source --
|
||||||
/// `apply_scroll` only takes what the walk says is there -- rather
|
/// the clamp only allows what the walk says is there -- rather
|
||||||
/// than corrected afterwards, and `overscroll_gap` is left for the
|
/// than corrected afterwards, and `overscroll_gap` is left for the
|
||||||
/// case a scroll cannot cause: content or a viewport that changed
|
/// case a scroll cannot cause: content or a viewport that changed
|
||||||
/// under a settled anchor.
|
/// under a settled anchor.
|
||||||
@@ -2387,7 +2442,8 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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);
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0);
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||||
@@ -2401,7 +2457,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn anchor_position_display_before_any_draw_is_none() {
|
fn anchor_position_display_before_any_draw_is_none() {
|
||||||
let list = LazySpan::new(Dir::DOWN, true);
|
let list = LazySpan::new(Dir::DOWN, Pin::End);
|
||||||
assert_eq!(list.anchor_position_display(), "idx=none");
|
assert_eq!(list.anchor_position_display(), "idx=none");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2410,7 +2466,7 @@ mod tests {
|
|||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
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 _ = (&root, &mut render);
|
||||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||||
assert!(list_ref.anchor_position_display().starts_with("idx="));
|
assert!(list_ref.anchor_position_display().starts_with("idx="));
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ mod lazy_span;
|
|||||||
mod max_size;
|
mod max_size;
|
||||||
mod offset;
|
mod offset;
|
||||||
mod pad;
|
mod pad;
|
||||||
mod scroll;
|
mod scroll_area;
|
||||||
|
mod scrollable;
|
||||||
mod sized;
|
mod sized;
|
||||||
mod span;
|
mod span;
|
||||||
mod stack;
|
mod stack;
|
||||||
@@ -15,7 +16,8 @@ pub use lazy_span::*;
|
|||||||
pub use max_size::*;
|
pub use max_size::*;
|
||||||
pub use offset::*;
|
pub use offset::*;
|
||||||
pub use pad::*;
|
pub use pad::*;
|
||||||
pub use scroll::*;
|
pub use scroll_area::*;
|
||||||
|
pub use scrollable::*;
|
||||||
pub use sized::*;
|
pub use sized::*;
|
||||||
pub use span::*;
|
pub use span::*;
|
||||||
pub use stack::*;
|
pub use stack::*;
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -83,63 +83,32 @@ widget_trait! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
/// Wrap this widget in a [`ScrollArea`] that pans along `axis`, with
|
||||||
self.scrollable_on(Axis::Y)
|
/// the wheel and a finger drag both registered -- how anything with a
|
||||||
}
|
/// fixed layout becomes scrollable.
|
||||||
|
|
||||||
/// `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.
|
|
||||||
///
|
///
|
||||||
/// Explicit, because the other kind is not a variation on it: a code
|
/// `pin` says which end the area opens at and clings to as its content
|
||||||
/// fence opened at the end of its longest line, which is the middle
|
/// grows, and it is spelled out rather than defaulted because the two
|
||||||
/// of a word (seen in `iris/run-headless.sh phone`, 2026-09-08). The
|
/// cases are not variations on each other: a composer wants the end,
|
||||||
/// two behaviours are one mechanism with the starting edge passed in,
|
/// where what is being typed is, and a code fence opened at the end of
|
||||||
/// and both names say which they are rather than one of them being a
|
/// its longest line, which is the middle of a word (seen in
|
||||||
/// default nobody reads.
|
/// `iris/run-headless.sh phone`, 2026-09-08).
|
||||||
fn scrollable_to_end(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
///
|
||||||
self.scroll_area(axis, true)
|
/// 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
|
||||||
/// `scrollable` along `axis`. A code fence pans across its own long
|
/// and a pin instead of having two?"). A code fence pans across its
|
||||||
/// lines exactly the way a transcript pans down its rows, so the two
|
/// own long lines exactly the way a transcript pans down its rows, so
|
||||||
/// are one function with the axis passed in rather than a second copy
|
/// the two are one mechanism with the direction passed in --
|
||||||
/// -- `DragArbiter::on` is the other half.
|
/// `DragArbiter::on` is the other half.
|
||||||
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
///
|
||||||
self.scroll_area(axis, false)
|
/// 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.
|
||||||
/// The one implementation behind [`Self::scrollable_on`] and
|
fn scrollable(self, axis: Axis, pin: Pin) -> impl WidgetIdFn<Rsc, ScrollArea> where Rsc: HasEvents {
|
||||||
/// [`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 {
|
|
||||||
move |state| {
|
move |state| {
|
||||||
Scroll::new(self.add_strong(state), axis, at_end)
|
let area = ScrollArea::new(self.add_strong(state), axis, pin);
|
||||||
.on(CursorSense::Scroll, move |ctx, rsc| {
|
scroll_senses(area, axis)(state)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ where
|
|||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
let texts = Span::empty(Dir::DOWN).gap(10).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")
|
let add_text = wtext("add")
|
||||||
.editable(EditMode::MultiLine)
|
.editable(EditMode::MultiLine)
|
||||||
.text_align(Align::LEFT)
|
.text_align(Align::LEFT)
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
|
|||||||
PHONE_FRAME_MS,
|
PHONE_FRAME_MS,
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
(screen.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"
|
"the fling must still be running 150ms in, or this test catches nothing"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
|
|||||||
PHONE_FRAME_MS,
|
PHONE_FRAME_MS,
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
(screen.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"
|
"the fling must still be running 150ms in, or this test catches nothing"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
|
|||||||
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
|
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
None,
|
None,
|
||||||
"a press that stopped a fling and moved nothing must not start another"
|
"a press that stopped a fling and moved nothing must not start another"
|
||||||
);
|
);
|
||||||
@@ -195,7 +195,7 @@ fn the_same_small_drag_on_a_settled_list_moves_nothing() {
|
|||||||
PHONE_FRAME_MS,
|
PHONE_FRAME_MS,
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!(screen.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"
|
"the fling must have stopped, or this is the same case as the test above"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! flicked sideways, has to keep moving after the finger leaves.
|
//! flicked sideways, has to keep moving after the finger leaves.
|
||||||
//!
|
//!
|
||||||
//! The fence is pushed here rather than hunted for in the bench fixture,
|
//! 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
|
//! 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
|
//! handle to it from the outside, and a coordinate would only prove that
|
||||||
//! *something* moved.
|
//! *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
|
/// 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
|
/// 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
|
/// 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.
|
/// coordinate would only prove that *something* moved.
|
||||||
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
|
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
|
||||||
h.render
|
h.render
|
||||||
@@ -27,7 +27,7 @@ fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, Pixe
|
|||||||
.ui
|
.ui
|
||||||
.widgets
|
.widgets
|
||||||
.get_dyn(id)
|
.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)
|
.is_some_and(|s| s.axis() == Axis::X)
|
||||||
})
|
})
|
||||||
.find_map(|id| {
|
.find_map(|id| {
|
||||||
@@ -41,7 +41,7 @@ fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
|
|||||||
.ui
|
.ui
|
||||||
.widgets
|
.widgets
|
||||||
.get_dyn(id)
|
.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")
|
.expect("the fence's scroll area is still drawn")
|
||||||
.is_scrolling()
|
.is_scrolling()
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ fn amt(h: &Harness, id: WidgetId) -> f32 {
|
|||||||
.ui
|
.ui
|
||||||
.widgets
|
.widgets
|
||||||
.get_dyn(id)
|
.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")
|
.expect("the fence's scroll area is still drawn")
|
||||||
.amt()
|
.amt()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ fn a_cancelled_flick_does_not_fling() {
|
|||||||
h.replay(&flick);
|
h.replay(&flick);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
None,
|
None,
|
||||||
"a gesture the platform took away must not fling"
|
"a gesture the platform took away must not fling"
|
||||||
);
|
);
|
||||||
@@ -110,7 +110,7 @@ fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
|
|||||||
after - before
|
after - before
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
None,
|
None,
|
||||||
"and it must not have flung either"
|
"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
|
/// The report itself: "if I scroll in a horizontal area and then tap in a
|
||||||
/// vertical area, it seems to snap."
|
/// 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
|
/// pointer capture the moment it commits. Everything else that was handed
|
||||||
/// a frame of that press is told so with `CursorSense::Cancel` -- and the
|
/// 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,
|
/// 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"));
|
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
|
||||||
h.replay(&flick);
|
h.replay(&flick);
|
||||||
|
|
||||||
let velocity = (screen.scroll)(&mut h.rsc)
|
let velocity = (screen.list)(&mut h.rsc)
|
||||||
.fling_velocity()
|
.fling_velocity()
|
||||||
.expect("the flick must release as a pan with a velocity, not a tap");
|
.expect("the flick must release as a pan with a velocity, not a tap");
|
||||||
// Compose's own answer for this recording's five samples, printed by
|
// Compose's own answer for this recording's five samples, printed by
|
||||||
@@ -98,7 +98,7 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
|||||||
.key_at(middle)
|
.key_at(middle)
|
||||||
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
|
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
|
||||||
};
|
};
|
||||||
if settled_at.is_none() && !(screen.scroll)(&mut h.rsc).is_scrolling() {
|
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
|
||||||
settled_at = Some(t);
|
settled_at = Some(t);
|
||||||
}
|
}
|
||||||
t += PHONE_FRAME_MS;
|
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")));
|
h.replay(&script("tap", include_str!("../touch/tap.touch")));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
None,
|
None,
|
||||||
"a tap must not fling"
|
"a tap must not fling"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
/// the next one. **Positive walks back through older rows** -- the
|
||||||
/// finger's own direction, and `Scroll::scroll`'s, which is the one
|
/// finger's own direction, and `Scroll::scroll`'s, which is the one
|
||||||
/// convention a delta has anywhere in iris since the transcript's scroll
|
/// 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
|
/// It used to be the opposite here, because a `LazySpan`'s anchor offset
|
||||||
/// ran the other way.
|
/// ran the other way.
|
||||||
fn scrolled(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
|
fn scrolled(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
|
||||||
(screen.scroll)(&mut h.rsc).scroll(amount);
|
(screen.list)(&mut h.rsc).scroll(amount);
|
||||||
h.frame(t);
|
h.frame(t);
|
||||||
t + PHONE_FRAME_MS
|
t + PHONE_FRAME_MS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ where
|
|||||||
let content = field
|
let content = field
|
||||||
// `scrollable_to_end`: what is being typed is at the end, so a
|
// `scrollable_to_end`: what is being typed is at the end, so a
|
||||||
// message longer than the six lines shown holds that end.
|
// 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))
|
.pad(dp(FIELD_PAD_DP))
|
||||||
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
||||||
.width(rest(1))
|
.width(rest(1))
|
||||||
|
|||||||
@@ -55,15 +55,13 @@ use selection::Selection;
|
|||||||
use std::{cell::RefCell, rc::Rc};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
pub struct TranscriptScreen {
|
pub struct TranscriptScreen {
|
||||||
/// The transcript's own `LazySpan` -- exposed so a caller can read
|
/// The transcript's own `LazySpan` -- the layout *and* the scroll
|
||||||
/// `.extent()`/call `.jump_to_end()` etc. directly for anything this
|
/// position, since a lazy span owns a `ScrollController` of its own
|
||||||
/// crate does not already wrap.
|
/// 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>,
|
pub list: WeakWidget<LazySpan>,
|
||||||
/// The scroll area around it, which owns the position, the fling and
|
|
||||||
/// the pin -- what a caller drives to scroll the transcript, or reads
|
|
||||||
/// to ask whether it is coasting. The `LazySpan` above lays out; it
|
|
||||||
/// does not scroll.
|
|
||||||
pub scroll: WeakWidget<Scroll>,
|
|
||||||
pub composer: composer::Composer,
|
pub composer: composer::Composer,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
/// How many times [`Self::apply`] has fallen back to a full rebuild --
|
/// How many times [`Self::apply`] has fallen back to a full rebuild --
|
||||||
@@ -360,7 +358,7 @@ where
|
|||||||
Rsc::State: FocusHost + OpenUrl,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let selection = Rc::new(RefCell::new(Selection::new()));
|
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
|
// The last row's block widgets are kept for the same reason
|
||||||
// `push_row` keeps them: a reply that is *already* streaming when the
|
// `push_row` keeps them: a reply that is *already* streaming when the
|
||||||
@@ -415,35 +413,22 @@ where
|
|||||||
.add(rsc);
|
.add(rsc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The scroll area. Everything about *scrolling* is here rather than
|
// The wheel, registered by hand rather than through
|
||||||
// in the list: the wheel, the fling, `amt`, and the clamp against the
|
// `LazySpan::scrollable()`, and this is the reason: that helper also
|
||||||
// ends. The `LazySpan` inside answers `Widget::scrolls_itself`, so
|
// registers a finger drag driving the span's own `DragGesture`, and
|
||||||
// this hands it deltas to apply itself (`apply_scroll`) instead of
|
// the transcript already has an arbiter -- `Selection`, which has to
|
||||||
// sliding it about as a lump -- which it cannot be, since which rows
|
// decide between panning and selecting text and so cannot let a second
|
||||||
// exist at all is a function of where it is scrolled to.
|
// `DragGesture` see the same frames. `DragGesture`'s doc states the
|
||||||
//
|
// rule: one gesture, one arbiter, each frame delivered exactly once.
|
||||||
// Built out rather than through `.scrollable_to_end()`, and this is
|
// The wheel handler here is identical to the helper's; only the drag
|
||||||
// the reason: that helper registers a finger drag on the `Scroll`,
|
// differs, and it arrives through `Selection::drag`, which hands
|
||||||
// driving `Scroll`'s own `DragGesture`, and the transcript already has
|
// committed pans and releases to this same span.
|
||||||
// an arbiter of its own -- `Selection`, which has to decide between
|
list.on(CursorSense::Scroll, |ctx, rsc| {
|
||||||
// panning and selecting text and so cannot let a second `DragGesture`
|
|
||||||
// see the same frames. `DragGesture`'s doc says it: one gesture, one
|
|
||||||
// arbiter, each frame delivered exactly once. So the wheel is
|
|
||||||
// registered here (identical to the helper's) and the finger arrives
|
|
||||||
// through `Selection::drag`, which hands committed pans and releases
|
|
||||||
// to this same `Scroll`.
|
|
||||||
//
|
|
||||||
// `at_end: true` -- a transcript opens at its newest message and stays
|
|
||||||
// there while replies arrive. That is the *pin*, and it is a different
|
|
||||||
// question from `Dir::DOWN` above, which says the oldest message is
|
|
||||||
// item 0 and sits at the top.
|
|
||||||
let scroll = Scroll::new(list.add_strong(rsc).any(), Axis::Y, true)
|
|
||||||
.on(CursorSense::Scroll, |ctx, rsc| {
|
|
||||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
let delta = ctx.data.scroll_delta.y * 50.0;
|
||||||
ctx.widget(rsc).scroll(delta);
|
ctx.widget(rsc).scroll(delta);
|
||||||
})
|
})
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
selection.borrow_mut().set_scroll_area(scroll);
|
selection.borrow_mut().set_scroll_area(list);
|
||||||
|
|
||||||
let (composer, composer_bar) = composer::build_composer(rsc);
|
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,
|
// there, which on the phone is the header bar (docs/IRIS_TODO.md,
|
||||||
// 2026-09-07: "code and a paragraph visible behind Run benchmark").
|
// 2026-09-07: "code and a paragraph visible behind Run benchmark").
|
||||||
// The same clip is what `LazySpan::draw` asserts it has.
|
// The same clip is what `LazySpan::draw` asserts it has.
|
||||||
let tree = (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)
|
.span(Dir::DOWN)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any();
|
.any();
|
||||||
|
|
||||||
(
|
(
|
||||||
TranscriptScreen {
|
TranscriptScreen {
|
||||||
scroll,
|
|
||||||
tail: RefCell::new(tail),
|
tail: RefCell::new(tail),
|
||||||
session_working: std::cell::Cell::new(false),
|
session_working: std::cell::Cell::new(false),
|
||||||
list,
|
list,
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ where
|
|||||||
// curve instead of leaving square pixels in the corners
|
// curve instead of leaving square pixels in the corners
|
||||||
// (Iris, 2026-09-07).
|
// (Iris, 2026-09-07).
|
||||||
BlockFrame::Verbatim { fill } => field
|
BlockFrame::Verbatim { fill } => field
|
||||||
.scrollable_on(Axis::X)
|
.scrollable(Axis::X, Pin::Start)
|
||||||
.pad(dp(FRAME_PAD_DP))
|
.pad(dp(FRAME_PAD_DP))
|
||||||
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||||
.width(rest(1))
|
.width(rest(1))
|
||||||
|
|||||||
@@ -53,15 +53,15 @@ pub struct Selection {
|
|||||||
/// in iris's default input layer, with only the pan-vs-select
|
/// in iris's default input layer, with only the pan-vs-select
|
||||||
/// *decision* staying here.
|
/// *decision* staying here.
|
||||||
gesture: DragGesture,
|
gesture: DragGesture,
|
||||||
/// The scroll area around the transcript's `LazySpan`, which owns the
|
/// The transcript's `LazySpan`, whose `ScrollController` owns the
|
||||||
/// position, the fling and `amt` -- what a committed pan and a release
|
/// 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
|
/// 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
|
/// needs the rows), hence the `Option` rather than a constructor
|
||||||
/// argument. A `Selection` without one still selects text and still
|
/// argument. A `Selection` without one still selects text and still
|
||||||
/// reports taps; it simply cannot pan, which is what the `debug_assert`
|
/// 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.
|
/// 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 {
|
impl Default for Selection {
|
||||||
@@ -80,11 +80,11 @@ impl Selection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand this the scroll area that wraps the transcript, once it
|
/// Hand this the transcript's `LazySpan` once it exists -- see the
|
||||||
/// exists -- see the `scroll` field. Called by `build_tree`; every
|
/// `scroll` field. Called by `build_tree`; every pan and fling this
|
||||||
/// pan and fling this arbitrates goes there rather than to the
|
/// arbitrates goes to that span's own `ScrollController`
|
||||||
/// `LazySpan`, which no longer owns a position.
|
/// (`Scrollable::scroll`/`fling`), which is where the position lives.
|
||||||
pub fn set_scroll_area(&mut self, scroll: WeakWidget<Scroll>) {
|
pub fn set_scroll_area(&mut self, scroll: WeakWidget<LazySpan>) {
|
||||||
self.scroll = Some(scroll);
|
self.scroll = Some(scroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ impl Selection {
|
|||||||
pointer: &PointerRequests,
|
pointer: &PointerRequests,
|
||||||
) -> GestureOutcome {
|
) -> GestureOutcome {
|
||||||
// A fresh touch-down cancels any fling still coasting from the
|
// A fresh touch-down cancels any fling still coasting from the
|
||||||
// previous gesture -- `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
|
// `Scroller::abortAnimation` for the same reason -- and, since
|
||||||
// 2026-09-07, *tells the gesture there was one*. A press that
|
// 2026-09-07, *tells the gesture there was one*. A press that
|
||||||
// caught moving content is a catch: it pans from this very sample
|
// caught moving content is a catch: it pans from this very sample
|
||||||
@@ -291,7 +291,7 @@ impl Selection {
|
|||||||
debug_assert!(
|
debug_assert!(
|
||||||
self.scroll.is_some(),
|
self.scroll.is_some(),
|
||||||
"Selection::drag with no scroll area: a committed pan would be silently dropped -- \
|
"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();
|
let mut press = PressState::default();
|
||||||
if self.gesture.starts_press(sense) {
|
if self.gesture.starts_press(sense) {
|
||||||
@@ -312,7 +312,7 @@ impl Selection {
|
|||||||
// selection follows from a gesture that was never ours.
|
// selection follows from a gesture that was never ours.
|
||||||
GestureOutcome::Cancelled | GestureOutcome::Undecided => {}
|
GestureOutcome::Cancelled | GestureOutcome::Undecided => {}
|
||||||
// `scroll(dy)`, not `scroll(-dy)`: since the position moved
|
// `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
|
// the crate -- the finger's -- rather than a `LazySpan` whose
|
||||||
// anchor offset ran the other way while claiming to mirror it.
|
// anchor offset ran the other way while claiming to mirror it.
|
||||||
GestureOutcome::Pan(dy) => {
|
GestureOutcome::Pan(dy) => {
|
||||||
@@ -344,7 +344,7 @@ impl Selection {
|
|||||||
// `DragGesture`'s `Some(v)` already encodes.
|
// `DragGesture`'s `Some(v)` already encodes.
|
||||||
GestureOutcome::Released(Some(v)) => {
|
GestureOutcome::Released(Some(v)) => {
|
||||||
// The half that actually makes it move -- see
|
// 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.
|
// computed, stored, and never advanced by anything.
|
||||||
//
|
//
|
||||||
// Only when `fling` actually took it: below Compose's
|
// Only when `fling` actually took it: below Compose's
|
||||||
@@ -461,14 +461,17 @@ mod tests {
|
|||||||
EditMode::MultiLine,
|
EditMode::MultiLine,
|
||||||
))
|
))
|
||||||
.weak();
|
.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();
|
let list_weak = list.weak();
|
||||||
// The scroll area the real screen puts around it: a committed pan
|
// The scroll area the real screen puts around it: a committed pan
|
||||||
// goes there, and `Selection` asserts it was told about one.
|
// goes there, and `Selection` asserts it was told about one.
|
||||||
let scroll = rsc
|
let scroll = rsc
|
||||||
.ui
|
.ui
|
||||||
.widgets
|
.widgets
|
||||||
.add_strong(Scroll::new(list.any(), Axis::Y, true))
|
.add_strong(LazySpan::new(Dir::DOWN, Pin::End))
|
||||||
.weak();
|
.weak();
|
||||||
let list = list_weak;
|
let list = list_weak;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// closely. A long line is **clipped** here rather than pannable, which a
|
||||||
/// markdown fence (`row.rs`'s `BlockFrame::Verbatim`) is not: adding
|
/// 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
|
/// 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
|
/// 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).
|
/// call (the fence, which does the same thing to a `TextEdit`, is fine).
|
||||||
@@ -272,7 +272,7 @@ where
|
|||||||
.wrap(false)
|
.wrap(false)
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
field
|
field
|
||||||
.scrollable_on(Axis::X)
|
.scrollable(Axis::X, Pin::Start)
|
||||||
.pad(dp(RAW_PAD_DP))
|
.pad(dp(RAW_PAD_DP))
|
||||||
.masked_by(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP)))
|
.masked_by(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP)))
|
||||||
.width(rest(1))
|
.width(rest(1))
|
||||||
|
|||||||
Reference in new issue
Block a user