283 lines
14 KiB
Markdown
283 lines
14 KiB
Markdown
# 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 platform wake
|
|
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/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.
|