Files
ai-app/docs/SCROLL.md
T
irisandClaude Opus 5 4ccfda6b8e Delete the decisions and design logs; scripts, rigs and xtask off the root
Iris: "remove both decisions and iris.md. I've decided to instead make
decisions when planning with agents rather than after they do things, and
they're both too long for me to wanna read, + don't cover all the
decisions I'll wanna make about the code anyways. I'll just naturally run
into things for now. Todo is important though."

So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and
AGENTS.md now says not to start another: raise a choice while planning it
with her, otherwise decide it and put the reasoning at the code it
governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them
-- same artefact, same reasoning, and she did not name it, so its six
decisions were folded into docs/SUBAGENTS.md rather than deleted.

Deleting the logs left ~30 citations dangling in code comments and docs.
Each states its reason inline and cited the file only for provenance, so
they now read "decided 2026-09-07" or name the module doc that carries
the reasoning.

The root had six things that were not a program or a document. Moved,
per "I only meant top level sh files":

  run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh  -> scripts/
  rigs/                                              -> scripts/rigs/
  xtask/                                             -> scripts/xtask/

A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh,
iris/*.sh and server/enroll-link.sh did not move.

`target/` at the root is deleted and cannot come back: there was never a
workspace there, and the 29 MB was only xtask's scratch space, now in
scripts/xtask/target/. `cargo xtask apk` still runs from the repo root
and now publishes to scripts/build/outputs/apk/<mode>/ -- one directory
deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk`
discovery pattern needs, and scripts/xtask/build would have been two.

Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy
and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and
signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and
the repo root is now eleven entries with no build output among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:16:24 -04:00

16 KiB

Scrolling in iris

How anything in iris scrolls, as of 2026-09-08. This is the current design, not a history — the git log has 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/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, measured whole 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, its own scroll amount, or a RequestRedraw handle. And do not add a scrolling method to the Widget trait: the three that used to be there (scrolls_itself, apply_scroll, scroll_offset) existed only so a Scroll could drive a LazySpan it had no business wrapping, and they are gone (Iris, 2026-09-08: "I don't like adding methods to widget, it seems like we can structure things better instead").

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 dirs 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 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.

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 — measure, then place

  1. take_delta, and move to where it asks.
  2. Draw the child in a box as long as last frame's length, to measure it. This is free in the common case: the same region as last frame means draw_inner returns immediately.
  3. Apply the pin and clamp against the length just measured.
  4. Draw the child again, at that length and position.

Only the second draw decides anything, and a frame on which the content did change pays one real extra draw — a frame on which it was being redrawn anyway. Placing against the hint and letting the next frame fix it is what hung the composer's text half a line outside its box on Iris's phone: layout is a pure function of the state, not of how many frames have been drawn, and there may be no next frame.

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

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

Pin says it either way round, because there are two questions and they are not the same one (Iris, 2026-09-08). Start/End are content-relative — the first row or the newest one, wherever the layout puts it — and Neg/Pos are axis-absolute: the top/left edge and the bottom/right one, whichever end of the content is there. They coincide for everything except a reversed LazySpan, where they are exact opposites, which is the whole reason both exist. The one question a scrollable acts on is pinned_to_end, and dir is what resolves a Pin into it.

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, 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 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

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 — 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 span by set_scroll_area after it exists (rows need a Selection, and the span needs the rows), and hands it 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 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.

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.
  • a_delta_moves_both_directions_the_same_way_on_screen — the sign is a screen direction, checked against where rows were drawn.
  • amt_counts_only_what_the_child_could_take — why the owner reports what it did rather than the caller adding up what it asked for.
  • 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 position conversions (flip_pos), as a_delta_moves_both_directions_the_same_way_on_screen is the delta one (flip_delta).
  • a_registered_fling_is_driven_by_tick_animations_and_then_unregisters — a fling that nothing registers never moves, whatever its velocity.

In app-rust/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.