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:
1 parent
b7474f61b0
commit
00e0a63887
5 files changed
+317
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user