iris: List becomes LazySpan, and takes a Dir

First of three steps agreed with Iris for getting scrolling out of the
list and into `Scroll`, so that `.scrollable()` is the one way anything
in iris scrolls. docs/IRIS_TODO.md's "In progress" block carries the
whole plan and the decisions behind it; this step is the rename and the
direction.

`List` -> `LazySpan`, and it moves in beside `Span` under
`widget/position/`. It is what `Span` is -- a sequence of children along
an axis -- laid out lazily from an anchor instead of eagerly from the
start, and the name says the one thing that matters about it. It also
stops colliding with `BlockKind::List` in the markdown code.
`ListRow` -> `LazyItem`; `RowKey` keeps its name, since rows are the
vocabulary in transcript-ui.

`Axis` -> `Dir`, with the sign meaning what it means in `Span`: which
end of the box item 0 sits at. **That is a different question from which
end the view is pinned to**, and conflating them would stand a
transcript on its head -- its oldest message is item 0 and sits at the
top (`Dir::DOWN`) while the view clings to the bottom. So the pin is its
own constructor argument, `LazySpan::new(dir, at_end)`, spelled the same
way as `Scroll::new`'s.

Making `Dir::UP` real rather than nominal is most of the diff. The walk
now works entirely in direction-relative pixels from the leading edge --
`Edge::Top`/`Bottom` are `Leading`/`Trailing`, `Placement` likewise, and
`RowExtent`'s fields and every local are `lead`/`trail` -- with two
places converting: `abs_region`, which flips the box for `Sign::Neg`,
and `flip_pos`, which converts the screen-space positions the public
helpers speak in (`note_tap`, `key_at`, `extent`, all fed by pointer
events) into the walk's space. Without the second, a reversed span would
hit-test at the mirror of where it drew.

`a_dir_up_span_grows_upward_from_item_zero` asserts on where each row was
**actually drawn** (`UiRenderState::active`), not on `extents`: the first
version of it read `extent()` and passed with `abs_region`'s flip deleted
-- checking the bookkeeping against itself while every row painted at the
mirror of where it belonged. It now fails with the flip removed (row 2 at
80..100 instead of 0..20), which is the check that matters.

Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace green (21 suites), including the phone-shaped
fixture tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 20:17:57 -04:00
1 parent 76fcbdccb9
commit 8e5928cc6a
30 files changed
+526 -329

No files matched your search

