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