# Scrolling in iris This is the current scrolling design; `docs/IRIS_TODO.md` holds open work. Read this before touching `iris/src/widget/position/scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that pans, flings or lays out a long list. ## The one rule **Everything a scroll position is made of lives in one `ScrollController`, and the widget that scrolls owns one.** The position, the pending delta, the travel left, the pin, the `DragGesture` and the `Flinger` are all in that struct (`scrollable.rs`); there is exactly one `Flinger` and one `DragGesture` implementation in the crate's widgets. A widget with one implements `Scrollable`, whose one required pair of methods hands the controller back, and gets `scroll`, `fling`, `drag`, `amt`, `is_scrolling`, `tick_fling` and the pin as default methods. Two widgets have one, and they differ only in how they spend a delta: - **`ScrollArea`** (`scroll_area.rs`) — a fixed child, drawn and then slid about as a lump, which is what makes a scroll tick an O(1) move of one subtree. `.scrollable(axis, pin)` wraps anything in one. - **`LazySpan`** (`lazy_span.rs`) — lays its own rows out from an anchor, so it cannot be a lump and is not wrapped in anything. Its own `.scrollable()` registers the same two senses against the controller it already has. Do not give a widget its own fling, scroll amount, or `RequestRedraw` handle, and do not add scrolling methods to the general `Widget` trait. ## 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 content direction. A `Dir::UP` span's earlier content is below, so `LazySpan::flip_delta` converts public screen-space deltas into the walk's direction-relative space. ## The contract between a controller and its owner Two calls, both inside the owner's `draw`, because a `draw` is the only place that knows where the content ends: 1. **`take_delta()`** — everything a wheel, a drag or a fling asked for since the last layout, in one number, already clamped to the travel that layout reported. Clipping it stops a fling. 2. **`set_travel(Travel)`** at the end, plus whichever of **`moved_by`** (movement) or **`set_amt`** (an absolute position) fits how that owner knows where it ended up. `Travel` is `{ back, fwd }` in the same screen-space units as a delta: `back` bounds a positive one, `fwd` a negative one, and `f32::INFINITY` means "the end is not in sight". That last is not a placeholder — a lazy layout genuinely cannot say how far its content runs without walking there, and `clamp` takes the answer with no branch of its own. **Why the delta is banked rather than applied where it arrives.** A wheel event, a drag frame and a fling tick all land between draws, and none of them can know whether there is content to move into. Applying them at the layout that follows is also what keeps layout a pure function of the state (Iris, 2026-09-08). The visible consequence, and the thing that catches a test out: **`amt` does not move until the next draw.** **Which clock a fling is ticked on.** The vsync the frame callback carries, not `Instant::now()` -- on Android `do_frame`'s `frame_time_nanos`, converted through the view's one `DeviceClock` (`sense.rs`), which also dates every touch sample, so a fling is advanced on the clock its own velocity was measured on. Frames are presented on an even cadence whatever clock they are computed on, so sampling the spline at "whenever the callback got to run" moves the content unevenly between frames that are shown evenly -- a shimmer that no frame-time percentile can see, since no frame was late. `docs/RUST.md` records the measurements. ### Why a remainder was not enough The `apply_scroll(&mut delta)` this replaced left the part it could not take in the caller's variable, and that 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. Now the owner reports what it *did* (`moved_by`, from the one place its anchor moves) as well as what it *can* do, and `amt_counts_only_what_the_child_could_take` is the test. ## What `amt` means The same direction for both owners, and a different origin: - `ScrollArea`: distance from the start of the content, clamped into the scroll range. An absolute position. - `LazySpan`: **movement, not position.** Paging rows in above moves the origin and the span cannot say by how much, never having measured them. A scrollbar needs a real content length before it can use either, and a lazy span has none. Do not invent one. ## `ScrollArea::draw` — draw, then place 1. `take_delta`, and move to where it asks. 2. Offer the child **last frame's** length and read the size it reports. An unchanged child returns from `draw_inner` without running `draw`. 3. Apply the pin and clamp against that size. 4. Place the retained drawing at its exact length and position. It is redrawn only if its reported size does not fit that box. There is no measurement mode and no discarded drawing. Placing against the old length and letting the next frame correct it is not valid: layout must finish from the current state even if no later frame arrives. The pin only re-pins on a frame with **no delta of its own**: the pin means "stay flush with the end as the content grows", and a reader who has just scrolled away has said otherwise. ## `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, and it drives its own controller: the walk is the only thing that can say how far it may go, so nothing above it is in a position to. ### Why it is not a `Span` inside a `ScrollArea` - A `Span` is skipped entirely in the steady state. When redrawn, it uses exact hints first, draws unknown fixed children forward from the cursor, and places retained drawings after flexible allocation. A child is redrawn only when its final box changes size. - A `ScrollArea`'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 one would never update which rows it shows. That is why a `LazySpan` owns its controller instead of being wrapped in one. - A lazy child cannot report a content length, so an area's clamp, end-pin and any future scrollbar would have nothing to work against. Walls are *discovered* by the walk instead. ### Direction and pin are separate questions `LazySpan::new(dir, pin)`, and `ScrollArea::new(inner, axis, pin)`. - **`dir`** means what it means in `Span`: which end of the box item 0 sits at, and which way the sequence grows. - **`pin`** is which end the view clings to as rows arrive. A transcript is `Dir::DOWN` (oldest message is item 0, at the top) with `Pin::End` (the view sits at the bottom). Conflating the two would stand it on its head. `Start`/`End` are content-relative; `Neg`/`Pos` are axis-absolute. They diverge for a reversed `LazySpan`. A scrollable acts on `pinned_to_end`, with `dir` resolving the chosen `Pin`. ### 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 render in two places at once**, 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, and why it happens at all **Because the span cannot see the wall until it has walked to it.** With rows loaded past an edge it reports `INFINITY` of travel that way, takes the whole delta, and the walk that follows discovers the content ran out 200px ago. Nothing else could be reported: the rows past the edge have never been measured, and measuring them is exactly the work virtualisation exists to skip. The other source is the content or the viewport changing under a settled anchor — a row that grew, a page dropped, the keyboard opening — where nothing scrolled at all. So `overscroll_gap` measures the gap from the ends the walk already placed, and `draw` moves the anchor by it and walks a **second time inside the same frame**. `moved_by` counts that correction along with the move that caused it, which is why `amt` stays equal to what is on screen rather than drifting by every overshoot. Layout is a pure function of state, not of how many frames have been drawn. 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. ## Directional input A scrollable registers `CursorSense::drag(axis)` and `CursorSense::Scroll(axis)`. Horizontal and vertical gestures are distinct input semantics, so a higher horizontal row does not consume an undecided press that may belong to the lower vertical transcript. Both may observe the press start; after movement crosses `DRAG_SLOP`, the pointer locks to its dominant axis, only the matching listener receives the drag, and capture cancels every other listener that had been tracking the press. Visual layers still decide priority between listeners for the same semantic. `drag_senses()` remains the deliberately direction-agnostic form for widgets such as selection that arbitrate the gesture themselves. Wheel input follows the same axis split; the desktop backend's Shift+wheel mapping produces a horizontal delta before dispatch, so it reaches the horizontal listener without a scroll-widget special case. ## The transcript's wiring `app-rust/src/ui/mod.rs`, `build_tree`. The transcript registers the wheel **by hand rather than calling `LazySpan::scrollable()`**, and this is not an oversight. That helper also registers a finger drag driving the span's own `DragGesture`, and the transcript already has an arbiter — `SelectionController`, 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. `SelectionController` is attached directly to the span and holds its weak handle, then hands committed pans and releases through `Scrollable::scroll`/`fling`. There is no wrapper widget: `TranscriptScreen::list` is the layout (`extent`, `key_at`, `jump_to_end`) *and* the position (`amt`, `fling`, `is_scrolling`). 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`, for 20, 200 or 2,000 total rows: **1 real draw and 1 child-coordinate move-slot write**, no primitive rewrites and no text reshaped. The visible-row walk remains: it is what admits and retires rows at the viewport boundary, and a newly admitted row has real initial-placement work of its own. An idle frame is `(0, 0, 0, 0)` — `draw_inner` does not even enter the widget. - A fully hinted `Span` draws each child once. Unknown fixed children draw provisionally and move; region-dependent children redraw if their final box has a different size. - Moving the currently retained run as a unit does **not** require the lazy span's unknowable total content length. Its anchor supplies the relation between stable local row boxes and their desired screen boxes; one retained child-coordinate slot carries that translation. The offset is occasionally rebased after 65,536 pixels to preserve `f32` precision, a rare O(visible) move-slot pass rather than steady-state work. ## Verification The unit and headless integration tests exercise direction, both walls, reversed hit-testing, fling registration, cancellation, nested horizontal pans, and transcript selection. `docs/RUST.md` defines the three test layers; use the cheapest layer that can observe the behavior under test.