Files
ai-app/iris/src/widget/position/lazy_span.rs
T
irisandClaude Opus 5 09778346a0 Prune the docs of work already done: 18,252 -> 7,567 lines
Iris: "the documentation is also pretty crazy too. Can you go through it
and remove everything that's already done and decided? There's entire md
files iirc for projects already complete. And many with checkboxes already
ticked off that just fill up context."

  docs/RUST.md        8503 -> 905    the framework bake-off (options,
                                     recommendation, twelve closed
                                     experiment boxes) and two superseded
                                     "where things stand" sections, out;
                                     what the experiments settled kept as
                                     one line each
  docs/IRIS_TODO.md   1383 -> 229    fifty closed items and six
                                     phone-report sections whose defects
                                     are all fixed
  docs/LAYOUT.md      1116 -> 829    the pre-implementation framing: the
                                     old trait, the checklist, the
                                     migration list, the pass conditions
  docs/TEXTURES.md     496 -> 240    the prior-art survey, the proposal
                                     and its review, all implemented
  docs/REVIEW-*.md     673 -> 0      two completed review passes; the two
                                     findings left open on purpose (mask
                                     hit-testing, the phone's font set)
                                     moved into RUST.md

What survives a prune is what cannot be cheaply re-derived: measurements
(the APK-size table, the phone bench reports), dead ends, invariants and
their reasons, and the design of what exists now rather than the route to
it. AGENTS.md now says that, so the next session prunes as it goes rather
than appending; docs/IRIS_TODO.md's header says items are deleted when
they land rather than ticked.

Deleting the two review files left eighteen citations dangling in code
comments that state their reason inline and cited the file for provenance
only — those now read "(review, 2026-09-06)" and carry no dead pointer.
The emulator's measured GPU capabilities moved to the this-machine-android
skill, where machine facts belong. IRIS.md and DECISIONS.md are dated
records and were not rewritten; each gained one note that paths in older
entries predate the 2026-09-08 crate merge, pointing at the mapping.

Not touched, deliberately: docs/DECISIONS.md's entries (that file *is* the
queue of things for Iris to review, so deleting decided items would remove
what it exists for) and iris/readme.md and iris/TODO, which are hers.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in every workspace, and every remaining docs/*.md cross-reference
resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:50:53 -04:00

2482 lines
110 KiB
Rust

//! `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 `ScrollArea`
//! 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
//! diffing) rather than adding a second move mechanism.
//!
//! ## Design
//!
//! **It knows nothing about masks.** What it does is *cull*: a row that
//! falls entirely outside the region this widget was offered is never
//! drawn (`intersects_viewport`). A row that *straddles* an edge is drawn
//! in full, because virtualisation decides which rows are drawn and never
//! how much of one -- so the overhang past this list's box reaches the
//! screen unless something clips it, and clipping is `.masked()`, which
//! the caller adds when it wants one. Iris, 2026-09-08: **"Why does the
//! mask matter at all. If you want a mask then you add `.masked()`. It
//! should just prevent rows that aren't in its region at all from drawing
//! ... Just like the opt in scrollable, masking should be opt in."**
//!
//! Two shapes this file went through before that, both worse. It asserted
//! `Painter::is_masked` and refused to draw otherwise, which made an
//! ordinary full-screen list -- every benchmark, every simple app --
//! panic for want of ceremony it did not need; and the case that actually
//! bites passed the check anyway, since a mask *larger* than the list's
//! box satisfies `is_masked` while still letting the overhang through.
//! Then it set a mask of its own, which is this widget deciding something
//! that is not its to decide: a caller that wants the overhang (or that
//! is already clipped by something bigger) has no way to say so, and the
//! transcript ended up double-masked.
//!
//! The fault a caller is opting *out* of, when it leaves `.masked()` off,
//! is the transcript panned to its top edge drawing code through the
//! header bar above it, on Iris's phone (docs/IRIS_TODO.md, 2026-09-07).
//!
//! **Rows are keyed by a `u64` (`RowKey`), not a generic type.** Every real
//! row source in this codebase (a transcript's monotonic sequence number, a
//! chat message id) is already an integer; a generic key would cost every
//! call site a type parameter for a capability nothing here needs yet --
//! the simplest thing that works, per the code rules.
//!
//! **Composed only while visible, for free.** `LazySpan` does not maintain its
//! own "which widgets are alive" bookkeeping. Its `draw` calls
//! `painter.widget`/`widget_within` only for the rows currently in view;
//! `UiRenderState::draw_inner` already diffs a redrawn widget's new
//! `children` against its old ones and frees (`remove_rec`) whatever is no
//! longer called (LAYOUT.md section on caching, and the "old_children"
//! removal in `draw_inner`). A row that scrolls out is therefore dropped
//! and its primitives freed the very next time `LazySpan` redraws -- no new
//! mechanism, just relying on the one LAYOUT.md already built.
//!
//! **Rows draw once and are moved, not re-laid-out, on scroll.** A `LazySpan`
//! is laid out outward from one **anchor** row (`Anchor { slot, edge,
//! offset }`: a slot index, which of its edges is pinned, and that edge's
//! pixel offset from the viewport's leading edge) rather than from a
//! single scroll amount measured from the top of all content -- there is
//! no "top of all content" to measure without walking every row, which is
//! exactly the O(N) cost virtualisation exists to avoid. Rows below the
//! anchor are placed **top-known** (`Placement::Leading`): offered an exact
//! top and a generous, oversized bottom, drawn once with
//! `painter.widget_within`, and their real height read back from the
//! returned `Size`. Rows above the anchor are placed **bottom-known**
//! (`Placement::Trailing`): since every widget in this crate paints itself
//! anchored top-left of whatever it is offered (LAYOUT.md's deviation 2),
//! placing a row so its *bottom* lands at an exact pixel needs the same
//! "learn the size, then move" trick `Aligned` already uses --
//! `painter.widget` at the full offered region to measure, then
//! `painter.reposition` (an O(1) offset write, no second draw) to the
//! exact box. On an ordinary scroll tick only `Anchor::offset` changes;
//! every already-visible row keeps the same *size* it was offered last
//! frame (top-known rows: same generous bottom bound; bottom-known rows:
//! the same full-region measurement, which `draw_inner`'s own
//! `active.region == region` check turns into a **no draw at all**, its
//! cached `Size` returned for free) so the per-row cost of a tick is one
//! `mov()`/`reposition()` write, never a redraw -- verified in this file's
//! `moves_stay_o1_across_list_size` test and in `benches/message_lazy_span.rs`.
//!
//! **The scroll anchor survives a row inserted above it.** The anchor
//! names a row by its *slot index*, not by an absolute content offset
//! measured from the top -- so `push_front` only has to shift the
//! anchor's slot by one (`+= 1`, an O(1) write) to keep it pointing at the
//! same logical row; nothing about where that row is drawn changes, and
//! rows outside the loaded window are never touched. This is the same
//! reason `push_back`/`pop_front`/`pop_back` are all O(1): the widget
//! never computes "total content height," only the local heights of the
//! rows it is actively placing.
//!
//! **"More" sentinels are two ordinary optional widgets, not a second
//! data model.** `more_before`/`more_after` are each `Option<StrongWidget>`
//! set by the caller (`WidgetPtr`'s own idiom); when present, the walk
//! outward from the anchor treats the sentinel as one more slot past the
//! real rows (`BEFORE_SLOT`/`AFTER_SLOT`, reserved `isize` values below
//! `0`/above any real index) rather than special-casing it, so a sentinel
//! costs nothing extra to place or to virtualise away.
//!
//! **"Hold the edge nearest the tap," done in the layout pass.**
//! `note_tap(viewport_pos)` records where the user last touched the list,
//! in viewport-relative pixels, without forcing a redraw by itself -- the
//! app is expected to call it and then mutate whatever row is expanding
//! (e.g. toggling a collapsed message), which is what actually marks that
//! row (and, by the existing resize-bubble in `UiRenderState::redraw`,
//! `LazySpan` itself) dirty. The *next* time `LazySpan::draw` runs, before placing
//! anything, it looks at `extents` (each visible row's on-screen box as of
//! the *previous* frame, cached while walking) to find which row contains
//! the tap, decides whether the tap was nearer that row's top or bottom
//! edge, and re-anchors to exactly that row/edge/pixel -- so the row this
//! frame draws at its *new* height with the chosen edge pinned to the same
//! screen position it already occupied, and only the far side visibly
//! grows or shrinks. This is a layout decision made before any primitive
//! is written for the frame, not a correction applied to an already-drawn
//! wrong frame.
//!
//! **A row's height is cached by key once measured**, and reused directly
//! (one `widget_within` at the exact box, no re-measurement) on every later
//! placement of that row -- not merely an optimisation: see `place`'s doc
//! for why a row that fills whatever it is offered (a `.background(rect
//! (...))`) needs this to ever be placed at the right size at all, and why
//! reusing `draw_twice` every frame instead would defeat `draw_inner`'s own
//! skip-or-move caching. Only a row's first-ever appearance pays the
//! two-draw measurement; nothing here estimates a height for an off-screen
//! row that has never been measured, so this stays independent of how many
//! rows exist outside the loaded window.
//!
//! **Only what overlaps the viewport is drawn, and it is drawn whole.**
//! One rule, `intersects_viewport`, used by both halves of that sentence:
//! a row straddling either edge is drawn in full and clipped by the
//! `.masked()` its caller must place it in (`LazySpan::draw` asserts that),
//! and a row that has left the viewport is not drawn at all. The walk
//! still traverses whatever lies between the anchor and the viewport, and
//! `rehome_anchor` moves the anchor back onto a visible row every frame so
//! that "whatever lies between" stays empty however far the list is
//! panned.
//!
//! **This widget scrolls itself, and everything that is not its layout
//! lives in a [`ScrollController`] it owns** -- the position, the gesture,
//! the fling and the pin, the same struct a `ScrollArea` holds
//! (`docs/SCROLL.md`). It is not wrapped in one of those and must not be:
//! a scroll tick offers a moved region of the same size, `draw_inner`
//! takes the `mov` path, and a virtualising child inside it would never
//! update which rows it shows. `.scrollable()` here is the span's own
//! inherent one, registering the wheel and the drag against that
//! controller.
//!
//! What is left in this file is the layout: an anchor, a walk outward
//! from it, and an honest answer about how far it can go
//! ([`Self::travel`]).
//!
//! **Overscroll cannot be entered by scrolling, and is taken back within
//! the frame when something else causes it.** The controller clamps a
//! delta to the travel the last walk reported, so a delta that runs off a
//! wall already in view is simply cut short. What that cannot cover is a
//! wall this span has not walked to yet -- with rows loaded past an edge
//! there is no bound to report -- or the content or viewport changing
//! under a settled anchor, and for both
//! `overscroll_gap` measures the gap from the ends the walk already
//! placed and `draw` moves the anchor by it and walks a second time
//! **before the frame ends** -- layout is a pure function of the state,
//! not of how many frames have been drawn (Iris, 2026-09-08), the same
//! rule `ScrollArea::draw` follows. A span shorter than its viewport is not
//! overscrolled and is left alone, still pinned to the end it was built
//! with.
use crate::prelude::*;
use iris_core::util::HashMap;
use std::collections::VecDeque;
use std::time::Instant;
/// A stable identifier for a loaded row, reused across pages so that a row
/// already measured and drawn is not treated as new when data is inserted
/// elsewhere. See the module doc for why this is a plain integer.
pub type RowKey = u64;
/// One loaded row: a stable key plus its content widget, built by the
/// caller (with access to the real `Rsc`) before it is handed to `LazySpan` --
/// `LazySpan` itself only ever sees `&dyn Widget` through `Painter`, per
/// LAYOUT.md's single-draw model, so it cannot build rows lazily on its
/// own.
pub struct LazyItem {
pub key: RowKey,
pub widget: StrongWidget,
}
impl LazyItem {
pub fn new(key: RowKey, widget: StrongWidget) -> Self {
Self { key, widget }
}
}
/// Which of a row's two edges is pinned in place, named along `dir`
/// rather than by screen position: `Leading` is the top of a `Dir::DOWN`
/// span and the bottom of a `Dir::UP` one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Edge {
Leading,
Trailing,
}
/// A slot index one below the lowest real item index, reserved for the
/// "more before" sentinel. `isize::MIN` rather than `-1` so it can never
/// collide with a real index no matter how `items` grows, and so it needs
/// no adjustment when rows are inserted or removed at the front (only real
/// indices shift; the sentinels are fixed constants).
const BEFORE_SLOT: isize = isize::MIN;
/// The mirror of `BEFORE_SLOT` for "more after" -- fixed regardless of how
/// many real items exist, so appending or removing at the back never has
/// to touch it either.
const AFTER_SLOT: isize = isize::MAX;
/// Where the span is anchored: `slot`'s `edge` renders at `offset` pixels
/// from the viewport's leading edge (its top, for a `Dir::DOWN` span), and
/// everything else is placed outward from that one fixed point. See the
/// module doc's "Rows draw once and are moved" section.
#[derive(Debug, Clone, Copy)]
struct Anchor {
slot: isize,
edge: Edge,
offset: f32,
}
/// A visible row's on-screen box as of the last successful layout, cached
/// only so `note_tap`'s effect can be resolved next frame without a scan
/// over anything outside the viewport. Cleared on any structural change
/// (`push_front`/`push_back`/`pop_front`/`pop_back`/`set_more_*`) rather
/// than kept in step with slot-index shifts, since a tap landing in the
/// same tick as a page insert is rare enough that "the tap is silently
/// dropped" is an acceptable answer and it avoids a second piece of index
/// bookkeeping to keep correct.
#[derive(Debug, Clone, Copy)]
struct RowExtent {
slot: isize,
/// Both in **direction-relative** pixels from this widget's leading
/// edge (`abs_region`'s space), not screen pixels -- the walk works in
/// one space and only the public helpers below convert
/// ([`LazySpan::to_screen`]).
lead: f32,
trail: f32,
}
/// Which of a row's edges the walk already knows, and where it is. The
/// two are symmetric -- one pinned edge plus a height gives the box --
/// so `place` parameterises over them through [`Self::edges`] rather
/// than carrying a copy of the same logic per direction.
#[derive(Clone, Copy)]
enum Placement {
/// This row's leading edge is known; its trailing edge is wherever its
/// own height puts it.
Leading(f32),
/// This row's trailing edge is known; its leading edge is that height
/// back from it.
Trailing(f32),
}
impl Placement {
/// The `(leading, trailing)` edges this placement implies for a row
/// of `height`.
fn edges(self, height: f32) -> (f32, f32) {
match self {
Placement::Leading(lead) => (lead, lead + height),
Placement::Trailing(trail) => (trail - height, trail),
}
}
}
/// A virtualised span of variable-height rows, laid out lazily from an
/// anchor. See the module doc for the design.
pub struct LazySpan {
/// Which end of this widget's box item 0 sits at, and which way the
/// sequence grows -- the same meaning `Span::dir` has, so the word is
/// one concept across both. Deliberately *not* the same question as
/// which end the view is pinned to (`snap_end`): a transcript's oldest
/// message is item 0 and sits at the **top** (`Dir::DOWN`) while the
/// view sits at the **bottom**, so conflating the two would stand it
/// on its head.
dir: Dir,
items: VecDeque<LazyItem>,
more_before: Option<StrongWidget>,
more_after: Option<StrongWidget>,
anchor: Option<Anchor>,
/// The position, the gesture, the fling and the pin -- everything
/// about scrolling that is not this widget's own layout, in the same
/// struct a `ScrollArea` holds rather than a protocol between the two
/// (`docs/SCROLL.md`). This widget's `draw` is one instance of the
/// contract in [`ScrollController`]'s module doc: take the delta,
/// lay out, report what it did and how far it can still go.
ctl: ScrollController,
viewport_len: f32,
/// `viewport_len` as of the *previous* draw -- what `repair_anchor`
/// compares against to tell "the container was actually resized" from
/// "an ordinary frame where `snap_end` merely hasn't been recomputed
/// since a `scroll()` call yet." Without this distinction,
/// `repair_anchor` would re-snap a deliberate scroll away from the
/// bottom back to flush on the very next frame, since `snap_end` is
/// only recomputed at the *end* of a layout pass and so still reads
/// `true` (from before the scroll) the next time `repair_anchor` runs.
last_viewport_len: f32,
pending_tap: Option<f32>,
extents: HashMap<RowKey, RowExtent>,
/// Each row's height as of its last real draw, kept across frames so
/// an already-measured row is placed directly at its exact box next
/// time (one `widget_within`, no oversized measurement pass) --
/// see `place`'s doc for why a fresh measurement can't be skipped
/// merely by translating an already-drawn primitive. Pruned when a
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
/// however many rows are currently loaded.
heights: HashMap<RowKey, f32>,
/// Whether the last walk found no more content before the leading
/// edge *and* nothing left to give back there -- what
/// [`Self::overscroll_gap`] reads. `false` by default, matching
/// "assume there is more content until a walk proves otherwise."
at_start: bool,
/// The mirror of `at_start` for the trailing end.
at_end: bool,
/// The extreme edges the last walk reached, in the walk's own
/// direction-relative pixels, kept so [`Self::travel`] can say
/// **exactly** how much of a delta this span is able to take rather
/// than only whether it is against a wall: with no more content past
/// an edge, the travel left in that direction is the distance from
/// that edge to the viewport's. An estimate here would leave `amt`
/// drifting from what is on screen by every overshoot into a wall.
content_lead: f32,
content_trail: f32,
/// Whether the last walk ran out of items before its leading /
/// trailing edge -- the structural half of `at_start`/`at_end`, and
/// what says whether `content_lead`/`content_trail` bound a scroll at
/// all. With more content past an edge there is no bound to give.
no_more_before: bool,
no_more_after: bool,
}
impl LazySpan {
/// `pin` says which end of its content this span opens at and clings
/// to as rows arrive -- [`Pin::End`] for a transcript, and independent
/// of `dir`, which says where item 0 is (see the field). `dir` is also
/// what resolves [`Pin::Pos`]/[`Pin::Neg`], the axis-absolute way of
/// asking the same question.
pub fn new(dir: Dir, pin: Pin) -> Self {
Self {
dir,
ctl: ScrollController::new(dir, pin),
items: VecDeque::new(),
more_before: None,
more_after: None,
anchor: None,
viewport_len: 0.0,
last_viewport_len: 0.0,
at_start: false,
at_end: false,
content_lead: 0.0,
content_trail: 0.0,
no_more_before: false,
no_more_after: false,
pending_tap: None,
extents: HashMap::default(),
heights: HashMap::default(),
}
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// Insert-above: O(1). The anchor is keyed by slot index, not by an
/// absolute content offset, so the only bookkeeping a prepended row
/// needs is shifting that one index -- nothing about an already-placed
/// row's position is touched. See the module doc.
pub fn push_front(&mut self, row: LazyItem) {
self.items.push_front(row);
if let Some(a) = &mut self.anchor
&& a.slot != AFTER_SLOT
&& a.slot >= 0
{
a.slot += 1;
}
self.extents.clear();
}
/// O(1). If the list is currently flush with its own end
/// ([`ScrollController::pinned_to_end`]), the new row becomes the
/// anchor so a live list stays pinned to its newest content.
pub fn push_back(&mut self, row: LazyItem) {
self.items.push_back(row);
if self.ctl.pinned_to_end() {
self.anchor = Some(Anchor {
slot: self.items.len() as isize - 1,
edge: Edge::Trailing,
offset: self.viewport_len,
});
}
self.extents.clear();
}
/// O(1). If the anchor was pinned to the row being removed, it is
/// invalidated here and repaired (falling back to the bottom-most
/// remaining row) on the next `draw`, since there is no more specific
/// answer than "wherever this widget's default now is."
pub fn pop_front(&mut self) -> Option<LazyItem> {
let popped = self.items.pop_front();
if let Some(row) = &popped {
if let Some(a) = &mut self.anchor {
if a.slot == 0 {
self.anchor = None;
} else if a.slot > 0 {
a.slot -= 1;
}
}
self.heights.remove(&row.key);
self.extents.clear();
}
popped
}
/// O(1); see `pop_front`.
pub fn pop_back(&mut self) -> Option<LazyItem> {
let old_len = self.items.len() as isize;
let popped = self.items.pop_back();
if let Some(row) = &popped {
if let Some(a) = &mut self.anchor
&& a.slot == old_len - 1
{
self.anchor = None;
}
self.heights.remove(&row.key);
self.extents.clear();
}
popped
}
pub fn set_more_before(&mut self, widget: Option<StrongWidget>) {
self.more_before = widget;
self.extents.clear();
}
pub fn set_more_after(&mut self, widget: Option<StrongWidget>) {
self.more_after = widget;
self.extents.clear();
}
/// Swap the last row's widget for a new one **without moving it**: the
/// slot index is unchanged, so an anchor already pointing at this slot
/// (in particular `snap_end`'s pinned-to-newest case) stays pinned, and
/// an anchor pointing anywhere else -- this row scrolled out of view --
/// is untouched, so nothing currently on screen moves. This is what a
/// streamed reply needs: the row whose *content* keeps changing after
/// it first appears is still the same row by position, even if its
/// `RowKey` happens to change too (rare -- only `heights`/`extents` care
/// about the key, and both are invalidated here the same way
/// `pop_back` already invalidates them for the row it removes).
/// `None` if the list is empty. O(1), same as `push_back`/`pop_back`.
pub fn replace_back(&mut self, row: LazyItem) -> Option<LazyItem> {
let idx = self.items.len().checked_sub(1)?;
let old = std::mem::replace(&mut self.items[idx], row);
self.heights.remove(&old.key);
self.extents.clear();
Some(old)
}
/// Drop every loaded row and reset to the same state `LazySpan::new` would
/// give -- the fallback path for a change `apply`-style incremental
/// callers can't express as a replace-or-append (RUST.md: `group_tool_runs`
/// regrouping an earlier row). `more_before`/`more_after` are left
/// alone: a full paging reset is a different operation from "the
/// content changed," and a caller that wants both calls
/// `set_more_before(None)`/`set_more_after(None)` itself.
pub fn clear(&mut self) {
self.items.clear();
self.anchor = None;
self.ctl.set_pinned_to_end(true);
self.heights.clear();
self.extents.clear();
}
/// Move the anchor's edge by `amt` pixels, where positive brings
/// **later** content into view.
///
/// Named apart from [`Scrollable::scroll`] rather than shadowing it:
/// the two run in different spaces and an inherent method silently
/// wins over a trait one, so a caller reaching for the public
/// convention would have got this instead.
///
/// Private, and in the direction-relative space the walk works in
/// rather than the screen space every public delta speaks in: the
/// anchor's offset says where the pinned edge *sits*, so moving the
/// content forward moves that number down, and for a `Sign::Neg`
/// `dir` "forward" is up the screen rather than down it.
/// [`Self::flip_delta`] is the one conversion, exactly as
/// [`Self::flip_pos`] is for positions.
///
/// Unclamped here, on purpose: it is one write. The controller does
/// the clamping, against the walls [`Self::travel`] published from the
/// last walk, and `overscroll_gap` gives back whatever that could not
/// know about.
fn move_anchor(&mut self, amt: f32) {
if self.anchor.is_none() {
return;
}
self.anchor.as_mut().unwrap().offset -= amt;
// Converted into the screen-space convention the controller and
// every caller outside this widget speak in. Every move this span
// makes goes through here, including the ones `overscroll_gap`
// gives back, so `amt` is what actually happened rather than what
// was asked for -- see `ScrollController::moved_by`. Jumps
// (`jump_to_end`/`jump_to_start`) deliberately do not: they are
// not travel across the content.
let moved = self.flip_delta(amt);
self.ctl.moved_by(moved);
}
/// The anchor's own row index and pixel offset, formatted the same
/// shape Compose's `firstVisibleItemIndex`/`firstVisibleItemScrollOffset`
/// report (`idx=N/off=Mpx`) -- what RUST.md's "Benchmark v2" fling
/// phase reads before/after/between its fling runs so the two apps'
/// travel can be compared directly. `more_before`/`more_after`
/// sentinels print as `idx=more-before`/`idx=more-after` rather than
/// leaking their internal `isize` representation; `idx=none` if the
/// list has never drawn (no anchor yet -- e.g. right after
/// `jump_to_end` and before the next frame runs `repair_anchor`).
pub fn anchor_position_display(&self) -> String {
match self.anchor {
None => "idx=none".to_string(),
Some(a) if a.slot == BEFORE_SLOT => "idx=more-before".to_string(),
Some(a) if a.slot == AFTER_SLOT => "idx=more-after".to_string(),
Some(a) => format!("idx={}/off={}px", a.slot, a.offset.round() as i64),
}
}
/// Snap to the newest content (last item, or the `more_after`
/// sentinel if set), aligned to the trailing edge of the viewport --
/// its bottom for a `Dir::DOWN` span. O(1).
pub fn jump_to_end(&mut self) {
self.anchor = None;
self.pending_tap = None;
}
/// Snap to the oldest loaded content (first item, or `more_before` if
/// set), aligned to the leading edge of the viewport -- its top for a
/// `Dir::DOWN` span. O(1).
pub fn jump_to_start(&mut self) {
let slot = if self.more_before.is_some() {
BEFORE_SLOT
} else if !self.items.is_empty() {
0
} else {
return;
};
self.anchor = Some(Anchor {
slot,
edge: Edge::Leading,
offset: 0.0,
});
self.pending_tap = None;
}
/// Convert between the screen-space pixel offsets every caller of this
/// widget speaks in -- a pointer position, a row's box, both measured
/// from this widget's **top** (or left) -- and the direction-relative
/// space the walk works in, which for a `Sign::Neg` `dir` runs the
/// other way. Its own inverse, so one function covers both directions.
///
/// The whole of the conversion lives at this boundary rather than in
/// the layout: `abs_region` is the only other place that knows which
/// way round the box is, and keeping the walk in one space is what
/// lets the anchor, the placement and the clamp be written once.
fn flip_pos(&self, pos: f32) -> f32 {
match self.dir.sign {
Sign::Pos => pos,
Sign::Neg => self.viewport_len - pos,
}
}
/// [`Self::flip_pos`] for a *delta*: convert between the screen-space
/// scroll deltas every caller speaks in -- positive scrolls the
/// reader up or left, whatever this span's `dir` is -- and the
/// direction-relative amount [`Self::scroll`] takes, where positive
/// always brings later content into view. Its own inverse, so one
/// function covers both directions and both ways round.
///
/// **The sign 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"). Without this a `Dir::UP` span pans backwards against
/// every other scrollable in iris for the same delta, because its
/// later content is *above* rather than below -- and a test written
/// in the walk's own space cannot see it, since both halves agree
/// with each other while the screen disagrees with both.
///
/// A position needs `viewport_len` to flip about and a delta does
/// not, which is why they are two functions rather than one.
fn flip_delta(&self, amt: f32) -> f32 {
match self.dir.sign {
Sign::Pos => -amt,
Sign::Neg => amt,
}
}
/// Record where (in pixels from this widget's top edge, the space a
/// pointer event arrives in) the user last touched it, for the *next*
/// layout pass in which some row's height changes to resolve against
/// -- see the module doc's "hold the edge nearest the tap" section.
/// Does not by itself mark anything dirty; the row whose height is
/// about to change is what triggers the redraw this is read during.
pub fn note_tap(&mut self, viewport_pos: f32) {
self.pending_tap = Some(self.flip_pos(viewport_pos));
}
/// The on-screen `(top, bottom)` viewport-pixel extent of `key`'s row
/// as of the last layout, or `None` if it was not among the rows drawn
/// then (off-screen, not yet loaded, or it hasn't drawn since). What a
/// caller reads to decide where to aim `note_tap` -- e.g. "the top of
/// the row that's about to expand" -- without duplicating this
/// widget's own layout math. Ordered top-then-bottom on screen
/// whichever way `dir` runs, since that is what a caller comparing it
/// against a pointer position needs.
pub fn extent(&self, key: RowKey) -> Option<(f32, f32)> {
self.extents.get(&key).map(|e| {
let (a, b) = (self.flip_pos(e.lead), self.flip_pos(e.trail));
(a.min(b), a.max(b))
})
}
/// The row whose on-screen box (as of the last layout) contains
/// `viewport_pos`, or `None` if it falls outside every row currently
/// drawn (a gap, a header, or off the loaded content entirely). O
/// (visible rows), same as `reanchor_at_tap`. What a caller resolves a
/// pointer-captured gesture's row-under-the-finger against once the
/// gesture is no longer being delivered through any one row's own hit
/// region -- see `iris::sense`'s pointer-capture doc.
pub fn key_at(&self, viewport_pos: f32) -> Option<RowKey> {
let pos = self.flip_pos(viewport_pos);
self.extents
.iter()
.find(|(_, ext)| pos >= ext.lead && pos <= ext.trail)
.map(|(&key, _)| key)
}
fn slot_exists(&self, slot: isize) -> bool {
match slot {
BEFORE_SLOT => self.more_before.is_some(),
AFTER_SLOT => self.more_after.is_some(),
s => s >= 0 && s < self.items.len() as isize,
}
}
fn slot_widget(&self, slot: isize) -> &StrongWidget {
match slot {
BEFORE_SLOT => self
.more_before
.as_ref()
.expect("BEFORE_SLOT placed with no more_before widget set"),
AFTER_SLOT => self
.more_after
.as_ref()
.expect("AFTER_SLOT placed with no more_after widget set"),
s => &self.items[s as usize].widget,
}
}
/// The real row key at `slot`, or `None` for a sentinel -- sentinels
/// have no key of their own to cache an extent under, so they are
/// simply not addressable by `note_tap`'s hit test (nothing to expand
/// there).
fn slot_key(&self, slot: isize) -> Option<RowKey> {
match slot {
BEFORE_SLOT | AFTER_SLOT => None,
s if s >= 0 && (s as usize) < self.items.len() => Some(self.items[s as usize].key),
_ => None,
}
}
fn prev_slot(&self, slot: isize) -> Option<isize> {
let len = self.items.len() as isize;
match slot {
BEFORE_SLOT => None,
AFTER_SLOT => {
if len > 0 {
Some(len - 1)
} else if self.more_before.is_some() {
Some(BEFORE_SLOT)
} else {
None
}
}
0 => {
if self.more_before.is_some() {
Some(BEFORE_SLOT)
} else {
None
}
}
s => Some(s - 1),
}
}
fn next_slot(&self, slot: isize) -> Option<isize> {
let len = self.items.len() as isize;
match slot {
AFTER_SLOT => None,
BEFORE_SLOT => {
if len > 0 {
Some(0)
} else if self.more_after.is_some() {
Some(AFTER_SLOT)
} else {
None
}
}
s if s == len - 1 => {
if self.more_after.is_some() {
Some(AFTER_SLOT)
} else {
None
}
}
s => Some(s + 1),
}
}
/// Repair the anchor if the row it names no longer exists (evicted by
/// a `pop_*`, or a sentinel that was cleared), and re-home a
/// still-flush-with-the-end anchor's offset when the viewport itself
/// resized. Falls back to bottom-anchored-at-the-newest-content,
/// matching this widget's default when nothing else is known.
fn repair_anchor(&mut self) {
if self.items.is_empty() && self.more_before.is_none() && self.more_after.is_none() {
self.anchor = None;
return;
}
if let Some(a) = self.anchor
&& self.slot_exists(a.slot)
{
if self.ctl.pinned_to_end() && self.viewport_len != self.last_viewport_len {
self.anchor.as_mut().unwrap().offset = self.viewport_len;
}
self.last_viewport_len = self.viewport_len;
return;
}
let len = self.items.len() as isize;
self.anchor = Some(if len > 0 {
Anchor {
slot: len - 1,
edge: Edge::Trailing,
offset: self.viewport_len,
}
} else if self.more_after.is_some() {
Anchor {
slot: AFTER_SLOT,
edge: Edge::Trailing,
offset: self.viewport_len,
}
} else {
Anchor {
slot: BEFORE_SLOT,
edge: Edge::Leading,
offset: 0.0,
}
});
}
/// Resolve a pending tap against last frame's row extents and, if it
/// landed inside one, re-anchor to that row's nearer edge at its
/// current on-screen position -- O(visible rows), never a scan of
/// anything off-screen. See the module doc. `tap` is already in the
/// walk's direction-relative space (`note_tap` converted it), so this
/// compares like with like whichever way `dir` runs.
fn reanchor_at_tap(&mut self, tap: f32) {
for ext in self.extents.values() {
if tap >= ext.lead && tap <= ext.trail {
let mid = (ext.lead + ext.trail) * 0.5;
let (edge, offset) = if tap < mid {
(Edge::Leading, ext.lead)
} else {
(Edge::Trailing, ext.trail)
};
self.anchor = Some(Anchor {
slot: ext.slot,
edge,
offset,
});
return;
}
}
}
/// Move the anchor onto a row that is actually on screen, without
/// moving anything that is drawn: the row it re-homes to keeps the
/// exact top edge this frame's layout gave it.
///
/// [`Self::scroll`] moves the anchor's *offset* and nothing else, so
/// panning away from the anchor's own row leaves that row further and
/// further outside the viewport, and every row between it and the
/// viewport has to be walked on every frame from then on -- before
/// `place`'s intersection test, drawn too. Measured on the bench
/// fixture before this: 8 scrolls of 3000px left **64 rows** placed in
/// a 2012px viewport, ~59 of them off-screen, and the ones above it
/// drawn straight over the header (docs/IRIS_TODO.md, 2026-09-07).
/// Re-homing each frame makes the walk O(visible) again whatever
/// distance was travelled, which is what the module doc claims.
///
/// Only when the anchor's own row has left the viewport, so
/// `update_snap_end`'s pinned-to-newest anchor -- last slot, bottom
/// edge at the viewport's own bottom, which intersects it -- is left
/// exactly as it is rather than rewritten into a top-edge anchor that
/// no longer reads as flush with the end.
fn rehome_anchor(&mut self) {
let Some(anchor) = self.anchor else {
return;
};
if self.extents.values().any(|e| e.slot == anchor.slot) {
return;
}
// The topmost row on screen, so the anchor's offset stays a small
// number near the viewport's own leading edge rather than
// whatever the last row's bottom happens to be.
let Some(first) = self
.extents
.values()
.min_by(|a, b| a.lead.total_cmp(&b.lead))
.copied()
else {
// Nothing on screen at all -- a list scrolled past its own
// content (`scroll` is deliberately unclamped). There is no
// on-screen row to re-home to, and inventing one would move
// the list; leave the anchor where it is and let the next
// scroll or `repair_anchor` bring content back.
return;
};
self.anchor = Some(Anchor {
slot: first.slot,
edge: Edge::Leading,
offset: first.lead,
});
}
/// The empty band at one edge that content on the other side of the
/// viewport could fill -- positive to move content toward the leading
/// edge -- or `None` when the layout already sits on its content.
/// This is what makes a `scroll` or a fling past the end of the
/// content settle *on* the end rather than beyond it.
///
/// `top`/`bottom` are the extreme edges the walk actually placed, so
/// the gap is already measured: `at_start` means nothing is above
/// `top`, and if `top` is nevertheless below the viewport's own
/// leading edge then those pixels are empty and always will be. This
/// is the whole of what the module doc used to list as deliberately
/// unsolved ("no overscroll clamping ... nothing to measure how much
/// content is left without walking it") -- true of *total* content
/// height, but the walk hands back both ends of the loaded run for
/// free, which is all a clamp needs. `tick_fling` stops a fling that
/// has reached an end, but stops it wherever the spline's last step
/// had already put it: a hard fling to the top of the bench fixture
/// left the first row **1398px below** a 600px viewport, i.e. the
/// whole screen blank, and it stayed there (docs/IRIS_TODO.md,
/// 2026-09-07: "black from the header down").
///
/// **Only when the opposite end is not also inside the viewport.**
/// Both at once means the content is shorter than the viewport, where
/// the space is not overscroll at all -- it is a bottom-anchored list
/// with three rows in it, and pulling those to the top would be this
/// widget rejecting its own default (`repair_anchor`).
fn overscroll_gap(&self, lead: f32, trail: f32) -> Option<f32> {
if self.at_start == self.at_end {
return None;
}
// `at_start`/`at_end` already carry the sign of their own gap
// (`top >= 0.0`, `bottom <= viewport_len`), so this is the gap
// itself, positive to move content toward the leading edge.
let gap = if self.at_start {
lead
} else {
trail - self.viewport_len
};
// Sub-pixel gaps are what floating-point row heights leave behind
// every frame; laying out again for one would leave another, and
// the list would never settle.
(gap.abs() >= 0.5).then_some(gap)
}
/// Place every row that reaches the viewport, outward from the
/// anchor, and return the extreme `(leading, trailing)` edges the walk
/// reached. Rebuilds `extents` and `at_start`/`at_end` from what it
/// placed; the caller clears `extents` first, since `reanchor_at_tap`
/// reads the previous frame's copy.
///
/// Called a second time in the same `draw` when the first pass lands
/// off the end of the content -- see [`Self::overscroll_gap`] and
/// `draw`.
fn lay_out(&mut self, painter: &mut Painter) -> (f32, f32) {
let anchor = self
.anchor
.expect("lay_out with no anchor: `draw` returns before this without one");
let placement = match anchor.edge {
Edge::Leading => Placement::Leading(anchor.offset),
Edge::Trailing => Placement::Trailing(anchor.offset),
};
let (mut lead, mut trail) = self.place(painter, anchor.slot, placement);
let mut idx_lead = anchor.slot;
while lead > 0.0 {
let Some(prev) = self.prev_slot(idx_lead) else {
break;
};
let (l, _) = self.place(painter, prev, Placement::Trailing(lead));
lead = l;
idx_lead = prev;
}
let mut idx_trail = anchor.slot;
while trail < self.viewport_len {
let Some(next) = self.next_slot(idx_trail) else {
break;
};
let (_, t) = self.place(painter, next, Placement::Leading(trail));
trail = t;
idx_trail = next;
}
// What a fling is clamped against -- see `at_start`'s field doc.
// `lead`/`trail` are the extreme edges actually placed this frame,
// and `prev_slot`/`next_slot` returning `None` is what "no more
// content" means everywhere else in this widget.
self.at_start = self.prev_slot(idx_lead).is_none() && lead >= 0.0;
self.at_end = self.next_slot(idx_trail).is_none() && trail <= self.viewport_len;
// The structural half of the same two questions, kept apart from
// `at_start`/`at_end` because they mean different things:
// "there is nothing loaded past this edge" is what bounds a
// scroll, while `at_start`/`at_end` add "and there is a gap to
// give back", which is what the overscroll clamp acts on.
self.no_more_before = self.prev_slot(idx_lead).is_none();
self.no_more_after = self.next_slot(idx_trail).is_none();
self.content_lead = lead;
self.content_trail = trail;
// Both halves of `intersects_viewport`'s rule, checked where they
// are cheap to check: what this pass put on screen is exactly what
// overlaps the viewport, and nothing above or below it can be
// seen. The first failed silently for a whole build -- an
// off-screen row draws correctly, it is just in the wrong place.
// `assert!` for R1's reason: it walks the rows *on screen*, a
// handful, once per draw, and a release build is the only build
// this fault has ever been seen in.
assert!(
self.extents
.values()
.all(|e| self.intersects_viewport(e.lead, e.trail)),
"a row outside the viewport (0..{}) is recorded as on screen: {:?}",
self.viewport_len,
self.extents
.values()
.find(|e| !self.intersects_viewport(e.lead, e.trail)),
);
(lead, trail)
}
fn update_snap_end(&mut self) {
let pinned = match self.anchor {
Some(a) => {
self.next_slot(a.slot).is_none()
&& a.edge == Edge::Trailing
&& (self.viewport_len - a.offset).abs() < 0.5
}
None => false,
};
self.ctl.set_pinned_to_end(pinned);
}
/// **The one rule for what this list draws**: a row is on screen if
/// any part of it is, so a row straddling either edge is drawn *in
/// full* and one that has left the viewport entirely is not drawn at
/// all. Both halves matter and they failed in opposite directions on
/// Iris's phone (docs/IRIS_TODO.md, 2026-09-07): rows already scrolled
/// past were still being drawn, over the header above the list, and
/// the part of a straddling row above the viewport had nothing
/// clipping it. The viewport here is the list's own box -- `0 ..
/// viewport_len`, `painter.region()` in window terms -- which is the
/// same box `LazySpan::draw` requires a mask on, so that what this test
/// admits and what the clip keeps are one region rather than two that
/// can disagree.
fn intersects_viewport(&self, lead: f32, trail: f32) -> bool {
trail > 0.0 && lead < self.viewport_len
}
/// The box between two edges measured **along `dir`** from this
/// widget's own leading edge, which for `Sign::Neg` is its bottom (or
/// right). Every other length in this widget -- `viewport_len`, an
/// `Anchor`'s offset, a `Placement`'s edges -- is in that same
/// direction-relative space, so the whole layout is written once and
/// only this function knows which way round the box is. The mirror is
/// `UiSpan::flip`, the same one `Span::draw` uses for a reversed
/// `Dir`.
fn abs_region(dir: Dir, start: f32, end: f32) -> UiRegion {
let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end));
let mut region = UiRegion::from_axis(dir.axis, span, UiSpan::FULL);
if dir.sign == Sign::Neg {
region.flip(dir.axis);
}
region
}
/// Place one slot (a real row or a sentinel) per `placement`, caching
/// its resolved extent (for the next frame's `note_tap` resolution) and
/// height (for its own next placement, see below), and return its
/// resolved `(leading, trailing)` edges in viewport pixels.
///
/// A row already measured on some earlier frame is placed directly at
/// its cached height's exact box -- one `widget_within`/`reposition`
/// pass, the same as any other widget placed by an already-known
/// region. A row seen for the first time has no cached height to place
/// it *at*, so it is measured first (an oversized, fixed-size region)
/// and then drawn a *second* time at the tight box that measurement
/// implies, via `Painter::draw_twice` -- not `reposition` (a pure
/// translation, no resize). This distinction is required, not just an
/// optimisation: a row is not always plain wrapped text --
/// `.background(rect(tint))` is an ordinary way to style one, and
/// `Rect::draw` is `is_size_independent` specifically because it fills
/// *whatever region it is given* (`Size::REST`, see `rect.rs`).
/// Measuring such a row at the oversized box has it paint an oversized
/// rect there; `reposition` only ever writes an offset, never a size,
/// so an every-frame reposition-only scheme would leave that primitive
/// oversized forever. Using `draw_twice` for *every* frame would fix
/// that but break the opposite property: its two calls use two
/// different regions, so whichever one `ActiveData.region` ends up
/// holding always disagrees with the *next* frame's first call,
/// forcing a real redraw every single frame instead of the cheap
/// skip-or-move `draw_inner` already provides for an unchanged or
/// merely-translated widget. Caching the height once measured is what
/// lets an already-seen row go back to that cheap path while a
/// first-seen one still gets a correctly-sized initial paint.
///
/// **A row whose measurement disagrees with the box it was offered is
/// drawn again, this frame, at the box its own height implies** --
/// both placements, since both offer a cached height and both can be
/// wrong the frame a row's content changes size. This is not an
/// optimisation to skip: a row is routinely `.background(rect(..))`
/// (a tool card *is* one), and `Rect::draw` fills whatever region it
/// is handed, so a row offered last frame's height paints its
/// background at last frame's height while its text lays out at the
/// new one -- Iris's 2026-09-08 report that "collapsing and opening an
/// edit card draws the card background a frame late, so it looks
/// closed even when there's text". A `reposition` does not fix it
/// (it writes an offset, never a size), which is what the bottom-
/// anchored half used to do. The extra draw happens only on the frame
/// a row actually changes height, which is a frame that was already
/// redrawing that row.
fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) {
// Every current caller derives `slot` from `repair_anchor`/
// `prev_slot`/`next_slot`, which already check existence -- but
// that invariant is enforced by convention across three call
// sites, not by this function, which would otherwise fail with a
// bare "index out of bounds" and no context (review,
// 2026-09-06). `slot_widget`, called from
// here, is what actually indexes/`.expect`s on it. Stays a
// `debug_assert!` under R1's rule: this runs once per row placed
// per frame, and its release failure is the `.expect` below rather
// than something silently wrong on screen.
debug_assert!(
self.slot_exists(slot),
"place() called with a slot that doesn't exist: {slot:?}"
);
let dir = self.dir;
let axis = dir.axis;
let output_len = painter.output_size().axis(axis);
let container_len = painter.region().axis(axis).len();
let density = painter.density();
let resolve = move |used: Size| -> f32 {
used.axis(axis)
.apply_rest(density)
.within_len(container_len)
.to_abs(output_len)
};
let key = self.slot_key(slot);
let cached = key.and_then(|k| self.heights.get(&k).copied());
// A row entirely outside the viewport is traversed but not drawn
// -- see `intersects_viewport`. The walk still has to *pass
// through* it, because its height is what says where the rows
// behind it land, but nothing about it reaches the screen, so
// drawing it costs a redraw (and, unclipped, paints over whatever
// is above the list) for content nobody can see. Only possible
// for a row whose height is already known: a first-time row has
// to be drawn to be measured at all, which is why the extent
// below is recorded from the intersection test rather than from
// "was this drawn".
if let Some(h) = cached {
let (lead, trail) = placement.edges(h);
if !self.intersects_viewport(lead, trail) {
return (lead, trail);
}
}
let widget = self.slot_widget(slot);
let height = match cached {
// Offered a box sized to the *cached* height (cheap to compare
// against last frame's offer, see `place`'s doc), but the
// height kept is what this draw actually reported -- if the
// row's real content grew since it was cached (and was
// therefore redrawn: an unchanged widget never disagrees with
// its own cache), it is drawn again here, this frame, at the
// box its own height implies rather than waiting a frame to
// self-correct.
Some(h) => {
let (lead, trail) = placement.edges(h);
let used = painter.widget_within(widget, Self::abs_region(dir, lead, trail));
let height = resolve(used);
if height != h {
let (lead, trail) = placement.edges(height);
painter.widget_within(widget, Self::abs_region(dir, lead, trail));
}
height
}
// Never measured, so there is no height to place it at: it is
// measured at an oversized region first and drawn again at the
// box that measurement implies (`draw_twice`, not
// `reposition`, which writes an offset and never a size).
//
// A bottom-known row measures at a *zero-anchored* region
// rather than at its own box: using the real box would make
// the measurement's offered size track this list's own height,
// so a sibling growing taller (the input-box case) would look
// like a resize to every bottom-known row and force a full
// redraw of each -- despite a row's content depending only on
// width.
None => {
let measure_from = match placement {
Placement::Leading(lead) => lead,
Placement::Trailing(_) => 0.0,
};
let first = Self::abs_region(dir, measure_from, measure_from + GENEROUS_PADDING);
let mut height = 0.0;
painter.draw_twice(widget, first, |used| {
height = resolve(used);
let (lead, trail) = placement.edges(height);
Self::abs_region(dir, lead, trail)
});
height
}
};
let (lead, trail) = placement.edges(height);
if let Some(k) = key {
self.heights.insert(k, height);
// `extents` is what is *on screen* (`key_at`'s doc, and
// `rehome_anchor` below reads it as exactly that), so a
// first-time row that had to be drawn to be measured and
// turned out to be off-screen does not go in it.
if self.intersects_viewport(lead, trail) {
self.extents.insert(k, RowExtent { slot, lead, trail });
}
}
(lead, trail)
}
}
/// The oversized bound offered along the primary axis when a row's real
/// extent isn't known yet (a fresh top-known placement) or is deliberately
/// discarded (a bottom-known measurement, see `place`). Large enough that
/// no real row's content is taller than this -- rows do not clip to the
/// height they're offered, only width drives a wrapped row's height -- and
/// a fixed module constant rather than derived from `viewport_len`, since
/// deriving it from a value that changes whenever the list itself resizes
/// (a sibling growing) would make the offered region's *size* change too,
/// defeating the same-size-different-position fast path `place` depends
/// on for an O(1) move.
const GENEROUS_PADDING: f32 = 100_000.0;
impl LazySpan {
/// Make this span scrollable: the wheel and a finger drag, registered
/// on the span itself.
///
/// **Inherent, and it shadows `WidgetLike::scrollable` on purpose.**
/// That one wraps its widget in a `ScrollArea`, which is exactly what
/// must not happen here -- a lump slid about by a parent would never
/// update which rows it shows -- and this span already owns the
/// controller such an area would have brought. Rust resolves an
/// inherent method before a trait one, so `list.scrollable()` finds
/// this, and it needs neither of the other's arguments: the axis is
/// `dir`'s and the pin was chosen at construction.
///
/// A span reached through a builder (already wrapped, already behind a
/// closure) gets the trait method instead, correctly -- by then it is
/// a different widget.
///
/// A caller with a drag arbiter of its own registers the wheel and
/// leaves the drag out rather than calling this: one gesture, one
/// arbiter (`transcript_ui`'s `Selection`, and `DragGesture`'s doc).
pub fn scrollable<Rsc: HasEvents>(self) -> impl WidgetIdFn<Rsc, LazySpan> {
let axis = self.dir.axis;
scroll_senses(self, axis)
}
}
impl Scrollable for LazySpan {
fn controller(&self) -> &ScrollController {
&self.ctl
}
fn controller_mut(&mut self) -> &mut ScrollController {
&mut self.ctl
}
}
impl LazySpan {
/// How far this span can still travel each way, from the edges the
/// last walk actually placed -- what the controller clamps the next
/// delta against, so that a delta running 250px past the end gives
/// 250 back rather than everything or nothing.
///
/// **The bound is exact where there is one, and `INFINITY` where there
/// is not.** With content still loaded past an edge this span
/// genuinely cannot say how far it goes without walking there, and
/// saying so is what lets the walk find the wall and `overscroll_gap`
/// hand the overshoot back inside the same frame.
fn travel(&self) -> Travel {
let forward = if self.no_more_after {
(self.content_trail - self.viewport_len).max(0.0)
} else {
f32::INFINITY
};
let backward = if self.no_more_before {
(-self.content_lead).max(0.0)
} else {
f32::INFINITY
};
// `forward`/`backward` are the walk's own directions -- toward
// later content and toward earlier -- and `Travel` is in screen
// space, so which is which depends on `dir` exactly as
// `flip_delta` does. A `Dir::UP` span's later content is *above*
// it, so scrolling back down the screen is what runs out first.
match self.dir.sign {
Sign::Pos => Travel {
back: backward,
fwd: forward,
},
Sign::Neg => Travel {
back: forward,
fwd: backward,
},
}
}
}
impl Widget for LazySpan {
/// A lazy span animates exactly one thing, its fling -- and it drives
/// its own rather than being handed deltas by a `ScrollArea` around
/// it, since which rows exist at all is a function of where it is
/// scrolled to and a moved lump would never update them.
fn tick(&mut self, now: Instant) -> bool {
self.tick_fling(now)
}
fn draw(&mut self, painter: &mut Painter) -> Size {
let axis = self.dir.axis;
let output_len = painter.output_size().axis(axis);
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
self.ctl.set_density(painter.density());
self.repair_anchor();
if self.anchor.is_none() {
self.extents.clear();
return Size::REST;
}
// What a wheel, a drag or a fling asked for since the last frame,
// already clamped to the travel that frame reported -- the walls
// it placed are the freshest answer available, and where they are
// stale (the content changed under a settled anchor) the walk
// below finds the real ones and `overscroll_gap` gives back the
// difference before this frame ends.
//
// Deliberately after the early return above: a delta that arrives
// while there is nothing to scroll stays banked rather than being
// silently spent on an empty list.
let delta = self.ctl.take_delta();
if delta != 0.0 {
let amt = self.flip_delta(delta);
self.move_anchor(amt);
}
// `reanchor_at_tap` reads `extents` as they stood after the
// *previous* frame's layout -- the last on-screen box for each
// visible row -- so the clear that starts rebuilding it for this
// frame has to wait until after this call, not before.
if let Some(tap) = self.pending_tap.take() {
self.reanchor_at_tap(tap);
}
self.extents.clear();
let (lead, trail) = self.lay_out(painter);
// **The clamp is applied inside the frame that found it**, not
// marked for the next one: layout is a pure function of the state
// rather than of how many frames have been drawn (Iris,
// 2026-09-08), and a correction that lands next frame is a frame
// drawn wrong -- with nothing guaranteed to ask for that next
// frame, since a fling that ran out at an end has already stopped
// requesting them, which is exactly what left the list parked past
// its own first row. Same shape as `Scroll::draw`, which measures
// its content and places it again in the one frame.
//
// One further pass settles it, always: the gap is measured from
// the edges this walk actually placed, so moving the anchor by it
// puts that edge exactly on the viewport's, and the rows the
// second walk brings into view are placed outward from there. The
// opposite end cannot open a new gap -- that would mean the
// content is shorter than the viewport, which `overscroll_gap`
// already declines to touch. The extra walk is paid only on a
// frame that was overscrolled, and it re-offers every row the same
// box at a new offset, which `draw_inner` dispatches as an O(1)
// move.
if let Some(gap) = self.overscroll_gap(lead, trail) {
self.move_anchor(gap);
self.extents.clear();
self.lay_out(painter);
}
self.rehome_anchor();
self.update_snap_end();
self.ctl.set_travel(self.travel());
Size::REST
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
/// Every row in `build_flingable_list` is this tall, which is what
/// makes `scroll_position` exact.
const FLING_ROW_H: f32 = 20.0;
/// How far a `build_flingable_list` span has scrolled from its very
/// first row, in pixels: read off the leading row on screen, whose
/// content position is exactly `slot * FLING_ROW_H` because every row
/// there is that tall. Measures where the content actually sits rather
/// than any bookkeeping about it, and unlike a single row's extent it
/// stays defined however far the list travels -- `extents` holds only
/// what is on screen (`LazySpan::intersects_viewport`).
fn scroll_position(list: &LazySpan) -> f32 {
let first = list
.extents
.values()
.min_by(|a, b| a.lead.total_cmp(&b.lead))
.expect("something is on screen");
first.slot as f32 * FLING_ROW_H - first.lead
}
struct TestRsc {
ui: UiData,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
}
/// A fixed-height row so its size is exact and predictable in tests --
/// mutating `.y` afterward is how tests simulate a row "expanding."
fn fixed_row(rsc: &mut TestRsc, height: f32) -> (WeakWidget<Sized>, StrongWidget) {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(height)),
});
(sized.weak(), sized.any())
}
/// Pushes one row per key and returns the rows' own weak handles (for
/// mutating a specific row's height later), in the same order as `keys`.
fn push_rows(
rsc: &mut TestRsc,
list: &mut LazySpan,
keys: &[RowKey],
height: f32,
) -> Vec<WeakWidget<Sized>> {
keys.iter()
.map(|&key| {
let (weak, w) = fixed_row(rsc, height);
list.push_back(LazyItem::new(key, w));
weak
})
.collect()
}
/// Adds `list` to the arena and returns both a typed weak handle (for
/// calling `LazySpan`'s own methods through `Widgets::get`/`get_mut`, which
/// need a `Sized` widget type) and the erased root `UiRenderState::update`
/// draws.
///
/// The root is a `Masked` around the list rather than the list
/// itself, because that is what every real caller has to do -- a
/// `LazySpan` draws the row straddling each edge in full and asserts
/// something is clipping it (`LazySpan::draw`). The mask is the full
/// window here, which is also the list's own box.
fn add_list(rsc: &mut TestRsc, list: LazySpan) -> (WeakWidget<LazySpan>, StrongWidget) {
let strong = rsc.ui.widgets.add_strong(list);
let weak = strong.weak();
let root = rsc.ui.widgets.add_strong(Masked {
shape: None,
inner: strong.any(),
});
(weak, root.any())
}
/// The case the top-edge cull and the overscroll clamp both had no
/// reason to touch: fewer rows than fit. Every one of them is drawn
/// (nothing here is outside the viewport), and `overscroll_gap`
/// leaves the list bottom-anchored -- the gap above the first row is
/// not overscroll, it is where this widget puts a short list, and
/// pulling it to the top would be the clamp overriding
/// `repair_anchor`'s own default.
#[test]
fn a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
// Several frames, since the clamp acts on the frame *after* the
// one that measured a gap: a wrong one would walk the rows up the
// screen 40px at a time rather than settle.
for _ in 0..4 {
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert_eq!(
list_ref.extents.len(),
3,
"every row of a short list is on screen"
);
let first = list_ref.extents[&0];
let last = list_ref.extents[&2];
assert!(
(first.lead - 40.0).abs() < 0.01 && (last.trail - 100.0).abs() < 0.01,
"a 60px list in a 100px viewport moved off the bottom: rows {}..{}",
first.lead,
last.trail,
);
}
}
/// `Dir::UP` is not a screen direction bolted on at the end: item 0
/// sits at the **bottom** and the sequence grows toward the top, which
/// is the mirror of `Dir::DOWN` and the reason the walk is written in
/// leading/trailing terms with `abs_region` the only place that knows
/// which way round the box is. Same three rows, same viewport, so the
/// only difference from
/// `a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom`
/// is `dir` -- and the newest row moves from the bottom of the screen
/// to the top.
///
/// Asserts on **where each row was actually drawn**
/// (`UiRenderState::active`), not on `extents`: those are kept in the
/// walk's own direction-relative space and converted on the way out,
/// so an `extent()`-only test passes even with the flip in
/// `abs_region` deleted -- it would be checking the bookkeeping
/// against itself while every row painted at the mirror of where it
/// belongs.
#[test]
fn a_dir_up_span_grows_upward_from_item_zero() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::UP, Pin::End);
let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let drawn = |render: &UiRenderState, row: &WeakWidget<Sized>| {
let px = render.active[&row.id()].region.to_px((100.0, 100.0).into());
(px.top_left.y, px.bot_right.y)
};
assert_eq!(
drawn(&render, &rows[2]),
(0.0, 20.0),
"the newest row of a Dir::UP span is drawn at the top of the screen"
);
assert_eq!(drawn(&render, &rows[1]), (20.0, 40.0));
assert_eq!(
drawn(&render, &rows[0]),
(40.0, 60.0),
"item 0 is drawn furthest down"
);
// And the public extent agrees with the pixels, in screen space.
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert_eq!(list_ref.extent(2), Some((0.0, 20.0)));
assert_eq!(list_ref.extent(0), Some((40.0, 60.0)));
}
/// A delta means a screen direction, not a logical one: the same
/// negative delta moves the content up the screen whichever way the
/// span is laid out (Iris, 2026-09-08 -- "positive should always
/// scroll up / left, and negative down / right"). A `Dir::UP` span
/// used to pan the opposite way for the same number, because
/// the delta reached the walk without the flip its
/// positions already went through.
///
/// Asserted on where rows were **drawn**, for the reason
/// `a_dir_up_span_grows_upward_from_item_zero` gives: an assertion in
/// the walk's own space checks the bookkeeping against itself and
/// passes with the flip deleted.
#[test]
fn a_delta_moves_both_directions_the_same_way_on_screen() {
// 10 rows of 20px in a 100px viewport. Both spans open pinned to
// the end of their content -- which is the bottom of the screen
// for `Dir::DOWN` and the top of it for `Dir::UP` -- so each is
// first walked into the middle, where both have content to move
// in either direction, and only then handed the same delta.
let moved_by = |dir: Dir| {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(dir, Pin::End);
let keys: Vec<RowKey> = (0..10).collect();
let rows = push_rows(&mut rsc, &mut list, &keys, 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let push = |rsc: &mut TestRsc, render: &mut UiRenderState, delta: f32| {
let before = rsc.ui.widgets.get(&list_weak).unwrap().amt();
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(delta);
render.update(&root, rsc);
let moved = before - rsc.ui.widgets.get(&list_weak).unwrap().amt();
assert!(
(moved - delta).abs() < 0.01,
"there was content to take the whole delta: asked {delta}, moved {moved}",
);
};
// Away from the pinned end, in whichever screen direction
// that is for this `dir`.
push(
&mut rsc,
&mut render,
match dir.sign {
Sign::Pos => 60.0,
Sign::Neg => -60.0,
},
);
// Row 4 is on screen in both spans now, and stays drawn
// across a move this small whichever way it goes.
let top = |render: &UiRenderState| {
render.active[&rows[4].id()]
.region
.to_px((100.0, 100.0).into())
.top_left
.y
};
let before = top(&render);
push(&mut rsc, &mut render, -10.0);
top(&render) - before
};
for dir in [Dir::DOWN, Dir::UP] {
let moved = moved_by(dir);
assert!(
(moved + 10.0).abs() < 0.5,
"a negative delta must move the content 10px up the screen, not {moved}px",
);
}
}
/// The conversion the reversed direction makes necessary: a pointer
/// position arrives in screen pixels while the walk works in
/// direction-relative ones, so a hit test that skipped the flip would
/// silently answer with the row mirrored across the viewport -- wrong
/// in a way that looks like a working list until you tap one.
#[test]
fn a_reversed_span_hit_tests_in_screen_space() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::UP, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert_eq!(
list_ref.key_at(10.0),
Some(2),
"10px down the screen is the newest row"
);
assert_eq!(list_ref.key_at(50.0), Some(0), "50px down is item 0");
assert_eq!(
list_ref.key_at(90.0),
None,
"below the content there is no row"
);
}
#[test]
fn bottom_anchored_by_default() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 60.0));
render.update(&root, &mut rsc);
// 5 rows of 20px into a 60px viewport: rows 2,3,4 visible, bottom
// (row 4) flush with the viewport's own bottom edge.
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert_eq!(list_ref.extents.len(), 3);
let last = list_ref.extents[&4];
assert!((last.trail - 60.0).abs() < 0.01);
assert!(!list_ref.extents.contains_key(&0));
assert!(!list_ref.extents.contains_key(&1));
}
/// A row shaped like the ordinary `.background(rect(tint))` idiom:
/// a `Stack` whose first child is a `Rect` (`is_size_independent`,
/// fills whatever region it is given -- see `rect.rs`) and whose
/// second (the one `StackSize::Child` reports as the row's own size)
/// is a `Sized`-wrapped `Rect` of the given height. Returns the
/// background rect's own id (to check what it actually painted at)
/// alongside the row widget.
fn background_styled_row(rsc: &mut TestRsc, height: f32) -> (WidgetId, StrongWidget) {
let (bg_id, _, row) = resizable_background_row(rsc, height);
(bg_id, row)
}
/// [`background_styled_row`] with the foreground's own `Sized` handed
/// back too, so a test can change the row's height the way a tool card
/// being collapsed or opened does.
fn resizable_background_row(
rsc: &mut TestRsc,
height: f32,
) -> (WidgetId, WeakWidget<Sized>, StrongWidget) {
let bg = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let bg_id = bg.id();
let fg_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let fg = rsc.ui.widgets.add_strong(Sized {
inner: fg_rect.any(),
x: None,
y: Some(Len::abs(height)),
});
let fg_weak = fg.weak();
let stack = Stack {
children: vec![bg.any(), fg.any()],
size: StackSize::Child(1),
};
(bg_id, fg_weak, rsc.ui.widgets.add_strong(stack).any())
}
/// Iris's 2026-09-08 report: "collapsing and opening an edit card
/// draws the card background a frame late, so it looks closed even
/// when there's text, and then looks open even when the text is
/// collapsed."
///
/// A row is offered a box sized to its *cached* height, and a
/// `.background(rect(..))` fills whatever box it is given -- so on the
/// frame a row changes height its text is laid out at the new height
/// and its background painted at the old one. Every row is exercised,
/// in both directions, because which of `place`'s two placements a row
/// takes depends on where it sits relative to the anchor and the fault
/// was in both.
#[test]
fn a_row_that_changes_height_draws_its_background_at_the_new_height_immediately() {
for key_to_change in 0..5u64 {
for new_height in [50.0f32, 8.0] {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
let mut rows = Vec::new();
for key in 0..5u64 {
let (bg_id, fg, row) = resizable_background_row(&mut rsc, 20.0);
rows.push((bg_id, fg));
list.push_back(LazyItem::new(key, row));
}
let (_, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
render.update(&root, &mut rsc);
let (bg_id, fg) = rows[key_to_change as usize];
rsc.ui.widgets.get_mut(&fg).unwrap().y = Some(Len::abs(new_height));
render.update(&root, &mut rsc);
let px = render.active[&bg_id].region.to_px((100.0, 100.0).into());
let drawn = px.size().y;
assert!(
(drawn - new_height).abs() < 0.5,
"row {key_to_change} resized to {new_height}px drew its background at {drawn}px on the same frame"
);
}
}
}
#[test]
fn a_fill_shaped_background_is_not_left_oversized() {
// Regression test for a real bug found building the I3 example:
// `place`'s Bottom-known branch used to measure a row at an
// oversized, fixed-size region and `reposition` (a pure
// translation) it into its final box. A row's own natural height
// is independent of that oversized offer (true for wrapped text),
// but a `Rect` background is *defined* to fill whatever it is
// given -- so it painted at the oversized size, and moving it
// afterward never shrank it back down. Only visible by checking
// what the background rect's own primitive covers, not the row's
// reported extent (which was already correct, since it comes from
// the *foreground* child).
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
let mut bg_ids = Vec::new();
for key in 0..5u64 {
let (bg_id, row) = background_styled_row(&mut rsc, 20.0);
bg_ids.push(bg_id);
list.push_back(LazyItem::new(key, row));
}
let (_, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
for &bg_id in &bg_ids {
let region = render.active[&bg_id].region;
let px = region.to_px((100.0, 100.0).into());
let height = px.size().y;
assert!(
(height - 20.0).abs() < 0.5,
"background rect should be exactly the row's height (20px), got {height}px \
-- an oversized measurement region leaking through would show as ~100000px"
);
}
}
#[test]
fn insert_above_anchor_is_o1_and_does_not_move_visible_rows() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[10, 11, 12], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 60.0));
render.update(&root, &mut rsc);
render.take_counters();
let extents_before = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone();
// Prepend far-above rows one at a time -- an O(1) push each,
// touching nothing currently on screen.
for key in 0..10u64 {
let (_, w) = fixed_row(&mut rsc, 20.0);
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.push_front(LazyItem::new(key, w));
}
render.update(&root, &mut rsc);
let (draws, _rewrites, _moves, _shapes) = render.take_counters();
// None of the already-visible rows (11, 12) were touched: the
// extents for those keys are numerically unchanged, and the only
// draws possible are for the list widget itself plus any row that
// entered view (none did -- the prepended rows are far above the
// anchor's slot, which only moved by an index, not a redraw).
let extents_after = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone();
for key in [11u64, 12] {
assert_eq!(
(extents_before[&key].lead, extents_before[&key].trail),
(extents_after[&key].lead, extents_after[&key].trail)
);
}
// The list widget itself draws once (it was marked dirty by the
// pushes); no row draw is attributable to the 10 prepended rows,
// since none of them ever entered the viewport.
assert!(
draws <= 2,
"insert-above touched more than the list itself: {draws} draws"
);
}
#[test]
fn expanding_a_row_holds_the_edge_nearest_the_tap() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
// Five rows of 20px; with a 100px viewport all are visible,
// anchored at the bottom by default (row 4's bottom at 100).
let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
// Row 2 occupies [40, 60) before it grows. Tap near its top edge
// (41) so growing it should hold *that* edge fixed and push row 3
// and row 4 further down, leaving rows 0/1 untouched above it.
let row2 = rows[2];
{
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
let ext = list_ref.extents[&2];
assert!((ext.lead - 40.0).abs() < 0.01);
assert!((ext.trail - 60.0).abs() < 0.01);
}
rsc.ui.widgets.get_mut(&list_weak).unwrap().note_tap(41.0);
// Grow row 2 from 20px to 50px -- marks it (and, once its size
// changes, the list) dirty via the ordinary redraw-bubble path.
rsc.ui.widgets.get_mut(&row2).unwrap().y = Some(Len::abs(50.0));
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
let row2_ext = list_ref.extents[&2];
let row3_ext = list_ref.extents[&3];
// Top edge held at 40 (nearest the tap at 41): row 2 now spans
// [40, 90), and row 3 -- below the grown row -- is pushed down to
// start at 90, not left at its old 60.
assert!(
(row2_ext.lead - 40.0).abs() < 0.01,
"top edge should stay put: {row2_ext:?}"
);
assert!(
(row2_ext.trail - 90.0).abs() < 0.01,
"bottom edge should move by the full +30 growth: {row2_ext:?}"
);
assert!(
(row3_ext.lead - 90.0).abs() < 0.01,
"row below the expanded row should be pushed down: {row3_ext:?}"
);
// Rows above the expanding row are untouched.
let row1_ext = list_ref.extents[&1];
assert!((row1_ext.lead - 20.0).abs() < 0.01);
assert!((row1_ext.trail - 40.0).abs() < 0.01);
}
#[test]
fn expanding_a_row_holds_the_bottom_edge_when_tap_is_lower() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
// Row 2 spans [40, 60). Tap near its bottom (59) instead.
let row2 = rows[2];
rsc.ui.widgets.get_mut(&list_weak).unwrap().note_tap(59.0);
rsc.ui.widgets.get_mut(&row2).unwrap().y = Some(Len::abs(50.0));
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
let row2_ext = list_ref.extents[&2];
let row1_ext = list_ref.extents[&1];
// Bottom edge held at 60: the row grows upward instead, and row 1
// (above it) is pushed up to end at 10, not left at 20.
assert!(
(row2_ext.trail - 60.0).abs() < 0.01,
"bottom edge should stay put: {row2_ext:?}"
);
assert!(
(row2_ext.lead - 10.0).abs() < 0.01,
"top edge should move by the full +30 growth: {row2_ext:?}"
);
assert!(
(row1_ext.trail - 10.0).abs() < 0.01,
"row above the expanded row should be pushed up: {row1_ext:?}"
);
}
#[test]
fn moves_stay_o1_across_list_size() {
for &n in &[20usize, 200, 2000] {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
let keys: Vec<RowKey> = (0..n as u64).collect();
push_rows(&mut rsc, &mut list, &keys, 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 200.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
// Backwards, into content that exists: a list opens flush with
// its newest end, so a *negative* delta from there is
// overscroll, and the clamp lays out a second time within the
// frame to give it back -- a correct extra pass, but not the
// ordinary scroll tick whose cost this test is about.
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
render.update(&root, &mut rsc);
let (draws, _rewrites, moves, _shapes) = render.take_counters();
// The visible window is a fixed ~10 rows regardless of n; an
// O(n) regression would show up as draws/moves scaling with
// list size instead of staying flat.
assert!(draws <= 12, "n={n}: expected O(visible), got {draws} draws");
assert!(moves >= 1, "n={n}: a scroll tick should move something");
assert!(moves <= 12, "n={n}: expected O(visible) moves, got {moves}");
}
}
/// The streamed-reply case (RUST.md's "streaming still costs a full
/// rebuild" fix, `transcript-ui::TranscriptScreen::apply`): a delta
/// swaps the last row's widget for a taller one, same key, same slot.
/// A list flush with its own end (the default, `snap_end`) must stay
/// flush -- the row grows *upward* from the pinned bottom edge, not
/// the other way around, exactly like an ordinary resize of that same
/// row would (`expanding_a_row_holds_the_bottom_edge_when_tap_is_lower`).
#[test]
fn replacing_the_last_row_stays_pinned_to_the_bottom() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 60.0));
render.update(&root, &mut rsc);
// Row 4 is flush with the viewport's bottom edge before the replace.
{
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!((list_ref.extents[&4].trail - 60.0).abs() < 0.01);
}
let (_weak, new_row) = fixed_row(&mut rsc, 40.0);
let old = rsc
.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.replace_back(LazyItem::new(4, new_row));
assert!(
old.is_some(),
"replace_back should hand back the row it evicted"
);
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
let row4 = list_ref.extents[&4];
assert!(
(row4.trail - 60.0).abs() < 0.01,
"still pinned to the newest end after the replace: {row4:?}"
);
assert!(
(row4.lead - 20.0).abs() < 0.01,
"grew upward, from the pinned bottom edge: {row4:?}"
);
}
/// Neither `replacing_the_last_row_stays_pinned_to_the_bottom` nor
/// its sibling below ever asserts the *evicted* key's own bookkeeping
/// is actually gone -- both replace row 4 with another row also keyed
/// `4`, so `heights.remove(&old.key)` removing and re-inserting the
/// same key would pass either test even if it did nothing (review,
/// 2026-09-06; the same class of bug -- a stale handle outliving what
/// it points to --
/// production-tested from `LazySpan`'s own side). Replacing with a
/// **different** key is what actually exercises the removal.
#[test]
fn replace_back_forgets_the_evicted_keys_own_height() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 60.0));
render.update(&root, &mut rsc);
assert!(
rsc.ui
.widgets
.get(&list_weak)
.unwrap()
.heights
.contains_key(&4)
);
let (_weak, new_row) = fixed_row(&mut rsc, 40.0);
let old = rsc
.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.replace_back(LazyItem::new(100, new_row));
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert_eq!(old.map(|o| o.key), Some(4));
assert!(
!list_ref.heights.contains_key(&4),
"the evicted key's cached height must not outlive the row it measured"
);
}
/// The other half of the same fix's contract: replacing a row that is
/// *not* on screen must not move anything that is. `replace_back` only
/// touches the last slot's own widget and this file's own `heights`/
/// `extents` caches for that one key -- nothing about `Anchor` changes
/// -- so the already-placed rows above it should come out at the exact
/// same boxes on the next frame.
#[test]
fn replacing_the_last_row_out_of_view_does_not_move_visible_rows() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 60.0));
// Settle at the default (bottom) anchor first -- `jump_to_start`
// does not touch `snap_end`, and `repair_anchor` only leaves a
// freshly-set anchor's offset alone once `viewport_len` has
// already matched `last_viewport_len` once, the same reason
// `moves_stay_o1_across_list_size` settles before the tick it
// actually measures.
render.update(&root, &mut rsc);
// Scrolled to the oldest content: rows 0,1,2 visible, row 4 is far
// below the viewport.
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
render.update(&root, &mut rsc);
let (before0, before1, before2) = {
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(!list_ref.extents.contains_key(&4));
(
list_ref.extents[&0],
list_ref.extents[&1],
list_ref.extents[&2],
)
};
let (_weak, new_row) = fixed_row(&mut rsc, 999.0);
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.replace_back(LazyItem::new(4, new_row));
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
for (key, before) in [(0u64, before0), (1, before1), (2, before2)] {
let after = list_ref.extents[&key];
assert_eq!(
(after.lead, after.trail),
(before.lead, before.trail),
"row {key} moved after an off-screen replace"
);
}
}
/// RUST.md's P0 phone report (Iris's screenshot, 2026-09-06): a
/// replaced row's primitives drawn a second time, overlapping the
/// replacement. Reproduces the exact path `TranscriptScreen::apply`'s
/// `ReplaceLast` case drives up to 400 times during a streamed reply
/// (`bench_client.rs`'s stream phase): the last slot's widget is
/// swapped for a brand-new one, same key, and (since a fresh widget
/// has no cached height) placed via `place`'s `draw_twice` path every
/// time -- the provisional-then-real two-draw sequence LAYOUT.md
/// documents as the one place in this crate that deliberately draws a
/// widget twice. If `draw_inner`'s old-children diffing or
/// `UiRenderState::remove`'s primitive freeing ever failed to retire
/// the evicted widget (or the provisional draw's own primitives), it
/// would show up here as `active_widgets` growing without bound.
/// **Passes as written** -- this pins the widget-arena layer as
/// correct in isolation; see the P0 box for where the duplicate was
/// actually chased to instead (`Span`'s two-phase draw and the
/// `redraw_all`-vs-`redraw_updates` split, still open).
#[test]
fn replacing_the_last_row_many_times_does_not_leak_primitives() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
for key in 0..5u64 {
let (_bg_id, row) = background_styled_row(&mut rsc, 20.0);
list.push_back(LazyItem::new(key, row));
}
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let before = render.active_widgets();
for i in 0..400u32 {
// A varying height keeps every replace on the `draw_twice`
// (cache-miss) path rather than settling into the O(1)
// same-size `mov` fast path once the height happens to repeat.
let (_bg_id, new_row) = background_styled_row(&mut rsc, 20.0 + (i % 3) as f32);
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.replace_back(LazyItem::new(4, new_row));
render.update(&root, &mut rsc);
}
let after = render.active_widgets();
assert_eq!(
before, after,
"400 replaces of the last row must leave exactly the same \
number of active widgets as before a leaked id (and the \
primitives that live as long as its ActiveData does) would \
show up here as growth"
);
}
/// The doubled `Compacted:` row from Iris's phone (docs/bench/
/// iris-phone-v2-2026-09-06.md), reproduced at its mechanism.
///
/// `replacing_the_last_row_many_times_does_not_leak_primitives` above
/// counts *widgets*, which is why it passed all along: the orphan's
/// owner is very much alive -- it is an earlier set of that same
/// widget's primitives that got stranded. What strands them is a row
/// marked dirty and then reached by its **ancestor's** redraw rather
/// than by its own: `draw_inner` only *read* the dirty mark, so the
/// whole branch that frees a redrawn widget's previous primitives was
/// skipped, and the fresh `ActiveData` overwrote the only handles that
/// could ever have freed them. `LazySpan` sets no mask, so that copy then
/// draws every frame at whatever region it last had -- including,
/// where the row was being measured at `GENEROUS_PADDING`, well below
/// the list's own box and under the composer.
///
/// Two rows, two shapes of the same fault: row 2 has a cached height
/// (one `widget_within`), row 4 is replaced so it has none (`place`'s
/// `draw_twice`, which reaches `draw_inner` twice for one id in one
/// frame and so orphans a copy even with no ancestor involved).
#[test]
fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
// Rows that own a primitive *at their own id* (a background rect),
// not only through a child: an orphan is a widget's own primitive
// outliving its own redraw, so a row whose top-level widget paints
// nothing itself cannot show one however broken the path is.
let mut rows = Vec::new();
for key in 0..5u64 {
let (bg_id, row) = background_styled_row(&mut rsc, 20.0);
rows.push((row.id(), bg_id));
list.push_back(LazyItem::new(key, row));
}
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
assert!(render.orphaned_primitives().is_empty());
// A streamed row's content changing: the row is marked dirty (any
// `.set()` on it does this)...
let (row2, row2_bg) = rows[2];
rsc.ui.widgets.get_dyn_mut(row2).unwrap();
rsc.ui.widgets.get_dyn_mut(row2_bg).unwrap();
// Redraw the *list* by name, so the dirty row is reached by its
// ancestor's draw rather than by `redraw_updates` happening to
// pick it first -- which is the order `HashSet` iteration makes
// arbitrary, and the reason this went unnoticed.
render.redraw(list_weak.id(), &mut rsc);
let orphans = render.orphaned_primitives();
assert!(
orphans.is_empty(),
"{} primitive(s) survived their own widget's redraw: {orphans:?}",
orphans.len(),
);
}
/// Enough rows, tall enough, that a fling toward the start has real
/// room to travel before the walls stop it -- shared by the tests
/// below.
///
/// Built the way a real caller does: `Masked(LazySpan)`, with the span
/// driving its own `ScrollController` -- the gesture, the fling and
/// `amt` are its own, and so is the walk that says how far it can
/// actually go. The mask is outside because what needs clipping is the
/// row straddling an edge, and masks are inherited down the chain.
fn build_flingable_list(
rsc: &mut TestRsc,
) -> (WeakWidget<LazySpan>, StrongWidget, UiRenderState) {
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), FLING_ROW_H);
let list = rsc.ui.widgets.add_strong(list);
let list_weak = list.weak();
let root = rsc.ui.widgets.add_strong(Masked {
shape: None,
inner: list.any(),
});
let root = root.any();
let mut render = UiRenderState::new();
render.resize((100.0, 600.0));
render.update(&root, rsc);
(list_weak, root, render)
}
/// Drive one frame of a fling: tick the span the way
/// `UiData::tick_animations` does, then draw. Answers whether the
/// fling is still going.
fn fling_frame(
rsc: &mut TestRsc,
scroll: &WeakWidget<LazySpan>,
root: &StrongWidget,
render: &mut UiRenderState,
now: Instant,
) -> bool {
let still = rsc.ui.widgets.get_mut(scroll).unwrap().tick(now);
render.update(root, rsc);
still
}
#[test]
fn a_fling_moves_the_list_and_then_settles() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
// Toward the start: **positive**, which is the finger's direction
// and `ScrollController::scroll`'s convention -- the one convention a delta
// has anywhere in the crate now. It used to be negative here,
// because a `LazySpan`'s anchor offset ran the opposite way to a
// `ScrollArea`'s `amt` while both were public and both claimed to
// mirror the other.
assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0));
assert!(rsc.ui.widgets.get(&scroll).unwrap().is_scrolling());
let start = Instant::now();
let mut still = true;
for step in 0..600 {
let now = start + std::time::Duration::from_millis(step * 16);
still = fling_frame(&mut rsc, &scroll, &root, &mut render, now);
if !still {
break;
}
}
assert!(!still, "fling never settled within 600 steps");
assert!(!rsc.ui.widgets.get(&scroll).unwrap().is_scrolling());
assert!(
scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()) < 200.0 * FLING_ROW_H,
"a fling toward the start should have moved the list back through its rows"
);
}
/// The registration half, which is the frame loop's rather than the
/// widget's: a fling that nothing registers never moves, however right
/// its velocity is -- which is exactly what a finger fling did on
/// Iris's phone for two builds.
#[test]
fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) {
let id = scroll.id();
rsc.ui.animate(id);
}
let start = Instant::now();
let mut steps = 0;
let mut animating = true;
while animating && steps < 600 {
let now = start + std::time::Duration::from_millis(steps * 16);
animating = rsc.ui.tick_animations(now);
render.update(&root, &mut rsc);
steps += 1;
}
assert!(!animating, "the driver never stopped within 600 frames");
assert!(steps > 1, "the fling settled without ever moving");
assert_ne!(
scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()),
before,
"the fling was registered but never applied"
);
// Nothing left registered, so the next frame costs nothing.
let now = start + std::time::Duration::from_millis(steps * 16);
assert!(!rsc.ui.tick_animations(now));
}
/// The sign, pinned across the whole handoff: gesture -> `ScrollArea` ->
/// controller -> anchor. A negative delta is the finger moving the
/// negative way along the axis, which pulls **later** content up into
/// view. Getting this wrong anywhere in that chain scrolls the list
/// backwards, which no type can catch.
#[test]
fn a_negative_delta_moves_toward_the_end() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
// Start well back from the end so there is room to move forward.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(2000.0);
render.update(&root, &mut rsc);
let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-500.0);
render.update(&root, &mut rsc);
let after = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
assert!(
after > before,
"a negative delta should move toward the end: {before} -> {after}"
);
assert!(
(after - before - 500.0).abs() < 0.5,
"and by exactly what was asked for, away from a wall: {before} -> {after}"
);
}
/// `Scroll::amt` is the accumulated movement the child actually made,
/// so it stays equal to what is on screen even when a delta runs off
/// the end of the content. This is the whole reason the span reports
/// what it *moved* rather than the caller adding up what it asked
/// for: an all-or-nothing answer would leave `amt` over-counted by
/// every overshoot, and nothing would ever correct it.
#[test]
fn amt_counts_only_what_the_child_could_take() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
// A short move away from the end, all of which is available.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100.0);
render.update(&root, &mut rsc);
assert!(
(rsc.ui.widgets.get(&scroll).unwrap().amt() + 100.0).abs() < 0.5,
"amt counts forward through the content, so 100px back is -100: {}",
rsc.ui.widgets.get(&scroll).unwrap().amt()
);
// 200 rows of 20px in a 600px viewport: 3400px of travel in all,
// so this asks for far more than is left and must be given only
// what there was.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0);
render.update(&root, &mut rsc);
let amt = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!(
(amt + 3400.0).abs() < 0.5,
"amt should equal the content's real travel, not what was asked for: {amt}"
);
}
/// A fling stops when the child says it could not take the delta,
/// rather than spending its remaining distance on content that is not
/// there. Before the clamp existed, a hard fling to the top of the
/// bench fixture left the first row 1398px below a 600px viewport --
/// the whole screen blank -- and it stayed there.
#[test]
fn a_fling_stops_at_the_first_row() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
// An enormous velocity that would travel far past all 200 rows if
// unclamped.
rsc.ui.widgets.get_mut(&scroll).unwrap().fling(50_000.0);
let start = Instant::now();
for step in 0..2000 {
let now = start + std::time::Duration::from_millis(step * 16);
if !fling_frame(&mut rsc, &scroll, &root, &mut render, now) {
break;
}
}
// No settling frame: the draw that runs out of content gives the
// pixels back inside that same frame, so the last frame the loop
// drew is already flush with the top.
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(list_ref.at_start, "the fling should have reached the start");
// Both edges, so neither an overshoot past the top nor one left
// uncorrected can pass. `extents` used to hold every row the walk
// placed, on screen or not, so this read was once satisfied by a
// first row sitting 1398px *below* the viewport with the whole
// screen blank.
let first = list_ref.extents[&0];
assert!(
first.lead.abs() < 0.5,
"a fling stopped at the start must leave the first row flush with the top, not {}px \
from it",
first.lead
);
}
/// Asking for more travel than the content has leaves it *on* its
/// first row rather than beyond it, in the frame that asked. Since the
/// position moved into `ScrollArea`, this is prevented at the source --
/// the clamp only allows what the walk says is there -- rather
/// than corrected afterwards, and `overscroll_gap` is left for the
/// case a scroll cannot cause: content or a viewport that changed
/// under a settled anchor.
#[test]
fn scrolling_past_the_start_lands_on_it_in_the_same_frame() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, root, mut render) = build_flingable_list(&mut rsc);
let list_weak = scroll;
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0);
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
let first = list_ref.extents[&0];
assert!(
first.lead.abs() < 0.5,
"the frame that overscrolled should end flush with the first row, not {}px from it",
first.lead,
);
}
#[test]
fn anchor_position_display_before_any_draw_is_none() {
let list = LazySpan::new(Dir::DOWN, Pin::End);
assert_eq!(list.anchor_position_display(), "idx=none");
}
#[test]
fn anchor_position_display_reports_slot_and_offset() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
let _ = (&root, &mut render);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(list_ref.anchor_position_display().starts_with("idx="));
assert!(!list_ref.anchor_position_display().contains("none"));
}
}