Redesign span layout around retained placement

This commit is contained in:
iris committed 2026-09-09 15:11:57 -04:00
1 parent 4b69f3cc6b
commit 992482414f
23 files changed
+426 -1005

No files matched your search

+19 -317
View File
@@ -1,169 +1,9 @@
//! `LazySpan`: a virtualised span of variable-height rows, laid out from
//! an anchor rather than eagerly like `Span`.
//! A virtualised, variable-height span laid out from an anchor.
//!
//! **`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
//! re-measuring 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.
//! It owns a `ScrollController`, draws only rows overlapping the viewport,
//! and leaves clipping to an optional `.masked()` wrapper. Cached row heights
//! let retained rows move directly; a new or resized row is drawn and then
//! placed at the exact extent it reports. See `docs/SCROLL.md`.
use crate::prelude::*;
use iris_core::util::HashMap;
use std::collections::VecDeque;
@@ -299,13 +139,7 @@ pub struct LazySpan {
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.
/// Last reported height, pruned when its row is evicted.
heights: HashMap<RowKey, f32>,
/// Whether the last walk found no more content before the leading
/// edge *and* nothing left to give back there -- what
@@ -1003,62 +837,8 @@ impl LazySpan {
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::measure` -- 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. Measuring on *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.
/// Place a row and remember its height for later walks.
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:?}"
@@ -1077,16 +857,6 @@ impl LazySpan {
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) {
@@ -1096,56 +866,31 @@ impl LazySpan {
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));
painter.place(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 (`Painter::measure` then a
// real draw, 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 height = resolve(painter.measure(widget, first));
let height = resolve(painter.widget_within(widget, first));
let (lead, trail) = placement.edges(height);
painter.widget_within(widget, Self::abs_region(dir, lead, trail));
painter.place(widget, 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 });
}
@@ -1154,16 +899,7 @@ impl LazySpan {
}
}
/// 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.
/// Primary-axis room for a row whose extent is not cached yet.
const GENEROUS_PADDING: f32 = 100_000.0;
impl LazySpan {
@@ -1321,6 +1057,10 @@ impl Widget for LazySpan {
self.ctl.set_travel(self.travel());
Size::REST
}
fn size_hint(&self, _axis: Axis) -> Option<Len> {
Some(Len::REST)
}
}
#[cfg(test)]
@@ -1719,7 +1459,7 @@ mod tests {
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
// oversized, fixed-size region and placement (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
@@ -1751,7 +1491,7 @@ mod tests {
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"
-- an oversized provisional region leaking through would show as ~100000px"
);
}
}
@@ -2094,23 +1834,7 @@ mod tests {
}
}
/// 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 measure-then-draw 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).
/// Replacing a streamed row must not grow the active widget set.
#[test]
fn replacing_the_last_row_many_times_does_not_leak_primitives() {
let mut rsc = TestRsc {
@@ -2129,9 +1853,6 @@ mod tests {
let before = render.active_widgets();
for i in 0..400u32 {
// A varying height keeps every replace on the measure-then-draw
// (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
@@ -2151,26 +1872,7 @@ mod tests {
);
}
/// 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
/// measure-then-draw, which reaches `draw_inner` twice for one id in one
/// frame and so orphans a copy even with no ancestor involved).
/// An ancestor redraw must retire every replaced primitive.
#[test]
fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() {
let mut rsc = TestRsc {