+48
View File
@@ -7,6 +7,54 @@ order and what "done" looks like. Tick and date them in place.
## Fix ## Fix
- [ ] **In progress (2026-09-08): scrolling moves out of the list.**
Agreed with Iris over the design exchange that followed the overscroll
clamp. The list stays -- a lazy layout is a real thing that `Span`
cannot be -- but everything about *scrolling* leaves it, so that
`.scrollable()` is the one way anything in iris scrolls. Three steps,
each independently verifiable:
1. **Rename and `Dir`.** `List` -> `LazySpan` (it is what `Span` is,
laid out lazily from an anchor; it also stops colliding with
`BlockKind::List` in the markdown code), `ListRow` -> `LazyItem`,
`RowKey` kept, `Axis` -> `Dir`. Direction (which end item 0 sits at)
and pin (which end the view clings to) are **separate**: a
transcript is `Dir::DOWN` with the pin at the end, and conflating
them would stand it on its head.
2. **Delete the physics from `LazySpan`.** Its `Flinger`, `density`,
`Arc<dyn RequestRedraw>`, `tick` and the whole `fling`/
`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity` surface
go; `Scroll` is then the only `Flinger` user and `sense.rs` already
holds the genuinely shared parts. Add to `Widget`:
`fn scrolls_itself(&self) -> bool` (a `&self` capability flag read
through `get_dyn`, which does **not** mark dirty) and
`fn apply_scroll(&mut self, delta: &mut f32)` (takes what it can,
leaves the rest).
3. **`Scroll` wraps it**, owning `amt` and the pin: measure the child,
`apply_scroll`, place it again -- the same measure-then-place idiom
`Scroll::draw` and `LazySpan::place` already use. The measuring call
is free in the common case (unchanged region, not dirty, so
`draw_inner` skips it) and really walks exactly when the content
changed, which is when its walls need re-reading. Reaching the child
through `get_dyn_mut` marks it dirty by itself, so the second call
really draws -- no `Painter::draw_again` and nothing marked by hand.
`transcript-ui`'s `Selection` retargets to the `Scroll`.
Decisions taken along the way, with their reasons, so they are not
re-litigated: the **height cache stays in the container** (Iris:
widgets may render to two places at once, so a size keyed by
`WidgetId` would break; and the framework's own `ActiveData::size` is
freed by `remove_rec` the moment a row is virtualised away, which is
exactly when it is needed). **No `redraw_on_move` flag** -- the child
returning from `apply_scroll` is already the signal. **`amt` for a lazy
child is accumulated actual movement, not a distance from the top of
the content**, since paging rows in above shifts the origin; that is
honest for every current use and must be written at the field so
nobody builds a scrollbar on it.
Step 1 is done. Steps 2 and 3 are not.
- [x] **`List::clamp_to_content` still corrects on the next frame - [x] **`List::clamp_to_content` still corrects on the next frame
(2026-09-08).** Iris's rule, stated while the composer's caret was (2026-09-08).** Iris's rule, stated while the composer's caret was
being fixed: "nothing in the framework should ever self heal because being fixed: "nothing in the framework should ever self heal because
+3 -3
View File
@@ -883,7 +883,7 @@ set once from `DisplayMetrics.density` in `android::view::new_peer`; the
desktop backend has no per-monitor density wired up yet and stays at desktop backend has no per-monitor density wired up yet and stays at
`1.0`. Every layout call site that used to call `.apply_rest()`/ `1.0`. Every layout call site that used to call `.apply_rest()`/
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`, `.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `List::place`, and `Sized`, `MaxSize`, `Aligned`, `Scroll`, `LazySpan::place`, and
`UiRenderState::reposition` itself). This also meant the Android `UiRenderState::reposition` itself). This also meant the Android
boundary's global logical-space stopgap could come out entirely: window boundary's global logical-space stopgap could come out entirely: window
size, touch coordinates and insets are physical pixels again, matching size, touch coordinates and insets are physical pixels again, matching
@@ -1095,7 +1095,7 @@ cost of a tool group's 4dp inset).
**A widget offered a box it does not fit is drawn again at the box its **A widget offered a box it does not fit is drawn again at the box its
own reported size implies, in the same frame.** Not next frame. The own reported size implies, in the same frame.** Not next frame. The
temptation to defer is real — `List::place` offers a row its *cached* temptation to defer is real — `LazySpan::place` offers a row its *cached*
height precisely so that an unchanged row hits `draw_inner`'s cheap height precisely so that an unchanged row hits `draw_inner`'s cheap
skip-or-move path, and `Scroll` sizes its child region from last frame's skip-or-move path, and `Scroll` sizes its child region from last frame's
content length for the same reason. But a `Rect` fills whatever region it content length for the same reason. But a `Rect` fills whatever region it
@@ -1112,5 +1112,5 @@ safe to apply everywhere: the second draw happens only on the frame a
widget's own size actually changes, which is a frame that was already widget's own size actually changes, which is a frame that was already
redrawing it. A widget whose reported size is a function of the box it redrawing it. A widget whose reported size is a function of the box it
was *offered* would disagree every frame and redraw every frame — which was *offered* would disagree every frame and redraw every frame — which
is why `List` requires content-sized rows, and has since long before is why `LazySpan` requires content-sized rows, and has since long before
this. this.
+2 -2
View File
@@ -48,7 +48,7 @@ Iris's two screenshots of the top edge -- rows drawn over the header in
one, a blank band in the other -- were **three** faults, and the rule one, a blank band in the other -- were **three** faults, and the rule
that fixes all three is the one the IRIS_TODO entry asked for: *a row is that fixes all three is the one the IRIS_TODO entry asked for: *a row is
drawn if any part of it overlaps the list's own box, and nothing outside drawn if any part of it overlaps the list's own box, and nothing outside
that box reaches the screen* (`List::intersects_viewport`). Neither that box reaches the screen* (`LazySpan::intersects_viewport`). Neither
suspected cause was right, which is worth reading before trusting the suspected cause was right, which is worth reading before trusting the
next suspicion in this file: there was no visible-range test comparing a next suspicion in this file: there was no visible-range test comparing a
row's top against the viewport's, and `03c6be8`'s header duplicate is row's top against the viewport's, and `03c6be8`'s header duplicate is
@@ -654,7 +654,7 @@ a change landed the way it did.
`fonts.xml` monospace declaration against fontique's actually-scanned `fonts.xml` monospace declaration against fontique's actually-scanned
families, Android-only, verified `mono=Some("Droid Sans Mono")` on this families, Android-only, verified `mono=Some("Droid Sans Mono")` on this
checkout's emulator. checkout's emulator.
- [x] Scroll clamped at both ends (e922b73, `List`'s overscroll clamp) - [x] Scroll clamped at both ends (e922b73, `LazySpan`'s overscroll clamp)
and Compose's velocity estimator (docs/IRIS_TODO.md, 2026-09-07 and Compose's velocity estimator (docs/IRIS_TODO.md, 2026-09-07
later). Ticked 2026-09-08 against those entries, which were already later). Ticked 2026-09-08 against those entries, which were already
`[x]` while this box was not. Two things this box's own wording had `[x]` while this box was not. Two things this box's own wording had
+6 -6
View File
@@ -42,7 +42,7 @@ const STREAM_SECONDS: u64 = 20;
/// swipe with these any more. /// swipe with these any more.
const LEGACY_CYCLES: usize = 6; const LEGACY_CYCLES: usize = 6;
/// Fling phase (v2): a real fling through `List::fling`, not a tween -- /// Fling phase (v2): a real fling through `LazySpan::fling`, not a tween --
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a /// Iris's ask was that it "travel way faster" than the v1 swipe, and a
/// tween can never exceed the distance/time it is given while a real /// tween can never exceed the distance/time it is given while a real
/// fling decays from an initial velocity the way a finger flick does. /// fling decays from an initial velocity the way a finger flick does.
@@ -908,9 +908,9 @@ where
} }
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away /// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
/// from it (toward older messages) through `List::fling`, then /// from it (toward older messages) through `LazySpan::fling`, then
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll` /// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
/// convention (`List::scroll`'s own doc: positive moves *later* content /// convention (`LazySpan::scroll`'s own doc: positive moves *later* content
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses, /// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
/// since `TranscriptList`'s `LazyColumn` and this list define "positive" /// since `TranscriptList`'s `LazyColumn` and this list define "positive"
/// the other way around; the two apps' *travel* is still directly /// the other way around; the two apps' *travel* is still directly
@@ -979,8 +979,8 @@ async fn read_anchor_position(
/// Register the list with the frame loop, exactly as a finger's own /// Register the list with the frame loop, exactly as a finger's own
/// release does (`transcript_ui::Selection::drag`'s `Released` arm) -- /// release does (`transcript_ui::Selection::drag`'s `Released` arm) --
/// `List::fling` sets a velocity and drives nothing by itself. /// `LazySpan::fling` sets a velocity and drives nothing by itself.
fn animate_list(list: iris::prelude::WeakWidget<iris::prelude::List>, rsc: &mut Rsc) { fn animate_list(list: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
let id = list.id(); let id = list.id();
rsc.ui_mut().animate(id); rsc.ui_mut().animate(id);
} }
@@ -991,7 +991,7 @@ fn animate_list(list: iris::prelude::WeakWidget<iris::prelude::List>, rsc: &mut
/// spline-decided `duration()` already caps how long it can run. /// spline-decided `duration()` already caps how long it can run.
/// ///
/// **It observes; it does not drive.** Until 2026-09-08 this loop called /// **It observes; it does not drive.** Until 2026-09-08 this loop called
/// `List::tick_fling` itself every `POLL_MS`, which advanced the /// `LazySpan::tick_fling` itself every `POLL_MS`, which advanced the
/// fling in 16ms steps -- so on Iris's 120Hz phone every second frame /// fling in 16ms steps -- so on Iris's 120Hz phone every second frame
/// redrew the list at a position it had already drawn, and the benchmark /// redrew the list at a position it had already drawn, and the benchmark
/// looked distinctly less smooth than the same list under her finger. /// looked distinctly less smooth than the same list under her finger.
+16 -16
View File
@@ -14,11 +14,11 @@
//! -- and it avoids a new dependency this crate does not otherwise need. //! -- and it avoids a new dependency this crate does not otherwise need.
//! Per the code rules, the plain option is also the one shorter to explain. //! Per the code rules, the plain option is also the one shorter to explain.
//! //!
//! **The list under test is `iris::widget::List` (RUST.md's I3), not a //! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a
//! `Scroll` over a `Span` of pre-built rows.** Earlier versions of this //! `Scroll` over a `Span` of pre-built rows.** Earlier versions of this
//! file built their own giant `Span` and wrapped it in `Scroll`, which //! file built their own giant `Span` and wrapped it in `Scroll`, which
//! meant (a)/(b)/(c) below were measuring "move one big child," never the //! meant (a)/(b)/(c) below were measuring "move one big child," never the
//! virtualised widget the app's transcript screen actually needs. `List` //! virtualised widget the app's transcript screen actually needs. `LazySpan`
//! still needs every row's *widget* built up front by the caller (its //! still needs every row's *widget* built up front by the caller (its
//! module doc explains why: it only ever sees `&dyn Widget` through //! module doc explains why: it only ever sees `&dyn Widget` through
//! `Painter`, so it cannot construct a row lazily on its own) -- what //! `Painter`, so it cannot construct a row lazily on its own) -- what
@@ -26,7 +26,7 @@
//! *drawn*, which is what the draw/rewrite/move counters below are //! *drawn*, which is what the draw/rewrite/move counters below are
//! measuring, not construction time. //! measuring, not construction time.
//! //!
//! Scenarios (LAYOUT.md's O(1) move chain, list.rs's module doc, and //! Scenarios (LAYOUT.md's O(1) move chain, lazy_span.rs's module doc, and
//! IRIS_TODO.md's "Benchmarks" wording): //! IRIS_TODO.md's "Benchmarks" wording):
//! //!
//! - (a) first-frame cost of a message list of N wrapped-text rows, some //! - (a) first-frame cost of a message list of N wrapped-text rows, some
@@ -41,11 +41,11 @@
//! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md //! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md
//! section 8 defines. //! section 8 defines.
//! - (d) insert-above-anchor: paging older history onto the front of an //! - (d) insert-above-anchor: paging older history onto the front of an
//! already-scrolled list. `List::push_front` is an O(1) index update //! already-scrolled list. `LazySpan::push_front` is an O(1) index update
//! (list.rs's module doc); this measures that none of the rows already //! (lazy_span.rs's module doc); this measures that none of the rows already
//! on screen are touched by it. //! on screen are touched by it.
//! - (e) expand-a-row-holding-its-edge: growing one row's height with a //! - (e) expand-a-row-holding-its-edge: growing one row's height with a
//! tap recorded near one of its edges (list.rs's `note_tap`) must move //! tap recorded near one of its edges (lazy_span.rs's `note_tap`) must move
//! only the rows on the far side of it, never redraw the ones already //! only the rows on the far side of it, never redraw the ones already
//! correctly placed. //! correctly placed.
//! //!
@@ -106,18 +106,18 @@ fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
} }
} }
/// A virtualised `List` of `n` message rows, one in `image_every` of them /// A virtualised `LazySpan` of `n` message rows, one in `image_every` of them
/// carrying an image (0 disables images entirely). Returns the list widget /// carrying an image (0 disables images entirely). Returns the list widget
/// (weak, so the caller can drive it) and the erased root to render. /// (weak, so the caller can drive it) and the erased root to render.
fn build_message_list( fn build_message_list(
rsc: &mut BenchRsc, rsc: &mut BenchRsc,
n: usize, n: usize,
image_every: usize, image_every: usize,
) -> (WeakWidget<List>, StrongWidget) { ) -> (WeakWidget<LazySpan>, StrongWidget) {
let mut list = List::new(Axis::Y); let mut list = LazySpan::new(Dir::DOWN, true);
for i in 0..n { for i in 0..n {
let row = build_row(rsc, i, image_every); let row = build_row(rsc, i, image_every);
list.push_back(ListRow::new(i as u64, row)); list.push_back(LazyItem::new(i as u64, row));
} }
let list = rsc.ui.widgets.add_strong(list); let list = rsc.ui.widgets.add_strong(list);
(list.weak(), list.any()) (list.weak(), list.any())
@@ -270,7 +270,7 @@ fn bench_input_grows(n: usize, lines: usize) {
/// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default /// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default
/// bottom, so a row prepended above it is genuinely "inserted above the /// bottom, so a row prepended above it is genuinely "inserted above the
/// anchor" rather than merely far off-screen at the far end. Each /// anchor" rather than merely far off-screen at the far end. Each
/// `push_front` is O(1) (list.rs's module doc: the anchor's slot is an /// `push_front` is O(1) (lazy_span.rs's module doc: the anchor's slot is an
/// index, bumped by one) and, since the prepended rows never enter the /// index, bumped by one) and, since the prepended rows never enter the
/// viewport, none of them should cost a draw either. /// viewport, none of them should cost a draw either.
fn bench_insert_above_anchor(n: usize, inserts: usize) { fn bench_insert_above_anchor(n: usize, inserts: usize) {
@@ -298,7 +298,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
.widgets .widgets
.get_mut(&list) .get_mut(&list)
.unwrap() .unwrap()
.push_front(ListRow::new(i as u64, row)); .push_front(LazyItem::new(i as u64, row));
let start = Instant::now(); let start = Instant::now();
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
total += start.elapsed(); total += start.elapsed();
@@ -325,7 +325,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
/// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is /// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is
/// directly controllable) is grown a little at a time, each time preceded /// directly controllable) is grown a little at a time, each time preceded
/// by `note_tap` aimed at its own top edge -- the exact mechanism list.rs's /// by `note_tap` aimed at its own top edge -- the exact mechanism lazy_span.rs's
/// module doc describes and its unit tests check for correctness. This /// module doc describes and its unit tests check for correctness. This
/// measures its *cost*: only the rows on the far side of the grown one /// measures its *cost*: only the rows on the far side of the grown one
/// (below it, since the top edge is held) should ever move, and nothing /// (below it, since the top edge is held) should ever move, and nothing
@@ -334,7 +334,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
let mut rsc = BenchRsc { let mut rsc = BenchRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
let mut list = List::new(Axis::Y); let mut list = LazySpan::new(Dir::DOWN, true);
// Near the end (not the very last row) so it is already on screen // Near the end (not the very last row) so it is already on screen
// under the list's default bottom-anchored placement, for every N -- // under the list's default bottom-anchored placement, for every N --
// no scrolling needed to bring it into view before measuring. // no scrolling needed to bring it into view before measuring.
@@ -349,10 +349,10 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
y: Some(abs(40.0)), y: Some(abs(40.0)),
}); });
growable = Some(sized.weak()); growable = Some(sized.weak());
list.push_back(ListRow::new(i as u64, sized.any())); list.push_back(LazyItem::new(i as u64, sized.any()));
} else { } else {
let row = build_row(&mut rsc, i, 20); let row = build_row(&mut rsc, i, 20);
list.push_back(ListRow::new(i as u64, row)); list.push_back(LazyItem::new(i as u64, row));
} }
} }
let list = rsc.ui.widgets.add_strong(list); let list = rsc.ui.widgets.add_strong(list);
+1 -1
View File
@@ -62,7 +62,7 @@ pub struct ActiveData {
/// the same answer rather than drifting), and both then rewrite the /// the same answer rather than drifting), and both then rewrite the
/// slot from the sum -- which is what lets a parent both move a child /// slot from the sum -- which is what lets a parent both move a child
/// with its own layout and place it inside that moved region in one /// with its own layout and place it inside that moved region in one
/// frame. `List::place`'s Bottom-known branch does exactly that once a /// frame. `LazySpan::place`'s Bottom-known branch does exactly that once a
/// row's blocks wrap. Reset to zero on a real redraw, with /// row's blocks wrap. Reset to zero on a real redraw, with
/// `move_applied` and the slot itself. /// `move_applied` and the slot itself.
pub repositioned: Vec2, pub repositioned: Vec2,
+1 -1
View File
@@ -25,7 +25,7 @@ pub struct UiData {
/// never goes stale -- see LAYOUT.md section 2. /// never goes stale -- see LAYOUT.md section 2.
pub move_offsets: TrackedArena<MoveOffset, u32>, pub move_offsets: TrackedArena<MoveOffset, u32>,
/// Every widget whose [`crate::Widget::tick`] should run before the /// Every widget whose [`crate::Widget::tick`] should run before the
/// next frame -- today, a `List` coasting through a fling. Added by /// next frame -- today, a `LazySpan` coasting through a fling. Added by
/// [`Self::animate`] when the animation starts and removed by /// [`Self::animate`] when the animation starts and removed by
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so /// [`Self::tick_animations`] the frame its `tick` answers `false`, so
/// a stopped animation costs nothing and a dropped widget cannot be /// a stopped animation costs nothing and a dropped widget cannot be
+1 -1
View File
@@ -169,7 +169,7 @@ impl<'a> Painter<'a> {
/// Whether anything is clipping what this widget draws -- its own /// Whether anything is clipping what this widget draws -- its own
/// [`Self::set_mask`], or one an ancestor set that it inherited. What /// [`Self::set_mask`], or one an ancestor set that it inherited. What
/// a widget whose contents may legitimately extend past its own box /// a widget whose contents may legitimately extend past its own box
/// (`iris::widget::List`, which draws a row straddling an edge in /// (`iris::widget::LazySpan`, which draws a row straddling an edge in
/// full) asserts before relying on being cut off there. /// full) asserts before relying on being cut off there.
pub fn is_masked(&self) -> bool { pub fn is_masked(&self) -> bool {
self.mask != MaskIdx::NONE self.mask != MaskIdx::NONE
+3 -3
View File
@@ -417,13 +417,13 @@ impl UiRenderState {
// Consumed here, not merely read: this call *is* the redraw the mark // Consumed here, not merely read: this call *is* the redraw the mark
// asked for, and leaving the mark set is what stranded a widget's // asked for, and leaving the mark set is what stranded a widget's
// primitives. `Painter::draw_twice` calls this twice for the same id // primitives. `Painter::draw_twice` calls this twice for the same id
// in one frame (`List::place`'s measurement pass), and on the second // in one frame (`LazySpan::place`'s measurement pass), and on the second
// call the still-set mark took the whole `if let` below -- including // call the still-set mark took the whole `if let` below -- including
// the `remove` that frees the first draw's primitives -- out of play, // the `remove` that frees the first draw's primitives -- out of play,
// so `active.insert` at the end overwrote the only handles that could // so `active.insert` at the end overwrote the only handles that could
// ever have freed them. The result is a full second copy of the row, // ever have freed them. The result is a full second copy of the row,
// drawn every frame from then on at the oversized measurement region // drawn every frame from then on at the oversized measurement region
// and, with `List` setting no mask, outside the list's own bounds: // and, with `LazySpan` setting no mask, outside the list's own bounds:
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md. // the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
// The same shape reaches any dirty widget an ancestor redraws first. // The same shape reaches any dirty widget an ancestor redraws first.
let dirty = rsc.widgets_mut().needs_redraw.remove(&id); let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
@@ -700,7 +700,7 @@ impl UiRenderState {
active.textures.clear(); active.textures.clear();
rsc.ui_mut().textures.free(); rsc.ui_mut().textures.free();
if undraw { if undraw {
// A captured widget that goes away mid-gesture (List's // A captured widget that goes away mid-gesture (LazySpan's
// virtualisation retiring a row, a rebuild) must not leave // virtualisation retiring a row, a rebuild) must not leave
// the pointer captured by an id nothing will ever draw // the pointer captured by an id nothing will ever draw
// again. That path out is the sensor pass's, not this // again. That path out is the sensor pass's, not this
+1 -1
View File
@@ -1,6 +1,6 @@
//! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that //! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that
//! standalone-image bind-group *creation* -- a real `wgpu` resource, unlike //! standalone-image bind-group *creation* -- a real `wgpu` resource, unlike
//! the counters in `benches/message_list.rs` -- goes to zero once every //! the counters in `benches/message_lazy_span.rs` -- goes to zero once every
//! image has loaded. This needs an actual `wgpu` device (`GpuTextures`, //! image has loaded. This needs an actual `wgpu` device (`GpuTextures`,
//! `UiRenderNode`), so unlike the rest of the suite it cannot run as a //! `UiRenderNode`), so unlike the rest of the suite it cannot run as a
//! plain binary; run it through `iris/run-headless.sh bench_images`, which //! plain binary; run it through `iris/run-headless.sh bench_images`, which
+5 -5
View File
@@ -1,4 +1,4 @@
//! RUST.md's I3: `iris::widget::List` with 800 rows of varied-length //! RUST.md's I3: `iris::widget::LazySpan` with 800 rows of varied-length
//! wrapped text, one in twelve carrying a small image, scrollable with the //! wrapped text, one in twelve carrying a small image, scrollable with the
//! mouse wheel. Run headless with `iris/run-headless.sh message_list --shot //! mouse wheel. Run headless with `iris/run-headless.sh message_list --shot
//! /tmp/message_list.png` -- there is no display on this machine, so that //! /tmp/message_list.png` -- there is no display on this machine, so that
@@ -10,8 +10,8 @@
//! different number of lines -- exactly the "variable-height rows" I3 //! different number of lines -- exactly the "variable-height rows" I3
//! asks for, and the thing a virtualised list gets wrong first if it is //! asks for, and the thing a virtualised list gets wrong first if it is
//! wrong at all (a gap, an overlap, a row the wrong colour). This example //! wrong at all (a gap, an overlap, a row the wrong colour). This example
//! is also what found `List::place`'s oversized-background bug (see //! is also what found `LazySpan::place`'s oversized-background bug (see
//! list.rs's module doc and its `a_fill_shaped_background_is_not_left_ //! lazy_span.rs's module doc and its `a_fill_shaped_background_is_not_left_
//! oversized` test) -- a plain unit test could have (and now does) catch //! oversized` test) -- a plain unit test could have (and now does) catch
//! it directly, but it was this screenshot rendering as a single blank //! it directly, but it was this screenshot rendering as a single blank
//! tinted rectangle that pointed at it first. //! tinted rectangle that pointed at it first.
@@ -98,10 +98,10 @@ impl DefaultAppState for State {
rsc: &mut DefaultRsc<Self>, rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>, _: Proxy<Self::Event>,
) -> Self { ) -> Self {
let mut list = List::new(Axis::Y); let mut list = LazySpan::new(Dir::DOWN, true);
for i in 0..ROWS { for i in 0..ROWS {
let row = build_row(rsc, i); let row = build_row(rsc, i);
list.push_back(ListRow::new(i as u64, row)); list.push_back(LazyItem::new(i as u64, row));
} }
let root = list let root = list
+1 -1
View File
@@ -476,7 +476,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
// both count. See `iris_core::FrameReport`'s own doc for exactly // both count. See `iris_core::FrameReport`'s own doc for exactly
// what this does and does not measure. // what this does and does not measure.
let frame_start = Instant::now(); let frame_start = Instant::now();
// Anything moving on its own -- today a `List` coasting through a // Anything moving on its own -- today a `LazySpan` coasting through a
// fling -- is advanced here, before the draw, and asks for the // fling -- is advanced here, before the draw, and asks for the
// next frame at the end of this one. See // next frame at the end of this one. See
// `UiData::tick_animations`; `default/mod.rs`'s // `UiData::tick_animations`; `default/mod.rs`'s
+4 -4
View File
@@ -141,8 +141,8 @@ impl TouchScript {
} }
/// Counts the frames something asked for without drawing any -- the /// Counts the frames something asked for without drawing any -- the
/// harness's `RequestRedraw`. A `List` coasting through a fling asks for /// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
/// the next frame through this (`List::set_redraw_handle`), so a test can /// the next frame through this (`LazySpan::set_redraw_handle`), so a test can
/// tell "nothing moved" from "nothing was even asked to move". /// tell "nothing moved" from "nothing was even asked to move".
#[derive(Default)] #[derive(Default)]
pub struct RedrawCounter(AtomicUsize); pub struct RedrawCounter(AtomicUsize);
@@ -335,7 +335,7 @@ impl Harness {
} }
/// The `Instant` this harness means by `t_ms`. Public because a /// The `Instant` this harness means by `t_ms`. Public because a
/// caller driving `List::tick_fling` or `DragGesture` by hand needs /// caller driving `LazySpan::tick_fling` or `DragGesture` by hand needs
/// to date those calls on the same clock the touch samples use. /// to date those calls on the same clock the touch samples use.
pub fn at(&self, t_ms: u64) -> Instant { pub fn at(&self, t_ms: u64) -> Instant {
self.base + Duration::from_millis(t_ms) self.base + Duration::from_millis(t_ms)
@@ -371,7 +371,7 @@ impl Harness {
/// Frames every `step_ms` up to and including `end_ms` -- what a /// Frames every `step_ms` up to and including `end_ms` -- what a
/// fling needs, since it moves only while something ticks it /// fling needs, since it moves only while something ticks it
/// (`List::fling`'s doc). Returns the time of the last frame run. /// (`LazySpan::fling`'s doc). Returns the time of the last frame run.
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 { pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
debug_assert!(step_ms > 0, "a frame loop with no step never ends"); debug_assert!(step_ms > 0, "a frame loop with no step never ends");
let mut t = from_ms; let mut t = from_ms;
+3 -3
View File
@@ -602,7 +602,7 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
/// A parent that both `mov`s a child (its own layout moved the box it /// A parent that both `mov`s a child (its own layout moved the box it
/// offers) and `reposition`s it inside that box in the same frame -- what /// offers) and `reposition`s it inside that box in the same frame -- what
/// `List::place`'s Bottom-known branch does once a row's cached height /// `LazySpan::place`'s Bottom-known branch does once a row's cached height
/// stops matching what the row reports, which is reachable as soon as a /// stops matching what the row reports, which is reachable as soon as a
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a"). /// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
struct MoveThenPlace { struct MoveThenPlace {
@@ -1041,8 +1041,8 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
dir: Dir::DOWN, dir: Dir::DOWN,
gap: Len::ZERO, gap: Len::ZERO,
}); });
let mut list = List::new(Axis::Y); let mut list = LazySpan::new(Dir::DOWN, true);
list.push_back(ListRow::new(0, outer.any())); list.push_back(LazyItem::new(0, outer.any()));
let list = rsc.ui.widgets.add_strong(list); let list = rsc.ui.widgets.add_strong(list);
let root = rsc let root = rsc
.ui .ui
+20 -20
View File
@@ -334,7 +334,7 @@ pub struct PointerRequests {
impl PointerRequests { impl PointerRequests {
/// Give `id` exclusive pointer input from the next dispatch on. `id` /// Give `id` exclusive pointer input from the next dispatch on. `id`
/// must be a widget that outlives the gesture -- a `List`'s own id, /// must be a widget that outlives the gesture -- a `LazySpan`'s own id,
/// not one of its virtualised rows, which can be retired mid-drag as /// not one of its virtualised rows, which can be retired mid-drag as
/// content scrolls. Overwrites any previous capture: a gesture that /// content scrolls. Overwrites any previous capture: a gesture that
/// starts a new one has already decided the old one is over, and the /// starts a new one has already decided the old one is over, and the
@@ -437,7 +437,7 @@ impl SensorUi for UiRenderState {
// capture. // capture.
if let Some(id) = requests.holder() { if let Some(id) = requests.holder() {
// The capture's path out for a widget that stopped being // The capture's path out for a widget that stopped being
// drawn mid-gesture -- a `List` row retired by virtualisation, // drawn mid-gesture -- a `LazySpan` row retired by virtualisation,
// a rebuilt subtree. Nothing can be delivered to an id with no // a rebuilt subtree. Nothing can be delivered to an id with no
// region, so the gesture ends here for everyone. // region, so the gesture ends here for everyone.
let Some(shape) = self.resolved_region(&id, rsc) else { let Some(shape) = self.resolved_region(&id, rsc) else {
@@ -924,7 +924,7 @@ pub struct PressState {
/// instead of waiting for a long-press. /// instead of waiting for a long-press.
pub already_selected: bool, pub already_selected: bool,
/// Whether the target was already moving under its own momentum (a /// Whether the target was already moving under its own momentum (a
/// `List` with a fling in flight, `List::is_scrolling`). See /// `LazySpan` with a fling in flight, `LazySpan::is_scrolling`). See
/// [`DragArbiter::press_start`]: a press on moving content is a catch, /// [`DragArbiter::press_start`]: a press on moving content is a catch,
/// and catches skip the slop entirely. /// and catches skip the slop entirely.
pub scrolling: bool, pub scrolling: bool,
@@ -1088,7 +1088,7 @@ impl DragArbiter {
// in full on this one frame is a visible jump the // in full on this one frame is a visible jump the
// instant `DRAG_SLOP` is crossed (IRIS_TODO.md's // instant `DRAG_SLOP` is crossed (IRIS_TODO.md's
// "scrolling down sometimes jitters the text," root- // "scrolling down sometimes jitters the text," root-
// caused by tracing `List`'s per-frame offset against // caused by tracing `LazySpan`'s per-frame offset against
// a synthetic monotonic drag: the offset held flat for // a synthetic monotonic drag: the offset held flat for
// every `Undecided` frame, then stepped by several // every `Undecided` frame, then stepped by several
// frames' worth of motion at once on the frame slop // frames' worth of motion at once on the frame slop
@@ -1125,7 +1125,7 @@ impl DragArbiter {
/// Whether the arbiter's current gesture (if any) has committed to /// Whether the arbiter's current gesture (if any) has committed to
/// panning -- what a caller checks at release time to decide whether /// panning -- what a caller checks at release time to decide whether
/// to hand the tracked velocity to [`crate::widget::List::fling`], per /// to hand the tracked velocity to [`crate::widget::LazySpan::fling`], per
/// IRIS_TODO.md's "swiping has no momentum": a fling must only follow /// IRIS_TODO.md's "swiping has no momentum": a fling must only follow
/// a pan, never a text selection that happened to end with the finger /// a pan, never a text selection that happened to end with the finger
/// still moving. /// still moving.
@@ -1158,7 +1158,7 @@ impl DragArbiter {
pub enum GestureOutcome { pub enum GestureOutcome {
Undecided, Undecided,
/// Same units and sign as [`DragOutcome::Pan`] -- the caller's own /// Same units and sign as [`DragOutcome::Pan`] -- the caller's own
/// convention (`List::scroll`'s, for a transcript) to apply. /// convention (`LazySpan::scroll`'s, for a transcript) to apply.
Pan(f32), Pan(f32),
SelectStart, SelectStart,
SelectExtend, SelectExtend,
@@ -1173,7 +1173,7 @@ pub enum GestureOutcome {
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`. /// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
/// `Some(velocity)` only if the gesture had committed to panning /// `Some(velocity)` only if the gesture had committed to panning
/// (never a tap, a long-press selection, or one still `Undecided`); /// (never a tap, a long-press selection, or one still `Undecided`);
/// same units as `Pan`, so a caller hands it to `List::fling` with /// same units as `Pan`, so a caller hands it to `LazySpan::fling` with
/// whatever sign flip it already applies to `Pan`. /// whatever sign flip it already applies to `Pan`.
Released(Option<f32>), Released(Option<f32>),
/// Another widget took the pointer (`CursorSense::Cancel`), so this /// Another widget took the pointer (`CursorSense::Cancel`), so this
@@ -1271,7 +1271,7 @@ impl DragGesture {
/// Feed one frame of a gesture through. `id` is the widget iris should /// Feed one frame of a gesture through. `id` is the widget iris should
/// give exclusive pointer input to once this gesture commits to /// give exclusive pointer input to once this gesture commits to
/// panning or selecting -- a stable widget that outlives the gesture /// panning or selecting -- a stable widget that outlives the gesture
/// (a `List`'s own id, not one of its virtualised rows, which can be /// (a `LazySpan`'s own id, not one of its virtualised rows, which can be
/// retired mid-drag as content scrolls). `pointer` is `CursorData`'s /// retired mid-drag as content scrolls). `pointer` is `CursorData`'s
/// own field, already in hand at every call site. `press` only matters /// own field, already in hand at every call site. `press` only matters
/// on the frames [`Self::starts_press`] answers true for -- see /// on the frames [`Self::starts_press`] answers true for -- see
@@ -1478,9 +1478,9 @@ const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1;
/// (`DragGestureNode.sendDragStopped` passes /// (`DragGestureNode.sendDragStopped` passes
/// `LocalViewConfiguration.maximumFlingVelocity` into /// `LocalViewConfiguration.maximumFlingVelocity` into
/// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it /// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it
/// in [`crate::widget::List::fling`] instead, because that is the only /// in [`crate::widget::LazySpan::fling`] instead, because that is the only
/// place that knows the density this has to be multiplied by. There is /// place that knows the density this has to be multiplied by. There is
/// deliberately **no** matching minimum: see `List::fling`. /// deliberately **no** matching minimum: see `LazySpan::fling`.
pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0; pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0;
/// Estimates a drag's speed along one axis the way Compose's touch /// Estimates a drag's speed along one axis the way Compose's touch
@@ -1608,7 +1608,7 @@ impl VelocityTracker {
/// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then /// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then
/// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum /// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum
/// itself is applied by the caller that knows the density /// itself is applied by the caller that knows the density
/// ([`crate::widget::List::fling`]). /// ([`crate::widget::LazySpan::fling`]).
/// ///
/// `0.0` with fewer than [`MIN_SAMPLE_SIZE`] usable samples, which is /// `0.0` with fewer than [`MIN_SAMPLE_SIZE`] usable samples, which is
/// Compose's answer too: a press and a single move carry no curve to /// Compose's answer too: a press and a single move carry no curve to
@@ -1655,7 +1655,7 @@ impl VelocityTracker {
// `calculateVelocity(maximumVelocity)`'s first branch, kept as the // `calculateVelocity(maximumVelocity)`'s first branch, kept as the
// outer guard even though the degenerate case is now detected // outer guard even though the degenerate case is now detected
// rather than clamped: a fit can still overflow on inputs nothing // rather than clamped: a fit can still overflow on inputs nothing
// here has produced, and `List::fling` asserts finiteness. // here has produced, and `LazySpan::fling` asserts finiteness.
if velocity.is_finite() { velocity } else { 0.0 } if velocity.is_finite() { velocity } else { 0.0 }
} }
} }
@@ -1876,7 +1876,7 @@ const FLING_FRICTION: f32 = 0.015;
/// small, which put an `ln` of a 56x-too-large ratio through /// small, which put an `ln` of a 56x-too-large ratio through
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds** /// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
/// instead of 1.6. Nothing could see it while a finger fling never /// instead of 1.6. Nothing could see it while a finger fling never
/// animated at all (`List::fling`'s doc), which is why two defects had to /// animated at all (`LazySpan::fling`'s doc), which is why two defects had to
/// be fixed before either was visible. /// be fixed before either was visible.
const FLING_TUNING: f32 = 0.84; const FLING_TUNING: f32 = 0.84;
fn deceleration_rate() -> f32 { fn deceleration_rate() -> f32 {
@@ -1896,8 +1896,8 @@ const GRAVITY_EARTH: f32 = 9.80665;
/// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the /// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the
/// wrong density changes how long a fling lasts exponentially rather than /// wrong density changes how long a fling lasts exponentially rather than
/// scaling it. An earlier version of this comment claimed the opposite and /// scaling it. An earlier version of this comment claimed the opposite and
/// `List::fling` passed `1.0`; on a 2.75-density screen that gave a /// `LazySpan::fling` passed `1.0`; on a 2.75-density screen that gave a
/// one-second flick a 45-second coast (measured 2026-09-07). `List` reads /// one-second flick a 45-second coast (measured 2026-09-07). `LazySpan` reads
/// its density from the painter now. /// its density from the painter now.
pub struct FlingCalculator { pub struct FlingCalculator {
physical_coefficient: f32, physical_coefficient: f32,
@@ -1919,7 +1919,7 @@ impl FlingCalculator {
/// Total signed distance the fling travels before settling, in the /// Total signed distance the fling travels before settling, in the
/// same pixel units `velocity` was given in. /// same pixel units `velocity` was given in.
pub fn distance(&self, velocity: f32) -> f32 { pub fn distance(&self, velocity: f32) -> f32 {
// See `List::fling`'s matching assertion -- a non-finite velocity // See `LazySpan::fling`'s matching assertion -- a non-finite velocity
// here silently produces a NaN distance rather than surfacing the // here silently produces a NaN distance rather than surfacing the
// bug that produced it (docs/REVIEW-2026-09-06.md finding 3). // bug that produced it (docs/REVIEW-2026-09-06.md finding 3).
debug_assert!(velocity.is_finite()); debug_assert!(velocity.is_finite());
@@ -1946,7 +1946,7 @@ impl FlingCalculator {
} }
/// The signed distance covered by `elapsed` into a fling of this /// The signed distance covered by `elapsed` into a fling of this
/// `velocity` -- what a per-frame ticker (`List::tick_fling`) calls to /// `velocity` -- what a per-frame ticker (`LazySpan::tick_fling`) calls to
/// find how far to have scrolled by now. Clamped to the full /// find how far to have scrolled by now. Clamped to the full
/// `distance()` once `elapsed` reaches `duration()`, so a caller need /// `distance()` once `elapsed` reaches `duration()`, so a caller need
/// not special-case "past the end." /// not special-case "past the end."
@@ -1964,7 +1964,7 @@ impl FlingCalculator {
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start /// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
/// to zero at `duration()`, which is the whole difference between a /// to zero at `duration()`, which is the whole difference between a
/// fling and a constant-speed slide, so it is what /// fling and a constant-speed slide, so it is what
/// `List::tick_fling`'s debug line reports: successive frames printing /// `LazySpan::tick_fling`'s debug line reports: successive frames printing
/// a shrinking number is the evidence that the curve is being followed /// a shrinking number is the evidence that the curve is being followed
/// at all. /// at all.
pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 { pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 {
@@ -1984,7 +1984,7 @@ impl FlingCalculator {
/// ///
/// It owns the curve and the clock and nothing else. Which way a positive /// It owns the curve and the clock and nothing else. Which way a positive
/// delta moves the content, and whether the content has anywhere left to /// delta moves the content, and whether the content has anywhere left to
/// go, are the caller's -- a `List` scrolls its anchor one way and a /// go, are the caller's -- a `LazySpan` scrolls its anchor one way and a
/// `Scroll` moves its `amt` the other, and a `Flinger` that tried to know /// `Scroll` moves its `amt` the other, and a `Flinger` that tried to know
/// which would have to be told, which is the same thing as not knowing. /// which would have to be told, which is the same thing as not knowing.
/// So a caller applies [`Self::tick`]'s delta in its own convention and /// So a caller applies [`Self::tick`]'s delta in its own convention and
@@ -2346,7 +2346,7 @@ mod fling_calculator_tests {
/// Summing the spline's own per-frame position deltas across the /// Summing the spline's own per-frame position deltas across the
/// whole fling has to land within 1% of the closed-form `distance()` /// whole fling has to land within 1% of the closed-form `distance()`
/// -- this is the guarantee that `List::tick_fling`'s per-frame reads /// -- this is the guarantee that `LazySpan::tick_fling`'s per-frame reads
/// of `position_at` actually add up to the total the fling promised, /// of `position_at` actually add up to the total the fling promised,
/// not merely that the two formulas look plausible independently. /// not merely that the two formulas look plausible independently.
#[test] #[test]
+1 -1
View File
@@ -453,7 +453,7 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
}; };
// The bystander *contains* the capturer, which is the real shape: a // The bystander *contains* the capturer, which is the real shape: a
// transcript's `List` and one row's own text both track the same // transcript's `LazySpan` and one row's own text both track the same
// press, and a `Stack`'s siblings would be on separate layers where // press, and a `Stack`'s siblings would be on separate layers where
// only the topmost is dispatched to at all. // only the topmost is dispatched to at all.
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
-2
View File
@@ -1,5 +1,4 @@
mod image; mod image;
mod list;
mod mask; mod mask;
mod position; mod position;
mod ptr; mod ptr;
@@ -8,7 +7,6 @@ mod text;
mod trait_fns; mod trait_fns;
pub use image::*; pub use image::*;
pub use list::*;
pub use mask::*; pub use mask::*;
pub use position::*; pub use position::*;
pub use ptr::*; pub use ptr::*;
File diff suppressed because it is too large. Load diff
+2
View File
@@ -1,5 +1,6 @@
mod align; mod align;
mod layer; mod layer;
mod lazy_span;
mod max_size; mod max_size;
mod offset; mod offset;
mod pad; mod pad;
@@ -10,6 +11,7 @@ mod stack;
pub use align::*; pub use align::*;
pub use layer::*; pub use layer::*;
pub use lazy_span::*;
pub use max_size::*; pub use max_size::*;
pub use offset::*; pub use offset::*;
pub use pad::*; pub use pad::*;
+7 -7
View File
@@ -20,14 +20,14 @@ pub struct Scroll {
/// therefore opened at the end of its longest line, mid-word /// therefore opened at the end of its longest line, mid-word
/// (`iris/run-headless.sh phone`, 2026-09-08). /// (`iris/run-headless.sh phone`, 2026-09-08).
content_len: Option<f32>, content_len: Option<f32>,
/// Touch panning, from the same `DragGesture` `List` is driven by /// Touch panning, from the same `DragGesture` `LazySpan` is driven by
/// (`transcript-ui::Selection::drag`) rather than a second copy of its /// (`transcript-ui::Selection::drag`) rather than a second copy of its
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in /// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
/// `sense.rs` and only what a committed pan *means* is decided here. /// `sense.rs` and only what a committed pan *means* is decided here.
/// See [`Self::drag`]. /// See [`Self::drag`].
gesture: DragGesture, gesture: DragGesture,
/// The momentum a release leaves behind, the same [`Flinger`] a /// The momentum a release leaves behind, the same [`Flinger`] a
/// `List` coasts on. Every scroll area flings, on either axis and /// `LazySpan` coasts on. Every scroll area flings, on either axis and
/// with nothing to opt into -- Compose's `scrollable` attaches /// with nothing to opt into -- Compose's `scrollable` attaches
/// `ScrollableDefaults.flingBehavior()` on every axis it is given, /// `ScrollableDefaults.flingBehavior()` on every axis it is given,
/// and Iris asked for the same (2026-09-08: "flinging should be /// and Iris asked for the same (2026-09-08: "flinging should be
@@ -50,7 +50,7 @@ impl Widget for Scroll {
let delta = self.fling.tick(now); let delta = self.fling.tick(now);
self.scroll(delta); self.scroll(delta);
// A fling must not keep spending its distance on content that is // A fling must not keep spending its distance on content that is
// not there. Unlike `List`, this widget knows exactly where its // not there. Unlike `LazySpan`, this widget knows exactly where its
// content ends -- `update_amt` has just clamped `amt` into it -- // content ends -- `update_amt` has just clamped `amt` into it --
// so the wall is read after the move rather than from what the // so the wall is read after the move rather than from what the
// last draw found. // last draw found.
@@ -101,7 +101,7 @@ impl Widget for Scroll {
// why the offset is the only thing that moves. A length is not // why the offset is the only thing that moves. A length is not
// knowable without drawing (LAYOUT.md section 5), so this draws // knowable without drawing (LAYOUT.md section 5), so this draws
// the child once to measure it and once to place it, the same // the child once to measure it and once to place it, the same
// measure-then-place idiom `Span::draw` and `List::place` // measure-then-place idiom `Span::draw` and `LazySpan::place`
// already use. Written out rather than through // already use. Written out rather than through
// `Painter::draw_twice`, which cannot take a closure needing // `Painter::draw_twice`, which cannot take a closure needing
// `&mut self` while `self.inner` is borrowed. // `&mut self` while `self.inner` is borrowed.
@@ -236,10 +236,10 @@ impl Scroll {
.handle(pointer, id, sense, pos_window, now, press) .handle(pointer, id, sense, pos_window, now, press)
{ {
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes // `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
// `-dy` to `List::scroll` because a `List`'s anchor offset and // `-dy` to `LazySpan::scroll` because a `LazySpan`'s anchor offset and
// this widget's `amt` run in *opposite* directions (offset is // this widget's `amt` run in *opposite* directions (offset is
// where the anchored edge sits; `amt` is how far the content // where the anchored edge sits; `amt` is how far the content
// has been pulled up past the top), even though `List::scroll`'s // has been pulled up past the top), even though `LazySpan::scroll`'s
// own doc claims to mirror this one's convention. The rule that // own doc claims to mirror this one's convention. The rule that
// holds for both, and the one to check a sign against, is that // holds for both, and the one to check a sign against, is that
// the content follows the finger. // the content follows the finger.
@@ -304,7 +304,7 @@ impl Scroll {
} }
/// Whether a fling is coasting here right now -- the same question /// Whether a fling is coasting here right now -- the same question
/// `List::is_scrolling` answers for the other scrolling widget, under /// `LazySpan::is_scrolling` answers for the other scrolling widget, under
/// the same name so there is one word for it. What a caller polls to /// the same name so there is one word for it. What a caller polls to
/// know whether this area is moving on its own (a test, and /// know whether this area is moving on its own (a test, and
/// [`PressState::scrolling`]'s own condition). /// [`PressState::scrolling`]'s own condition).
+1 -1
View File
@@ -120,7 +120,7 @@ widget_trait! {
ctx.widget(rsc).scroll(delta); ctx.widget(rsc).scroll(delta);
}) })
// A finger drag, through the same `DragGesture` the // A finger drag, through the same `DragGesture` the
// transcript's `List` is panned by -- `Scroll::drag`'s doc // transcript's `LazySpan` is panned by -- `Scroll::drag`'s doc
// has the arbitration and why there is no fling. The wheel // has the arbitration and why there is no fling. The wheel
// above and this are the two inputs of one scroll, so they // above and this are the two inputs of one scroll, so they
// are registered together rather than left to each caller. // are registered together rather than left to each caller.
+1 -1
View File
@@ -127,7 +127,7 @@ impl DefaultAppState for Client {
} }
// A fling coasts only while something asks for the next // A fling coasts only while something asks for the next
// frame; on the desktop that is the window's own redraw // frame; on the desktop that is the window's own redraw
// request (`List::fling`'s doc). // request (`LazySpan::fling`'s doc).
let handle = rsc.tasks.redraw_handle(); let handle = rsc.tasks.redraw_handle();
(opened.screen.list)(rsc).set_redraw_handle(handle); (opened.screen.list)(rsc).set_redraw_handle(handle);
Some(opened.screen) Some(opened.screen)
@@ -17,7 +17,7 @@ use iris::sense::DRAG_SLOP;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// The screen open on the fixture, framed twice -- once to draw, once for /// The screen open on the fixture, framed twice -- once to draw, once for
/// `List::repair_anchor` to resolve the opening `snap_end` into a real /// `LazySpan::repair_anchor` to resolve the opening `snap_end` into a real
/// anchor, which is what every assertion about scroll position reads. /// anchor, which is what every assertion about scroll position reads.
fn opened() -> (Harness, transcript_ui::TranscriptScreen) { fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE); let mut h = Harness::new(phone_size(), PHONE_SCALE);
@@ -28,7 +28,7 @@ fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
} }
/// Where the content is, in window pixels: the top of whichever row is /// Where the content is, in window pixels: the top of whichever row is
/// under the middle of the viewport. `List` has no travel accessor and /// under the middle of the viewport. `LazySpan` has no travel accessor and
/// this needs none -- a row's own extent moves exactly as far as the /// this needs none -- a row's own extent moves exactly as far as the
/// content does, and the row is picked once so the two readings compare. /// content does, and the row is picked once so the two readings compare.
fn tracked_row(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> (RowKey, f32) { fn tracked_row(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> (RowKey, f32) {
@@ -13,7 +13,7 @@ use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// The screen open on the fixture, framed twice: once to draw, once for /// The screen open on the fixture, framed twice: once to draw, once for
/// `List::repair_anchor` to resolve the opening `snap_end` into a real /// `LazySpan::repair_anchor` to resolve the opening `snap_end` into a real
/// anchor, which is what every assertion about scroll position reads. /// anchor, which is what every assertion about scroll position reads.
fn opened() -> (Harness, transcript_ui::TranscriptScreen) { fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE); let mut h = Harness::new(phone_size(), PHONE_SCALE);
@@ -34,7 +34,7 @@ fn offset(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> String {
/// (a) and (b) together, because the second is only meaningful if the /// (a) and (b) together, because the second is only meaningful if the
/// first happened: the recorded flick must release with a real velocity /// first happened: the recorded flick must release with a real velocity
/// (`GestureOutcome::Released(Some(v))`, which is the only thing that /// (`GestureOutcome::Released(Some(v))`, which is the only thing that
/// puts a value in `List::fling_velocity`), and the list must then /// puts a value in `LazySpan::fling_velocity`), and the list must then
/// actually travel and stop on the spline's own schedule. /// actually travel and stop on the spline's own schedule.
#[test] #[test]
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() { fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
@@ -73,7 +73,7 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
let mut settled_at = None; let mut settled_at = None;
let mut t = flick.end_ms(); let mut t = flick.end_ms();
// Travel in pixels, measured from a row's own on-screen extent, since // Travel in pixels, measured from a row's own on-screen extent, since
// `List` has no travel accessor and this needs none: follow whatever // `LazySpan` has no travel accessor and this needs none: follow whatever
// row is under the viewport's middle until it leaves, then pick // row is under the viewport's middle until it leaves, then pick
// another. Deliberately an *under*-count -- the frame a row leaves on // another. Deliberately an *under*-count -- the frame a row leaves on
// contributes nothing -- which is why it is only ever a lower bound. // contributes nothing -- which is why it is only ever a lower bound.
+4 -4
View File
@@ -5,7 +5,7 @@
//! What these are about is docs/IRIS_TODO.md's 2026-09-07 phone report -- //! What these are about is docs/IRIS_TODO.md's 2026-09-07 phone report --
//! rows scrolled above the viewport still drawn, over the header, and a //! rows scrolled above the viewport still drawn, over the header, and a
//! blank band where the row straddling the top edge should be. Both are //! blank band where the row straddling the top edge should be. Both are
//! one rule (`List::intersects_viewport`): a row is drawn if any part of //! one rule (`LazySpan::intersects_viewport`): a row is drawn if any part of
//! it is inside the list's own box, and nothing outside that box reaches //! it is inside the list's own box, and nothing outside that box reaches
//! the screen. //! the screen.
@@ -44,7 +44,7 @@ fn list_box(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> PixelRegio
} }
/// Every row the list drew this frame, as `(top, bottom)` window pixels, /// Every row the list drew this frame, as `(top, bottom)` window pixels,
/// topmost first. A `List`'s direct children are exactly its rows, and /// topmost first. A `LazySpan`'s direct children are exactly its rows, and
/// `draw_inner`'s old-children diffing means a row it did not place this /// `draw_inner`'s old-children diffing means a row it did not place this
/// frame is not among them. /// frame is not among them.
fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32, f32)> { fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32, f32)> {
@@ -198,7 +198,7 @@ fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
/// ///
/// The box is asserted on every leg *except the first*, because a row /// The box is asserted on every leg *except the first*, because a row
/// whose height has never been measured has to be drawn to be measured /// whose height has never been measured has to be drawn to be measured
/// (`List::place`'s doc), which on the first walk back is every row /// (`LazySpan::place`'s doc), which on the first walk back is every row
/// entering from the top. Every later leg crosses the same rows with /// entering from the top. Every later leg crosses the same rows with
/// every height already known -- including the second walk *back*, which /// every height already known -- including the second walk *back*, which
/// is there because a regression that draws rows in the wrong place while /// is there because a regression that draws rows in the wrong place while
@@ -293,7 +293,7 @@ fn scrolling_past_the_first_row_settles_on_it() {
t = scrolled(&mut h, &screen, -100_000.0, t); t = scrolled(&mut h, &screen, -100_000.0, t);
} }
// No settling frame on purpose: the draw that discovers the gap gives // No settling frame on purpose: the draw that discovers the gap gives
// it back inside that same frame (`List::overscroll_gap`), so the last // it back inside that same frame (`LazySpan::overscroll_gap`), so the last
// frame `scrolled` drew is already flush with the first row. Adding // frame `scrolled` drew is already flush with the first row. Adding
// one here would hide a regression to the old next-frame correction. // one here would hide a regression to the old next-frame correction.
let rows = drawn_rows(&h, &screen); let rows = drawn_rows(&h, &screen);
+2 -2
View File
@@ -1,9 +1,9 @@
//! The message composer at the bottom of the transcript screen: a //! The message composer at the bottom of the transcript screen: a
//! multi-line editable field with a natural (not fixed) height, so it //! multi-line editable field with a natural (not fixed) height, so it
//! grows as typed into -- IRIS_TODO.md's "input box" benchmark case //! grows as typed into -- IRIS_TODO.md's "input box" benchmark case
//! (`iris/benches/message_list.rs` exercises the mechanism in isolation; //! (`iris/benches/message_lazy_span.rs` exercises the mechanism in isolation;
//! this wires the same `TextEdit`-with-no-`Sized`-wrapper idiom into the //! this wires the same `TextEdit`-with-no-`Sized`-wrapper idiom into the
//! real screen). `lib.rs` gives the transcript `List` `.height(rest(1))` //! real screen). `lib.rs` gives the transcript `LazySpan` `.height(rest(1))`
//! beside this widget in a `Span::down`, so the list's own draw already //! beside this widget in a `Span::down`, so the list's own draw already
//! measures whatever vertical space is left each frame -- nothing here //! measures whatever vertical space is left each frame -- nothing here
//! computes a height by hand, and growing this field is exactly the //! computes a height by hand, and growing this field is exactly the
+18 -18
View File
@@ -7,7 +7,7 @@
//! //!
//! ```text //! ```text
//! +------------------------------------------+ //! +------------------------------------------+
//! | iris::widget::List (transcript_ui::row) | <- .height(rest(1)) //! | iris::widget::LazySpan (transcript_ui::row) | <- .height(rest(1))
//! | row 1: sender label + one TextEdit | //! | row 1: sender label + one TextEdit |
//! | row 2: sender label + one TextEdit | //! | row 2: sender label + one TextEdit |
//! | row 3 (Tools): collapsed/expanded | //! | row 3 (Tools): collapsed/expanded |
@@ -38,7 +38,7 @@
//! through one shared `iris::sense::DragArbiter` //! through one shared `iris::sense::DragArbiter`
//! (`Selection::drag`, `selection.rs`), which decides pan vs. select the //! (`Selection::drag`, `selection.rs`), which decides pan vs. select the
//! way Android itself does -- see `DragArbiter`'s own doc and //! way Android itself does -- see `DragArbiter`'s own doc and
//! `DECISIONS.md` for the exact rule. `List` scrolls correctly when //! `DECISIONS.md` for the exact rule. `LazySpan` scrolls correctly when
//! driven programmatically (I3's benchmark), via the mouse wheel (wired //! driven programmatically (I3's benchmark), via the mouse wheel (wired
//! below, `CursorSense::Scroll`), and now via a touch pan starting on a //! below, `CursorSense::Scroll`), and now via a touch pan starting on a
//! row's own text too. //! row's own text too.
@@ -55,10 +55,10 @@ use selection::Selection;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
pub struct TranscriptScreen { pub struct TranscriptScreen {
/// The transcript's own `List` -- exposed so a caller can read /// The transcript's own `LazySpan` -- exposed so a caller can read
/// `.extent()`/call `.jump_to_end()` etc. directly for anything this /// `.extent()`/call `.jump_to_end()` etc. directly for anything this
/// crate does not already wrap. /// crate does not already wrap.
pub list: WeakWidget<List>, pub list: WeakWidget<LazySpan>,
pub composer: composer::Composer, pub composer: composer::Composer,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
/// How many times [`Self::apply`] has fallen back to a full rebuild -- /// How many times [`Self::apply`] has fallen back to a full rebuild --
@@ -83,7 +83,7 @@ pub struct TranscriptScreen {
impl TranscriptScreen { impl TranscriptScreen {
/// Append one more folded row at the live end of the transcript -- /// Append one more folded row at the live end of the transcript --
/// what a caller's SSE loop or a sent message calls as new events /// what a caller's SSE loop or a sent message calls as new events
/// arrive. `List::push_back` is O(1) and keeps the view pinned to the /// arrive. `LazySpan::push_back` is O(1) and keeps the view pinned to the
/// newest content when it already was (I3). /// newest content when it already was (I3).
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow) pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
where where
@@ -96,7 +96,7 @@ impl TranscriptScreen {
row, row,
self.session_working.get(), self.session_working.get(),
); );
(self.list)(rsc).push_back(ListRow::new(key, widget)); (self.list)(rsc).push_back(LazyItem::new(key, widget));
*self.tail.borrow_mut() = tail.map(|t| (key, t)); *self.tail.borrow_mut() = tail.map(|t| (key, t));
} }
@@ -218,7 +218,7 @@ impl TranscriptScreen {
/// - **only the last row's content changed** (the common case: a delta /// - **only the last row's content changed** (the common case: a delta
/// folded into a still-open assistant message): that one row is /// folded into a still-open assistant message): that one row is
/// rebuilt (`row::build_row`, the same path a fresh row goes /// rebuilt (`row::build_row`, the same path a fresh row goes
/// through) and swapped in with [`List::replace_back`] -- every /// through) and swapped in with [`LazySpan::replace_back`] -- every
/// other row is untouched, so nothing else redraws or moves. Any /// other row is untouched, so nothing else redraws or moves. Any
/// further new rows are appended after it, for the (also common) /// further new rows are appended after it, for the (also common)
/// case of a delta that both finishes the open reply and starts the /// case of a delta that both finishes the open reply and starts the
@@ -228,7 +228,7 @@ impl TranscriptScreen {
/// happens when `group_tool_runs` regroups already-seen items (a tool /// happens when `group_tool_runs` regroups already-seen items (a tool
/// run's calls that used to be separate rows join once the run closes) /// run's calls that used to be separate rows join once the run closes)
/// -- falls back to a full rebuild: every row is dropped /// -- falls back to a full rebuild: every row is dropped
/// (`List::clear`) and rebuilt from `new`. Counted in /// (`LazySpan::clear`) and rebuilt from `new`. Counted in
/// [`Self::take_rebuilds`] so a caller (a report, a test) can see how /// [`Self::take_rebuilds`] so a caller (a report, a test) can see how
/// often the fallback actually fires rather than assuming it never /// often the fallback actually fires rather than assuming it never
/// does. /// does.
@@ -285,7 +285,7 @@ impl TranscriptScreen {
&new_rows[common], &new_rows[common],
self.session_working.get(), self.session_working.get(),
); );
let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget)); let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget));
drop(evicted); // frees the old row's widget, same as a pop would drop(evicted); // frees the old row's widget, same as a pop would
*self.tail.borrow_mut() = kept.map(|t| (new_key, t)); *self.tail.borrow_mut() = kept.map(|t| (new_key, t));
for row in &new_rows[common + 1..] { for row in &new_rows[common + 1..] {
@@ -295,7 +295,7 @@ impl TranscriptScreen {
RowDiff::Rebuild => { RowDiff::Rebuild => {
// A row before the tail changed (a regroup) -- nothing // A row before the tail changed (a regroup) -- nothing
// short of a full rebuild expresses that. `Selection` // short of a full rebuild expresses that. `Selection`
// gets cleared the same way `List` does, right before the // gets cleared the same way `LazySpan` does, right before the
// rows it was pointing at go with it -- `push_row` below // rows it was pointing at go with it -- `push_row` below
// re-`register`s whatever survives as it rebuilds each // re-`register`s whatever survives as it rebuilds each
// row (docs/REVIEW-2026-09-06.md finding 1: a key that // row (docs/REVIEW-2026-09-06.md finding 1: a key that
@@ -355,7 +355,7 @@ where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
let selection = Rc::new(RefCell::new(Selection::new())); let selection = Rc::new(RefCell::new(Selection::new()));
let list = List::new(Axis::Y).add(rsc); let list = LazySpan::new(Dir::DOWN, true).add(rsc);
// The last row's block widgets are kept for the same reason // The last row's block widgets are kept for the same reason
// `push_row` keeps them: a reply that is *already* streaming when the // `push_row` keeps them: a reply that is *already* streaming when the
@@ -371,13 +371,13 @@ where
// and claiming a call is running because the screen happens to be // and claiming a call is running because the screen happens to be
// opening is exactly the inferred-as-measured mistake. // opening is exactly the inferred-as-measured mistake.
let (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false); let (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false);
list(rsc).push_back(ListRow::new(key, widget)); list(rsc).push_back(LazyItem::new(key, widget));
tail = kept.map(|t| (key, t)); tail = kept.map(|t| (key, t));
} }
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s // Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
// `scrollable()` uses for `Scroll`, applied directly to `List` since // `scrollable()` uses for `Scroll`, applied directly to `LazySpan` since
// `List` already does its own placement and needs no `Scroll` wrapper. // `LazySpan` already does its own placement and needs no `Scroll` wrapper.
// Real touch-drag panning is the known gap in this module's doc. // Real touch-drag panning is the known gap in this module's doc.
list.on(CursorSense::Scroll, |ctx, rsc| { list.on(CursorSense::Scroll, |ctx, rsc| {
let delta = ctx.data.scroll_delta.y * 50.0; let delta = ctx.data.scroll_delta.y * 50.0;
@@ -392,7 +392,7 @@ where
// each frame of one gesture exactly once. `ctx.data.pos`/`size` are // each frame of one gesture exactly once. `ctx.data.pos`/`size` are
// already relative to `list`'s own on-screen box (this is what it was // already relative to `list`'s own on-screen box (this is what it was
// registered against), which is exactly the viewport-pixel space // registered against), which is exactly the viewport-pixel space
// `List::key_at`/`extent` work in, so the row-under-the-pointer is // `LazySpan::key_at`/`extent` work in, so the row-under-the-pointer is
// resolved from those instead of a per-row hit test. // resolved from those instead of a per-row hit test.
{ {
let selection = selection.clone(); let selection = selection.clone();
@@ -423,11 +423,11 @@ where
let (composer, composer_bar) = composer::build_composer(rsc); let (composer, composer_bar) = composer::build_composer(rsc);
// `.masked()`: the list draws the row straddling each of its edges in // `.masked()`: the list draws the row straddling each of its edges in
// full (`List::intersects_viewport`), so without a clip the top of // full (`LazySpan::intersects_viewport`), so without a clip the top of
// that row is drawn above the list -- through whatever the app put // that row is drawn above the list -- through whatever the app put
// there, which on the phone is the header bar (docs/IRIS_TODO.md, // there, which on the phone is the header bar (docs/IRIS_TODO.md,
// 2026-09-07: "code and a paragraph visible behind Run benchmark"). // 2026-09-07: "code and a paragraph visible behind Run benchmark").
// The same clip is what `List::draw` asserts it has. // The same clip is what `LazySpan::draw` asserts it has.
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar) let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(rsc) .add_strong(rsc)
@@ -591,7 +591,7 @@ mod diff_tests {
/// `Selection`, the gap docs/REVIEW-2026-09-06.md finding 8 named: the /// `Selection`, the gap docs/REVIEW-2026-09-06.md finding 8 named: the
/// pure `diff_rows` decision above and `selection.rs`'s own registration /// pure `diff_rows` decision above and `selection.rs`'s own registration
/// tests each pass in isolation, and neither alone catches finding 1 (a /// tests each pass in isolation, and neither alone catches finding 1 (a
/// regrouped-away row's key surviving in `Selection` after `List::clear()` /// regrouped-away row's key surviving in `Selection` after `LazySpan::clear()`
/// has already freed its widget). This fails before `Selection::clear()` /// has already freed its widget). This fails before `Selection::clear()`
/// existed and the `Rebuild` arm called it, with a panic from /// existed and the `Rebuild` arm called it, with a panic from
/// `TextEditable::edit` resolving the freed slot. /// `TextEditable::edit` resolving the freed slot.
+11 -11
View File
@@ -1,4 +1,4 @@
//! One `iris::widget::list::ListRow` per folded transcript row //! One `iris::widget::list::LazyItem` per folded transcript row
//! (`client_core::transcript_fold::TranscriptRow`). A row is a **column of //! (`client_core::transcript_fold::TranscriptRow`). A row is a **column of
//! one `TextEdit` per top-level markdown block** (paragraph, heading, //! one `TextEdit` per top-level markdown block** (paragraph, heading,
//! fence, list, table -- `client_core::markdown_blocks`), each rendered //! fence, list, table -- `client_core::markdown_blocks`), each rendered
@@ -17,11 +17,11 @@
//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by //! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by
//! `client_core::transcript_fold::group_tool_runs`) is the row that proves //! `client_core::transcript_fold::group_tool_runs`) is the row that proves
//! behaviour 3's "hold the edge nearest the tap" on expand: tapping its //! behaviour 3's "hold the edge nearest the tap" on expand: tapping its
//! header calls `List::note_tap` at the row's own on-screen position //! header calls `LazySpan::note_tap` at the row's own on-screen position
//! (read back from `List::extent`, since the tap event only knows its //! (read back from `LazySpan::extent`, since the tap event only knows its
//! position *within* this row) before toggling a `WidgetPtr` between the //! position *within* this row) before toggling a `WidgetPtr` between the
//! collapsed summary and the full detail -- the same two-step contract //! collapsed summary and the full detail -- the same two-step contract
//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`. //! `lazy_span.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
use crate::markdown::{BlockFrame, Link, frame_of, render_block}; use crate::markdown::{BlockFrame, Link, frame_of, render_block};
use crate::selection::{SelKey, Selection}; use crate::selection::{SelKey, Selection};
@@ -43,7 +43,7 @@ const BLOCK_GAP_DP: f32 = 8.0;
/// regardless of which row's base size surrounds it. /// regardless of which row's base size surrounds it.
pub const BASE_SIZE: f32 = 16.0; pub const BASE_SIZE: f32 = 16.0;
/// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `List` wants. /// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `LazySpan` wants.
/// `ItemKey::RunId` is a string (a tool call's own id), so it is hashed into /// `ItemKey::RunId` is a string (a tool call's own id), so it is hashed into
/// one -- collisions are not a correctness risk worth guarding against here /// one -- collisions are not a correctness risk worth guarding against here
/// (a `DefaultHasher` collision across the run ids one session produces is /// (a `DefaultHasher` collision across the run ids one session produces is
@@ -181,7 +181,7 @@ const FRAME_RADIUS_DP: f32 = 8.0;
/// them without rebuilding the handler. /// them without rebuilding the handler.
fn build_block<Rsc: HasEvents>( fn build_block<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<List>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: SelKey, key: SelKey,
block: &Block, block: &Block,
@@ -225,7 +225,7 @@ where
// id and every further frame, including the terminal `Drop`, // id and every further frame, including the terminal `Drop`,
// reaches `lib.rs`'s list-level registration instead -- see // reaches `lib.rs`'s list-level registration instead -- see
// `iris::sense`'s pointer-capture doc for why that has to be a // `iris::sense`'s pointer-capture doc for why that has to be a
// stable id rather than this row's, which `List` can retire mid- // stable id rather than this row's, which `LazySpan` can retire mid-
// drag as content scrolls. // drag as content scrolls.
// //
// `Cancel` is the one that is *not* optional, and leaving it out // `Cancel` is the one that is *not* optional, and leaving it out
@@ -315,7 +315,7 @@ where
/// than a row (`selection::SelKey`). /// than a row (`selection::SelKey`).
fn build_text_row<Rsc: HasEvents>( fn build_text_row<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<List>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
sender: Option<&str>, sender: Option<&str>,
@@ -385,7 +385,7 @@ impl RowBlocks {
pub fn apply_delta<Rsc: HasEvents>( pub fn apply_delta<Rsc: HasEvents>(
&mut self, &mut self,
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<List>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
sender: Option<&str>, sender: Option<&str>,
@@ -459,7 +459,7 @@ impl RowBlocks {
fn build_single<Rsc: HasEvents>( fn build_single<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<List>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
item: &TranscriptItem, item: &TranscriptItem,
@@ -489,7 +489,7 @@ pub enum TailRow {
pub fn build_row<Rsc: HasEvents>( pub fn build_row<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<List>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
row: &FoldedRow, row: &FoldedRow,
working: bool, working: bool,
+15 -11
View File
@@ -5,7 +5,7 @@
//! `xilem` (RUST.md's E2 box, citing //! `xilem` (RUST.md's E2 box, citing
//! `masonry/src/widgets/text_area.rs:414-459`). Each transcript row here is //! `masonry/src/widgets/text_area.rs:414-459`). Each transcript row here is
//! still its own `TextEdit` (one per row, not one per transcript, since a //! still its own `TextEdit` (one per row, not one per transcript, since a
//! row is what `List` virtualises), so this is not literally "one //! row is what `LazySpan` virtualises), so this is not literally "one
//! `PlainEditor`" either -- iris's answer is a coordinator that drives each //! `PlainEditor`" either -- iris's answer is a coordinator that drives each
//! visible row's *own* selection primitives (`TextEditCtx::select`/ //! visible row's *own* selection primitives (`TextEditCtx::select`/
//! `select_all`/`deselect`, already built for a single field) from one //! `select_all`/`deselect`, already built for a single field) from one
@@ -72,10 +72,10 @@ impl Selection {
/// A row's selectable text became visible/known. Every addition here /// A row's selectable text became visible/known. Every addition here
/// needs its removal (`unregister`, or `clear` for all of them at /// needs its removal (`unregister`, or `clear` for all of them at
/// once) -- called when `List` evicts the row (`pop_front`/ /// once) -- called when `LazySpan` evicts the row (`pop_front`/
/// `pop_back`/`clear`), so this map never outgrows however many rows /// `pop_back`/`clear`), so this map never outgrows however many rows
/// are actually loaded. `List::place` guards the twin of this same /// are actually loaded. `LazySpan::place` guards the twin of this same
/// class of bug on the list's own side (`list.rs`'s `slot_exists` /// class of bug on the list's own side (`lazy_span.rs`'s `slot_exists`
/// assertion) -- a derived handle that silently outlives what it /// assertion) -- a derived handle that silently outlives what it
/// points to; the next caller adding a third row-keyed side table /// points to; the next caller adding a third row-keyed side table
/// should read both. /// should read both.
@@ -83,12 +83,12 @@ impl Selection {
self.rows.insert(key, text); self.rows.insert(key, text);
} }
/// Drops every registration at once -- the same shape `List::clear()` /// Drops every registration at once -- the same shape `LazySpan::clear()`
/// clears the list, and what `TranscriptScreen::apply`'s `Rebuild` arm /// clears the list, and what `TranscriptScreen::apply`'s `Rebuild` arm
/// calls right before it, since a full rebuild drops every row's old /// calls right before it, since a full rebuild drops every row's old
/// widget and `push_row` re-`register`s each surviving key's new one /// widget and `push_row` re-`register`s each surviving key's new one
/// as it goes (review docs/REVIEW-2026-09-06.md finding 1: the /// as it goes (review docs/REVIEW-2026-09-06.md finding 1: the
/// `Rebuild` arm used to call only `List::clear()`, leaving any key /// `Rebuild` arm used to call only `LazySpan::clear()`, leaving any key
/// dropped by the regroup -- present in the old rows, absent from the /// dropped by the regroup -- present in the old rows, absent from the
/// new ones -- pointing at a widget the list had just freed, so the /// new ones -- pointing at a widget the list had just freed, so the
/// next long-press anywhere panicked in `begin`'s deselect loop). /// next long-press anywhere panicked in `begin`'s deselect loop).
@@ -237,7 +237,7 @@ impl Selection {
/// pointer is currently over -- row-local, as `begin`/`extend` want. /// pointer is currently over -- row-local, as `begin`/`extend` want.
/// `None` once the gesture is pointer-captured (`iris::sense`'s /// `None` once the gesture is pointer-captured (`iris::sense`'s
/// pointer-capture doc) and the current position falls outside every /// pointer-capture doc) and the current position falls outside every
/// row `List` has loaded (a gap, or off the end of the content); a /// row `LazySpan` has loaded (a gap, or off the end of the content); a
/// `Pan` outcome never needs it, so this only actually matters mid- /// `Pan` outcome never needs it, so this only actually matters mid-
/// selection, where it is rare and the frame is simply dropped. /// selection, where it is rare and the frame is simply dropped.
/// `pos_window` is in window space, since a pan's delta has to stay /// `pos_window` is in window space, since a pan's delta has to stay
@@ -252,7 +252,7 @@ impl Selection {
pub fn drag( pub fn drag(
&mut self, &mut self,
ui: &mut impl UiRsc, ui: &mut impl UiRsc,
list: WeakWidget<List>, list: WeakWidget<LazySpan>,
row: Option<(SelKey, Vec2, Vec2)>, row: Option<(SelKey, Vec2, Vec2)>,
pos_window: Vec2, pos_window: Vec2,
sense: CursorSense, sense: CursorSense,
@@ -260,7 +260,7 @@ impl Selection {
pointer: &PointerRequests, pointer: &PointerRequests,
) -> GestureOutcome { ) -> GestureOutcome {
// A fresh touch-down cancels any fling still coasting from the // A fresh touch-down cancels any fling still coasting from the
// previous gesture -- `List::fling`'s own doc, and Android's // previous gesture -- `LazySpan::fling`'s own doc, and Android's
// `Scroller::abortAnimation` for the same reason -- and, since // `Scroller::abortAnimation` for the same reason -- and, since
// 2026-09-07, *tells the gesture there was one*. A press that // 2026-09-07, *tells the gesture there was one*. A press that
// caught moving content is a catch: it pans from this very sample // caught moving content is a catch: it pans from this very sample
@@ -312,7 +312,7 @@ impl Selection {
GestureOutcome::Released(Some(v)) => { GestureOutcome::Released(Some(v)) => {
list(ui).fling(-v); list(ui).fling(-v);
// The half that actually makes it move -- see // The half that actually makes it move -- see
// `List::fling`'s doc. Without it the velocity is // `LazySpan::fling`'s doc. Without it the velocity is
// computed, stored, and never advanced by anything. // computed, stored, and never advanced by anything.
// //
// Only when `fling` actually took it: below Compose's // Only when `fling` actually took it: below Compose's
@@ -427,7 +427,11 @@ mod tests {
EditMode::MultiLine, EditMode::MultiLine,
)) ))
.weak(); .weak();
let list = rsc.ui.widgets.add_strong(List::new(Axis::Y)).weak(); let list = rsc
.ui
.widgets
.add_strong(LazySpan::new(Dir::DOWN, true))
.weak();
let mut sel = Selection::new(); let mut sel = Selection::new();
sel.register((1, 0), field); sel.register((1, 0), field);
+6 -6
View File
@@ -153,7 +153,7 @@ struct Shared {
/// Filled in immediately after construction -- the `WidgetPtr` cannot /// Filled in immediately after construction -- the `WidgetPtr` cannot
/// exist before the `Rc` every handler inside it captures. /// exist before the `Rc` every handler inside it captures.
content: RefCell<Option<WeakWidget<WidgetPtr>>>, content: RefCell<Option<WeakWidget<WidgetPtr>>>,
list: WeakWidget<List>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
/// Whether a call in this row could still be running -- the caller's /// Whether a call in this row could still be running -- the caller's
@@ -214,9 +214,9 @@ fn on_tap<Rsc: HasEvents>(
/// Hold the edge the reader is looking at while this row changes height. /// Hold the edge the reader is looking at while this row changes height.
/// ///
/// `List::note_tap` wants a viewport-relative position and this row only /// `LazySpan::note_tap` wants a viewport-relative position and this row only
/// knows its own box, so `List::extent` (last frame's on-screen box for /// knows its own box, so `LazySpan::extent` (last frame's on-screen box for
/// this key) turns the two into the position `list.rs`'s hold-the-edge /// this key) turns the two into the position `lazy_span.rs`'s hold-the-edge
/// pass resolves against -- the two-step contract that module's doc /// pass resolves against -- the two-step contract that module's doc
/// describes for `AGENTS.md`'s `holdTopEdge`. /// describes for `AGENTS.md`'s `holdTopEdge`.
fn note_tap(rsc: &mut impl UiRsc, shared: &Shared) { fn note_tap(rsc: &mut impl UiRsc, shared: &Shared) {
@@ -745,7 +745,7 @@ impl Shared {
/// with no result never came back rather than still running. /// with no result never came back rather than still running.
pub fn build_tool_row<Rsc: HasEvents>( pub fn build_tool_row<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<List>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
calls: Vec<TranscriptItem>, calls: Vec<TranscriptItem>,
@@ -795,7 +795,7 @@ impl ToolRow {
/// Exists because the expanded appearance is otherwise unreachable /// Exists because the expanded appearance is otherwise unreachable
/// from anything that cannot press the screen -- a headless /// from anything that cannot press the screen -- a headless
/// screenshot on this displayless machine, and a test. Same path a tap /// screenshot on this displayless machine, and a test. Same path a tap
/// takes, including `List::note_tap`, so what it produces is what a /// takes, including `LazySpan::note_tap`, so what it produces is what a
/// reader would have got. /// reader would have got.
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool) pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
where where