diff --git a/AGENTS.md b/AGENTS.md index 10103a4..0d81c31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,11 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout". public API** -- Iris, 2026-09-08), working list, decisions log, layout/render design, and texture-atlas design, and the client-core crate's design, respectively. + - `docs/SCROLL.md` — how anything in iris scrolls: `Scroll` owns the + position, the gesture and the fling; a child either gets moved or + answers `Widget::scrolls_itself` and is handed deltas. Read it before + touching `scroll.rs`, `lazy_span.rs`, or anything that pans, flings + or lays out a long list. - `.dev-updater.ron` — what Dev Updater builds here: the server (run as `service: Managed(…)`, supervised by Dev Updater's own implementation rather than a script kept here) and the APK, in parallel. It points at diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index 4b3fb2b..75998cb 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -79,6 +79,9 @@ order and what "done" looks like. Tick and date them in place. through, and the fixture recordings' expected velocity flipped sign with its magnitude unchanged. + **`docs/SCROLL.md` is the standing reference** for how scrolling works + now -- read that rather than reconstructing it from this entry. + **Still open, and the one thing to decide:** the *pin* ("stay at the end as rows are appended") is still each widget's own -- `Scroll` has `snap_end` for an ordinary child, `LazySpan` has one for itself, and diff --git a/docs/SCROLL.md b/docs/SCROLL.md new file mode 100644 index 0000000..243779b --- /dev/null +++ b/docs/SCROLL.md @@ -0,0 +1,298 @@ +# 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`): + +```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. diff --git a/iris/src/widget/position/lazy_span.rs b/iris/src/widget/position/lazy_span.rs index eb6ef04..11ae7de 100644 --- a/iris/src/widget/position/lazy_span.rs +++ b/iris/src/widget/position/lazy_span.rs @@ -1,5 +1,9 @@ //! `LazySpan`: a virtualised span of variable-height rows, laid out from //! an anchor rather than eagerly like `Span`. +//! +//! **`docs/SCROLL.md` is the overview** -- how this widget and `Scroll` +//! divide the work, the one sign convention, the `Widget` handoff, and +//! what is still open. Read it first; this file is the detail. //! RUST.md's I3. Read LAYOUT.md first -- this widget is built entirely out //! of primitives that design already provides (`Painter::widget`/ //! `widget_within`/`reposition`, and `draw_inner`'s own old-children diff --git a/iris/src/widget/position/scroll.rs b/iris/src/widget/position/scroll.rs index 9608e5e..32533b1 100644 --- a/iris/src/widget/position/scroll.rs +++ b/iris/src/widget/position/scroll.rs @@ -1,3 +1,10 @@ +//! `Scroll`: the one place a scroll position, a gesture and a fling live. +//! +//! **`docs/SCROLL.md` is the overview** -- the two kinds of child (moved, +//! or handed deltas through `Widget::apply_scroll`), the one sign +//! convention, what `amt` means for each, and what is still open. Read it +//! first; this file is the detail. + use crate::prelude::*; use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState}; use std::time::Instant;