docs: SCROLL.md, the standing reference for how iris scrolls

For the next session, since this one is about to be cleared. Current
design only -- `Scroll` owns the position, the gesture and the fling; a
child is either moved or answers `Widget::scrolls_itself` and is handed
deltas; one sign convention, the finger's. It carries the things that are
expensive to rediscover and easy to undo by accident: why the two `&self`
capability methods must not be `&mut` (`get_dyn_mut` marks dirty), why
`scroll_offset` exists beside `apply_scroll`'s remainder, why the
measuring draw is free, why nothing is marked by hand, why the height
cache stays in the container, why the transcript builds its `Scroll` by
hand instead of through `.scrollable_to_end()`, and the measured numbers
behind "a `LazySpan` is not a `Span`".

Also names the one thing still open -- the pin -- with the two ways to
close it and an instruction to ask Iris rather than guess.

`scroll.rs` and `lazy_span.rs` now point at it from their module docs
rather than restating it, AGENTS.md lists it beside the other design
documents, and IRIS_TODO.md's in-progress entry defers to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 21:11:34 -04:00
1 parent b7474f61b0
commit 00e0a63887
5 files changed
+317

No files matched your search

+5
View File
@@ -76,6 +76,11 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
public API** -- Iris, 2026-09-08), working list, decisions log,
layout/render design, and texture-atlas design, and the client-core
crate's design, respectively.
- `docs/SCROLL.md` — how anything in iris scrolls: `Scroll` owns the
position, the gesture and the fling; a child either gets moved or
answers `Widget::scrolls_itself` and is handed deltas. Read it before
touching `scroll.rs`, `lazy_span.rs`, or anything that pans, flings
or lays out a long list.
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
`service: Managed(…)`, supervised by Dev Updater's own implementation
rather than a script kept here) and the APK, in parallel. It points at
+3
View File
@@ -79,6 +79,9 @@ order and what "done" looks like. Tick and date them in place.
through, and the fixture recordings' expected velocity flipped sign
with its magnitude unchanged.
**`docs/SCROLL.md` is the standing reference** for how scrolling works
now -- read that rather than reconstructing it from this entry.
**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
+298
View File
@@ -0,0 +1,298 @@
# Scrolling in iris
How anything in iris scrolls, as of 2026-09-08. This is the current
design, not a history — `docs/IRIS.md`'s dated entries have the account of
how it got here, and `docs/IRIS_TODO.md` has what is still open.
Read this before touching `iris/src/widget/position/scroll.rs`,
`iris/src/widget/position/lazy_span.rs`, or anything that pans, flings or
lays out a long list.
## The one rule
**`Scroll` owns the position, the gesture and the fling. Nothing else
does.** A widget inside it either gets moved by it or is handed deltas to
apply itself, and either way the scroll state lives in the `Scroll`. There
is exactly one `Flinger` in the crate's widgets and one `DragGesture`
implementation, both in `iris/src/sense.rs`.
So: `.scrollable_on(axis)` / `.scrollable_to_end(axis)` is how anything
becomes scrollable, including a `LazySpan`. Do not give a widget its own
fling, its own scroll amount, or a `RequestRedraw` handle — that is what
was just removed.
## One convention for a delta
**Positive moves the content the positive way along the axis — the
finger's direction — which brings *earlier* content into view.**
That is `Scroll::scroll`'s sign, `Scroll::fling`'s, and
`Widget::apply_scroll`'s. It holds from the gesture all the way down to a
row's anchor. `LazySpan`'s internal `scroll` runs the other way (its
anchor offset says where the pinned edge *sits*), and it is private, with
the single negation inside its `apply_scroll`.
There used to be two public conventions under the same name, and every
call site had to know which widget it was talking to. If you add a third
scrolling thing, it takes the finger's. `a_negative_delta_moves_toward_
the_end` (in `lazy_span.rs`) pins this across the whole handoff, because
nothing else can catch a list scrolling backwards.
## The `Widget` handoff
Three default methods on `Widget` (`iris/core/src/widget/mod.rs`):
```rust
fn scrolls_itself(&self) -> bool { false }
fn apply_scroll(&mut self, delta: &mut f32) {}
fn scroll_offset(&self) -> f32 { 0.0 }
```
- **`scrolls_itself`** — "I position my own content; hand me deltas rather
than sliding me about." Default `false`: an ordinary child is a lump its
parent moves, which is what makes a scroll tick an O(1) move of one
subtree instead of a redraw.
- **`apply_scroll`** — take as much of `delta` as you can actually move,
leave the rest. What comes back short is how the parent learns the
content ran out.
- **`scroll_offset`** — accumulated content movement, so the parent can
keep an honest account. See "why the remainder is not enough" below.
**`scrolls_itself` and `scroll_offset` are `&self` on purpose.** Reaching a
widget through `Widgets::get_dyn_mut` *marks it dirty*
(`iris/core/src/widget/widgets.rs`). Asking either question through a
`&mut` path would dirty every ordinary child on every scroll tick and cost
exactly the O(1) move the scheme exists for. `Painter::scrolls_itself` and
`Painter::scroll_offset` go through `get_dyn`; only
`Painter::apply_scroll` takes `&mut`.
### Why the remainder is not enough on its own
`apply_scroll` leaving a remainder was meant to be the whole story. It is
not, because **a lazy layout usually cannot say where its content ends
until it has walked there.** With the wall out of view it takes the delta
in full, and the walk that follows gives part of it back. So the remainder
is exact only when the wall was already visible, and a parent adding
remainders up would over-count by every overshoot and never correct.
`Scroll` therefore reads `scroll_offset` *after* the placing draw and sets
`amt` from it. `amt_counts_only_what_the_child_could_take` is the test;
it fails if you try to go back to remainders alone.
## `Scroll::draw` — two paths
`Scroll` reads `scrolls_itself` every draw and branches once, on the
capability rather than on any concrete type.
**Ordinary child (`draw_moved_child`)** — unchanged from before: offer the
child a box as long as last frame's content length to measure it, apply
the end-pin and the clamp against the measured length, then place it at
the length just measured. Two draws, the second free unless the content
changed. `amt` is a distance from the start of the content.
**Self-positioning child (`draw_self_scrolling_child`)** — measure, apply,
place:
1. `painter.widget_within(child, UiRegion::FULL)` — the measuring draw.
2. `painter.apply_scroll(child, &mut delta)` — the child takes what it can.
3. `painter.widget_within(child, UiRegion::FULL)` — the placing draw.
4. `amt = -painter.scroll_offset(child)`.
Two properties make this work, and both are easy to break:
- **The measuring draw is free in the common case.** It offers the same
box as last frame, so with nothing dirty `draw_inner` returns
immediately and the child's stored walls from its last walk are still
correct — because nothing changed. When the content *did* change the
child is dirty, really walks, and the walls are fresh, which is exactly
when they need to be.
- **Nothing is marked dirty by hand.** Reaching the child through
`get_dyn_mut` in step 2 is itself what dirties it, so step 3 really
draws rather than taking `draw_inner`'s unchanged-region skip. This is
why there is no `Painter::draw_again` and why one should not come back:
a mechanism for "give me a corrective frame later" is the thing this
shape replaces.
### What `amt` means
- Ordinary child: distance from the start of the content, clamped into the
scroll range. An absolute position.
- Self-positioning child: **movement, not position.** Paging rows in above
moves the origin and the child cannot say by how much, never having
measured them. The direction is the same as an ordinary child's; the
absolute value is not comparable between the two.
A scrollbar needs a real content length before it can use either, and a
lazy child has none. Do not invent one.
## `LazySpan`
`iris/src/widget/position/lazy_span.rs`. A virtualised sequence of
variable-height rows, laid out from an anchor. It is what `Span` is, done
lazily. It **does not scroll** — it lays out and answers honestly about
how far it can go.
### Why it is not `Span::scrollable()`
Measured 2026-09-08, and worth not re-deriving:
- A `Span` is skipped entirely in the steady state, but **when it is
redrawn it costs two draws per child** (21 draws for 10 children):
phase 1 offers each child the ambient region to learn its length,
phase 2 offers it its real share. So any mutation of a `Span` redraws
all of it — 24 draws for 11 children after one prepend.
- A `Scroll`'s efficiency and virtualisation pull opposite ways: a scroll
tick offers a same-size moved region, `draw_inner` takes the `mov` path,
and the child's `draw` never runs. A virtualising child inside a plain
`Scroll` would never update which rows it shows. That is what
`scrolls_itself` resolves.
- A lazy child cannot report a content length, so `Scroll`'s clamp,
end-pin and any future scrollbar have nothing to work against. Walls
are *discovered* by the walk instead.
### Direction and pin are separate questions
`LazySpan::new(dir, at_end)`.
- **`dir`** means what it means in `Span`: which end of the box item 0
sits at, and which way the sequence grows.
- **`at_end`** is the pin: which end the view clings to as rows arrive.
A transcript is `Dir::DOWN` (oldest message is item 0, at the top) with
the pin at the end (the view sits at the bottom). Conflating the two would
stand it on its head. `Scroll::new`'s third argument is the same flag for
the ordinary case.
### Two coordinate spaces, two conversion points
The walk works entirely in **direction-relative** pixels from the leading
edge — which for `Sign::Neg` is the bottom or the right. `Edge`,
`Placement`, `RowExtent`'s `lead`/`trail` and every local are in that
space, so the layout is written once for both directions. Exactly two
functions know which way round the box is:
- **`abs_region`** flips the box for `Sign::Neg`.
- **`flip_pos`** converts the screen-space positions the public helpers
speak in (`note_tap`, `key_at`, `extent`, all fed by pointer events).
Skip the second and a reversed span hit-tests at the mirror of where it
drew — which looks like a working list until you tap one.
`a_dir_up_span_grows_upward_from_item_zero` guards this, and it asserts on
where rows were **actually drawn** (`UiRenderState::active`) rather than
on `extents`, because an `extents`-only assertion passes with the flip
deleted: it checks the bookkeeping against itself.
### The row-height cache stays in the container
`heights`, keyed by `RowKey`. Two reasons it cannot move into the
framework:
1. **`ActiveData::size` dies exactly when it is needed.** The moment
`LazySpan` culls a row it stops offering it a region, `draw_inner`'s
old-children diff calls `remove_rec`, and the `ActiveData` — with its
`size` — is freed. The framework's copy is gone for precisely the rows
the walk has to pass through without drawing.
2. **A widget may one day render in two places at once** (Iris,
2026-09-08), so anything keyed by `WidgetId` alone that describes where
or how big a widget was drawn will be wrong then. Where and how big
belongs to the owner that placed it.
Virtualisation *means* traversing rows without drawing them, and a size
you can only get by drawing is no use for deciding not to draw.
### Overscroll
`apply_scroll` only takes what the walk says is there, so **scrolling
cannot enter overscroll.** What it cannot prevent is the content or the
viewport changing under a settled anchor, and for that `overscroll_gap`
measures the gap from the ends the walk already placed and `draw` moves
the anchor and walks a **second time inside the same frame**.
Layout is a pure function of the state, not of how many frames have been
drawn (Iris, 2026-09-08). A correction that lands next frame is a frame
drawn wrong, and there may be no next frame — a fling that stopped is not
asking for one.
## The transcript's wiring
`iris/transcript-ui/src/lib.rs`, `build_tree`.
The transcript builds its `Scroll` **by hand rather than through
`.scrollable_to_end()`**, and this is not an oversight. That helper
registers a finger drag driving `Scroll`'s own `DragGesture`, and the
transcript already has an arbiter — `Selection`, which must decide between
panning and selecting text and so cannot let a second `DragGesture` see
the same frames. `DragGesture`'s doc states the rule: one gesture, one
arbiter, each frame delivered exactly once. The wheel handler registered
here is identical to the helper's; only the drag differs.
`Selection` is given the scroll area by `set_scroll_area` after the
`Scroll` exists (rows need a `Selection`, and the `Scroll` needs the
rows), and hands it committed pans and releases. `TranscriptScreen` exposes
both `list` (layout, `extent`/`key_at`/`jump_to_end`) and `scroll`
(position, fling, `amt`).
A `Selection` with no scroll area still selects and still reports taps but
cannot pan; there is a `debug_assert` in `drag` naming that.
## Measurements worth not re-taking
- A settled scroll tick of a `LazySpan` with 31 rows on screen:
**1 real draw and 31 move-slot writes**, no primitive rewrites and no
text reshaped. An idle frame is `(0, 0, 0, 0)``draw_inner` does not
even enter the widget. This is the number any "store the edges and only
recompute what changed" optimisation would have to beat, and it is why
the walk was left alone.
- `Span`, redrawn: two draws per child (see above).
- The one design that would collapse those 31 moves into a single delta
write is moving the content as a unit, which needs a content length —
which a lazy layout cannot supply.
## Still open
**The pin lives in each widget, not in `Scroll`.** Iris asked for `amt`
and the at-end control both to live in `Scroll` so a caller always edits
the `Scroll`. `amt` does; the pin does not, because applying a pin happens
when a row is appended — between frames, with no painter in hand — so it
cannot arrive through `apply_scroll` as it stands. Moving it needs either
a fourth `Widget` method or a parameter on `apply_scroll`
(`apply_scroll(&mut self, delta: &mut f32, pinned_to_end: bool)` reads
best: one method, and the signature says "here is the state you need from
me"). Nothing external edits a pin today — the transcript sets it once at
construction and calls `jump_to_end` on the span for the rest — so this is
a design question rather than a missing capability. **Ask Iris which she
wants before building it.**
## Tests that pin the behaviour
In `lazy_span.rs`, all of these fail if the corresponding piece is undone:
- `a_negative_delta_moves_toward_the_end` — the sign, end to end.
- `amt_counts_only_what_the_child_could_take``scroll_offset`'s reason
for existing.
- `a_fling_stops_at_the_first_row`,
`scrolling_past_the_start_lands_on_it_in_the_same_frame` — the walls,
with no settling frame drawn on purpose.
- `a_dir_up_span_grows_upward_from_item_zero`,
`a_reversed_span_hit_tests_in_screen_space` — the two conversions.
- `a_registered_fling_is_driven_by_tick_animations_and_then_unregisters`
a fling that nothing registers never moves, whatever its velocity.
In `iris/transcript-fixture/tests/` (layer 1, no window or GPU):
- `top_edge.rs`'s `scrolling_past_the_first_row_settles_on_it` /
`scrolling_past_the_last_row_settles_on_it` — both ends, no settling
frame.
- `phone_screen.rs`'s `a_recorded_flick_releases_with_a_velocity_and_
flings_the_list` — the velocity against
`benches/velocity_reference.py`'s number, and the fling's travel against
`benches/fling_spline_reference.py`'s.
- `phone_screen.rs`'s `a_long_press_and_drag_selects_text` — what caught
two `DragGesture`s fighting over the transcript.
- `catch_a_fling.rs`, `gesture_cancel.rs`, `fence_fling.rs` — press-catches
a coasting area, cancels, and a code fence panning sideways
independently of the transcript.
`docs/RUST.md`'s "Three test layers" says which layer answers what. Test
at the cheapest one that can answer the question; the emulator is for JNI,
the IME, insets and one verification run, not for iterating on layout.
+4
View File
@@ -1,5 +1,9 @@
//! `LazySpan`: a virtualised span of variable-height rows, laid out from
//! an anchor rather than eagerly like `Span`.
//!
//! **`docs/SCROLL.md` is the overview** -- how this widget and `Scroll`
//! divide the work, the one sign convention, the `Widget` handoff, and
//! what is still open. Read it first; this file is the detail.
//! RUST.md's I3. Read LAYOUT.md first -- this widget is built entirely out
//! of primitives that design already provides (`Painter::widget`/
//! `widget_within`/`reposition`, and `draw_inner`'s own old-children
+7
View File
@@ -1,3 +1,10 @@
//! `Scroll`: the one place a scroll position, a gesture and a fling live.
//!
//! **`docs/SCROLL.md` is the overview** -- the two kinds of child (moved,
//! or handed deltas through `Widget::apply_scroll`), the one sign
//! convention, what `amt` means for each, and what is still open. Read it
//! first; this file is the detail.
use crate::prelude::*;
use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState};
use std::time::Instant;