Files
ai-app/docs/SCROLL.md
T
irisandClaude Opus 5 00e0a63887 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>
2026-09-08 21:11:34 -04:00

14 KiB

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):

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_takescroll_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 DragGestures 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.