Positive scrolls the reader up or left and negative down or right, whichever way the widget receiving it lays its content out (Iris, 2026-09-08: "that way it always works as the user would expect"). `LazySpan` took the delta straight into the direction-relative space its walk works in, so a `Dir::UP` span -- whose later content is *above* -- panned the opposite way from every other scrollable in iris for the same number. `flip_delta` is the conversion, the counterpart of the `flip_pos` that positions already went through, and the two places that meet the outside world (`apply_scroll` and `moved`) are the only ones that use it. Nothing built a `Dir::UP` span yet, so this was latent; the existing sign test could not have found it either, since it asserts in the walk's own space where both halves agree with each other while disagreeing with the screen. The new test compares the two `dir`s against where rows were actually drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
15 KiB
Markdown
310 lines
15 KiB
Markdown
# 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 scrolls the reader up or left; negative down or right.** The
|
|
content's pixels therefore move the positive way along the axis for a
|
|
positive delta — the finger's direction — and that is `Scroll::scroll`'s
|
|
sign, `Scroll::fling`'s, and `Widget::apply_scroll`'s, from the gesture
|
|
all the way down to a row's anchor.
|
|
|
|
**It is a screen direction, not a logical one** (Iris, 2026-09-08:
|
|
"positive should always scroll up / left, and negative down / right ...
|
|
that way it always works as the user would expect"). The earlier wording
|
|
— "positive brings *earlier* content into view" — is true only of a span
|
|
laid out forwards: a `Dir::UP` list's earlier content is *below*, so the
|
|
same delta panned it the opposite way from every other scrollable in
|
|
iris. `LazySpan::flip_delta` is the conversion into the walk's own
|
|
direction-relative space, the exact counterpart of `flip_pos` for
|
|
positions, and its `scroll` (private) is the only thing that speaks that
|
|
space.
|
|
|
|
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 this one. Two tests pin it, and neither is
|
|
redundant: `a_negative_delta_moves_toward_the_end` follows the sign
|
|
across the whole handoff, and `a_delta_moves_both_directions_the_same_
|
|
way_on_screen` checks the two `dir`s against **where rows were drawn** —
|
|
an assertion written in the walk's own space passes with the flip
|
|
deleted, because it checks the bookkeeping against itself.
|
|
|
|
## The `Widget` handoff
|
|
|
|
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.
|