diff --git a/AGENTS.md b/AGENTS.md index 0d81c31..87292fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,11 +76,12 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout". public API** -- Iris, 2026-09-08), working list, decisions log, layout/render design, and texture-atlas design, and the client-core crate's design, respectively. - - `docs/SCROLL.md` — how anything in iris scrolls: `Scroll` owns the - position, the gesture and the fling; a child either gets moved or - answers `Widget::scrolls_itself` and is handed deltas. Read it before - touching `scroll.rs`, `lazy_span.rs`, or anything that pans, flings - or lays out a long list. + - `docs/SCROLL.md` — how anything in iris scrolls: one + `ScrollController` holds the position, the gesture, the fling and the + pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own + one each through the `Scrollable` trait. Read it before touching + `scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that + pans, flings or lays out a long list. - `.dev-updater.ron` — what Dev Updater builds here: the server (run as `service: Managed(…)`, supervised by Dev Updater's own implementation rather than a script kept here) and the APK, in parallel. It points at diff --git a/docs/IRIS.md b/docs/IRIS.md index 5765b26..221aa9d 100644 --- a/docs/IRIS.md +++ b/docs/IRIS.md @@ -12,7 +12,68 @@ things still stay out. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. -## 2026-09-08 (last): `List` is `LazySpan`, and scrolling belongs to `Scroll` +## 2026-09-08 (newest): one `ScrollController`, a `Scrollable` trait, and `Pin` + +Your three points on `docs/SCROLL.md`, in one change. The shape is the one +you proposed: **a controller both scrolling widgets contain**, rather than +a protocol between them. + +**`Scroll` is `ScrollArea`**, because it only scrolls a predefined area -- +your word for it. **`ScrollController`** (`widget/position/scrollable.rs`) +holds everything that is not a particular widget's layout: the position, +the pending delta, the travel left each way, the pin, the `DragGesture` +and the `Flinger`. **`Scrollable`** is the trait over it -- one required +pair of methods handing the controller back, and `scroll`, `fling`, `drag`, +`amt`, `is_scrolling`, `cancel_fling`, `tick_fling` and the pin as +defaults. + +**The three scrolling methods are off `Widget`.** `scrolls_itself`, +`apply_scroll` and `scroll_offset` existed only so a `Scroll` could drive a +`LazySpan` it had no business wrapping. A `LazySpan` owns its own +controller now, so there is no wrapper, no measure/apply/place dance +between two widgets, and no `amt` with two meanings depending on which kind +of child it had. The transcript's tree lost a node with it: `list` is the +layout *and* the position. + + before Masked(Scroll(LazySpan)) .scrollable_to_end(Axis::Y) + after Masked(LazySpan) .scrollable() + +**`.scrollable(axis, pin)`** is the only one now -- `scrollable`, +`scrollable_on` and `scrollable_to_end` were one mechanism with the +arguments hidden in the names. A `LazySpan` has an **inherent** +`scrollable()` that shadows it, since Rust resolves inherent methods +first: same word at the call site, and the wrapping version cannot reach a +widget that must not be wrapped. The axis and pin are already its own. + +**`Pin` says which end either way round.** `Start`/`End` are +content-relative, `Neg`/`Pos` axis-absolute -- your ask, so a caller can +say "the bottom" and mean it whichever way the content runs. They coincide +for everything except a reversed `LazySpan`, where they are opposites. + +**A delta's sign is now a screen direction**, positive scrolling up or +left. It was "positive brings earlier content into view", which points the +opposite way for a `Dir::UP` span -- a real defect, latent only because +nothing builds one yet, and invisible to the existing test because that +test asserts in the same space the bug lives in. + +**Why overscroll exists at all**, since you asked: a lazy span cannot see +the wall until it has walked to it, so with rows loaded past an edge it +honestly reports infinite travel, takes the whole delta, and the walk +finds the content ran out 200px ago. It is given back inside the same +frame. The rows past the edge have never been measured, and measuring them +is the work virtualisation exists to skip. + +**One behaviour changed**: a delta is applied by the next `draw` rather +than the moment it arrives, since the layout is the only thing that knows +where the content ends. Nothing on screen differs -- input is followed by +a frame -- but `amt` no longer moves between draws, which several tests +were reading. + +The pin question `SCROLL.md` had open ("the pin lives in each widget, not +in `Scroll`") is closed by this: it is one field on the controller, and a +caller edits one place. + +## 2026-09-08 (earlier): `List` is `LazySpan`, and scrolling belongs to `Scroll` From the design exchange after the overscroll fix, where you asked whether `List` could just be `Span::scrollable()`. It cannot -- a lazy diff --git a/docs/SCROLL.md b/docs/SCROLL.md index c5ee78c..4473b5c 100644 --- a/docs/SCROLL.md +++ b/docs/SCROLL.md @@ -4,22 +4,38 @@ How anything in iris scrolls, as of 2026-09-08. This is the current design, not a history — `docs/IRIS.md`'s dated entries have the account of how it got here, and `docs/IRIS_TODO.md` has what is still open. -Read this before touching `iris/src/widget/position/scroll.rs`, -`iris/src/widget/position/lazy_span.rs`, or anything that pans, flings or -lays out a long list. +Read this before touching `iris/src/widget/position/scrollable.rs`, +`scroll_area.rs`, `lazy_span.rs`, or anything that pans, flings or lays +out a long list. ## The one rule -**`Scroll` owns the position, the gesture and the fling. Nothing else -does.** A widget inside it either gets moved by it or is handed deltas to -apply itself, and either way the scroll state lives in the `Scroll`. There -is exactly one `Flinger` in the crate's widgets and one `DragGesture` -implementation, both in `iris/src/sense.rs`. +**Everything a scroll position is made of lives in one `ScrollController`, +and the widget that scrolls owns one.** The position, the pending delta, +the travel left, the pin, the `DragGesture` and the `Flinger` are all in +that struct (`scrollable.rs`); there is exactly one `Flinger` and one +`DragGesture` implementation in the crate's widgets. A widget with one +implements `Scrollable`, whose one required pair of methods hands the +controller back, and gets `scroll`, `fling`, `drag`, `amt`, +`is_scrolling`, `tick_fling` and the pin as default methods. -So: `.scrollable_on(axis)` / `.scrollable_to_end(axis)` is how anything -becomes scrollable, including a `LazySpan`. Do not give a widget its own -fling, its own scroll amount, or a `RequestRedraw` handle — that is what -was just removed. +Two widgets have one, and they differ only in how they spend a delta: + +- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, measured whole and + then slid about as a lump, which is what makes a scroll tick an O(1) + move of one subtree. `.scrollable(axis, pin)` wraps anything in one. +- **`LazySpan`** (`lazy_span.rs`) — lays its own rows out from an anchor, + so it cannot be a lump and is not wrapped in anything. Its own + `.scrollable()` registers the same two senses against the controller it + already has. + +Do not give a widget its own fling, its own scroll amount, or a +`RequestRedraw` handle. And do not add a scrolling method to the `Widget` +trait: the three that used to be there (`scrolls_itself`, `apply_scroll`, +`scroll_offset`) existed only so a `Scroll` could drive a `LazySpan` it +had no business wrapping, and they are gone (Iris, 2026-09-08: "I don't +like adding methods to widget, it seems like we can structure things +better instead"). ## One convention for a delta @@ -49,101 +65,84 @@ way_on_screen` checks the two `dir`s against **where rows were drawn** — an assertion written in the walk's own space passes with the flip deleted, because it checks the bookkeeping against itself. -## The `Widget` handoff +## The contract between a controller and its owner -Three default methods on `Widget` (`iris/core/src/widget/mod.rs`): +Two calls, both inside the owner's `draw`, because a `draw` is the only +place that knows where the content ends: -```rust -fn scrolls_itself(&self) -> bool { false } -fn apply_scroll(&mut self, delta: &mut f32) {} -fn scroll_offset(&self) -> f32 { 0.0 } -``` +1. **`take_delta()`** — everything a wheel, a drag or a fling asked for + since the last layout, in one number, already clamped to the travel + that layout reported. Clipping it stops a fling. +2. **`set_travel(Travel)`** at the end, plus whichever of **`moved_by`** + (movement) or **`set_amt`** (an absolute position) fits how that owner + knows where it ended up. -- **`scrolls_itself`** — "I position my own content; hand me deltas rather - than sliding me about." Default `false`: an ordinary child is a lump its - parent moves, which is what makes a scroll tick an O(1) move of one - subtree instead of a redraw. -- **`apply_scroll`** — take as much of `delta` as you can actually move, - leave the rest. What comes back short is how the parent learns the - content ran out. -- **`scroll_offset`** — accumulated content movement, so the parent can - keep an honest account. See "why the remainder is not enough" below. +`Travel` is `{ back, fwd }` in the same screen-space units as a delta: +`back` bounds a positive one, `fwd` a negative one, and `f32::INFINITY` +means "the end is not in sight". That last is not a placeholder — a lazy +layout genuinely cannot say how far its content runs without walking +there, and `clamp` takes the answer with no branch of its own. -**`scrolls_itself` and `scroll_offset` are `&self` on purpose.** Reaching a -widget through `Widgets::get_dyn_mut` *marks it dirty* -(`iris/core/src/widget/widgets.rs`). Asking either question through a -`&mut` path would dirty every ordinary child on every scroll tick and cost -exactly the O(1) move the scheme exists for. `Painter::scrolls_itself` and -`Painter::scroll_offset` go through `get_dyn`; only -`Painter::apply_scroll` takes `&mut`. +**Why the delta is banked rather than applied where it arrives.** A wheel +event, a drag frame and a fling tick all land between draws, and none of +them can know whether there is content to move into. Applying them at the +layout that follows is also what keeps layout a pure function of the state +(Iris, 2026-09-08). The visible consequence, and the thing that catches a +test out: **`amt` does not move until the next draw.** -### Why the remainder is not enough on its own +### Why a remainder was not enough -`apply_scroll` leaving a remainder was meant to be the whole story. It is -not, because **a lazy layout usually cannot say where its content ends -until it has walked there.** With the wall out of view it takes the delta -in full, and the walk that follows gives part of it back. So the remainder -is exact only when the wall was already visible, and a parent adding -remainders up would over-count by every overshoot and never correct. +The `apply_scroll(&mut delta)` this replaced left the part it could not +take in the caller's variable, and that was meant to be the whole story. +It is not, because **a lazy layout usually cannot say where its content +ends until it has walked there.** With the wall out of view it takes the +delta in full, and the walk that follows gives part of it back. So the +remainder is exact only when the wall was already visible, and a parent +adding remainders up would over-count by every overshoot and never +correct. Now the owner reports what it *did* (`moved_by`, from the one +place its anchor moves) as well as what it *can* do, and +`amt_counts_only_what_the_child_could_take` is the test. -`Scroll` therefore reads `scroll_offset` *after* the placing draw and sets -`amt` from it. `amt_counts_only_what_the_child_could_take` is the test; -it fails if you try to go back to remainders alone. +## What `amt` means -## `Scroll::draw` — two paths +The same direction for both owners, and a different origin: -`Scroll` reads `scrolls_itself` every draw and branches once, on the -capability rather than on any concrete type. - -**Ordinary child (`draw_moved_child`)** — unchanged from before: offer the -child a box as long as last frame's content length to measure it, apply -the end-pin and the clamp against the measured length, then place it at -the length just measured. Two draws, the second free unless the content -changed. `amt` is a distance from the start of the content. - -**Self-positioning child (`draw_self_scrolling_child`)** — measure, apply, -place: - -1. `painter.widget_within(child, UiRegion::FULL)` — the measuring draw. -2. `painter.apply_scroll(child, &mut delta)` — the child takes what it can. -3. `painter.widget_within(child, UiRegion::FULL)` — the placing draw. -4. `amt = -painter.scroll_offset(child)`. - -Two properties make this work, and both are easy to break: - -- **The measuring draw is free in the common case.** It offers the same - box as last frame, so with nothing dirty `draw_inner` returns - immediately and the child's stored walls from its last walk are still - correct — because nothing changed. When the content *did* change the - child is dirty, really walks, and the walls are fresh, which is exactly - when they need to be. -- **Nothing is marked dirty by hand.** Reaching the child through - `get_dyn_mut` in step 2 is itself what dirties it, so step 3 really - draws rather than taking `draw_inner`'s unchanged-region skip. This is - why there is no `Painter::draw_again` and why one should not come back: - a mechanism for "give me a corrective frame later" is the thing this - shape replaces. - -### What `amt` means - -- Ordinary child: distance from the start of the content, clamped into the +- `ScrollArea`: distance from the start of the content, clamped into the scroll range. An absolute position. -- Self-positioning child: **movement, not position.** Paging rows in above - moves the origin and the child cannot say by how much, never having - measured them. The direction is the same as an ordinary child's; the - absolute value is not comparable between the two. +- `LazySpan`: **movement, not position.** Paging rows in above moves the + origin and the span cannot say by how much, never having measured them. A scrollbar needs a real content length before it can use either, and a -lazy child has none. Do not invent one. +lazy span has none. Do not invent one. + +## `ScrollArea::draw` — measure, then place + +1. `take_delta`, and move to where it asks. +2. Draw the child in a box as long as **last frame's** length, to measure + it. This is free in the common case: the same region as last frame + means `draw_inner` returns immediately. +3. Apply the pin and clamp against the length just measured. +4. Draw the child again, at that length and position. + +Only the second draw decides anything, and a frame on which the content +did change pays one real extra draw — a frame on which it was being +redrawn anyway. Placing against the hint and letting the next frame fix it +is what hung the composer's text half a line outside its box on Iris's +phone: **layout is a pure function of the state, not of how many frames +have been drawn**, and there may be no next frame. + +The pin only re-pins on a frame with **no delta of its own**: the pin +means "stay flush with the end as the content grows", and a reader who has +just scrolled away has said otherwise. ## `LazySpan` `iris/src/widget/position/lazy_span.rs`. A virtualised sequence of variable-height rows, laid out from an anchor. It is what `Span` is, done -lazily. It **does not scroll** — it lays out and answers honestly about -how far it can go. +lazily, and it drives its own controller: the walk is the only thing that +can say how far it may go, so nothing above it is in a position to. -### Why it is not `Span::scrollable()` +### Why it is not a `Span` inside a `ScrollArea` Measured 2026-09-08, and worth not re-deriving: @@ -152,27 +151,35 @@ Measured 2026-09-08, and worth not re-deriving: phase 1 offers each child the ambient region to learn its length, phase 2 offers it its real share. So any mutation of a `Span` redraws all of it — 24 draws for 11 children after one prepend. -- A `Scroll`'s efficiency and virtualisation pull opposite ways: a scroll - tick offers a same-size moved region, `draw_inner` takes the `mov` path, - and the child's `draw` never runs. A virtualising child inside a plain - `Scroll` would never update which rows it shows. That is what - `scrolls_itself` resolves. -- A lazy child cannot report a content length, so `Scroll`'s clamp, - end-pin and any future scrollbar have nothing to work against. Walls - are *discovered* by the walk instead. +- A `ScrollArea`'s efficiency and virtualisation pull opposite ways: a + scroll tick offers a same-size moved region, `draw_inner` takes the + `mov` path, and the child's `draw` never runs. A virtualising child + inside one would never update which rows it shows. That is why a + `LazySpan` owns its controller instead of being wrapped in one. +- A lazy child cannot report a content length, so an area's clamp, + end-pin and any future scrollbar would have nothing to work against. + Walls are *discovered* by the walk instead. ### Direction and pin are separate questions -`LazySpan::new(dir, at_end)`. +`LazySpan::new(dir, pin)`, and `ScrollArea::new(inner, axis, pin)`. - **`dir`** means what it means in `Span`: which end of the box item 0 sits at, and which way the sequence grows. -- **`at_end`** is the pin: which end the view clings to as rows arrive. +- **`pin`** is which end the view clings to as rows arrive. A transcript is `Dir::DOWN` (oldest message is item 0, at the top) with -the pin at the end (the view sits at the bottom). Conflating the two would -stand it on its head. `Scroll::new`'s third argument is the same flag for -the ordinary case. +`Pin::End` (the view sits at the bottom). Conflating the two would stand +it on its head. + +**`Pin` says it either way round**, because there are two questions and +they are not the same one (Iris, 2026-09-08). `Start`/`End` are +content-relative — the first row or the newest one, wherever the layout +puts it — and `Neg`/`Pos` are axis-absolute: the top/left edge and the +bottom/right one, whichever end of the content is there. They coincide for +everything except a reversed `LazySpan`, where they are exact opposites, +which is the whole reason both exist. The one question a scrollable acts +on is `pinned_to_end`, and `dir` is what resolves a `Pin` into it. ### Two coordinate spaces, two conversion points @@ -211,13 +218,22 @@ framework: Virtualisation *means* traversing rows without drawing them, and a size you can only get by drawing is no use for deciding not to draw. -### Overscroll +### Overscroll, and why it happens at all -`apply_scroll` only takes what the walk says is there, so **scrolling -cannot enter overscroll.** What it cannot prevent is the content or the -viewport changing under a settled anchor, and for that `overscroll_gap` -measures the gap from the ends the walk already placed and `draw` moves -the anchor and walks a **second time inside the same frame**. +**Because the span cannot see the wall until it has walked to it.** With +rows loaded past an edge it reports `INFINITY` of travel that way, takes +the whole delta, and the walk that follows discovers the content ran out +200px ago. Nothing else could be reported: the rows past the edge have +never been measured, and measuring them is exactly the work +virtualisation exists to skip. The other source is the content or the +viewport changing under a settled anchor — a row that grew, a page +dropped, the keyboard opening — where nothing scrolled at all. + +So `overscroll_gap` measures the gap from the ends the walk already +placed, and `draw` moves the anchor by it and walks a **second time +inside the same frame**. `moved_by` counts that correction along with the +move that caused it, which is why `amt` stays equal to what is on screen +rather than drifting by every overshoot. Layout is a pure function of the state, not of how many frames have been drawn (Iris, 2026-09-08). A correction that lands next frame is a frame @@ -228,20 +244,21 @@ asking for one. `iris/transcript-ui/src/lib.rs`, `build_tree`. -The transcript builds its `Scroll` **by hand rather than through -`.scrollable_to_end()`**, and this is not an oversight. That helper -registers a finger drag driving `Scroll`'s own `DragGesture`, and the +The transcript registers the wheel **by hand rather than calling +`LazySpan::scrollable()`**, and this is not an oversight. That helper also +registers a finger drag driving the span's own `DragGesture`, and the transcript already has an arbiter — `Selection`, which must decide between panning and selecting text and so cannot let a second `DragGesture` see the same frames. `DragGesture`'s doc states the rule: one gesture, one arbiter, each frame delivered exactly once. The wheel handler registered here is identical to the helper's; only the drag differs. -`Selection` is given the scroll area by `set_scroll_area` after the -`Scroll` exists (rows need a `Selection`, and the `Scroll` needs the -rows), and hands it committed pans and releases. `TranscriptScreen` exposes -both `list` (layout, `extent`/`key_at`/`jump_to_end`) and `scroll` -(position, fling, `amt`). +`Selection` is given the span by `set_scroll_area` after it exists (rows +need a `Selection`, and the span needs the rows), and hands it committed +pans and releases through `Scrollable::scroll`/`fling`. There is no +wrapper widget: `TranscriptScreen::list` is the layout (`extent`, +`key_at`, `jump_to_end`) *and* the position (`amt`, `fling`, +`is_scrolling`). A `Selection` with no scroll area still selects and still reports taps but cannot pan; there is a `debug_assert` in `drag` naming that. @@ -259,33 +276,22 @@ cannot pan; there is a `debug_assert` in `drag` naming that. write is moving the content as a unit, which needs a content length — which a lazy layout cannot supply. -## Still open - -**The pin lives in each widget, not in `Scroll`.** Iris asked for `amt` -and the at-end control both to live in `Scroll` so a caller always edits -the `Scroll`. `amt` does; the pin does not, because applying a pin happens -when a row is appended — between frames, with no painter in hand — so it -cannot arrive through `apply_scroll` as it stands. Moving it needs either -a fourth `Widget` method or a parameter on `apply_scroll` -(`apply_scroll(&mut self, delta: &mut f32, pinned_to_end: bool)` reads -best: one method, and the signature says "here is the state you need from -me"). Nothing external edits a pin today — the transcript sets it once at -construction and calls `jump_to_end` on the span for the rest — so this is -a design question rather than a missing capability. **Ask Iris which she -wants before building it.** - ## Tests that pin the behaviour In `lazy_span.rs`, all of these fail if the corresponding piece is undone: - `a_negative_delta_moves_toward_the_end` — the sign, end to end. -- `amt_counts_only_what_the_child_could_take` — `scroll_offset`'s reason - for existing. +- `a_delta_moves_both_directions_the_same_way_on_screen` — the sign is a + screen direction, checked against where rows were *drawn*. +- `amt_counts_only_what_the_child_could_take` — why the owner reports what + it did rather than the caller adding up what it asked for. - `a_fling_stops_at_the_first_row`, `scrolling_past_the_start_lands_on_it_in_the_same_frame` — the walls, with no settling frame drawn on purpose. - `a_dir_up_span_grows_upward_from_item_zero`, - `a_reversed_span_hit_tests_in_screen_space` — the two conversions. + `a_reversed_span_hit_tests_in_screen_space` — the position conversions + (`flip_pos`), as `a_delta_moves_both_directions_the_same_way_on_screen` + is the delta one (`flip_delta`). - `a_registered_fling_is_driven_by_tick_animations_and_then_unregisters` — a fling that nothing registers never moves, whatever its velocity. diff --git a/iris/android-app/src/bench_client.rs b/iris/android-app/src/bench_client.rs index a967cd3..f5cc9b8 100644 --- a/iris/android-app/src/bench_client.rs +++ b/iris/android-app/src/bench_client.rs @@ -914,7 +914,7 @@ where /// brings earlier content into view. It was negative here until /// 2026-09-08, when the transcript's scroll position moved out of the /// `LazySpan` -- whose anchor offset ran the other way -- and into the -/// `Scroll` around it. The two apps' *travel* is directly comparable +/// `ScrollArea` around it. The two apps' *travel* is directly comparable /// whichever way the signs run, because both report it as a row index plus /// a pixel offset rather than a signed distance. async fn run_fling_phase( @@ -938,8 +938,8 @@ async fn run_fling_phase( for _ in 0..FLING_COUNT { ctx.update(|state: &mut BenchClient, rsc| { if let Some(screen) = &state.screen { - (screen.scroll)(rsc).fling(FLING_VELOCITY_PX_S); - animate_scroll(screen.scroll, rsc); + (screen.list)(rsc).fling(FLING_VELOCITY_PX_S); + animate_scroll(screen.list, rsc); } }); redraw.request_redraw(); @@ -951,8 +951,8 @@ async fn run_fling_phase( for _ in 0..FLING_COUNT { ctx.update(|state: &mut BenchClient, rsc| { if let Some(screen) = &state.screen { - (screen.scroll)(rsc).fling(-FLING_VELOCITY_PX_S); - animate_scroll(screen.scroll, rsc); + (screen.list)(rsc).fling(-FLING_VELOCITY_PX_S); + animate_scroll(screen.list, rsc); } }); redraw.request_redraw(); @@ -978,10 +978,11 @@ async fn read_anchor_position( .await } -/// Register the scroll area with the frame loop, exactly as a finger's own -/// release does (`transcript_ui::Selection::drag`'s `Released` arm) -- -/// `Scroll::fling` sets a velocity and drives nothing by itself. -fn animate_scroll(scroll: iris::prelude::WeakWidget, rsc: &mut Rsc) { +/// Register the scrolling widget with the frame loop, exactly as a +/// finger's own release does (`transcript_ui::Selection::drag`'s +/// `Released` arm) -- `Scrollable::fling` sets a velocity and drives +/// nothing by itself. +fn animate_scroll(scroll: iris::prelude::WeakWidget, rsc: &mut Rsc) { let id = scroll.id(); rsc.ui_mut().animate(id); } @@ -992,7 +993,7 @@ fn animate_scroll(scroll: iris::prelude::WeakWidget, rsc: /// spline-decided `duration()` already caps how long it can run. /// /// **It observes; it does not drive.** Until 2026-09-08 this loop called -/// `Scroll::tick` itself every `POLL_MS`, which advanced the +/// `Scrollable::tick_fling` itself every `POLL_MS`, which advanced the /// fling in 16ms steps -- so on Iris's 120Hz phone every second frame /// redrew the list at a position it had already drawn, and the benchmark /// looked distinctly less smooth than the same list under her finger. @@ -1011,7 +1012,7 @@ async fn wait_for_fling_settle( let started = Instant::now(); while started.elapsed() < cap { let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen { - Some(screen) => (screen.scroll)(rsc).is_scrolling(), + Some(screen) => (screen.list)(rsc).is_scrolling(), None => false, }) .await; diff --git a/iris/benches/message_list.rs b/iris/benches/message_list.rs index 1996f0e..a9c9b9d 100644 --- a/iris/benches/message_list.rs +++ b/iris/benches/message_list.rs @@ -15,8 +15,8 @@ //! Per the code rules, the plain option is also the one shorter to explain. //! //! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a -//! `Scroll` over a `Span` of pre-built rows.** Earlier versions of this -//! file built their own giant `Span` and wrapped it in `Scroll`, which +//! `ScrollArea` over a `Span` of pre-built rows.** Earlier versions of this +//! file built their own giant `Span` and wrapped it in `ScrollArea`, which //! meant (a)/(b)/(c) below were measuring "move one big child," never the //! virtualised widget the app's transcript screen actually needs. `LazySpan` //! still needs every row's *widget* built up front by the caller (its @@ -113,22 +113,17 @@ fn build_message_list( rsc: &mut BenchRsc, n: usize, image_every: usize, -) -> (WeakWidget, WeakWidget, StrongWidget) { - let mut list = LazySpan::new(Dir::DOWN, true); +) -> (WeakWidget, StrongWidget) { + let mut list = LazySpan::new(Dir::DOWN, Pin::End); for i in 0..n { let row = build_row(rsc, i, image_every); list.push_back(LazyItem::new(i as u64, row)); } let list = rsc.ui.widgets.add_strong(list); - let list_weak = list.weak(); - // Scrolled through a `Scroll`, like every other scroll area in iris - // since the position moved out of the list: what this measures has to - // be the path the app actually takes. - let scroll = rsc - .ui - .widgets - .add_strong(Scroll::new(list.any(), Axis::Y, true)); - (list_weak, scroll.weak(), scroll.any()) + // Driven through the span's own `ScrollController`, like every other + // scroll area in iris: what this measures has to be the path the app + // actually takes. + (list.weak(), list.any()) } fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) { @@ -143,7 +138,7 @@ fn bench_first_frame(n: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; - let (_list, _scroll, root) = build_message_list(&mut rsc, n, 20); + let (_list, root) = build_message_list(&mut rsc, n, 20); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); @@ -161,14 +156,14 @@ fn bench_first_frame(n: usize) { } /// (b) Per-frame cost of scrolling an already-laid-out list of N rows. -/// Warms up (one no-op tick, matching `Scroll`'s own need for it before an +/// Warms up (one no-op tick, matching `ScrollArea`'s own need for it before an /// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move /// rather than a resize), then times a run of individual scroll ticks. fn bench_scroll(n: usize, ticks: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; - let (_list, scroll, root) = build_message_list(&mut rsc, n, 20); + let (scroll, root) = build_message_list(&mut rsc, n, 20); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); render.update(&root, &mut rsc); @@ -215,7 +210,7 @@ fn bench_input_grows(n: usize, lines: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; - let (_list, scroll, list_root) = build_message_list(&mut rsc, n, 20); + let (scroll, list_root) = build_message_list(&mut rsc, n, 20); let list_area = rsc.ui.widgets.add_strong(Sized { inner: list_root, x: None, @@ -285,7 +280,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; - let (list, _scroll, root) = build_message_list(&mut rsc, n, 20); + let (list, root) = build_message_list(&mut rsc, n, 20); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); render.update(&root, &mut rsc); @@ -342,7 +337,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); // Near the end (not the very last row) so it is already on screen // under the list's default bottom-anchored placement, for every N -- // no scrolling needed to bring it into view before measuring. diff --git a/iris/core/src/render/data.rs b/iris/core/src/render/data.rs index aa130fe..6583efd 100644 --- a/iris/core/src/render/data.rs +++ b/iris/core/src/render/data.rs @@ -101,7 +101,7 @@ pub struct Mask { /// One widget's cumulative on-screen translation, and the slot of the /// ancestor to add on top of it. `parent == u32::MAX` ends the chain. A /// pure abs-pixel delta, not a general `UiRegion` remap -- sufficient for -/// every call site that moves a widget (`Scroll`, `Offset`) since both are +/// every call site that moves a widget (`ScrollArea`, `Offset`) since both are /// translations of an already-drawn subtree. See LAYOUT.md section 2. /// /// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is diff --git a/iris/core/src/ui/access.rs b/iris/core/src/ui/access.rs index 87322a2..485598f 100644 --- a/iris/core/src/ui/access.rs +++ b/iris/core/src/ui/access.rs @@ -9,7 +9,7 @@ //! The tree itself is deliberately flat -- one synthetic `Role::Window` //! root with every named widget as a direct child, in no particular order. //! iris's actual widget nesting (a label three `Span`s deep inside a -//! `Scroll`) carries no accessibility meaning of its own here: nothing +//! `ScrollArea`) carries no accessibility meaning of its own here: nothing //! upstream of a named leaf needs a node, since a screen reader's own //! traversal (and uiautomator's tap-by-name, the pass condition this was //! built for) works from each node's on-screen bounds rather than from diff --git a/iris/core/src/ui/painter.rs b/iris/core/src/ui/painter.rs index 3077a76..3aba7d3 100644 --- a/iris/core/src/ui/painter.rs +++ b/iris/core/src/ui/painter.rs @@ -166,43 +166,6 @@ impl<'a> Painter<'a> { self.mask = self.own_mask; } - /// Ask a child whether it positions its own content - /// ([`Widget::scrolls_itself`]) -- read through `get_dyn`, which does - /// **not** mark it dirty, which is the whole reason this is a separate - /// question from [`Self::apply_scroll`] rather than something that - /// falls out of calling it. `false` for a child that has gone. - pub fn scrolls_itself(&self, id: &StrongWidget) -> 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(&mut self, id: &StrongWidget, 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(&self, id: &StrongWidget) -> f32 { - self.rsc - .widgets() - .get_dyn(id.id()) - .map_or(0.0, |w| w.scroll_offset()) - } - /// Whether anything is clipping what this widget draws -- its own /// [`Self::set_mask`], or one an ancestor set that it inherited. What /// a widget whose contents may legitimately extend past its own box diff --git a/iris/core/src/widget/mod.rs b/iris/core/src/widget/mod.rs index ecfa790..9b180bd 100644 --- a/iris/core/src/widget/mod.rs +++ b/iris/core/src/widget/mod.rs @@ -60,76 +60,6 @@ pub trait Widget: Any { fn tick(&mut self, now: std::time::Instant) -> bool { false } - - /// Whether this widget positions its own content and should be handed - /// scroll deltas ([`Self::apply_scroll`]) instead of being moved by - /// the `Scroll` around it. Default `false`: an ordinary child is a - /// fixed lump its parent slides about, which is what makes a scroll - /// tick an O(1) move of one subtree rather than a redraw. - /// - /// A lazy layout has to answer `true`, because the two halves of a - /// scroll are not separable for it: which children exist at all is a - /// function of where it is scrolled to, so it cannot be a lump, and it - /// cannot report a content length for the parent to clamp against - /// either -- it has never measured the rows it has not drawn. - /// - /// **`&self` on purpose.** Reaching a widget through - /// `Widgets::get_dyn_mut` marks it dirty, so asking the question - /// through [`Self::apply_scroll`] would dirty every ordinary child on - /// every scroll tick and cost exactly the redraw the move path exists - /// to avoid. This is read through `get_dyn`, which does not mark. - fn scrolls_itself(&self) -> bool { - false - } - - /// Take as much of `delta` as this widget can actually move, and leave - /// the rest in it. Only called on a widget whose - /// [`Self::scrolls_itself`] is `true`. - /// - /// The sign is `Scroll::scroll`'s, and it is a **screen** direction - /// rather than a logical one: positive scrolls the reader up or left, - /// negative down or right, whichever way this widget's own content - /// happens to be laid out (Iris, 2026-09-08). A convention phrased as - /// "positive brings earlier content into view" points the opposite - /// way for a widget laid out backwards, so the same delta would pan - /// one list up and another down. - /// - /// What is left behind is how the caller learns it reached a wall -- - /// asked for 300, got 50 back means the content ran out 250 short -- - /// which is all a fling needs to know to stop, and all a pin needs to - /// know to re-pin. There is deliberately nothing here reporting an - /// absolute position: a lazy layout's origin moves when content is - /// loaded above it, so any such number would be a fiction. - /// - /// Called between the two draws of `Scroll::draw`, so the walls this - /// answers against were measured by the first of them. - #[allow(unused_variables)] - fn apply_scroll(&mut self, delta: &mut f32) {} - - /// How far this widget has moved its own content in total, in - /// [`Self::apply_scroll`]'s direction convention -- for a parent - /// keeping an account of where a self-positioning child has got to. - /// - /// **Why this exists and `apply_scroll`'s remainder is not enough.** - /// A lazy layout usually cannot say where its content ends until it - /// has walked there, so `apply_scroll` takes a delta in full whenever - /// the wall is not already in view, and the wall is found by the walk - /// that follows -- which gives some of it back. The remainder is - /// therefore right only when the wall was already visible, and a - /// parent adding remainders up would over-count by every overshoot - /// and never correct. Read after the child has been placed, this is - /// what actually happened. - /// - /// `&self`, so asking does not mark the child dirty - /// ([`Self::scrolls_itself`] has the reasoning). - /// - /// Counts scrolling only: a jump straight to an item is not travel - /// across the content and does not appear here, because for a layout - /// whose origin moves as content is paged in there is no distance - /// between the two positions to report. - fn scroll_offset(&self) -> f32 { - 0.0 - } } impl Widget for () { diff --git a/iris/examples/bench_images.rs b/iris/examples/bench_images.rs index 6b18be6..0ab403f 100644 --- a/iris/examples/bench_images.rs +++ b/iris/examples/bench_images.rs @@ -54,7 +54,7 @@ impl DefaultAppState for State { let root = rsc .ui .widgets - .add_strong(Scroll::new(span.any(), Axis::Y, true)); + .add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::End)); ui_state.set_root(root.any()); Self { ui_state, diff --git a/iris/examples/message_list.rs b/iris/examples/message_list.rs index a1f22c2..4b63859 100644 --- a/iris/examples/message_list.rs +++ b/iris/examples/message_list.rs @@ -98,18 +98,20 @@ impl DefaultAppState for State { rsc: &mut DefaultRsc, _: Proxy, ) -> Self { - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); for i in 0..ROWS { let row = build_row(rsc, i); list.push_back(LazyItem::new(i as u64, row)); } - // `.scrollable_to_end`, like anything else that scrolls: the - // wheel and the drag are the `Scroll`'s, and the list only lays - // out. Masked outside it, since a `LazySpan` draws the row - // straddling each edge in full and asserts something clips it. + // `.scrollable()`, like anything else that scrolls -- here the + // span's own inherent one, which registers the wheel and the drag + // against the controller it already owns rather than wrapping it + // in a `ScrollArea`. Masked outside it, since a `LazySpan` draws + // the row straddling each edge in full and asserts something clips + // it. let root = list - .scrollable_to_end(Axis::Y) + .scrollable() .masked() .background(rect(Color::WHITE)) .add_strong(rsc); diff --git a/iris/src/attr.rs b/iris/src/attr.rs index 5f7a4dd..c925007 100644 --- a/iris/src/attr.rs +++ b/iris/src/attr.rs @@ -146,7 +146,7 @@ fn on_press( // vertical drag still is not a selection. Android's own `EditText` // scrolls its overflowed text on a vertical drag and starts a // selection only from a long press; a scroll area wrapping this - // field (`Scroll::drag`) is what actually pans, and it needs the + // field (`ScrollController::drag`) is what actually pans, and it needs the // first frames of the gesture not to have selected anything behind // it before it crosses `DRAG_SLOP` and takes pointer capture. // `press_origin` carries the same meaning here as in the unfocused diff --git a/iris/src/harness.rs b/iris/src/harness.rs index 9da9604..c34050b 100644 --- a/iris/src/harness.rs +++ b/iris/src/harness.rs @@ -335,7 +335,7 @@ impl Harness { } /// The `Instant` this harness means by `t_ms`. Public because a - /// caller driving `Scroll::tick` or `DragGesture` by hand needs + /// caller driving `ScrollController::tick` or `DragGesture` by hand needs /// to date those calls on the same clock the touch samples use. pub fn at(&self, t_ms: u64) -> Instant { self.base + Duration::from_millis(t_ms) @@ -371,7 +371,7 @@ impl Harness { /// Frames every `step_ms` up to and including `end_ms` -- what a /// fling needs, since it moves only while something ticks it - /// (`Scroll::fling`'s doc). Returns the time of the last frame run. + /// (`ScrollController::fling`'s doc). Returns the time of the last frame run. pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 { debug_assert!(step_ms > 0, "a frame loop with no step never ends"); let mut t = from_ms; diff --git a/iris/src/layout_tests.rs b/iris/src/layout_tests.rs index bafc923..2f3a1fe 100644 --- a/iris/src/layout_tests.rs +++ b/iris/src/layout_tests.rs @@ -23,7 +23,7 @@ impl UiRsc for TestRsc { } } -/// A `Scroll` over a `Span` of `n` fixed-height rects -- N primitives large +/// A `ScrollArea` over a `Span` of `n` fixed-height rects -- N primitives large /// enough that an O(N) regression in the move path would show up as a /// non-trivial counter rather than being lost in noise (LAYOUT.md section /// 8, condition 3, using rects rather than glyphs to avoid pulling the font @@ -33,7 +33,7 @@ impl UiRsc for TestRsc { fn scrolled_rects( rsc: &mut TestRsc, n: usize, -) -> (WeakWidget, StrongWidget, Vec>) { +) -> (WeakWidget, StrongWidget, Vec>) { let mut span = Span::empty(Dir::DOWN); let mut rects = Vec::with_capacity(n); for _ in 0..n { @@ -57,11 +57,11 @@ fn scrolled_rects( // the top and states its sign convention against that. An // end-anchored area now sits at its end from its first drawn // frame (`Scroll::draw` measures and places in the same frame), - // so `at_end: true` here would mean scrolling down from a + // so `Pin::End` here would mean scrolling down from a // position that is already the bottom -- a clamped no-op, which // reads as "the move path is broken" rather than as the test // starting somewhere it did not mean to. - .add_strong(Scroll::new(span.any(), Axis::Y, false)); + .add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::Start)); let weak = scroll.weak(); (weak, scroll.any(), rects) } @@ -76,7 +76,7 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() { render.resize((800.0, 20000.0)); render.update(&root, &mut rsc); - // Two, not one: the first offers `Scroll`'s content the container's + // Two, not one: the first offers `ScrollArea`'s content the container's // own length as a placeholder (nothing has been measured yet) and // `Scroll::draw` asks to be drawn again once it knows the real one, // which the second update is. Only after that is the tree settled -- @@ -99,12 +99,12 @@ fn scrolling_moves_in_o1_without_a_redraw() { let mut render = UiRenderState::new(); render.resize((800.0, 600.0)); - // The first draw offers `Scroll`'s content a zero-height region + // The first draw offers `ScrollArea`'s content a zero-height region // (nothing has been measured yet) and learns the real content length // from what comes back; `update()` only redraws widgets actually // marked dirty, so that corrected length is not reflected in the // content's own *active* region until something -- here a no-op - // scroll tick -- actually asks `Scroll` to redraw again. Only after + // scroll tick -- actually asks `ScrollArea` to redraw again. Only after // that warm-up does the content's offered size stop changing between // draws, which is what makes a further, real scroll tick a same-size // move instead of a resize. See scroll.rs. @@ -123,7 +123,7 @@ fn scrolling_moves_in_o1_without_a_redraw() { // The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and // 1 move_offsets write, independent of how many rects are in the - // scrolled subtree. `draws` here is exactly 1: `Scroll` itself is + // scrolled subtree. `draws` here is exactly 1: `ScrollArea` itself is // marked dirty by `scroll()` and its own body is cheap arithmetic with // no primitives of its own, so it is the one real `Widget::draw` this // counts -- the 500 rects underneath move via the O(1) chain and are @@ -324,7 +324,7 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() { ); } -/// `Scroll` used to be documented as resolving its own lengths against +/// `ScrollArea` used to be documented as resolving its own lengths against /// `Painter::output_size` -- the window -- which read as if a scroll area /// smaller than the screen could not work, and cost a session's /// investigation before the composer was wired up (docs/RUST.md, @@ -347,7 +347,7 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() { .widgets // Start-anchored, so the `scroll(-37.0)` below has somewhere to // go -- see `scrolled_rects`' note on the same choice. - .add_strong(Scroll::new(tall.any(), Axis::Y, false)); + .add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start)); let scroll_w = scroll.weak(); let scroll_id = scroll.id(); let capped = rsc.ui.widgets.add_strong(MaxSize { @@ -382,8 +382,11 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() { ); // Panning is bounded by content minus *container*: 900, not the 400 - // a 600px window would give. + // a 600px window would give. The draw is what spends the delta -- a + // controller banks it until the layout that knows where the content + // ends (`ScrollController::take_delta`). rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0); + render.update(&root, &mut rsc); assert!( (rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01, "amt={}", @@ -392,7 +395,7 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() { } /// The half `hit_testing_follows_a_scrolled_widget` could not see: it -/// checks a *descendant* of the widget `Scroll` actually moves, whose own +/// checks a *descendant* of the widget `ScrollArea` actually moves, whose own /// `region` is stale and is corrected entirely by the move chain. The /// moved widget itself had its `region` updated *and* the chain delta /// added on top, so its hit box sat at twice the pan -- which is why a @@ -415,7 +418,7 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() { .widgets // Start-anchored, so the `scroll(-37.0)` below has somewhere to // go -- see `scrolled_rects`' note on the same choice. - .add_strong(Scroll::new(tall.any(), Axis::Y, false)); + .add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start)); let scroll_w = scroll.weak(); let root = scroll.any(); @@ -500,7 +503,7 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() { /// places a child using the `abs`/`rel` of the length it reported, so a /// `MaxSize` handing back the caller's own `dp(168)` gave the composer's /// bar a slot of **zero** the moment its content grew past six lines -- -/// and the `Scroll` inside then measured its container at -63px (the +/// and the `ScrollArea` inside then measured its container at -63px (the /// padding, subtracted from nothing) and panned the whole message out of /// view. Measured on this checkout's emulator, 2026-09-06: /// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`. @@ -948,7 +951,7 @@ fn a_plain_mask_still_clips_to_a_square_box() { /// middle of a word (`iris/run-headless.sh phone`, 2026-09-08). #[test] fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() { - for (name, at_end, want) in [("read", false, 0.0), ("written", true, 4900.0)] { + for (name, pin, want) in [("read", Pin::Start, 0.0), ("written", Pin::End, 4900.0)] { let mut rsc = TestRsc { ui: UiData::default(), }; @@ -961,7 +964,7 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() { let scroll = rsc .ui .widgets - .add_strong(Scroll::new(tall.any(), Axis::Y, at_end)); + .add_strong(ScrollArea::new(tall.any(), Axis::Y, pin)); let weak = scroll.weak(); let root = scroll.any(); @@ -1041,7 +1044,7 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() { dir: Dir::DOWN, gap: Len::ZERO, }); - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); list.push_back(LazyItem::new(0, outer.any())); let list = rsc.ui.widgets.add_strong(list); let root = rsc diff --git a/iris/src/sense.rs b/iris/src/sense.rs index 895caa6..9c628d5 100644 --- a/iris/src/sense.rs +++ b/iris/src/sense.rs @@ -100,7 +100,7 @@ impl CursorSense { /// and [`Self::Cancel`] if somebody else captured it first. /// /// One function rather than a set spelled out per call site, because - /// the two terminal senses are exactly what gets forgotten: a `Scroll` + /// the two terminal senses are exactly what gets forgotten: a `ScrollArea` /// registered `click_or_drag | unclick` and so never saw the end of /// any gesture it had captured, which left its arbiter panning from a /// stale position and made the *next* drag jump by the distance @@ -651,7 +651,7 @@ pub fn should_run( // sideways is left `hover == On` (the capture branch above returns // before the loop that would have updated it), so the *next* touch // down anywhere on the screen decayed it to `End`, ran the fence's - // `Scroll::drag` with a `PressStart`, and -- the press being a catch + // `ScrollController::drag` with a `PressStart`, and -- the press being a catch // of its own fling, which commits with no slop -- captured the whole // gesture 500px away from the fence. The list under the finger moved // by nothing at all. @@ -924,7 +924,7 @@ pub struct PressState { /// instead of waiting for a long-press. pub already_selected: bool, /// Whether the target was already moving under its own momentum (a - /// `LazySpan` with a fling in flight, `Scroll::is_scrolling`). See + /// `LazySpan` with a fling in flight, `Scrollable::is_scrolling`). See /// [`DragArbiter::press_start`]: a press on moving content is a catch, /// and catches skip the slop entirely. pub scrolling: bool, @@ -1125,7 +1125,7 @@ impl DragArbiter { /// Whether the arbiter's current gesture (if any) has committed to /// panning -- what a caller checks at release time to decide whether - /// to hand the tracked velocity to [`crate::widget::Scroll::fling`], per + /// to hand the tracked velocity to [`crate::widget::ScrollController::fling`], per /// IRIS_TODO.md's "swiping has no momentum": a fling must only follow /// a pan, never a text selection that happened to end with the finger /// still moving. @@ -1158,7 +1158,7 @@ impl DragArbiter { pub enum GestureOutcome { Undecided, /// Same units and sign as [`DragOutcome::Pan`] -- the caller's own - /// convention (`Scroll::scroll`'s, for a transcript) to apply. + /// convention (`ScrollController::scroll`'s, for a transcript) to apply. Pan(f32), SelectStart, SelectExtend, @@ -1173,7 +1173,7 @@ pub enum GestureOutcome { /// The drag ended -- `PressEnd` or the capture's own terminal `Drop`. /// `Some(velocity)` only if the gesture had committed to panning /// (never a tap, a long-press selection, or one still `Undecided`); - /// same units as `Pan`, so a caller hands it to `Scroll::fling` with + /// same units as `Pan`, so a caller hands it to `ScrollController::fling` with /// whatever sign flip it already applies to `Pan`. Released(Option), /// Another widget took the pointer (`CursorSense::Cancel`), so this @@ -1478,9 +1478,9 @@ const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1; /// (`DragGestureNode.sendDragStopped` passes /// `LocalViewConfiguration.maximumFlingVelocity` into /// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it -/// in [`crate::widget::Scroll::fling`] instead, because that is the only +/// in [`crate::widget::ScrollController::fling`] instead, because that is the only /// place that knows the density this has to be multiplied by. There is -/// deliberately **no** matching minimum: see `Scroll::fling`. +/// deliberately **no** matching minimum: see `ScrollController::fling`. pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0; /// Estimates a drag's speed along one axis the way Compose's touch @@ -1608,7 +1608,7 @@ impl VelocityTracker { /// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then /// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum /// itself is applied by the caller that knows the density - /// ([`crate::widget::Scroll::fling`]). + /// ([`crate::widget::ScrollController::fling`]). /// /// `0.0` with fewer than [`MIN_SAMPLE_SIZE`] usable samples, which is /// Compose's answer too: a press and a single move carry no curve to @@ -1655,7 +1655,7 @@ impl VelocityTracker { // `calculateVelocity(maximumVelocity)`'s first branch, kept as the // outer guard even though the degenerate case is now detected // rather than clamped: a fit can still overflow on inputs nothing - // here has produced, and `Scroll::fling` asserts finiteness. + // here has produced, and `ScrollController::fling` asserts finiteness. if velocity.is_finite() { velocity } else { 0.0 } } } @@ -1876,7 +1876,7 @@ const FLING_FRICTION: f32 = 0.015; /// small, which put an `ln` of a 56x-too-large ratio through /// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds** /// instead of 1.6. Nothing could see it while a finger fling never -/// animated at all (`Scroll::fling`'s doc), which is why two defects had to +/// animated at all (`ScrollController::fling`'s doc), which is why two defects had to /// be fixed before either was visible. const FLING_TUNING: f32 = 0.84; fn deceleration_rate() -> f32 { @@ -1896,7 +1896,7 @@ const GRAVITY_EARTH: f32 = 9.80665; /// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the /// wrong density changes how long a fling lasts exponentially rather than /// scaling it. An earlier version of this comment claimed the opposite and -/// `Scroll::fling` passed `1.0`; on a 2.75-density screen that gave a +/// `ScrollController::fling` passed `1.0`; on a 2.75-density screen that gave a /// one-second flick a 45-second coast (measured 2026-09-07). `LazySpan` reads /// its density from the painter now. pub struct FlingCalculator { @@ -1919,7 +1919,7 @@ impl FlingCalculator { /// Total signed distance the fling travels before settling, in the /// same pixel units `velocity` was given in. pub fn distance(&self, velocity: f32) -> f32 { - // See `Scroll::fling`'s matching assertion -- a non-finite velocity + // See `ScrollController::fling`'s matching assertion -- a non-finite velocity // here silently produces a NaN distance rather than surfacing the // bug that produced it (docs/REVIEW-2026-09-06.md finding 3). debug_assert!(velocity.is_finite()); @@ -1946,7 +1946,7 @@ impl FlingCalculator { } /// The signed distance covered by `elapsed` into a fling of this - /// `velocity` -- what a per-frame ticker (`Scroll::tick`) calls to + /// `velocity` -- what a per-frame ticker (`ScrollController::tick`) calls to /// find how far to have scrolled by now. Clamped to the full /// `distance()` once `elapsed` reaches `duration()`, so a caller need /// not special-case "past the end." @@ -1964,7 +1964,7 @@ impl FlingCalculator { /// `FlingInfo.velocity`. It falls from roughly `velocity` at the start /// to zero at `duration()`, which is the whole difference between a /// fling and a constant-speed slide, so it is what - /// `Scroll::tick`'s debug line reports: successive frames printing + /// `ScrollController::tick`'s debug line reports: successive frames printing /// a shrinking number is the evidence that the curve is being followed /// at all. pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 { @@ -1985,7 +1985,7 @@ impl FlingCalculator { /// It owns the curve and the clock and nothing else. Which way a positive /// delta moves the content, and whether the content has anywhere left to /// go, are the caller's -- a `LazySpan` scrolls its anchor one way and a -/// `Scroll` moves its `amt` the other, and a `Flinger` that tried to know +/// `ScrollArea` moves its `amt` the other, and a `Flinger` that tried to know /// which would have to be told, which is the same thing as not knowing. /// So a caller applies [`Self::tick`]'s delta in its own convention and /// calls [`Self::stop`] when it runs out of content. @@ -2346,7 +2346,7 @@ mod fling_calculator_tests { /// Summing the spline's own per-frame position deltas across the /// whole fling has to land within 1% of the closed-form `distance()` - /// -- this is the guarantee that `Scroll::tick`'s per-frame reads + /// -- this is the guarantee that `ScrollController::tick`'s per-frame reads /// of `position_at` actually add up to the total the fling promised, /// not merely that the two formulas look plausible independently. #[test] diff --git a/iris/src/sense_tests.rs b/iris/src/sense_tests.rs index 2131d98..357761d 100644 --- a/iris/src/sense_tests.rs +++ b/iris/src/sense_tests.rs @@ -1,5 +1,5 @@ //! IRIS_TODO.md's "Input does not fall through by input type": a widget -//! that only registered `click()` used to also block a `Scroll` meant for +//! that only registered `click()` used to also block a `ScrollArea` meant for //! whatever is behind it, because `run_sensors` decided "consumed, stop //! looking at lower layers" from mere hover, not from anything actually //! matching. Exercised as a plain unit test for the same reason @@ -104,6 +104,7 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() { let mut scroll_cursor = cursor_at((50.0, 50.0).into()); scroll_cursor.scroll_delta = (0.0, 10.0).into(); render.run_sensors(&mut rsc, &mut state, scroll_cursor, (100.0, 100.0).into()); + render.update(&root, &mut rsc); assert!( scrolled.get(), @@ -117,6 +118,7 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() { let mut click_cursor = cursor_at((50.0, 50.0).into()); click_cursor.buttons.left = ActivationState::Start; render.run_sensors(&mut rsc, &mut state, click_cursor, (100.0, 100.0).into()); + render.update(&root, &mut rsc); assert!( clicked.get(), @@ -174,6 +176,7 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() { let mut press = cursor_at((5.0, 5.0).into()); press.buttons.left = ActivationState::Start; render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into()); + render.update(&draggable, &mut rsc); assert_eq!( pointer_input(&mut rsc).holder(), Some(draggable.id()), @@ -185,6 +188,7 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() { let mut release = cursor_at((95.0, 95.0).into()); release.buttons.left = ActivationState::End; render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into()); + render.update(&draggable, &mut rsc); assert!( dropped.get(), @@ -241,6 +245,7 @@ fn capturing_one_widget_starves_every_other_widget_of_events() { let mut state = (); let cursor = cursor_at((50.0, 50.0).into()); render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into()); + render.update(&root, &mut rsc); assert!( !b_hovered.get(), @@ -248,11 +253,11 @@ fn capturing_one_widget_starves_every_other_widget_of_events() { ); } -/// IRIS_TODO.md's "the composer has no touch-drag scroll": `Scroll` only +/// IRIS_TODO.md's "the composer has no touch-drag scroll": `ScrollArea` only /// answered a wheel, so a finger drag over overflowed text did nothing. /// End-to-end over the real wiring -- `scrollable()`'s own registration, -/// `run_sensors`' dispatch, `Scroll::drag`, `DragGesture`'s arbitration and -/// pointer capture -- rather than only `Scroll::drag`'s own unit tests in +/// `run_sensors`' dispatch, `ScrollController::drag`, `DragGesture`'s arbitration and +/// pointer capture -- rather than only `ScrollController::drag`'s own unit tests in /// `scroll.rs`, because the registration is exactly the half those cannot /// see. #[test] @@ -265,7 +270,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() { // 1000px of content in a 100px window: room to pan. let scroll_strong = rect(UiColor::WHITE) .height(Len::abs(1000.0)) - .scrollable() + .scrollable(Axis::Y, Pin::Start) .add_strong(&mut rsc); let scroll = scroll_strong.weak(); let root = scroll_strong.any(); @@ -273,7 +278,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() { let mut render = UiRenderState::new(); render.resize((100.0, 100.0)); render.update(&root, &mut rsc); - // `Scroll` reads its content length back from the draw it just did, so + // `ScrollArea` reads its content length back from the draw it just did, so // the frame after is the first one that knows there is anything to pan // -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is // how `layout_tests.rs` asks for that second frame, and it also drops @@ -286,6 +291,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() { let mut down = cursor_at((50.0, 80.0).into()); down.buttons.left = ActivationState::Start; render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into()); + render.update(&root, &mut rsc); assert_eq!( rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0, @@ -296,6 +302,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() { let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into()); nudge.buttons.left = ActivationState::On; render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into()); + render.update(&root, &mut rsc); assert_eq!( rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0, @@ -307,6 +314,7 @@ fn a_finger_drag_over_a_scroll_area_pans_it() { let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into()); drag.buttons.left = ActivationState::On; render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into()); + render.update(&root, &mut rsc); let after = rsc.ui.widgets.get(&scroll).unwrap().amt(); assert!( (after - 40.0).abs() < 0.01, @@ -368,8 +376,8 @@ fn the_clock_orders_samples_across_events() { /// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to /// some position when horizontally scrolling." /// -/// A `Scroll` that has committed to a pan holds the pointer, so the -/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable_on` +/// A `ScrollArea` that has committed to a pan holds the pointer, so the +/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable` /// used to register `click_or_drag | unclick` only, which `should_run` /// never matches a `Drop` against. So the widget never learned its own /// gesture had ended: its `DragArbiter` stayed `Panning` at the position @@ -385,7 +393,7 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() { }; let scroll_strong = rect(UiColor::WHITE) .height(Len::abs(1000.0)) - .scrollable() + .scrollable(Axis::Y, Pin::Start) .add_strong(&mut rsc); let scroll = scroll_strong.weak(); let root = scroll_strong.any(); @@ -398,24 +406,25 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() { let mut state = (); let win = Vec2::new(100.0, 100.0); - let mut send = |render: &UiRenderState, rsc: &mut SenseRsc, y: f32, button| { + let mut send = |render: &mut UiRenderState, rsc: &mut SenseRsc, y: f32, button| { let mut c = cursor_at((50.0, y).into()); c.buttons.left = button; render.run_sensors(rsc, &mut state, c, win); + render.update(&root, rsc); }; // One pan of 40px past the slop, then a release well outside the // widget -- the ordinary shape of a flick. - send(&render, &mut rsc, 80.0, ActivationState::Start); + send(&mut render, &mut rsc, 80.0, ActivationState::Start); send( - &render, + &mut render, &mut rsc, 80.0 - (DRAG_SLOP + 40.0), ActivationState::On, ); let after_first = rsc.ui.widgets.get(&scroll).unwrap().amt(); assert!((after_first - 40.0).abs() < 0.01, "amt={after_first}"); - send(&render, &mut rsc, 400.0, ActivationState::End); + send(&mut render, &mut rsc, 400.0, ActivationState::End); assert_eq!( pointer_input(&mut rsc).holder(), None, @@ -425,7 +434,7 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() { // A second gesture, starting where the first one did. If the arbiter // were still panning from the release position, this first frame // would apply the whole distance between the two at once. - send(&render, &mut rsc, 80.0, ActivationState::Start); + send(&mut render, &mut rsc, 80.0, ActivationState::Start); let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt(); assert!( (after_second - after_first).abs() < 0.01, @@ -503,11 +512,13 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() { let mut down = cursor_at((50.0, 50.0).into()); down.buttons.left = ActivationState::Start; render.run_sensors(&mut rsc, &mut state, down, win); + render.update(&root, &mut rsc); assert_eq!(cancelled.get(), 0, "nothing has captured yet"); let mut moved = cursor_at((50.0, 20.0).into()); moved.buttons.left = ActivationState::On; render.run_sensors(&mut rsc, &mut state, moved, win); + render.update(&root, &mut rsc); assert!(capturer_saw.get() > 0, "the capturer never saw the press"); assert_eq!( pointer_input(&mut rsc).holder(), @@ -525,9 +536,11 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() { let mut more = cursor_at((50.0, 10.0).into()); more.buttons.left = ActivationState::On; render.run_sensors(&mut rsc, &mut state, more, win); + render.update(&root, &mut rsc); let mut up = cursor_at((50.0, 10.0).into()); up.buttons.left = ActivationState::End; render.run_sensors(&mut rsc, &mut state, up, win); + render.update(&root, &mut rsc); assert_eq!(cancelled.get(), 1, "cancelled more than once"); assert_eq!( ended.get(), @@ -569,15 +582,15 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() { let outer_strong = rect(UiColor::WHITE) .width(Len::abs(1000.0)) .height(Len::abs(1000.0)) - .scrollable_on(Axis::X) + .scrollable(Axis::X, Pin::Start) // The inner area's own handle, taken as the chain is built -- - // the whole point is to exercise `scrollable_on`'s real + // the whole point is to exercise `scrollable`'s real // registration on both, so neither is assembled by hand. .with_id(move |_rsc, id| { record.set(Some(id)); id }) - .scrollable() + .scrollable(Axis::Y, Pin::Start) .add_strong(&mut rsc); let inner = seen.get().unwrap(); let outer = outer_strong.weak(); @@ -599,9 +612,11 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() { let mut down = cursor_at((50.0, 80.0).into()); down.buttons.left = ActivationState::Start; render.run_sensors(&mut rsc, &mut state, down, win); + render.update(&root, &mut rsc); let mut drag = cursor_at(to); drag.buttons.left = ActivationState::On; render.run_sensors(&mut rsc, &mut state, drag, win); + render.update(&root, &mut rsc); let moved = rsc.ui.widgets.get(&areas[pans]).unwrap().amt(); let unmoved = rsc.ui.widgets.get(&areas[still]).unwrap().amt(); @@ -628,7 +643,7 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() { /// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is /// not `Off`) -- and `should_run` derived a press from the button alone, /// so that farewell frame also carried a `PressStart`. A widget nowhere -/// near the finger therefore opened a gesture, and a `Scroll` catching +/// near the finger therefore opened a gesture, and a `ScrollArea` catching /// its own fling commits with no slop, so it captured the pointer and the /// whole gesture went to it. /// @@ -646,13 +661,13 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { // is the first, the bottom half the second. Each area's own handle is // taken as its chain is built (`with_id`, the same way the nested-axes // test above does it), since what is under test is `scrollable()`'s - // real registration rather than a `Scroll` assembled by hand. - let seen: [Rc>>>; 2] = Default::default(); - let half = |slot: &Rc>>>| { + // real registration rather than a `ScrollArea` assembled by hand. + let seen: [Rc>>>; 2] = Default::default(); + let half = |slot: &Rc>>>| { let record = slot.clone(); rect(UiColor::WHITE) .height(Len::abs(1000.0)) - .scrollable() + .scrollable(Axis::Y, Pin::Start) .with_id(move |_rsc, id| { record.set(Some(id)); id @@ -694,6 +709,7 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { c.buttons.left = button; c.time = base + std::time::Duration::from_millis(at_ms); render.run_sensors(rsc, state, c, win); + render.update(&root, rsc); }; sample( &mut render, diff --git a/iris/src/widget/position/lazy_span.rs b/iris/src/widget/position/lazy_span.rs index 445377a..a04e134 100644 --- a/iris/src/widget/position/lazy_span.rs +++ b/iris/src/widget/position/lazy_span.rs @@ -1,7 +1,7 @@ //! `LazySpan`: a virtualised span of variable-height rows, laid out from //! an anchor rather than eagerly like `Span`. //! -//! **`docs/SCROLL.md` is the overview** -- how this widget and `Scroll` +//! **`docs/SCROLL.md` is the overview** -- how this widget and `ScrollArea` //! divide the work, the one sign convention, the `Widget` handoff, and //! what is still open. Read it first; this file is the detail. //! RUST.md's I3. Read LAYOUT.md first -- this widget is built entirely out @@ -109,31 +109,39 @@ //! that "whatever lies between" stays empty however far the list is //! panned. //! -//! **This widget does not scroll; it is scrolled.** There is no fling -//! here, no gesture, no scroll position and no redraw handle -- a -//! `Scroll` around it owns all of that, hands it deltas through -//! [`Widget::apply_scroll`], and reads back what it managed to move -//! ([`Widget::scroll_offset`]). What is left here is the layout: an -//! anchor, a walk outward from it, and an honest answer about how far it -//! can go. So `.scrollable_to_end()` is how a `LazySpan` is scrolled, -//! which is how everything else in iris is scrolled too. +//! **This widget scrolls itself, and everything that is not its layout +//! lives in a [`ScrollController`] it owns** -- the position, the gesture, +//! the fling and the pin, the same struct a `ScrollArea` holds +//! (`docs/SCROLL.md`). It is not wrapped in one of those and must not be: +//! a scroll tick offers a moved region of the same size, `draw_inner` +//! takes the `mov` path, and a virtualising child inside it would never +//! update which rows it shows. `.scrollable()` here is the span's own +//! inherent one, registering the wheel and the drag against that +//! controller. +//! +//! What is left in this file is the layout: an anchor, a walk outward +//! from it, and an honest answer about how far it can go +//! ([`Self::travel`]). //! //! **Overscroll cannot be entered by scrolling, and is taken back within -//! the frame when something else causes it.** `apply_scroll` only ever -//! takes as much as the walk says is there, so a delta that runs off the -//! end simply comes back short. What it cannot prevent is the content or -//! the viewport changing under a settled anchor, and for that +//! the frame when something else causes it.** The controller clamps a +//! delta to the travel the last walk reported, so a delta that runs off a +//! wall already in view is simply cut short. What that cannot cover is a +//! wall this span has not walked to yet -- with rows loaded past an edge +//! there is no bound to report -- or the content or viewport changing +//! under a settled anchor, and for both //! `overscroll_gap` measures the gap from the ends the walk already //! placed and `draw` moves the anchor by it and walks a second time //! **before the frame ends** -- layout is a pure function of the state, //! not of how many frames have been drawn (Iris, 2026-09-08), the same -//! rule `Scroll::draw` follows. A span shorter than its viewport is not +//! rule `ScrollArea::draw` follows. A span shorter than its viewport is not //! overscrolled and is left alone, still pinned to the end it was built //! with. use crate::prelude::*; use iris_core::util::HashMap; use std::collections::VecDeque; +use std::time::Instant; /// A stable identifier for a loaded row, reused across pages so that a row /// already measured and drawn is not treated as new when data is inserted @@ -246,11 +254,13 @@ pub struct LazySpan { more_before: Option, more_after: Option, anchor: Option, - /// Whether the anchor currently sits flush against the true end of the - /// content (last item or `more_after`, bottom edge at the viewport's - /// own bottom) -- if so, appending a new row keeps it pinned there, - /// mirroring `Scroll::snap_end`. - snap_end: bool, + /// The position, the gesture, the fling and the pin -- everything + /// about scrolling that is not this widget's own layout, in the same + /// struct a `ScrollArea` holds rather than a protocol between the two + /// (`docs/SCROLL.md`). This widget's `draw` is one instance of the + /// contract in [`ScrollController`]'s module doc: take the delta, + /// lay out, report what it did and how far it can still go. + ctl: ScrollController, viewport_len: f32, /// `viewport_len` as of the *previous* draw -- what `repair_anchor` /// compares against to tell "the container was actually resized" from @@ -279,17 +289,12 @@ pub struct LazySpan { /// The mirror of `at_start` for the trailing end. at_end: bool, /// The extreme edges the last walk reached, in the walk's own - /// direction-relative pixels, kept so [`Widget::apply_scroll`] can say + /// direction-relative pixels, kept so [`Self::travel`] can say /// **exactly** how much of a delta this span is able to take rather /// than only whether it is against a wall: with no more content past /// an edge, the travel left in that direction is the distance from - /// that edge to the viewport's. `Scroll` adds what was taken to its - /// own `amt`, so an estimate here would leave that number drifting - /// from what is on screen by every overshoot into a wall. - /// - /// Measured by the *first* of `Scroll::draw`'s two draws of this - /// widget, which is the whole reason that draw exists -- see - /// `Widget::apply_scroll`. + /// that edge to the viewport's. An estimate here would leave `amt` + /// drifting from what is on screen by every overshoot into a wall. content_lead: f32, content_trail: f32, /// Whether the last walk ran out of items before its leading / @@ -298,30 +303,22 @@ pub struct LazySpan { /// all. With more content past an edge there is no bound to give. no_more_before: bool, no_more_after: bool, - /// Accumulated content movement in [`Widget::apply_scroll`]'s - /// direction convention -- every `scroll` this span makes, including - /// the ones `overscroll_gap` gives back, so a `Scroll` above reads - /// what actually happened rather than adding up what it asked for. - /// See [`Widget::scroll_offset`] for why the remainder alone will not - /// do. Jumps (`jump_to_end`/`jump_to_start`) are deliberately not - /// counted: they are not travel across the content. - moved: f32, } impl LazySpan { - /// `at_end` starts the span pinned to the end of its content and keeps - /// it there while rows are appended -- the transcript's case, and the - /// same flag under the same name as [`Scroll::new`]'s. `false` starts - /// at the beginning, which is what anything read from the top wants. - /// Independent of `dir`, which says where item 0 is; see the field. - pub fn new(dir: Dir, at_end: bool) -> Self { + /// `pin` says which end of its content this span opens at and clings + /// to as rows arrive -- [`Pin::End`] for a transcript, and independent + /// of `dir`, which says where item 0 is (see the field). `dir` is also + /// what resolves [`Pin::Pos`]/[`Pin::Neg`], the axis-absolute way of + /// asking the same question. + pub fn new(dir: Dir, pin: Pin) -> Self { Self { dir, + ctl: ScrollController::new(dir, pin), items: VecDeque::new(), more_before: None, more_after: None, anchor: None, - snap_end: at_end, viewport_len: 0.0, last_viewport_len: 0.0, at_start: false, @@ -330,7 +327,6 @@ impl LazySpan { content_trail: 0.0, no_more_before: false, no_more_after: false, - moved: 0.0, pending_tap: None, extents: HashMap::default(), heights: HashMap::default(), @@ -361,12 +357,11 @@ impl LazySpan { } /// O(1). If the list is currently flush with its own end - /// (`snap_end`), the new row becomes the anchor so a live list stays - /// pinned to its newest content -- the same policy `Scroll` applies - /// via `snap_end`. + /// ([`ScrollController::pinned_to_end`]), the new row becomes the + /// anchor so a live list stays pinned to its newest content. pub fn push_back(&mut self, row: LazyItem) { self.items.push_back(row); - if self.snap_end { + if self.ctl.pinned_to_end() { self.anchor = Some(Anchor { slot: self.items.len() as isize - 1, edge: Edge::Trailing, @@ -451,7 +446,7 @@ impl LazySpan { pub fn clear(&mut self) { self.items.clear(); self.anchor = None; - self.snap_end = true; + self.ctl.set_pinned_to_end(true); self.heights.clear(); self.extents.clear(); } @@ -459,6 +454,11 @@ impl LazySpan { /// Move the anchor's edge by `amt` pixels, where positive brings /// **later** content into view. /// + /// Named apart from [`Scrollable::scroll`] rather than shadowing it: + /// the two run in different spaces and an inherent method silently + /// wins over a trait one, so a caller reaching for the public + /// convention would have got this instead. + /// /// Private, and in the direction-relative space the walk works in /// rather than the screen space every public delta speaks in: the /// anchor's offset says where the pinned edge *sits*, so moving the @@ -467,16 +467,24 @@ impl LazySpan { /// [`Self::flip_delta`] is the one conversion, exactly as /// [`Self::flip_pos`] is for positions. /// - /// Unclamped here, on purpose: it is one write. `apply_scroll` does - /// the clamping, against walls the walk measured. - fn scroll(&mut self, amt: f32) { - if let Some(a) = &mut self.anchor { - a.offset -= amt; - // Converted into the screen-space convention every caller - // outside this widget uses, since `moved` is what - // `Widget::scroll_offset` hands upward. - self.moved += self.flip_delta(amt); + /// Unclamped here, on purpose: it is one write. The controller does + /// the clamping, against the walls [`Self::travel`] published from the + /// last walk, and `overscroll_gap` gives back whatever that could not + /// know about. + fn move_anchor(&mut self, amt: f32) { + if self.anchor.is_none() { + return; } + self.anchor.as_mut().unwrap().offset -= amt; + // Converted into the screen-space convention the controller and + // every caller outside this widget speak in. Every move this span + // makes goes through here, including the ones `overscroll_gap` + // gives back, so `amt` is what actually happened rather than what + // was asked for -- see `ScrollController::moved_by`. Jumps + // (`jump_to_end`/`jump_to_start`) deliberately do not: they are + // not travel across the content. + let moved = self.flip_delta(amt); + self.ctl.moved_by(moved); } /// The anchor's own row index and pixel offset, formatted the same @@ -701,7 +709,7 @@ impl LazySpan { if let Some(a) = self.anchor && self.slot_exists(a.slot) { - if self.snap_end && self.viewport_len != self.last_viewport_len { + if self.ctl.pinned_to_end() && self.viewport_len != self.last_viewport_len { self.anchor.as_mut().unwrap().offset = self.viewport_len; } self.last_viewport_len = self.viewport_len; @@ -925,7 +933,7 @@ impl LazySpan { } fn update_snap_end(&mut self) { - self.snap_end = match self.anchor { + let pinned = match self.anchor { Some(a) => { self.next_slot(a.slot).is_none() && a.edge == Edge::Trailing @@ -933,6 +941,7 @@ impl LazySpan { } None => false, }; + self.ctl.set_pinned_to_end(pinned); } /// **The one rule for what this list draws**: a row is on screen if @@ -1133,53 +1142,89 @@ impl LazySpan { /// on for an O(1) move. const GENEROUS_PADDING: f32 = 100_000.0; -impl Widget for LazySpan { - /// A lazy layout cannot be slid about as a lump: which rows exist at - /// all is a function of where it is scrolled to, and it has no content - /// length to hand its parent to clamp against, since it has never - /// measured the rows it has not drawn. So it takes the delta itself. - /// See [`Widget::scrolls_itself`]. - fn scrolls_itself(&self) -> bool { - true +impl LazySpan { + /// Make this span scrollable: the wheel and a finger drag, registered + /// on the span itself. + /// + /// **Inherent, and it shadows `WidgetLike::scrollable` on purpose.** + /// That one wraps its widget in a `ScrollArea`, which is exactly what + /// must not happen here -- a lump slid about by a parent would never + /// update which rows it shows -- and this span already owns the + /// controller such an area would have brought. Rust resolves an + /// inherent method before a trait one, so `list.scrollable()` finds + /// this, and it needs neither of the other's arguments: the axis is + /// `dir`'s and the pin was chosen at construction. + /// + /// A span reached through a builder (already wrapped, already behind a + /// closure) gets the trait method instead, correctly -- by then it is + /// a different widget. + /// + /// A caller with a drag arbiter of its own registers the wheel and + /// leaves the drag out rather than calling this: one gesture, one + /// arbiter (`transcript_ui`'s `Selection`, and `DragGesture`'s doc). + pub fn scrollable(self) -> impl WidgetIdFn { + let axis = self.dir.axis; + scroll_senses(self, axis) + } +} + +impl Scrollable for LazySpan { + fn controller(&self) -> &ScrollController { + &self.ctl } - fn scroll_offset(&self) -> f32 { - self.moved + fn controller_mut(&mut self) -> &mut ScrollController { + &mut self.ctl } +} - /// Take what the loaded content allows and leave the rest, so the - /// `Scroll` above learns it hit a wall from what comes back. +impl LazySpan { + /// How far this span can still travel each way, from the edges the + /// last walk actually placed -- what the controller clamps the next + /// delta against, so that a delta running 250px past the end gives + /// 250 back rather than everything or nothing. /// - /// `delta` arrives in screen space -- positive scrolls the reader up - /// or left -- and the walk works in direction-relative pixels, so - /// [`Self::flip_delta`] is the one conversion, on the way in and on - /// the remainder's way back out. - /// - /// **The bound is exact, not a "we are at the wall" flag.** With no - /// more content past an edge, the travel left in that direction is the - /// distance from the edge the last walk placed to the viewport's own, - /// so a delta that runs 250px past the end gives 250 back rather than - /// everything or nothing. That is what lets `Scroll::amt` stay equal - /// to what is actually on screen instead of drifting by every - /// overshoot. The walls come from the walk `Scroll::draw` runs - /// immediately before this. - fn apply_scroll(&mut self, delta: &mut f32) { - // `f32::INFINITY` rather than a big number or an `Option`: with - // content past this edge there is genuinely no bound to apply, and - // `clamp` says exactly that with no branch of its own. - let max_forward = if self.no_more_after { + /// **The bound is exact where there is one, and `INFINITY` where there + /// is not.** With content still loaded past an edge this span + /// genuinely cannot say how far it goes without walking there, and + /// saying so is what lets the walk find the wall and `overscroll_gap` + /// hand the overshoot back inside the same frame. + fn travel(&self) -> Travel { + let forward = if self.no_more_after { (self.content_trail - self.viewport_len).max(0.0) } else { f32::INFINITY }; - let max_backward = if self.no_more_before { + let backward = if self.no_more_before { (-self.content_lead).max(0.0) } else { f32::INFINITY }; - let taken = self.flip_delta(*delta).clamp(-max_backward, max_forward); - self.scroll(taken); - *delta -= self.flip_delta(taken); + // `forward`/`backward` are the walk's own directions -- toward + // later content and toward earlier -- and `Travel` is in screen + // space, so which is which depends on `dir` exactly as + // `flip_delta` does. A `Dir::UP` span's later content is *above* + // it, so scrolling back down the screen is what runs out first. + match self.dir.sign { + Sign::Pos => Travel { + back: backward, + fwd: forward, + }, + Sign::Neg => Travel { + back: forward, + fwd: backward, + }, + } + } +} + +impl Widget for LazySpan { + /// A lazy span animates exactly one thing, its fling -- and it drives + /// its own rather than being handed deltas by a `ScrollArea` around + /// it, since which rows exist at all is a function of where it is + /// scrolled to and a moved lump would never update them. + fn tick(&mut self, now: Instant) -> bool { + self.tick_fling(now) } fn draw(&mut self, painter: &mut Painter) -> Size { @@ -1207,12 +1252,29 @@ impl Widget for LazySpan { let output_len = painter.output_size().axis(axis); self.viewport_len = painter.region().axis(axis).len().to_abs(output_len); + self.ctl.set_density(painter.density()); self.repair_anchor(); if self.anchor.is_none() { self.extents.clear(); return Size::REST; } + // What a wheel, a drag or a fling asked for since the last frame, + // already clamped to the travel that frame reported -- the walls + // it placed are the freshest answer available, and where they are + // stale (the content changed under a settled anchor) the walk + // below finds the real ones and `overscroll_gap` gives back the + // difference before this frame ends. + // + // Deliberately after the early return above: a delta that arrives + // while there is nothing to scroll stays banked rather than being + // silently spent on an empty list. + let delta = self.ctl.take_delta(); + if delta != 0.0 { + let amt = self.flip_delta(delta); + self.move_anchor(amt); + } + // `reanchor_at_tap` reads `extents` as they stood after the // *previous* frame's layout -- the last on-screen box for each // visible row -- so the clear that starts rebuilding it for this @@ -1245,13 +1307,14 @@ impl Widget for LazySpan { // box at a new offset, which `draw_inner` dispatches as an O(1) // move. if let Some(gap) = self.overscroll_gap(lead, trail) { - self.scroll(gap); + self.move_anchor(gap); self.extents.clear(); self.lay_out(painter); } self.rehome_anchor(); self.update_snap_end(); + self.ctl.set_travel(self.travel()); Size::REST } } @@ -1355,7 +1418,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1405,7 +1468,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::UP, true); + let mut list = LazySpan::new(Dir::UP, Pin::End); let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1440,7 +1503,7 @@ mod tests { /// span is laid out (Iris, 2026-09-08 -- "positive should always /// scroll up / left, and negative down / right"). A `Dir::UP` span /// used to pan the opposite way for the same number, because - /// `apply_scroll` handed the delta to the walk without the flip its + /// the delta reached the walk without the flip its /// positions already went through. /// /// Asserted on where rows were **drawn**, for the reason @@ -1458,7 +1521,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(dir, true); + let mut list = LazySpan::new(dir, Pin::End); let keys: Vec = (0..10).collect(); let rows = push_rows(&mut rsc, &mut list, &keys, 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1467,14 +1530,15 @@ mod tests { render.resize((100.0, 100.0)); render.update(&root, &mut rsc); - let push = |rsc: &mut TestRsc, render: &mut UiRenderState, mut delta: f32| { - rsc.ui - .widgets - .get_mut(&list_weak) - .unwrap() - .apply_scroll(&mut delta); - assert_eq!(delta, 0.0, "there was content to take the whole delta"); + let push = |rsc: &mut TestRsc, render: &mut UiRenderState, delta: f32| { + let before = rsc.ui.widgets.get(&list_weak).unwrap().amt(); + rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(delta); render.update(&root, rsc); + let moved = before - rsc.ui.widgets.get(&list_weak).unwrap().amt(); + assert!( + (moved - delta).abs() < 0.01, + "there was content to take the whole delta: asked {delta}, moved {moved}", + ); }; // Away from the pinned end, in whichever screen direction // that is for this `dir`. @@ -1520,7 +1584,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::UP, true); + let mut list = LazySpan::new(Dir::UP, Pin::End); push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1547,7 +1611,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1619,7 +1683,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); let mut rows = Vec::new(); for key in 0..5u64 { let (bg_id, fg, row) = resizable_background_row(&mut rsc, 20.0); @@ -1663,7 +1727,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); let mut bg_ids = Vec::new(); for key in 0..5u64 { let (bg_id, row) = background_styled_row(&mut rsc, 20.0); @@ -1693,7 +1757,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); push_rows(&mut rsc, &mut list, &[10, 11, 12], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1743,7 +1807,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); // Five rows of 20px; with a 100px viewport all are visible, // anchored at the bottom by default (row 4's bottom at 100). let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); @@ -1800,7 +1864,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1839,7 +1903,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); let keys: Vec = (0..n as u64).collect(); push_rows(&mut rsc, &mut list, &keys, 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1852,12 +1916,11 @@ mod tests { render.take_counters(); // Backwards, into content that exists: a list opens flush with - // its newest end, so scrolling *forward* from there is + // its newest end, so a *negative* delta from there is // overscroll, and the clamp lays out a second time within the // frame to give it back -- a correct extra pass, but not the - // ordinary - // scroll tick whose cost this test is about. - rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(-5.0); + // ordinary scroll tick whose cost this test is about. + rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0); render.update(&root, &mut rsc); let (draws, _rewrites, moves, _shapes) = render.take_counters(); @@ -1882,7 +1945,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1936,7 +1999,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1979,7 +2042,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -2049,7 +2112,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); for key in 0..5u64 { let (_bg_id, row) = background_styled_row(&mut rsc, 20.0); list.push_back(LazyItem::new(key, row)); @@ -2109,7 +2172,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = LazySpan::new(Dir::DOWN, true); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); // Rows that own a primitive *at their own id* (a background rect), // not only through a child: an orphan is a widget's own primitive // outliving its own redraw, so a row whose top-level widget paints @@ -2151,47 +2214,35 @@ mod tests { /// room to travel before the walls stop it -- shared by the tests /// below. /// - /// Built the way a real caller does since the scroll position moved - /// out of this widget: `Masked(Scroll(LazySpan))`. The `Scroll` - /// contributes the gesture, the fling and `amt`; the `LazySpan` lays - /// out and says how far it can actually go - /// (`Widget::apply_scroll`). The mask is outside the `Scroll` because - /// what needs clipping is the row straddling an edge, and masks are - /// inherited down the chain. + /// Built the way a real caller does: `Masked(LazySpan)`, with the span + /// driving its own `ScrollController` -- the gesture, the fling and + /// `amt` are its own, and so is the walk that says how far it can + /// actually go. The mask is outside because what needs clipping is the + /// row straddling an edge, and masks are inherited down the chain. fn build_flingable_list( rsc: &mut TestRsc, - ) -> ( - WeakWidget, - WeakWidget, - StrongWidget, - UiRenderState, - ) { - let mut list = LazySpan::new(Dir::DOWN, true); + ) -> (WeakWidget, StrongWidget, UiRenderState) { + let mut list = LazySpan::new(Dir::DOWN, Pin::End); push_rows(rsc, &mut list, &(0..200).collect::>(), FLING_ROW_H); let list = rsc.ui.widgets.add_strong(list); let list_weak = list.weak(); - let scroll = rsc - .ui - .widgets - .add_strong(Scroll::new(list.any(), Axis::Y, true)); - let scroll_weak = scroll.weak(); let root = rsc.ui.widgets.add_strong(Masked { shape: None, - inner: scroll.any(), + inner: list.any(), }); let root = root.any(); let mut render = UiRenderState::new(); render.resize((100.0, 600.0)); render.update(&root, rsc); - (list_weak, scroll_weak, root, render) + (list_weak, root, render) } - /// Drive one frame of a fling: tick the `Scroll` the way + /// Drive one frame of a fling: tick the span the way /// `UiData::tick_animations` does, then draw. Answers whether the /// fling is still going. fn fling_frame( rsc: &mut TestRsc, - scroll: &WeakWidget, + scroll: &WeakWidget, root: &StrongWidget, render: &mut UiRenderState, now: Instant, @@ -2206,13 +2257,14 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc); + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + let list_weak = scroll; // Toward the start: **positive**, which is the finger's direction - // and `Scroll::scroll`'s convention -- the one convention a delta + // and `ScrollController::scroll`'s convention -- the one convention a delta // has anywhere in the crate now. It used to be negative here, // because a `LazySpan`'s anchor offset ran the opposite way to a - // `Scroll`'s `amt` while both were public and both claimed to + // `ScrollArea`'s `amt` while both were public and both claimed to // mirror the other. assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0)); assert!(rsc.ui.widgets.get(&scroll).unwrap().is_scrolling()); @@ -2243,7 +2295,8 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc); + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + let list_weak = scroll; let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) { @@ -2273,8 +2326,8 @@ mod tests { assert!(!rsc.ui.tick_animations(now)); } - /// The sign, pinned across the whole handoff: gesture -> `Scroll` -> - /// `apply_scroll` -> anchor. A negative delta is the finger moving the + /// The sign, pinned across the whole handoff: gesture -> `ScrollArea` -> + /// controller -> anchor. A negative delta is the finger moving the /// negative way along the axis, which pulls **later** content up into /// view. Getting this wrong anywhere in that chain scrolls the list /// backwards, which no type can catch. @@ -2283,7 +2336,8 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc); + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + let list_weak = scroll; // Start well back from the end so there is room to move forward. rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(2000.0); render.update(&root, &mut rsc); @@ -2304,16 +2358,16 @@ mod tests { /// `Scroll::amt` is the accumulated movement the child actually made, /// so it stays equal to what is on screen even when a delta runs off - /// the end of the content. This is the whole reason `apply_scroll` - /// hands back a remainder rather than a "hit a wall" flag: an - /// all-or-nothing answer would leave `amt` over-counted by every - /// overshoot, and nothing would ever correct it. + /// the end of the content. This is the whole reason the span reports + /// what it *moved* rather than the caller adding up what it asked + /// for: an all-or-nothing answer would leave `amt` over-counted by + /// every overshoot, and nothing would ever correct it. #[test] fn amt_counts_only_what_the_child_could_take() { let mut rsc = TestRsc { ui: UiData::default(), }; - let (_list, scroll, root, mut render) = build_flingable_list(&mut rsc); + let (scroll, root, mut render) = build_flingable_list(&mut rsc); // A short move away from the end, all of which is available. rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100.0); render.update(&root, &mut rsc); @@ -2345,7 +2399,8 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc); + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + let list_weak = scroll; // An enormous velocity that would travel far past all 200 rows if // unclamped. rsc.ui.widgets.get_mut(&scroll).unwrap().fling(50_000.0); @@ -2377,8 +2432,8 @@ mod tests { /// Asking for more travel than the content has leaves it *on* its /// first row rather than beyond it, in the frame that asked. Since the - /// position moved into `Scroll`, this is prevented at the source -- - /// `apply_scroll` only takes what the walk says is there -- rather + /// position moved into `ScrollArea`, this is prevented at the source -- + /// the clamp only allows what the walk says is there -- rather /// than corrected afterwards, and `overscroll_gap` is left for the /// case a scroll cannot cause: content or a viewport that changed /// under a settled anchor. @@ -2387,7 +2442,8 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let (list_weak, scroll, root, mut render) = build_flingable_list(&mut rsc); + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + let list_weak = scroll; rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0); render.update(&root, &mut rsc); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); @@ -2401,7 +2457,7 @@ mod tests { #[test] fn anchor_position_display_before_any_draw_is_none() { - let list = LazySpan::new(Dir::DOWN, true); + let list = LazySpan::new(Dir::DOWN, Pin::End); assert_eq!(list.anchor_position_display(), "idx=none"); } @@ -2410,7 +2466,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let (list_weak, _scroll, root, mut render) = build_flingable_list(&mut rsc); + let (list_weak, root, mut render) = build_flingable_list(&mut rsc); let _ = (&root, &mut render); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert!(list_ref.anchor_position_display().starts_with("idx=")); diff --git a/iris/src/widget/position/mod.rs b/iris/src/widget/position/mod.rs index c033f1c..d9c1499 100644 --- a/iris/src/widget/position/mod.rs +++ b/iris/src/widget/position/mod.rs @@ -4,7 +4,8 @@ mod lazy_span; mod max_size; mod offset; mod pad; -mod scroll; +mod scroll_area; +mod scrollable; mod sized; mod span; mod stack; @@ -15,7 +16,8 @@ pub use lazy_span::*; pub use max_size::*; pub use offset::*; pub use pad::*; -pub use scroll::*; +pub use scroll_area::*; +pub use scrollable::*; pub use sized::*; pub use span::*; pub use stack::*; diff --git a/iris/src/widget/position/scroll.rs b/iris/src/widget/position/scroll.rs deleted file mode 100644 index aa95fb2..0000000 --- a/iris/src/widget/position/scroll.rs +++ /dev/null @@ -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, - /// 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 { - 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 - ); - } -} diff --git a/iris/src/widget/position/scroll_area.rs b/iris/src/widget/position/scroll_area.rs new file mode 100644 index 0000000..42fbf2e --- /dev/null +++ b/iris/src/widget/position/scroll_area.rs @@ -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, +} + +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, + 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() + ); + } +} diff --git a/iris/src/widget/position/scrollable.rs b/iris/src/widget/position/scrollable.rs new file mode 100644 index 0000000..5d0fac9 --- /dev/null +++ b/iris/src/widget/position/scrollable.rs @@ -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 { + 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 { + 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(w: WL, axis: Axis) -> impl WidgetIdFn +where + Rsc: HasEvents, + W: Widget + Scrollable, + WL: WidgetLike, +{ + 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); + } + }) +} diff --git a/iris/src/widget/trait_fns.rs b/iris/src/widget/trait_fns.rs index ffb94e1..13283ef 100644 --- a/iris/src/widget/trait_fns.rs +++ b/iris/src/widget/trait_fns.rs @@ -83,63 +83,32 @@ widget_trait! { } } - fn scrollable(self) -> impl WidgetIdFn where Rsc: HasEvents { - self.scrollable_on(Axis::Y) - } - - /// `scrollable_on`, but starting pinned to the **end** of its content - /// and staying there while the content grows -- what a composer wants, - /// where the newest line is the one being written. + /// Wrap this widget in a [`ScrollArea`] that pans along `axis`, with + /// the wheel and a finger drag both registered -- how anything with a + /// fixed layout becomes scrollable. /// - /// Explicit, because the other kind is not a variation on it: a code - /// fence opened at the end of its longest line, which is the middle - /// of a word (seen in `iris/run-headless.sh phone`, 2026-09-08). The - /// two behaviours are one mechanism with the starting edge passed in, - /// and both names say which they are rather than one of them being a - /// default nobody reads. - fn scrollable_to_end(self, axis: Axis) -> impl WidgetIdFn where Rsc: HasEvents { - self.scroll_area(axis, true) - } - - /// `scrollable` along `axis`. A code fence pans across its own long - /// lines exactly the way a transcript pans down its rows, so the two - /// are one function with the axis passed in rather than a second copy - /// -- `DragArbiter::on` is the other half. - fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn where Rsc: HasEvents { - self.scroll_area(axis, false) - } - - /// The one implementation behind [`Self::scrollable_on`] and - /// [`Self::scrollable_to_end`] -- see the latter for what `at_end` - /// decides. - fn scroll_area(self, axis: Axis, at_end: bool) -> impl WidgetIdFn where Rsc: HasEvents { + /// `pin` says which end the area opens at and clings to as its content + /// grows, and it is spelled out rather than defaulted because the two + /// cases are not variations on each other: a composer wants the end, + /// where what is being typed is, and a code fence opened at the end of + /// its longest line, which is the middle of a word (seen in + /// `iris/run-headless.sh phone`, 2026-09-08). + /// + /// One method with the axis and the pin passed in, rather than the + /// three named variants this used to be (Iris, 2026-09-08: "can we + /// make both scroll methods become `.scrollable`, and it takes an axis + /// and a pin instead of having two?"). A code fence pans across its + /// own long lines exactly the way a transcript pans down its rows, so + /// the two are one mechanism with the direction passed in -- + /// `DragArbiter::on` is the other half. + /// + /// A [`LazySpan`] has an inherent `scrollable` of its own that this + /// does not reach: it owns a controller already and must not be + /// wrapped in an area that would slide it about as a lump. + fn scrollable(self, axis: Axis, pin: Pin) -> impl WidgetIdFn where Rsc: HasEvents { move |state| { - Scroll::new(self.add_strong(state), axis, at_end) - .on(CursorSense::Scroll, move |ctx, rsc| { - let delta = ctx.data.scroll_delta.axis(axis) * 50.0; - ctx.widget(rsc).scroll(delta); - }) - // A finger drag, through the same `DragGesture` the - // transcript's `LazySpan` is panned by -- `Scroll::drag`'s doc - // has the arbitration and why there is no fling. The wheel - // above and this are the two inputs of one scroll, so they - // are registered together rather than left to each caller. - .on(CursorSense::drag_senses(), |ctx, rsc: &mut Rsc| { - let id = ctx.widget.id(); - let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos); - let flung = - ctx.widget(rsc) - .drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time); - // The half that actually makes it move -- a fling is - // set by the widget and driven by the frame loop, and - // only this side can reach the loop. Only when one - // actually started: registering a widget that is not - // animating asks the next frame to find that out. - if flung { - rsc.ui_mut().animate(id); - } - }) - .add(state) + let area = ScrollArea::new(self.add_strong(state), axis, pin); + scroll_senses(area, axis)(state) } } diff --git a/iris/tabs-ui/src/lib.rs b/iris/tabs-ui/src/lib.rs index 2ddd72f..f678ec4 100644 --- a/iris/tabs-ui/src/lib.rs +++ b/iris/tabs-ui/src/lib.rs @@ -101,7 +101,10 @@ where .add(rsc); let texts = Span::empty(Dir::DOWN).gap(10).add(rsc); - let msg_area = texts.scrollable().masked().background(rect(Color::SKY)); + let msg_area = texts + .scrollable(Axis::Y, Pin::Start) + .masked() + .background(rect(Color::SKY)); let add_text = wtext("add") .editable(EditMode::MultiLine) .text_align(Align::LEFT) diff --git a/iris/transcript-fixture/tests/catch_a_fling.rs b/iris/transcript-fixture/tests/catch_a_fling.rs index 483e8c5..1300d37 100644 --- a/iris/transcript-fixture/tests/catch_a_fling.rs +++ b/iris/transcript-fixture/tests/catch_a_fling.rs @@ -124,7 +124,7 @@ fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() { PHONE_FRAME_MS, ); assert!( - (screen.scroll)(&mut h.rsc).is_scrolling(), + (screen.list)(&mut h.rsc).is_scrolling(), "the fling must still be running 150ms in, or this test catches nothing" ); @@ -151,7 +151,7 @@ fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() { PHONE_FRAME_MS, ); assert!( - (screen.scroll)(&mut h.rsc).is_scrolling(), + (screen.list)(&mut h.rsc).is_scrolling(), "the fling must still be running 150ms in, or this test catches nothing" ); @@ -161,7 +161,7 @@ fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() { h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8); assert_eq!( - (screen.scroll)(&mut h.rsc).fling_velocity(), + (screen.list)(&mut h.rsc).fling_velocity(), None, "a press that stopped a fling and moved nothing must not start another" ); @@ -195,7 +195,7 @@ fn the_same_small_drag_on_a_settled_list_moves_nothing() { PHONE_FRAME_MS, ); assert!( - !(screen.scroll)(&mut h.rsc).is_scrolling(), + !(screen.list)(&mut h.rsc).is_scrolling(), "the fling must have stopped, or this is the same case as the test above" ); diff --git a/iris/transcript-fixture/tests/fence_fling.rs b/iris/transcript-fixture/tests/fence_fling.rs index ff26f33..3f04d00 100644 --- a/iris/transcript-fixture/tests/fence_fling.rs +++ b/iris/transcript-fixture/tests/fence_fling.rs @@ -3,7 +3,7 @@ //! flicked sideways, has to keep moving after the finger leaves. //! //! The fence is pushed here rather than hunted for in the bench fixture, -//! so the test knows which row it is pressing and where. The `Scroll` it +//! so the test knows which row it is pressing and where. The `ScrollArea` it //! asserts on is found by walking what is actually drawn -- there is no //! handle to it from the outside, and a coordinate would only prove that //! *something* moved. @@ -15,7 +15,7 @@ use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; /// The horizontal scroll area drawn inside `top..bottom`, with the box /// it was drawn at -- a fence is the only thing in a transcript that pans /// sideways. Found by walking what is actually drawn, because there is no -/// handle to a fence's own `Scroll` from the outside and a bare +/// handle to a fence's own `ScrollArea` from the outside and a bare /// coordinate would only prove that *something* moved. fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> { h.render @@ -27,7 +27,7 @@ fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, Pixe .ui .widgets .get_dyn(id) - .and_then(|w| w.as_any().downcast_ref::()) + .and_then(|w| w.as_any().downcast_ref::()) .is_some_and(|s| s.axis() == Axis::X) }) .find_map(|id| { @@ -41,7 +41,7 @@ fn is_scrolling(h: &Harness, id: WidgetId) -> bool { .ui .widgets .get_dyn(id) - .and_then(|w| w.as_any().downcast_ref::()) + .and_then(|w| w.as_any().downcast_ref::()) .expect("the fence's scroll area is still drawn") .is_scrolling() } @@ -51,7 +51,7 @@ fn amt(h: &Harness, id: WidgetId) -> f32 { .ui .widgets .get_dyn(id) - .and_then(|w| w.as_any().downcast_ref::()) + .and_then(|w| w.as_any().downcast_ref::()) .expect("the fence's scroll area is still drawn") .amt() } diff --git a/iris/transcript-fixture/tests/gesture_cancel.rs b/iris/transcript-fixture/tests/gesture_cancel.rs index 6f11436..e414dcd 100644 --- a/iris/transcript-fixture/tests/gesture_cancel.rs +++ b/iris/transcript-fixture/tests/gesture_cancel.rs @@ -60,7 +60,7 @@ fn a_cancelled_flick_does_not_fling() { h.replay(&flick); assert_eq!( - (screen.scroll)(&mut h.rsc).fling_velocity(), + (screen.list)(&mut h.rsc).fling_velocity(), None, "a gesture the platform took away must not fling" ); @@ -110,7 +110,7 @@ fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() { after - before ); assert_eq!( - (screen.scroll)(&mut h.rsc).fling_velocity(), + (screen.list)(&mut h.rsc).fling_velocity(), None, "and it must not have flung either" ); @@ -119,7 +119,7 @@ fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() { /// The report itself: "if I scroll in a horizontal area and then tap in a /// vertical area, it seems to snap." /// -/// A markdown fence pans sideways through its own `Scroll`, which takes +/// A markdown fence pans sideways through its own `ScrollArea`, which takes /// pointer capture the moment it commits. Everything else that was handed /// a frame of that press is told so with `CursorSense::Cancel` -- and the /// widget the press actually landed on is the fence's own text block, diff --git a/iris/transcript-fixture/tests/phone_screen.rs b/iris/transcript-fixture/tests/phone_screen.rs index 8ea4cd9..6a1399c 100644 --- a/iris/transcript-fixture/tests/phone_screen.rs +++ b/iris/transcript-fixture/tests/phone_screen.rs @@ -44,7 +44,7 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() { let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch")); h.replay(&flick); - let velocity = (screen.scroll)(&mut h.rsc) + let velocity = (screen.list)(&mut h.rsc) .fling_velocity() .expect("the flick must release as a pan with a velocity, not a tap"); // Compose's own answer for this recording's five samples, printed by @@ -98,7 +98,7 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() { .key_at(middle) .and_then(|key| list.extent(key).map(|(top, _)| (key, top))), }; - if settled_at.is_none() && !(screen.scroll)(&mut h.rsc).is_scrolling() { + if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() { settled_at = Some(t); } t += PHONE_FRAME_MS; @@ -142,7 +142,7 @@ fn a_tap_on_a_row_moves_nothing() { h.replay(&script("tap", include_str!("../touch/tap.touch"))); assert_eq!( - (screen.scroll)(&mut h.rsc).fling_velocity(), + (screen.list)(&mut h.rsc).fling_velocity(), None, "a tap must not fling" ); diff --git a/iris/transcript-fixture/tests/top_edge.rs b/iris/transcript-fixture/tests/top_edge.rs index bec6d90..1cff4a8 100644 --- a/iris/transcript-fixture/tests/top_edge.rs +++ b/iris/transcript-fixture/tests/top_edge.rs @@ -66,11 +66,11 @@ fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32 /// the next one. **Positive walks back through older rows** -- the /// finger's own direction, and `Scroll::scroll`'s, which is the one /// convention a delta has anywhere in iris since the transcript's scroll -/// position moved out of the `LazySpan` and into the `Scroll` around it. +/// position lives in the `LazySpan`'s own `ScrollController`. /// It used to be the opposite here, because a `LazySpan`'s anchor offset /// ran the other way. fn scrolled(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, amount: f32, t: u64) -> u64 { - (screen.scroll)(&mut h.rsc).scroll(amount); + (screen.list)(&mut h.rsc).scroll(amount); h.frame(t); t + PHONE_FRAME_MS } diff --git a/iris/transcript-ui/src/composer.rs b/iris/transcript-ui/src/composer.rs index ca76775..a88f938 100644 --- a/iris/transcript-ui/src/composer.rs +++ b/iris/transcript-ui/src/composer.rs @@ -113,7 +113,7 @@ where let content = field // `scrollable_to_end`: what is being typed is at the end, so a // message longer than the six lines shown holds that end. - .scrollable_to_end(Axis::Y) + .scrollable(Axis::Y, Pin::End) .pad(dp(FIELD_PAD_DP)) .max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0)) .width(rest(1)) diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs index a025847..7e27e1d 100644 --- a/iris/transcript-ui/src/lib.rs +++ b/iris/transcript-ui/src/lib.rs @@ -55,15 +55,13 @@ use selection::Selection; use std::{cell::RefCell, rc::Rc}; pub struct TranscriptScreen { - /// The transcript's own `LazySpan` -- exposed so a caller can read - /// `.extent()`/call `.jump_to_end()` etc. directly for anything this - /// crate does not already wrap. + /// The transcript's own `LazySpan` -- the layout *and* the scroll + /// position, since a lazy span owns a `ScrollController` of its own + /// rather than being wrapped in a `ScrollArea` (`docs/SCROLL.md`). + /// Exposed so a caller can read `.extent()`, drive it through + /// `Scrollable` (`.scroll()`, `.fling()`, `.amt()`) or call + /// `.jump_to_end()` directly. pub list: WeakWidget, - /// 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, pub composer: composer::Composer, selection: Rc>, /// How many times [`Self::apply`] has fallen back to a full rebuild -- @@ -360,7 +358,7 @@ where Rsc::State: FocusHost + OpenUrl, { let selection = Rc::new(RefCell::new(Selection::new())); - let list = LazySpan::new(Dir::DOWN, true).add(rsc); + let list = LazySpan::new(Dir::DOWN, Pin::End).add(rsc); // The last row's block widgets are kept for the same reason // `push_row` keeps them: a reply that is *already* streaming when the @@ -415,35 +413,22 @@ where .add(rsc); } - // The scroll area. Everything about *scrolling* is here rather than - // in the list: the wheel, the fling, `amt`, and the clamp against the - // ends. The `LazySpan` inside answers `Widget::scrolls_itself`, so - // this hands it deltas to apply itself (`apply_scroll`) instead of - // sliding it about as a lump -- which it cannot be, since which rows - // exist at all is a function of where it is scrolled to. - // - // Built out rather than through `.scrollable_to_end()`, and this is - // the reason: that helper registers a finger drag on the `Scroll`, - // driving `Scroll`'s own `DragGesture`, and the transcript already has - // an arbiter of its own -- `Selection`, which has to decide between - // panning and selecting text and so cannot let a second `DragGesture` - // see the same frames. `DragGesture`'s doc says it: one gesture, one - // arbiter, each frame delivered exactly once. So the wheel is - // registered here (identical to the helper's) and the finger arrives - // through `Selection::drag`, which hands committed pans and releases - // to this same `Scroll`. - // - // `at_end: true` -- a transcript opens at its newest message and stays - // there while replies arrive. That is the *pin*, and it is a different - // question from `Dir::DOWN` above, which says the oldest message is - // item 0 and sits at the top. - let scroll = Scroll::new(list.add_strong(rsc).any(), Axis::Y, true) - .on(CursorSense::Scroll, |ctx, rsc| { - let delta = ctx.data.scroll_delta.y * 50.0; - ctx.widget(rsc).scroll(delta); - }) - .add(rsc); - selection.borrow_mut().set_scroll_area(scroll); + // The wheel, registered by hand rather than through + // `LazySpan::scrollable()`, and this is the reason: that helper also + // registers a finger drag driving the span's own `DragGesture`, and + // the transcript already has an arbiter -- `Selection`, which has to + // decide between panning and selecting text and so cannot let a second + // `DragGesture` see the same frames. `DragGesture`'s doc states the + // rule: one gesture, one arbiter, each frame delivered exactly once. + // The wheel handler here is identical to the helper's; only the drag + // differs, and it arrives through `Selection::drag`, which hands + // committed pans and releases to this same span. + list.on(CursorSense::Scroll, |ctx, rsc| { + let delta = ctx.data.scroll_delta.y * 50.0; + ctx.widget(rsc).scroll(delta); + }) + .add(rsc); + selection.borrow_mut().set_scroll_area(list); let (composer, composer_bar) = composer::build_composer(rsc); @@ -453,14 +438,13 @@ where // there, which on the phone is the header bar (docs/IRIS_TODO.md, // 2026-09-07: "code and a paragraph visible behind Run benchmark"). // The same clip is what `LazySpan::draw` asserts it has. - let tree = (scroll.width(rest(1)).height(rest(1)).masked(), composer_bar) + let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar) .span(Dir::DOWN) .add_strong(rsc) .any(); ( TranscriptScreen { - scroll, tail: RefCell::new(tail), session_working: std::cell::Cell::new(false), list, diff --git a/iris/transcript-ui/src/row.rs b/iris/transcript-ui/src/row.rs index 8812833..f1b0b82 100644 --- a/iris/transcript-ui/src/row.rs +++ b/iris/transcript-ui/src/row.rs @@ -277,7 +277,7 @@ where // curve instead of leaving square pixels in the corners // (Iris, 2026-09-07). BlockFrame::Verbatim { fill } => field - .scrollable_on(Axis::X) + .scrollable(Axis::X, Pin::Start) .pad(dp(FRAME_PAD_DP)) .masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP))) .width(rest(1)) diff --git a/iris/transcript-ui/src/selection.rs b/iris/transcript-ui/src/selection.rs index 01d4665..701fa4a 100644 --- a/iris/transcript-ui/src/selection.rs +++ b/iris/transcript-ui/src/selection.rs @@ -53,15 +53,15 @@ pub struct Selection { /// in iris's default input layer, with only the pan-vs-select /// *decision* staying here. gesture: DragGesture, - /// The scroll area around the transcript's `LazySpan`, which owns the + /// The transcript's `LazySpan`, whose `ScrollController` owns the /// position, the fling and `amt` -- what a committed pan and a release /// are handed to. Set by `build_tree` the moment it exists, which is - /// after this (rows need a `Selection` to be built, and the `Scroll` + /// after this (rows need a `Selection` to be built, and the span /// needs the rows), hence the `Option` rather than a constructor /// argument. A `Selection` without one still selects text and still /// reports taps; it simply cannot pan, which is what the `debug_assert` /// in `drag` is there to catch in the tests that build one by hand. - scroll: Option>, + scroll: Option>, } impl Default for Selection { @@ -80,11 +80,11 @@ impl Selection { } } - /// Hand this the scroll area that wraps the transcript, once it - /// exists -- see the `scroll` field. Called by `build_tree`; every - /// pan and fling this arbitrates goes there rather than to the - /// `LazySpan`, which no longer owns a position. - pub fn set_scroll_area(&mut self, scroll: WeakWidget) { + /// Hand this the transcript's `LazySpan` once it exists -- see the + /// `scroll` field. Called by `build_tree`; every pan and fling this + /// arbitrates goes to that span's own `ScrollController` + /// (`Scrollable::scroll`/`fling`), which is where the position lives. + pub fn set_scroll_area(&mut self, scroll: WeakWidget) { self.scroll = Some(scroll); } @@ -278,7 +278,7 @@ impl Selection { pointer: &PointerRequests, ) -> GestureOutcome { // A fresh touch-down cancels any fling still coasting from the - // previous gesture -- `Scroll::fling`'s own doc, and Android's + // previous gesture -- `ScrollController::fling`'s own doc, and Android's // `Scroller::abortAnimation` for the same reason -- and, since // 2026-09-07, *tells the gesture there was one*. A press that // caught moving content is a catch: it pans from this very sample @@ -291,7 +291,7 @@ impl Selection { debug_assert!( self.scroll.is_some(), "Selection::drag with no scroll area: a committed pan would be silently dropped -- \ - call `set_scroll_area` after building the `Scroll` around the transcript" + call `set_scroll_area` after building the transcript's `LazySpan`" ); let mut press = PressState::default(); if self.gesture.starts_press(sense) { @@ -312,7 +312,7 @@ impl Selection { // selection follows from a gesture that was never ours. GestureOutcome::Cancelled | GestureOutcome::Undecided => {} // `scroll(dy)`, not `scroll(-dy)`: since the position moved - // into `Scroll` there is one convention for a scroll delta in + // into the controller there is one convention for a scroll delta in // the crate -- the finger's -- rather than a `LazySpan` whose // anchor offset ran the other way while claiming to mirror it. GestureOutcome::Pan(dy) => { @@ -344,7 +344,7 @@ impl Selection { // `DragGesture`'s `Some(v)` already encodes. GestureOutcome::Released(Some(v)) => { // The half that actually makes it move -- see - // `Scroll::fling`'s doc. Without it the velocity is + // `ScrollController::fling`'s doc. Without it the velocity is // computed, stored, and never advanced by anything. // // Only when `fling` actually took it: below Compose's @@ -461,14 +461,17 @@ mod tests { EditMode::MultiLine, )) .weak(); - let list = rsc.ui.widgets.add_strong(LazySpan::new(Dir::DOWN, true)); + let list = rsc + .ui + .widgets + .add_strong(LazySpan::new(Dir::DOWN, Pin::End)); let list_weak = list.weak(); // The scroll area the real screen puts around it: a committed pan // goes there, and `Selection` asserts it was told about one. let scroll = rsc .ui .widgets - .add_strong(Scroll::new(list.any(), Axis::Y, true)) + .add_strong(LazySpan::new(Dir::DOWN, Pin::End)) .weak(); let list = list_weak; diff --git a/iris/transcript-ui/src/tool.rs b/iris/transcript-ui/src/tool.rs index 9498f1b..83bbcfc 100644 --- a/iris/transcript-ui/src/tool.rs +++ b/iris/transcript-ui/src/tool.rs @@ -256,7 +256,7 @@ fn disclosure(glyph: &'static str) -> TextBuilder { /// where its arguments end, and the long one is the one being read /// closely. A long line is **clipped** here rather than pannable, which a /// markdown fence (`row.rs`'s `BlockFrame::Verbatim`) is not: adding -/// `.scrollable_on(Axis::X)` to this non-editable `Text` made it draw +/// `.scrollable(Axis::X, Pin::Start)` to this non-editable `Text` made it draw /// nothing at all -- an empty panel where the command should be, seen on /// 2026-09-06 in `docs/bench/p1b-2026-09-06/` and bisected to that one /// call (the fence, which does the same thing to a `TextEdit`, is fine). @@ -272,7 +272,7 @@ where .wrap(false) .add(rsc); field - .scrollable_on(Axis::X) + .scrollable(Axis::X, Pin::Start) .pad(dp(RAW_PAD_DP)) .masked_by(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP))) .width(rest(1))