From 8e5928cc6a19c07d6b021eff6cf1ae3aaa522ab8 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Tue, 8 Sep 2026 20:17:57 -0400 Subject: [PATCH] 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 --- docs/IRIS_TODO.md | 48 ++ docs/LAYOUT.md | 6 +- docs/RUST.md | 4 +- iris/android-app/src/bench_client.rs | 12 +- iris/benches/message_list.rs | 32 +- iris/core/src/ui/active.rs | 2 +- iris/core/src/ui/mod.rs | 2 +- iris/core/src/ui/painter.rs | 2 +- iris/core/src/ui/render_state.rs | 6 +- iris/examples/bench_images.rs | 2 +- iris/examples/message_list.rs | 10 +- iris/src/android/view.rs | 2 +- iris/src/harness.rs | 8 +- iris/src/layout_tests.rs | 6 +- iris/src/sense.rs | 40 +- iris/src/sense_tests.rs | 2 +- iris/src/widget/mod.rs | 2 - .../widget/{list.rs => position/lazy_span.rs} | 531 +++++++++++------- iris/src/widget/position/mod.rs | 2 + iris/src/widget/position/scroll.rs | 14 +- iris/src/widget/trait_fns.rs | 2 +- iris/transcript-fixture/examples/phone.rs | 2 +- .../transcript-fixture/tests/catch_a_fling.rs | 4 +- iris/transcript-fixture/tests/phone_screen.rs | 6 +- iris/transcript-fixture/tests/top_edge.rs | 8 +- iris/transcript-ui/src/composer.rs | 4 +- iris/transcript-ui/src/lib.rs | 36 +- iris/transcript-ui/src/row.rs | 22 +- iris/transcript-ui/src/selection.rs | 26 +- iris/transcript-ui/src/tool.rs | 12 +- 30 files changed, 526 insertions(+), 329 deletions(-) rename iris/src/widget/{list.rs => position/lazy_span.rs} (84%) diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index da2e28d..3e4bb66 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -7,6 +7,54 @@ order and what "done" looks like. Tick and date them in place. ## 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`, `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 (2026-09-08).** Iris's rule, stated while the composer's caret was being fixed: "nothing in the framework should ever self heal because diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index 967e625..d53617b 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -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 `1.0`. Every layout call site that used to call `.apply_rest()`/ `.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 boundary's global logical-space stopgap could come out entirely: window 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 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 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 @@ -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 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 -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. diff --git a/docs/RUST.md b/docs/RUST.md index 0f851e5..e8db7f8 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -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 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 -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 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 @@ -654,7 +654,7 @@ a change landed the way it did. `fonts.xml` monospace declaration against fontique's actually-scanned families, Android-only, verified `mono=Some("Droid Sans Mono")` on this 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 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 diff --git a/iris/android-app/src/bench_client.rs b/iris/android-app/src/bench_client.rs index 7ee06c6..e0dcd43 100644 --- a/iris/android-app/src/bench_client.rs +++ b/iris/android-app/src/bench_client.rs @@ -42,7 +42,7 @@ const STREAM_SECONDS: u64 = 20; /// swipe with these any more. 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 /// 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. @@ -908,9 +908,9 @@ where } /// 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` -/// 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, /// since `TranscriptList`'s `LazyColumn` and this list define "positive" /// 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 /// release does (`transcript_ui::Selection::drag`'s `Released` arm) -- -/// `List::fling` sets a velocity and drives nothing by itself. -fn animate_list(list: iris::prelude::WeakWidget, rsc: &mut Rsc) { +/// `LazySpan::fling` sets a velocity and drives nothing by itself. +fn animate_list(list: iris::prelude::WeakWidget, rsc: &mut Rsc) { let id = list.id(); rsc.ui_mut().animate(id); } @@ -991,7 +991,7 @@ fn animate_list(list: iris::prelude::WeakWidget, rsc: &mut /// spline-decided `duration()` already caps how long it can run. /// /// **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 /// redrew the list at a position it had already drawn, and the benchmark /// looked distinctly less smooth than the same list under her finger. diff --git a/iris/benches/message_list.rs b/iris/benches/message_list.rs index f923250..935c421 100644 --- a/iris/benches/message_list.rs +++ b/iris/benches/message_list.rs @@ -14,11 +14,11 @@ //! -- 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. //! -//! **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 //! 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 -//! 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 //! module doc explains why: it only ever sees `&dyn Widget` through //! `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 //! 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): //! //! - (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 //! section 8 defines. //! - (d) insert-above-anchor: paging older history onto the front of an -//! already-scrolled list. `List::push_front` is an O(1) index update -//! (list.rs's module doc); this measures that none of the rows already +//! already-scrolled list. `LazySpan::push_front` is an O(1) index update +//! (lazy_span.rs's module doc); this measures that none of the rows already //! on screen are touched by it. //! - (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 //! 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 /// (weak, so the caller can drive it) and the erased root to render. fn build_message_list( rsc: &mut BenchRsc, n: usize, image_every: usize, -) -> (WeakWidget, StrongWidget) { - let mut list = List::new(Axis::Y); +) -> (WeakWidget, StrongWidget) { + let mut list = LazySpan::new(Dir::DOWN, true); for i in 0..n { 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); (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 /// bottom, so a row prepended above it is genuinely "inserted above the /// 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 /// viewport, none of them should cost a draw either. fn bench_insert_above_anchor(n: usize, inserts: usize) { @@ -298,7 +298,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) { .widgets .get_mut(&list) .unwrap() - .push_front(ListRow::new(i as u64, row)); + .push_front(LazyItem::new(i as u64, row)); let start = Instant::now(); render.update(&root, &mut rsc); 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 /// 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 /// 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 @@ -334,7 +334,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) { let mut rsc = BenchRsc { 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 // under the list's default bottom-anchored placement, for every N -- // 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)), }); 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 { 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); diff --git a/iris/core/src/ui/active.rs b/iris/core/src/ui/active.rs index c6e0675..468bfd3 100644 --- a/iris/core/src/ui/active.rs +++ b/iris/core/src/ui/active.rs @@ -62,7 +62,7 @@ pub struct ActiveData { /// 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 /// 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 /// `move_applied` and the slot itself. pub repositioned: Vec2, diff --git a/iris/core/src/ui/mod.rs b/iris/core/src/ui/mod.rs index 966ba70..42d92df 100644 --- a/iris/core/src/ui/mod.rs +++ b/iris/core/src/ui/mod.rs @@ -25,7 +25,7 @@ pub struct UiData { /// never goes stale -- see LAYOUT.md section 2. pub move_offsets: TrackedArena, /// 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::tick_animations`] the frame its `tick` answers `false`, so /// a stopped animation costs nothing and a dropped widget cannot be diff --git a/iris/core/src/ui/painter.rs b/iris/core/src/ui/painter.rs index 816d8bf..3aba7d3 100644 --- a/iris/core/src/ui/painter.rs +++ b/iris/core/src/ui/painter.rs @@ -169,7 +169,7 @@ impl<'a> Painter<'a> { /// Whether anything is clipping what this widget draws -- its own /// [`Self::set_mask`], or one an ancestor set that it inherited. What /// 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. pub fn is_masked(&self) -> bool { self.mask != MaskIdx::NONE diff --git a/iris/core/src/ui/render_state.rs b/iris/core/src/ui/render_state.rs index f29c1ed..c5cd0f7 100644 --- a/iris/core/src/ui/render_state.rs +++ b/iris/core/src/ui/render_state.rs @@ -417,13 +417,13 @@ impl UiRenderState { // 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 // 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 // the `remove` that frees the first draw's primitives -- out of play, // 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, // 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 same shape reaches any dirty widget an ancestor redraws first. let dirty = rsc.widgets_mut().needs_redraw.remove(&id); @@ -700,7 +700,7 @@ impl UiRenderState { active.textures.clear(); rsc.ui_mut().textures.free(); 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 // the pointer captured by an id nothing will ever draw // again. That path out is the sensor pass's, not this diff --git a/iris/examples/bench_images.rs b/iris/examples/bench_images.rs index 6b802c0..6b18be6 100644 --- a/iris/examples/bench_images.rs +++ b/iris/examples/bench_images.rs @@ -1,6 +1,6 @@ //! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that //! 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`, //! `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 diff --git a/iris/examples/message_list.rs b/iris/examples/message_list.rs index 6eb7237..878f9f7 100644 --- a/iris/examples/message_list.rs +++ b/iris/examples/message_list.rs @@ -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 //! 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 @@ -10,8 +10,8 @@ //! different number of lines -- exactly the "variable-height rows" I3 //! 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 -//! is also what found `List::place`'s oversized-background bug (see -//! list.rs's module doc and its `a_fill_shaped_background_is_not_left_ +//! is also what found `LazySpan::place`'s oversized-background bug (see +//! 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 //! it directly, but it was this screenshot rendering as a single blank //! tinted rectangle that pointed at it first. @@ -98,10 +98,10 @@ impl DefaultAppState for State { rsc: &mut DefaultRsc, _: Proxy, ) -> Self { - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); for i in 0..ROWS { 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 diff --git a/iris/src/android/view.rs b/iris/src/android/view.rs index 39ce3d7..07e3bad 100644 --- a/iris/src/android/view.rs +++ b/iris/src/android/view.rs @@ -476,7 +476,7 @@ impl IrisViewPeer { // both count. See `iris_core::FrameReport`'s own doc for exactly // what this does and does not measure. 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 // next frame at the end of this one. See // `UiData::tick_animations`; `default/mod.rs`'s diff --git a/iris/src/harness.rs b/iris/src/harness.rs index ee2e1ca..ae5fc3d 100644 --- a/iris/src/harness.rs +++ b/iris/src/harness.rs @@ -141,8 +141,8 @@ impl TouchScript { } /// Counts the frames something asked for without drawing any -- the -/// harness's `RequestRedraw`. A `List` coasting through a fling asks for -/// the next frame through this (`List::set_redraw_handle`), so a test can +/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for +/// the next frame through this (`LazySpan::set_redraw_handle`), so a test can /// tell "nothing moved" from "nothing was even asked to move". #[derive(Default)] pub struct RedrawCounter(AtomicUsize); @@ -335,7 +335,7 @@ impl Harness { } /// 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. pub fn at(&self, t_ms: u64) -> Instant { 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 /// 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 { debug_assert!(step_ms > 0, "a frame loop with no step never ends"); let mut t = from_ms; diff --git a/iris/src/layout_tests.rs b/iris/src/layout_tests.rs index 4ef7ad6..bafc923 100644 --- a/iris/src/layout_tests.rs +++ b/iris/src/layout_tests.rs @@ -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 /// 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 /// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a"). struct MoveThenPlace { @@ -1041,8 +1041,8 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() { dir: Dir::DOWN, gap: Len::ZERO, }); - let mut list = List::new(Axis::Y); - list.push_back(ListRow::new(0, outer.any())); + let mut list = LazySpan::new(Dir::DOWN, true); + list.push_back(LazyItem::new(0, outer.any())); let list = rsc.ui.widgets.add_strong(list); let root = rsc .ui diff --git a/iris/src/sense.rs b/iris/src/sense.rs index 953b6d5..a8e186a 100644 --- a/iris/src/sense.rs +++ b/iris/src/sense.rs @@ -334,7 +334,7 @@ pub struct PointerRequests { impl PointerRequests { /// 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 /// content scrolls. Overwrites any previous capture: a gesture that /// starts a new one has already decided the old one is over, and the @@ -437,7 +437,7 @@ impl SensorUi for UiRenderState { // capture. if let Some(id) = requests.holder() { // 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 // region, so the gesture ends here for everyone. let Some(shape) = self.resolved_region(&id, rsc) else { @@ -924,7 +924,7 @@ pub struct PressState { /// instead of waiting for a long-press. pub already_selected: bool, /// 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, /// and catches skip the slop entirely. pub scrolling: bool, @@ -1088,7 +1088,7 @@ impl DragArbiter { // in full on this one frame is a visible jump the // instant `DRAG_SLOP` is crossed (IRIS_TODO.md's // "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 // every `Undecided` frame, then stepped by several // 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 /// 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 /// a pan, never a text selection that happened to end with the finger /// still moving. @@ -1158,7 +1158,7 @@ impl DragArbiter { pub enum GestureOutcome { Undecided, /// 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), SelectStart, SelectExtend, @@ -1173,7 +1173,7 @@ pub enum GestureOutcome { /// The drag ended -- `PressEnd` or the capture's own terminal `Drop`. /// `Some(velocity)` only if the gesture had committed to panning /// (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`. Released(Option), /// 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 /// give exclusive pointer input to once this gesture commits to /// 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 /// own field, already in hand at every call site. `press` only matters /// on the frames [`Self::starts_press`] answers true for -- see @@ -1478,9 +1478,9 @@ const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1; /// (`DragGestureNode.sendDragStopped` passes /// `LocalViewConfiguration.maximumFlingVelocity` into /// `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 -/// deliberately **no** matching minimum: see `List::fling`. +/// deliberately **no** matching minimum: see `LazySpan::fling`. pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0; /// 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 /// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum /// 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 /// 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 // outer guard even though the degenerate case is now detected // 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 } } } @@ -1876,7 +1876,7 @@ const FLING_FRICTION: f32 = 0.015; /// small, which put an `ln` of a 56x-too-large ratio through /// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds** /// 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. const FLING_TUNING: f32 = 0.84; 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 /// wrong density changes how long a fling lasts exponentially rather than /// 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 -/// one-second flick a 45-second coast (measured 2026-09-07). `List` reads +/// `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). `LazySpan` reads /// its density from the painter now. pub struct FlingCalculator { physical_coefficient: f32, @@ -1919,7 +1919,7 @@ impl FlingCalculator { /// Total signed distance the fling travels before settling, in the /// same pixel units `velocity` was given in. 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 // bug that produced it (docs/REVIEW-2026-09-06.md finding 3). debug_assert!(velocity.is_finite()); @@ -1946,7 +1946,7 @@ impl FlingCalculator { } /// 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 /// `distance()` once `elapsed` reaches `duration()`, so a caller need /// not special-case "past the end." @@ -1964,7 +1964,7 @@ impl FlingCalculator { /// `FlingInfo.velocity`. It falls from roughly `velocity` at the start /// to zero at `duration()`, which is the whole difference between a /// 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 /// at all. 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 /// 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 /// 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 @@ -2346,7 +2346,7 @@ mod fling_calculator_tests { /// Summing the spline's own per-frame position deltas across the /// 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, /// not merely that the two formulas look plausible independently. #[test] diff --git a/iris/src/sense_tests.rs b/iris/src/sense_tests.rs index e8f5dc4..2131d98 100644 --- a/iris/src/sense_tests.rs +++ b/iris/src/sense_tests.rs @@ -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 - // 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 // only the topmost is dispatched to at all. let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); diff --git a/iris/src/widget/mod.rs b/iris/src/widget/mod.rs index 9131788..016fbe8 100644 --- a/iris/src/widget/mod.rs +++ b/iris/src/widget/mod.rs @@ -1,5 +1,4 @@ mod image; -mod list; mod mask; mod position; mod ptr; @@ -8,7 +7,6 @@ mod text; mod trait_fns; pub use image::*; -pub use list::*; pub use mask::*; pub use position::*; pub use ptr::*; diff --git a/iris/src/widget/list.rs b/iris/src/widget/position/lazy_span.rs similarity index 84% rename from iris/src/widget/list.rs rename to iris/src/widget/position/lazy_span.rs index 8bf0052..6b0cc16 100644 --- a/iris/src/widget/list.rs +++ b/iris/src/widget/position/lazy_span.rs @@ -1,4 +1,5 @@ -//! `List`: a virtualised, bottom-anchored list of variable-height rows. +//! `LazySpan`: a virtualised span of variable-height rows, laid out from +//! an anchor rather than eagerly like `Span`. //! RUST.md's I3. Read LAYOUT.md first -- this widget is built entirely out //! of primitives that design already provides (`Painter::widget`/ //! `widget_within`/`reposition`, and `draw_inner`'s own old-children @@ -12,28 +13,28 @@ //! call site a type parameter for a capability nothing here needs yet -- //! the simplest thing that works, per the code rules. //! -//! **Composed only while visible, for free.** `List` does not maintain its +//! **Composed only while visible, for free.** `LazySpan` does not maintain its //! own "which widgets are alive" bookkeeping. Its `draw` calls //! `painter.widget`/`widget_within` only for the rows currently in view; //! `UiRenderState::draw_inner` already diffs a redrawn widget's new //! `children` against its old ones and frees (`remove_rec`) whatever is no //! longer called (LAYOUT.md section on caching, and the "old_children" //! removal in `draw_inner`). A row that scrolls out is therefore dropped -//! and its primitives freed the very next time `List` redraws -- no new +//! and its primitives freed the very next time `LazySpan` redraws -- no new //! mechanism, just relying on the one LAYOUT.md already built. //! -//! **Rows draw once and are moved, not re-laid-out, on scroll.** A `List` +//! **Rows draw once and are moved, not re-laid-out, on scroll.** A `LazySpan` //! is laid out outward from one **anchor** row (`Anchor { slot, edge, //! offset }`: a slot index, which of its edges is pinned, and that edge's //! pixel offset from the viewport's leading edge) rather than from a //! single scroll amount measured from the top of all content -- there is //! no "top of all content" to measure without walking every row, which is //! exactly the O(N) cost virtualisation exists to avoid. Rows below the -//! anchor are placed **top-known** (`Placement::Top`): offered an exact +//! anchor are placed **top-known** (`Placement::Leading`): offered an exact //! top and a generous, oversized bottom, drawn once with //! `painter.widget_within`, and their real height read back from the //! returned `Size`. Rows above the anchor are placed **bottom-known** -//! (`Placement::Bottom`): since every widget in this crate paints itself +//! (`Placement::Trailing`): since every widget in this crate paints itself //! anchored top-left of whatever it is offered (LAYOUT.md's deviation 2), //! placing a row so its *bottom* lands at an exact pixel needs the same //! "learn the size, then move" trick `Aligned` already uses -- @@ -46,7 +47,7 @@ //! `active.region == region` check turns into a **no draw at all**, its //! cached `Size` returned for free) so the per-row cost of a tick is one //! `mov()`/`reposition()` write, never a redraw -- verified in this file's -//! `moves_stay_o1_across_list_size` test and in `benches/message_list.rs`. +//! `moves_stay_o1_across_list_size` test and in `benches/message_lazy_span.rs`. //! //! **The scroll anchor survives a row inserted above it.** The anchor //! names a row by its *slot index*, not by an absolute content offset @@ -72,7 +73,7 @@ //! app is expected to call it and then mutate whatever row is expanding //! (e.g. toggling a collapsed message), which is what actually marks that //! row (and, by the existing resize-bubble in `UiRenderState::redraw`, -//! `List` itself) dirty. The *next* time `List::draw` runs, before placing +//! `LazySpan` itself) dirty. The *next* time `LazySpan::draw` runs, before placing //! anything, it looks at `extents` (each visible row's on-screen box as of //! the *previous* frame, cached while walking) to find which row contains //! the tap, decides whether the tap was nearer that row's top or bottom @@ -97,7 +98,7 @@ //! **Only what overlaps the viewport is drawn, and it is drawn whole.** //! One rule, `intersects_viewport`, used by both halves of that sentence: //! a row straddling either edge is drawn in full and clipped by the -//! `.masked()` its caller must place it in (`List::draw` asserts that), +//! `.masked()` its caller must place it in (`LazySpan::draw` asserts that), //! and a row that has left the viewport is not drawn at all. The walk //! still traverses whatever lies between the anchor and the viewport, and //! `rehome_anchor` moves the anchor back onto a visible row every frame so @@ -123,26 +124,28 @@ use std::{collections::VecDeque, sync::Arc, time::Instant}; pub type RowKey = u64; /// One loaded row: a stable key plus its content widget, built by the -/// caller (with access to the real `Rsc`) before it is handed to `List` -- -/// `List` itself only ever sees `&dyn Widget` through `Painter`, per +/// caller (with access to the real `Rsc`) before it is handed to `LazySpan` -- +/// `LazySpan` itself only ever sees `&dyn Widget` through `Painter`, per /// LAYOUT.md's single-draw model, so it cannot build rows lazily on its /// own. -pub struct ListRow { +pub struct LazyItem { pub key: RowKey, pub widget: StrongWidget, } -impl ListRow { +impl LazyItem { pub fn new(key: RowKey, widget: StrongWidget) -> Self { Self { key, widget } } } -/// Which of a row's two edges is pinned in place. +/// Which of a row's two edges is pinned in place, named along `dir` +/// rather than by screen position: `Leading` is the top of a `Dir::DOWN` +/// span and the bottom of a `Dir::UP` one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Edge { - Top, - Bottom, + Leading, + Trailing, } /// A slot index one below the lowest real item index, reserved for the @@ -156,8 +159,8 @@ const BEFORE_SLOT: isize = isize::MIN; /// to touch it either. const AFTER_SLOT: isize = isize::MAX; -/// Where the list is anchored: `slot`'s `edge` renders at `offset` pixels -/// from the viewport's leading edge (top, for a `Axis::Y` list), and +/// Where the span is anchored: `slot`'s `edge` renders at `offset` pixels +/// from the viewport's leading edge (its top, for a `Dir::DOWN` span), and /// everything else is placed outward from that one fixed point. See the /// module doc's "Rows draw once and are moved" section. #[derive(Debug, Clone, Copy)] @@ -178,8 +181,12 @@ struct Anchor { #[derive(Debug, Clone, Copy)] struct RowExtent { slot: isize, - top: f32, - bottom: f32, + /// Both in **direction-relative** pixels from this widget's leading + /// edge (`abs_region`'s space), not screen pixels -- the walk works in + /// one space and only the public helpers below convert + /// ([`LazySpan::to_screen`]). + lead: f32, + trail: f32, } /// Which of a row's edges the walk already knows, and where it is. The @@ -190,10 +197,10 @@ struct RowExtent { enum Placement { /// This row's leading edge is known; its trailing edge is wherever its /// own height puts it. - Top(f32), + Leading(f32), /// This row's trailing edge is known; its leading edge is that height /// back from it. - Bottom(f32), + Trailing(f32), } impl Placement { @@ -201,17 +208,24 @@ impl Placement { /// of `height`. fn edges(self, height: f32) -> (f32, f32) { match self { - Placement::Top(top) => (top, top + height), - Placement::Bottom(bottom) => (bottom - height, bottom), + Placement::Leading(lead) => (lead, lead + height), + Placement::Trailing(trail) => (trail - height, trail), } } } -/// A virtualised, bottom-anchored list of variable-height rows. See the -/// module doc for the design. -pub struct List { - axis: Axis, - items: VecDeque, +/// A virtualised span of variable-height rows, laid out lazily from an +/// anchor. See the module doc for the design. +pub struct LazySpan { + /// Which end of this widget's box item 0 sits at, and which way the + /// sequence grows -- the same meaning `Span::dir` has, so the word is + /// one concept across both. Deliberately *not* the same question as + /// which end the view is pinned to (`snap_end`): a transcript's oldest + /// message is item 0 and sits at the **top** (`Dir::DOWN`) while the + /// view sits at the **bottom**, so conflating the two would stand it + /// on its head. + dir: Dir, + items: VecDeque, more_before: Option, more_after: Option, anchor: Option, @@ -278,15 +292,20 @@ pub struct List { at_end: bool, } -impl List { - pub fn new(axis: Axis) -> Self { +impl LazySpan { + /// `at_end` starts the span pinned to the end of its content and keeps + /// it there while rows are appended -- the transcript's case, and the + /// same flag under the same name as [`Scroll::new`]'s. `false` starts + /// at the beginning, which is what anything read from the top wants. + /// Independent of `dir`, which says where item 0 is; see the field. + pub fn new(dir: Dir, at_end: bool) -> Self { Self { - axis, + dir, items: VecDeque::new(), more_before: None, more_after: None, anchor: None, - snap_end: true, + snap_end: at_end, viewport_len: 0.0, last_viewport_len: 0.0, fling: Flinger::new(), @@ -312,7 +331,7 @@ impl List { /// absolute content offset, so the only bookkeeping a prepended row /// needs is shifting that one index -- nothing about an already-placed /// row's position is touched. See the module doc. - pub fn push_front(&mut self, row: ListRow) { + pub fn push_front(&mut self, row: LazyItem) { self.items.push_front(row); if let Some(a) = &mut self.anchor && a.slot != AFTER_SLOT @@ -327,12 +346,12 @@ impl List { /// (`snap_end`), the new row becomes the anchor so a live list stays /// pinned to its newest content -- the same policy `Scroll` applies /// via `snap_end`. - pub fn push_back(&mut self, row: ListRow) { + pub fn push_back(&mut self, row: LazyItem) { self.items.push_back(row); if self.snap_end { self.anchor = Some(Anchor { slot: self.items.len() as isize - 1, - edge: Edge::Bottom, + edge: Edge::Trailing, offset: self.viewport_len, }); } @@ -343,7 +362,7 @@ impl List { /// invalidated here and repaired (falling back to the bottom-most /// remaining row) on the next `draw`, since there is no more specific /// answer than "wherever this widget's default now is." - pub fn pop_front(&mut self) -> Option { + pub fn pop_front(&mut self) -> Option { let popped = self.items.pop_front(); if let Some(row) = &popped { if let Some(a) = &mut self.anchor { @@ -360,7 +379,7 @@ impl List { } /// O(1); see `pop_front`. - pub fn pop_back(&mut self) -> Option { + pub fn pop_back(&mut self) -> Option { let old_len = self.items.len() as isize; let popped = self.items.pop_back(); if let Some(row) = &popped { @@ -396,7 +415,7 @@ impl List { /// about the key, and both are invalidated here the same way /// `pop_back` already invalidates them for the row it removes). /// `None` if the list is empty. O(1), same as `push_back`/`pop_back`. - pub fn replace_back(&mut self, row: ListRow) -> Option { + pub fn replace_back(&mut self, row: LazyItem) -> Option { let idx = self.items.len().checked_sub(1)?; let old = std::mem::replace(&mut self.items[idx], row); self.heights.remove(&old.key); @@ -404,7 +423,7 @@ impl List { Some(old) } - /// Drop every loaded row and reset to the same state `List::new` would + /// Drop every loaded row and reset to the same state `LazySpan::new` would /// give -- the fallback path for a change `apply`-style incremental /// callers can't express as a replace-or-append (RUST.md: `group_tool_runs` /// regrouping an earlier row). `more_before`/`more_after` are left @@ -567,14 +586,16 @@ impl List { } /// Snap to the newest content (last item, or the `more_after` - /// sentinel if set), bottom-aligned to the viewport. O(1). + /// sentinel if set), aligned to the trailing edge of the viewport -- + /// its bottom for a `Dir::DOWN` span. O(1). pub fn jump_to_end(&mut self) { self.anchor = None; self.pending_tap = None; } /// Snap to the oldest loaded content (first item, or `more_before` if - /// set), top-aligned to the viewport. O(1). + /// set), aligned to the leading edge of the viewport -- its top for a + /// `Dir::DOWN` span. O(1). pub fn jump_to_start(&mut self) { let slot = if self.more_before.is_some() { BEFORE_SLOT @@ -585,30 +606,52 @@ impl List { }; self.anchor = Some(Anchor { slot, - edge: Edge::Top, + edge: Edge::Leading, offset: 0.0, }); self.pending_tap = None; } - /// Record where (in pixels from the viewport's leading edge) the user - /// last touched the list, for the *next* layout pass in which some - /// row's height changes to resolve against -- see the module doc's - /// "hold the edge nearest the tap" section. Does not by itself mark - /// anything dirty; the row whose height is about to change is what - /// triggers the redraw this is read during. + /// Convert between the screen-space pixel offsets every caller of this + /// widget speaks in -- a pointer position, a row's box, both measured + /// from this widget's **top** (or left) -- and the direction-relative + /// space the walk works in, which for a `Sign::Neg` `dir` runs the + /// other way. Its own inverse, so one function covers both directions. + /// + /// The whole of the conversion lives at this boundary rather than in + /// the layout: `abs_region` is the only other place that knows which + /// way round the box is, and keeping the walk in one space is what + /// lets the anchor, the placement and the clamp be written once. + fn flip_pos(&self, pos: f32) -> f32 { + match self.dir.sign { + Sign::Pos => pos, + Sign::Neg => self.viewport_len - pos, + } + } + + /// Record where (in pixels from this widget's top edge, the space a + /// pointer event arrives in) the user last touched it, for the *next* + /// layout pass in which some row's height changes to resolve against + /// -- see the module doc's "hold the edge nearest the tap" section. + /// Does not by itself mark anything dirty; the row whose height is + /// about to change is what triggers the redraw this is read during. pub fn note_tap(&mut self, viewport_pos: f32) { - self.pending_tap = Some(viewport_pos); + self.pending_tap = Some(self.flip_pos(viewport_pos)); } /// The on-screen `(top, bottom)` viewport-pixel extent of `key`'s row /// as of the last layout, or `None` if it was not among the rows drawn - /// then (off-screen, not yet loaded, or the list hasn't drawn since). - /// What a caller reads to decide where to aim `note_tap` -- e.g. "the - /// top of the row that's about to expand" -- without duplicating this - /// widget's own layout math. + /// then (off-screen, not yet loaded, or it hasn't drawn since). What a + /// caller reads to decide where to aim `note_tap` -- e.g. "the top of + /// the row that's about to expand" -- without duplicating this + /// widget's own layout math. Ordered top-then-bottom on screen + /// whichever way `dir` runs, since that is what a caller comparing it + /// against a pointer position needs. pub fn extent(&self, key: RowKey) -> Option<(f32, f32)> { - self.extents.get(&key).map(|e| (e.top, e.bottom)) + self.extents.get(&key).map(|e| { + let (a, b) = (self.flip_pos(e.lead), self.flip_pos(e.trail)); + (a.min(b), a.max(b)) + }) } /// The row whose on-screen box (as of the last layout) contains @@ -619,9 +662,10 @@ impl List { /// gesture is no longer being delivered through any one row's own hit /// region -- see `iris::sense`'s pointer-capture doc. pub fn key_at(&self, viewport_pos: f32) -> Option { + let pos = self.flip_pos(viewport_pos); self.extents .iter() - .find(|(_, ext)| viewport_pos >= ext.top && viewport_pos <= ext.bottom) + .find(|(_, ext)| pos >= ext.lead && pos <= ext.trail) .map(|(&key, _)| key) } @@ -730,19 +774,19 @@ impl List { self.anchor = Some(if len > 0 { Anchor { slot: len - 1, - edge: Edge::Bottom, + edge: Edge::Trailing, offset: self.viewport_len, } } else if self.more_after.is_some() { Anchor { slot: AFTER_SLOT, - edge: Edge::Bottom, + edge: Edge::Trailing, offset: self.viewport_len, } } else { Anchor { slot: BEFORE_SLOT, - edge: Edge::Top, + edge: Edge::Leading, offset: 0.0, } }); @@ -751,15 +795,17 @@ impl List { /// Resolve a pending tap against last frame's row extents and, if it /// landed inside one, re-anchor to that row's nearer edge at its /// current on-screen position -- O(visible rows), never a scan of - /// anything off-screen. See the module doc. - fn reanchor_at_tap(&mut self, tap_y: f32) { + /// anything off-screen. See the module doc. `tap` is already in the + /// walk's direction-relative space (`note_tap` converted it), so this + /// compares like with like whichever way `dir` runs. + fn reanchor_at_tap(&mut self, tap: f32) { for ext in self.extents.values() { - if tap_y >= ext.top && tap_y <= ext.bottom { - let mid = (ext.top + ext.bottom) * 0.5; - let (edge, offset) = if tap_y < mid { - (Edge::Top, ext.top) + if tap >= ext.lead && tap <= ext.trail { + let mid = (ext.lead + ext.trail) * 0.5; + let (edge, offset) = if tap < mid { + (Edge::Leading, ext.lead) } else { - (Edge::Bottom, ext.bottom) + (Edge::Trailing, ext.trail) }; self.anchor = Some(Anchor { slot: ext.slot, @@ -804,7 +850,7 @@ impl List { let Some(first) = self .extents .values() - .min_by(|a, b| a.top.total_cmp(&b.top)) + .min_by(|a, b| a.lead.total_cmp(&b.lead)) .copied() else { // Nothing on screen at all -- a list scrolled past its own @@ -816,8 +862,8 @@ impl List { }; self.anchor = Some(Anchor { slot: first.slot, - edge: Edge::Top, - offset: first.top, + edge: Edge::Leading, + offset: first.lead, }); } @@ -847,7 +893,7 @@ impl List { /// the space is not overscroll at all -- it is a bottom-anchored list /// with three rows in it, and pulling those to the top would be this /// widget rejecting its own default (`repair_anchor`). - fn overscroll_gap(&self, top: f32, bottom: f32) -> Option { + fn overscroll_gap(&self, lead: f32, trail: f32) -> Option { if self.at_start == self.at_end { return None; } @@ -855,9 +901,9 @@ impl List { // (`top >= 0.0`, `bottom <= viewport_len`), so this is the gap // itself, positive to move content toward the leading edge. let gap = if self.at_start { - top + lead } else { - bottom - self.viewport_len + trail - self.viewport_len }; // Sub-pixel gaps are what floating-point row heights leave behind // every frame; laying out again for one would leave another, and @@ -879,37 +925,37 @@ impl List { .anchor .expect("lay_out with no anchor: `draw` returns before this without one"); let placement = match anchor.edge { - Edge::Top => Placement::Top(anchor.offset), - Edge::Bottom => Placement::Bottom(anchor.offset), + Edge::Leading => Placement::Leading(anchor.offset), + Edge::Trailing => Placement::Trailing(anchor.offset), }; - let (mut top, mut bottom) = self.place(painter, anchor.slot, placement); + let (mut lead, mut trail) = self.place(painter, anchor.slot, placement); - let mut idx_top = anchor.slot; - while top > 0.0 { - let Some(prev) = self.prev_slot(idx_top) else { + let mut idx_lead = anchor.slot; + while lead > 0.0 { + let Some(prev) = self.prev_slot(idx_lead) else { break; }; - let (t, _) = self.place(painter, prev, Placement::Bottom(top)); - top = t; - idx_top = prev; + let (l, _) = self.place(painter, prev, Placement::Trailing(lead)); + lead = l; + idx_lead = prev; } - let mut idx_bottom = anchor.slot; - while bottom < self.viewport_len { - let Some(next) = self.next_slot(idx_bottom) else { + let mut idx_trail = anchor.slot; + while trail < self.viewport_len { + let Some(next) = self.next_slot(idx_trail) else { break; }; - let (_, b) = self.place(painter, next, Placement::Top(bottom)); - bottom = b; - idx_bottom = next; + let (_, t) = self.place(painter, next, Placement::Leading(trail)); + trail = t; + idx_trail = next; } - // What `tick_fling` clamps a fling against -- see `at_start`'s - // field doc. `top`/`bottom` are the extreme edges actually placed - // this frame, and `prev_slot`/`next_slot` returning `None` is what - // "no more content" means everywhere else in this widget. - self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0; - self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len; + // What a fling is clamped against -- see `at_start`'s field doc. + // `lead`/`trail` are the extreme edges actually placed this frame, + // and `prev_slot`/`next_slot` returning `None` is what "no more + // content" means everywhere else in this widget. + self.at_start = self.prev_slot(idx_lead).is_none() && lead >= 0.0; + self.at_end = self.next_slot(idx_trail).is_none() && trail <= self.viewport_len; // Both halves of `intersects_viewport`'s rule, checked where they // are cheap to check: what this pass put on screen is exactly what @@ -922,21 +968,21 @@ impl List { assert!( self.extents .values() - .all(|e| self.intersects_viewport(e.top, e.bottom)), + .all(|e| self.intersects_viewport(e.lead, e.trail)), "a row outside the viewport (0..{}) is recorded as on screen: {:?}", self.viewport_len, self.extents .values() - .find(|e| !self.intersects_viewport(e.top, e.bottom)), + .find(|e| !self.intersects_viewport(e.lead, e.trail)), ); - (top, bottom) + (lead, trail) } fn update_snap_end(&mut self) { self.snap_end = match self.anchor { Some(a) => { self.next_slot(a.slot).is_none() - && a.edge == Edge::Bottom + && a.edge == Edge::Trailing && (self.viewport_len - a.offset).abs() < 0.5 } None => false, @@ -952,16 +998,28 @@ impl List { /// the part of a straddling row above the viewport had nothing /// clipping it. The viewport here is the list's own box -- `0 .. /// viewport_len`, `painter.region()` in window terms -- which is the - /// same box `List::draw` requires a mask on, so that what this test + /// same box `LazySpan::draw` requires a mask on, so that what this test /// admits and what the clip keeps are one region rather than two that /// can disagree. - fn intersects_viewport(&self, top: f32, bottom: f32) -> bool { - bottom > 0.0 && top < self.viewport_len + fn intersects_viewport(&self, lead: f32, trail: f32) -> bool { + trail > 0.0 && lead < self.viewport_len } - fn abs_region(axis: Axis, start: f32, end: f32) -> UiRegion { + /// The box between two edges measured **along `dir`** from this + /// widget's own leading edge, which for `Sign::Neg` is its bottom (or + /// right). Every other length in this widget -- `viewport_len`, an + /// `Anchor`'s offset, a `Placement`'s edges -- is in that same + /// direction-relative space, so the whole layout is written once and + /// only this function knows which way round the box is. The mirror is + /// `UiSpan::flip`, the same one `Span::draw` uses for a reversed + /// `Dir`. + fn abs_region(dir: Dir, start: f32, end: f32) -> UiRegion { let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end)); - UiRegion::from_axis(axis, span, UiSpan::FULL) + let mut region = UiRegion::from_axis(dir.axis, span, UiSpan::FULL); + if dir.sign == Sign::Neg { + region.flip(dir.axis); + } + region } /// Place one slot (a real row or a sentinel) per `placement`, caching @@ -1024,7 +1082,8 @@ impl List { self.slot_exists(slot), "place() called with a slot that doesn't exist: {slot:?}" ); - let axis = self.axis; + let dir = self.dir; + let axis = dir.axis; let output_len = painter.output_size().axis(axis); let container_len = painter.region().axis(axis).len(); let density = painter.density(); @@ -1048,9 +1107,9 @@ impl List { // below is recorded from the intersection test rather than from // "was this drawn". if let Some(h) = cached { - let (top, bottom) = placement.edges(h); - if !self.intersects_viewport(top, bottom) { - return (top, bottom); + let (lead, trail) = placement.edges(h); + if !self.intersects_viewport(lead, trail) { + return (lead, trail); } } @@ -1065,12 +1124,12 @@ impl List { // box its own height implies rather than waiting a frame to // self-correct. Some(h) => { - let (top, bottom) = placement.edges(h); - let used = painter.widget_within(widget, Self::abs_region(axis, top, bottom)); + let (lead, trail) = placement.edges(h); + let used = painter.widget_within(widget, Self::abs_region(dir, lead, trail)); let height = resolve(used); if height != h { - let (top, bottom) = placement.edges(height); - painter.widget_within(widget, Self::abs_region(axis, top, bottom)); + let (lead, trail) = placement.edges(height); + painter.widget_within(widget, Self::abs_region(dir, lead, trail)); } height } @@ -1088,31 +1147,31 @@ impl List { // width. None => { let measure_from = match placement { - Placement::Top(top) => top, - Placement::Bottom(_) => 0.0, + Placement::Leading(lead) => lead, + Placement::Trailing(_) => 0.0, }; - let first = Self::abs_region(axis, measure_from, measure_from + GENEROUS_PADDING); + let first = Self::abs_region(dir, measure_from, measure_from + GENEROUS_PADDING); let mut height = 0.0; painter.draw_twice(widget, first, |used| { height = resolve(used); - let (top, bottom) = placement.edges(height); - Self::abs_region(axis, top, bottom) + let (lead, trail) = placement.edges(height); + Self::abs_region(dir, lead, trail) }); height } }; - let (top, bottom) = placement.edges(height); + let (lead, trail) = placement.edges(height); if let Some(k) = key { self.heights.insert(k, height); // `extents` is what is *on screen* (`key_at`'s doc, and // `rehome_anchor` below reads it as exactly that), so a // first-time row that had to be drawn to be measured and // turned out to be off-screen does not go in it. - if self.intersects_viewport(top, bottom) { - self.extents.insert(k, RowExtent { slot, top, bottom }); + if self.intersects_viewport(lead, trail) { + self.extents.insert(k, RowExtent { slot, lead, trail }); } } - (top, bottom) + (lead, trail) } } @@ -1128,8 +1187,8 @@ impl List { /// on for an O(1) move. const GENEROUS_PADDING: f32 = 100_000.0; -impl Widget for List { - /// A `List` animates exactly one thing, a fling +impl Widget for LazySpan { + /// A `LazySpan` animates exactly one thing, a fling /// ([`Self::tick_fling`]). The registration that makes this run is /// `UiData::animate` beside the `fling` call -- see `fling`'s own doc. fn tick(&mut self, now: Instant) -> bool { @@ -1137,7 +1196,7 @@ impl Widget for List { } fn draw(&mut self, painter: &mut Painter) -> Size { - let axis = self.axis; + let axis = self.dir.axis; // Learned from the frame rather than passed in: a fling's // deceleration is a physical quantity and needs the real display // density, and `draw` is where this widget meets the only thing @@ -1149,18 +1208,18 @@ impl Widget for List { // clipping it, a transcript panned to its top edge drew code and // paragraphs straight through the header bar above it on Iris's // phone (docs/IRIS_TODO.md, 2026-09-07). Clipping is `.masked()`, - // one mechanism, applied by whoever places the list -- a `List` + // one mechanism, applied by whoever places the list -- a `LazySpan` // cannot set the mask itself, since `Painter::set_mask` allows one // mask per widget and rows of this list already use their own // (`transcript-ui`'s `row.rs`, `tool.rs`). So it checks instead. // // `assert!`, not `debug_assert!`: one bool per draw, and what it - // catches is a `List` painting over its surroundings with nothing + // catches is a `LazySpan` painting over its surroundings with nothing // on screen saying so -- the fault e922b73 was written to fix. // Every build that runs is release (docs/REVIEW-2026-09-07.md's R1). assert!( painter.is_masked(), - "a `List` must be drawn inside something `.masked()`: it draws rows straddling both \ + "a `LazySpan` must be drawn inside something `.masked()`: it draws rows straddling both \ edges in full, so the parts outside its own box reach the screen otherwise", ); let output_len = painter.output_size().axis(axis); @@ -1181,7 +1240,7 @@ impl Widget for List { } self.extents.clear(); - let (top, bottom) = self.lay_out(painter); + let (lead, trail) = self.lay_out(painter); // **The clamp is applied inside the frame that found it**, not // marked for the next one: layout is a pure function of the state @@ -1203,7 +1262,7 @@ impl Widget for List { // frame that was overscrolled, and it re-offers every row the same // box at a new offset, which `draw_inner` dispatches as an O(1) // move. - if let Some(gap) = self.overscroll_gap(top, bottom) { + if let Some(gap) = self.overscroll_gap(lead, trail) { self.scroll(gap); self.extents.clear(); self.lay_out(painter); @@ -1248,30 +1307,30 @@ mod tests { /// mutating a specific row's height later), in the same order as `keys`. fn push_rows( rsc: &mut TestRsc, - list: &mut List, + list: &mut LazySpan, keys: &[RowKey], height: f32, ) -> Vec> { keys.iter() .map(|&key| { let (weak, w) = fixed_row(rsc, height); - list.push_back(ListRow::new(key, w)); + list.push_back(LazyItem::new(key, w)); weak }) .collect() } /// Adds `list` to the arena and returns both a typed weak handle (for - /// calling `List`'s own methods through `Widgets::get`/`get_mut`, which + /// calling `LazySpan`'s own methods through `Widgets::get`/`get_mut`, which /// need a `Sized` widget type) and the erased root `UiRenderState::update` /// draws. /// /// The root is a `Masked` around the list rather than the list /// itself, because that is what every real caller has to do -- a - /// `List` draws the row straddling each edge in full and asserts - /// something is clipping it (`List::draw`). The mask is the full + /// `LazySpan` draws the row straddling each edge in full and asserts + /// something is clipping it (`LazySpan::draw`). The mask is the full /// window here, which is also the list's own box. - fn add_list(rsc: &mut TestRsc, list: List) -> (WeakWidget, StrongWidget) { + fn add_list(rsc: &mut TestRsc, list: LazySpan) -> (WeakWidget, StrongWidget) { let strong = rsc.ui.widgets.add_strong(list); let weak = strong.weak(); let root = rsc.ui.widgets.add_strong(Masked { @@ -1293,7 +1352,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1313,20 +1372,104 @@ mod tests { let first = list_ref.extents[&0]; let last = list_ref.extents[&2]; assert!( - (first.top - 40.0).abs() < 0.01 && (last.bottom - 100.0).abs() < 0.01, + (first.lead - 40.0).abs() < 0.01 && (last.trail - 100.0).abs() < 0.01, "a 60px list in a 100px viewport moved off the bottom: rows {}..{}", - first.top, - last.bottom, + first.lead, + last.trail, ); } } + /// `Dir::UP` is not a screen direction bolted on at the end: item 0 + /// sits at the **bottom** and the sequence grows toward the top, which + /// is the mirror of `Dir::DOWN` and the reason the walk is written in + /// leading/trailing terms with `abs_region` the only place that knows + /// which way round the box is. Same three rows, same viewport, so the + /// only difference from + /// `a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom` + /// is `dir` -- and the newest row moves from the bottom of the screen + /// to the top. + /// + /// Asserts on **where each row was actually drawn** + /// (`UiRenderState::active`), not on `extents`: those are kept in the + /// walk's own direction-relative space and converted on the way out, + /// so an `extent()`-only test passes even with the flip in + /// `abs_region` deleted -- it would be checking the bookkeeping + /// against itself while every row painted at the mirror of where it + /// belongs. + #[test] + fn a_dir_up_span_grows_upward_from_item_zero() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::UP, true); + let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + let drawn = |render: &UiRenderState, row: &WeakWidget| { + let px = render.active[&row.id()].region.to_px((100.0, 100.0).into()); + (px.top_left.y, px.bot_right.y) + }; + assert_eq!( + drawn(&render, &rows[2]), + (0.0, 20.0), + "the newest row of a Dir::UP span is drawn at the top of the screen" + ); + assert_eq!(drawn(&render, &rows[1]), (20.0, 40.0)); + assert_eq!( + drawn(&render, &rows[0]), + (40.0, 60.0), + "item 0 is drawn furthest down" + ); + + // And the public extent agrees with the pixels, in screen space. + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert_eq!(list_ref.extent(2), Some((0.0, 20.0))); + assert_eq!(list_ref.extent(0), Some((40.0, 60.0))); + } + + /// The conversion the reversed direction makes necessary: a pointer + /// position arrives in screen pixels while the walk works in + /// direction-relative ones, so a hit test that skipped the flip would + /// silently answer with the row mirrored across the viewport -- wrong + /// in a way that looks like a working list until you tap one. + #[test] + fn a_reversed_span_hit_tests_in_screen_space() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::UP, true); + push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert_eq!( + list_ref.key_at(10.0), + Some(2), + "10px down the screen is the newest row" + ); + assert_eq!(list_ref.key_at(50.0), Some(0), "50px down is item 0"); + assert_eq!( + list_ref.key_at(90.0), + None, + "below the content there is no row" + ); + } + #[test] fn bottom_anchored_by_default() { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1339,7 +1482,7 @@ mod tests { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert_eq!(list_ref.extents.len(), 3); let last = list_ref.extents[&4]; - assert!((last.bottom - 60.0).abs() < 0.01); + assert!((last.trail - 60.0).abs() < 0.01); assert!(!list_ref.extents.contains_key(&0)); assert!(!list_ref.extents.contains_key(&1)); } @@ -1398,12 +1541,12 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); let mut rows = Vec::new(); for key in 0..5u64 { let (bg_id, fg, row) = resizable_background_row(&mut rsc, 20.0); rows.push((bg_id, fg)); - list.push_back(ListRow::new(key, row)); + list.push_back(LazyItem::new(key, row)); } let (_, root) = add_list(&mut rsc, list); @@ -1442,12 +1585,12 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); let mut bg_ids = Vec::new(); for key in 0..5u64 { let (bg_id, row) = background_styled_row(&mut rsc, 20.0); bg_ids.push(bg_id); - list.push_back(ListRow::new(key, row)); + list.push_back(LazyItem::new(key, row)); } let (_, root) = add_list(&mut rsc, list); @@ -1472,7 +1615,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); push_rows(&mut rsc, &mut list, &[10, 11, 12], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1491,7 +1634,7 @@ mod tests { .widgets .get_mut(&list_weak) .unwrap() - .push_front(ListRow::new(key, w)); + .push_front(LazyItem::new(key, w)); } render.update(&root, &mut rsc); let (draws, _rewrites, _moves, _shapes) = render.take_counters(); @@ -1504,8 +1647,8 @@ mod tests { let extents_after = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone(); for key in [11u64, 12] { assert_eq!( - (extents_before[&key].top, extents_before[&key].bottom), - (extents_after[&key].top, extents_after[&key].bottom) + (extents_before[&key].lead, extents_before[&key].trail), + (extents_after[&key].lead, extents_after[&key].trail) ); } // The list widget itself draws once (it was marked dirty by the @@ -1522,7 +1665,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); // Five rows of 20px; with a 100px viewport all are visible, // anchored at the bottom by default (row 4's bottom at 100). let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); @@ -1539,8 +1682,8 @@ mod tests { { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let ext = list_ref.extents[&2]; - assert!((ext.top - 40.0).abs() < 0.01); - assert!((ext.bottom - 60.0).abs() < 0.01); + assert!((ext.lead - 40.0).abs() < 0.01); + assert!((ext.trail - 60.0).abs() < 0.01); } rsc.ui.widgets.get_mut(&list_weak).unwrap().note_tap(41.0); @@ -1557,21 +1700,21 @@ mod tests { // [40, 90), and row 3 -- below the grown row -- is pushed down to // start at 90, not left at its old 60. assert!( - (row2_ext.top - 40.0).abs() < 0.01, + (row2_ext.lead - 40.0).abs() < 0.01, "top edge should stay put: {row2_ext:?}" ); assert!( - (row2_ext.bottom - 90.0).abs() < 0.01, + (row2_ext.trail - 90.0).abs() < 0.01, "bottom edge should move by the full +30 growth: {row2_ext:?}" ); assert!( - (row3_ext.top - 90.0).abs() < 0.01, + (row3_ext.lead - 90.0).abs() < 0.01, "row below the expanded row should be pushed down: {row3_ext:?}" ); // Rows above the expanding row are untouched. let row1_ext = list_ref.extents[&1]; - assert!((row1_ext.top - 20.0).abs() < 0.01); - assert!((row1_ext.bottom - 40.0).abs() < 0.01); + assert!((row1_ext.lead - 20.0).abs() < 0.01); + assert!((row1_ext.trail - 40.0).abs() < 0.01); } #[test] @@ -1579,7 +1722,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1599,15 +1742,15 @@ mod tests { // Bottom edge held at 60: the row grows upward instead, and row 1 // (above it) is pushed up to end at 10, not left at 20. assert!( - (row2_ext.bottom - 60.0).abs() < 0.01, + (row2_ext.trail - 60.0).abs() < 0.01, "bottom edge should stay put: {row2_ext:?}" ); assert!( - (row2_ext.top - 10.0).abs() < 0.01, + (row2_ext.lead - 10.0).abs() < 0.01, "top edge should move by the full +30 growth: {row2_ext:?}" ); assert!( - (row1_ext.bottom - 10.0).abs() < 0.01, + (row1_ext.trail - 10.0).abs() < 0.01, "row above the expanded row should be pushed up: {row1_ext:?}" ); } @@ -1618,7 +1761,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); let keys: Vec = (0..n as u64).collect(); push_rows(&mut rsc, &mut list, &keys, 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1661,7 +1804,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1672,7 +1815,7 @@ mod tests { // Row 4 is flush with the viewport's bottom edge before the replace. { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); - assert!((list_ref.extents[&4].bottom - 60.0).abs() < 0.01); + assert!((list_ref.extents[&4].trail - 60.0).abs() < 0.01); } let (_weak, new_row) = fixed_row(&mut rsc, 40.0); @@ -1681,7 +1824,7 @@ mod tests { .widgets .get_mut(&list_weak) .unwrap() - .replace_back(ListRow::new(4, new_row)); + .replace_back(LazyItem::new(4, new_row)); assert!( old.is_some(), "replace_back should hand back the row it evicted" @@ -1692,11 +1835,11 @@ mod tests { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let row4 = list_ref.extents[&4]; assert!( - (row4.bottom - 60.0).abs() < 0.01, + (row4.trail - 60.0).abs() < 0.01, "still pinned to the newest end after the replace: {row4:?}" ); assert!( - (row4.top - 20.0).abs() < 0.01, + (row4.lead - 20.0).abs() < 0.01, "grew upward, from the pinned bottom edge: {row4:?}" ); } @@ -1708,14 +1851,14 @@ mod tests { /// same key would pass either test even if it did nothing (docs/ /// REVIEW-2026-09-06.md finding 10; this is `Selection`'s finding 1 /// class of bug -- a stale handle outliving what it points to -- - /// production-tested from `List`'s own side). Replacing with a + /// production-tested from `LazySpan`'s own side). Replacing with a /// **different** key is what actually exercises the removal. #[test] fn replace_back_forgets_the_evicted_keys_own_height() { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1737,7 +1880,7 @@ mod tests { .widgets .get_mut(&list_weak) .unwrap() - .replace_back(ListRow::new(100, new_row)); + .replace_back(LazyItem::new(100, new_row)); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert_eq!(old.map(|o| o.key), Some(4)); @@ -1758,7 +1901,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1791,7 +1934,7 @@ mod tests { .widgets .get_mut(&list_weak) .unwrap() - .replace_back(ListRow::new(4, new_row)); + .replace_back(LazyItem::new(4, new_row)); render.update(&root, &mut rsc); @@ -1799,8 +1942,8 @@ mod tests { for (key, before) in [(0u64, before0), (1, before1), (2, before2)] { let after = list_ref.extents[&key]; assert_eq!( - (after.top, after.bottom), - (before.top, before.bottom), + (after.lead, after.trail), + (before.lead, before.trail), "row {key} moved after an off-screen replace" ); } @@ -1828,10 +1971,10 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); for key in 0..5u64 { let (_bg_id, row) = background_styled_row(&mut rsc, 20.0); - list.push_back(ListRow::new(key, row)); + list.push_back(LazyItem::new(key, row)); } let (list_weak, root) = add_list(&mut rsc, list); @@ -1849,7 +1992,7 @@ mod tests { .widgets .get_mut(&list_weak) .unwrap() - .replace_back(ListRow::new(4, new_row)); + .replace_back(LazyItem::new(4, new_row)); render.update(&root, &mut rsc); } let after = render.active_widgets(); @@ -1874,7 +2017,7 @@ mod tests { /// than by its own: `draw_inner` only *read* the dirty mark, so the /// whole branch that frees a redrawn widget's previous primitives was /// skipped, and the fresh `ActiveData` overwrote the only handles that - /// could ever have freed them. `List` sets no mask, so that copy then + /// could ever have freed them. `LazySpan` sets no mask, so that copy then /// draws every frame at whatever region it last had -- including, /// where the row was being measured at `GENEROUS_PADDING`, well below /// the list's own box and under the composer. @@ -1888,7 +2031,7 @@ mod tests { let mut rsc = TestRsc { ui: UiData::default(), }; - let mut list = List::new(Axis::Y); + let mut list = LazySpan::new(Dir::DOWN, true); // Rows that own a primitive *at their own id* (a background rect), // not only through a child: an orphan is a widget's own primitive // outliving its own redraw, so a row whose top-level widget paints @@ -1897,7 +2040,7 @@ mod tests { for key in 0..5u64 { let (bg_id, row) = background_styled_row(&mut rsc, 20.0); rows.push((row.id(), bg_id)); - list.push_back(ListRow::new(key, row)); + list.push_back(LazyItem::new(key, row)); } let (list_weak, root) = add_list(&mut rsc, list); @@ -1936,20 +2079,22 @@ mod tests { /// thing `scroll`/`tick_fling` write) rather than the spline's /// bookkeeping, and unlike a single row's extent it stays defined /// however far the list travels -- `extents` holds only what is - /// on screen (`List::intersects_viewport`). - fn scroll_position(list: &List) -> f32 { + /// on screen (`LazySpan::intersects_viewport`). + fn scroll_position(list: &LazySpan) -> f32 { let top = list .extents .values() - .min_by(|a, b| a.top.total_cmp(&b.top)) + .min_by(|a, b| a.lead.total_cmp(&b.lead)) .expect("something is on screen"); - top.slot as f32 * FLING_ROW_H - top.top + top.slot as f32 * FLING_ROW_H - top.lead } const FLING_ROW_H: f32 = 20.0; - fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget, StrongWidget, UiRenderState) { - let mut list = List::new(Axis::Y); + fn build_flingable_list( + rsc: &mut TestRsc, + ) -> (WeakWidget, StrongWidget, UiRenderState) { + let mut list = LazySpan::new(Dir::DOWN, true); push_rows(rsc, &mut list, &(0..200).collect::>(), FLING_ROW_H); let (list_weak, root) = add_list(rsc, list); let mut render = UiRenderState::new(); @@ -2066,7 +2211,7 @@ mod tests { // happened toward the end rather than staying put. if let Some(after) = list_ref.extents.get(&0) { assert!( - after.top < before.top, + after.lead < before.lead, "fling toward the end did not move content up" ); } @@ -2083,7 +2228,7 @@ mod tests { /// per-tick delta to a decelerating curve (`FlingCalculator:: /// position_at`'s own monotonic-and-clamped property, one level /// down, already covers the calculator alone; this is the same - /// property through `List::tick_fling`'s `scroll`/`extents` + /// property through `LazySpan::tick_fling`'s `scroll`/`extents` /// accumulation). #[test] fn tick_fling_applies_shrinking_incremental_deltas() { @@ -2114,7 +2259,7 @@ mod tests { "fling settled before collecting enough samples" ); // Skip the first tick (the slop-transition jump the arbiter - // applies is a `List::fling`-adjacent concern, not this curve, + // applies is a `LazySpan::fling`-adjacent concern, not this curve, // but the very first frame can still carry rounding noise from // `jump_to_start`'s own layout settling). for w in deltas[1..].windows(2) { @@ -2171,7 +2316,7 @@ mod tests { render.update(&root, &mut rsc); assert!(!still); let after = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199]; - assert_eq!((before.top, before.bottom), (after.top, after.bottom)); + assert_eq!((before.lead, before.trail), (after.lead, after.trail)); } /// The clamp on the frame that discovers it, for an ordinary @@ -2196,9 +2341,9 @@ mod tests { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let first = list_ref.extents[&0]; assert!( - first.top.abs() < 0.5, + first.lead.abs() < 0.5, "the frame that overscrolled should end flush with the first row, not {}px from it", - first.top, + first.lead, ); } @@ -2239,16 +2384,16 @@ mod tests { // nor one left uncorrected can pass. let first = list_ref.extents[&0]; assert!( - first.top.abs() < 0.5, + first.lead.abs() < 0.5, "a fling stopped at the start must leave the first row flush with the top, not {}px \ from it", - first.top + first.lead ); } #[test] fn anchor_position_display_before_any_draw_is_none() { - let list = List::new(Axis::Y); + let list = LazySpan::new(Dir::DOWN, true); assert_eq!(list.anchor_position_display(), "idx=none"); } diff --git a/iris/src/widget/position/mod.rs b/iris/src/widget/position/mod.rs index c389b6c..c033f1c 100644 --- a/iris/src/widget/position/mod.rs +++ b/iris/src/widget/position/mod.rs @@ -1,5 +1,6 @@ mod align; mod layer; +mod lazy_span; mod max_size; mod offset; mod pad; @@ -10,6 +11,7 @@ mod stack; pub use align::*; pub use layer::*; +pub use lazy_span::*; pub use max_size::*; pub use offset::*; pub use pad::*; diff --git a/iris/src/widget/position/scroll.rs b/iris/src/widget/position/scroll.rs index 155b47f..04b4401 100644 --- a/iris/src/widget/position/scroll.rs +++ b/iris/src/widget/position/scroll.rs @@ -20,14 +20,14 @@ pub struct Scroll { /// therefore opened at the end of its longest line, mid-word /// (`iris/run-headless.sh phone`, 2026-09-08). content_len: Option, - /// 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 /// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in /// `sense.rs` and only what a committed pan *means* is decided here. /// See [`Self::drag`]. gesture: DragGesture, /// 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 /// `ScrollableDefaults.flingBehavior()` on every axis it is given, /// 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); self.scroll(delta); // 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 -- // so the wall is read after the move rather than from what the // last draw found. @@ -101,7 +101,7 @@ impl Widget for Scroll { // why the offset is the only thing that moves. A length is not // knowable without drawing (LAYOUT.md section 5), so this draws // 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 // `Painter::draw_twice`, which cannot take a closure needing // `&mut self` while `self.inner` is borrowed. @@ -236,10 +236,10 @@ impl Scroll { .handle(pointer, id, sense, pos_window, now, press) { // `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 // 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 // holds for both, and the one to check a sign against, is that // the content follows the finger. @@ -304,7 +304,7 @@ impl Scroll { } /// 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 /// know whether this area is moving on its own (a test, and /// [`PressState::scrolling`]'s own condition). diff --git a/iris/src/widget/trait_fns.rs b/iris/src/widget/trait_fns.rs index a1b26e6..ffb94e1 100644 --- a/iris/src/widget/trait_fns.rs +++ b/iris/src/widget/trait_fns.rs @@ -120,7 +120,7 @@ widget_trait! { ctx.widget(rsc).scroll(delta); }) // 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 // above and this are the two inputs of one scroll, so they // are registered together rather than left to each caller. diff --git a/iris/transcript-fixture/examples/phone.rs b/iris/transcript-fixture/examples/phone.rs index 53a0451..bd896b3 100644 --- a/iris/transcript-fixture/examples/phone.rs +++ b/iris/transcript-fixture/examples/phone.rs @@ -127,7 +127,7 @@ impl DefaultAppState for Client { } // A fling coasts only while something asks for the next // 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(); (opened.screen.list)(rsc).set_redraw_handle(handle); Some(opened.screen) diff --git a/iris/transcript-fixture/tests/catch_a_fling.rs b/iris/transcript-fixture/tests/catch_a_fling.rs index 54848c1..1300d37 100644 --- a/iris/transcript-fixture/tests/catch_a_fling.rs +++ b/iris/transcript-fixture/tests/catch_a_fling.rs @@ -17,7 +17,7 @@ use iris::sense::DRAG_SLOP; use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; /// 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. fn opened() -> (Harness, transcript_ui::TranscriptScreen) { 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 -/// 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 /// 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) { diff --git a/iris/transcript-fixture/tests/phone_screen.rs b/iris/transcript-fixture/tests/phone_screen.rs index 494ec23..3a9920d 100644 --- a/iris/transcript-fixture/tests/phone_screen.rs +++ b/iris/transcript-fixture/tests/phone_screen.rs @@ -13,7 +13,7 @@ use iris::prelude::*; use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; /// 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. fn opened() -> (Harness, transcript_ui::TranscriptScreen) { 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 /// first happened: the recorded flick must release with a real velocity /// (`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. #[test] 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 t = flick.end_ms(); // 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 // another. Deliberately an *under*-count -- the frame a row leaves on // contributes nothing -- which is why it is only ever a lower bound. diff --git a/iris/transcript-fixture/tests/top_edge.rs b/iris/transcript-fixture/tests/top_edge.rs index e71035f..c7a802a 100644 --- a/iris/transcript-fixture/tests/top_edge.rs +++ b/iris/transcript-fixture/tests/top_edge.rs @@ -5,7 +5,7 @@ //! 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 //! 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 //! 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, -/// 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 /// frame is not among them. fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32, f32)> { @@ -198,7 +198,7 @@ fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec { /// /// 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 -/// (`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 /// every height already known -- including the second walk *back*, which /// 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); } // 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 // one here would hide a regression to the old next-frame correction. let rows = drawn_rows(&h, &screen); diff --git a/iris/transcript-ui/src/composer.rs b/iris/transcript-ui/src/composer.rs index fcf6c1d..ca76775 100644 --- a/iris/transcript-ui/src/composer.rs +++ b/iris/transcript-ui/src/composer.rs @@ -1,9 +1,9 @@ //! The message composer at the bottom of the transcript screen: a //! multi-line editable field with a natural (not fixed) height, so it //! 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 -//! 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 //! measures whatever vertical space is left each frame -- nothing here //! computes a height by hand, and growing this field is exactly the diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs index b6306f1..c8ab02a 100644 --- a/iris/transcript-ui/src/lib.rs +++ b/iris/transcript-ui/src/lib.rs @@ -7,7 +7,7 @@ //! //! ```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 2: sender label + one TextEdit | //! | row 3 (Tools): collapsed/expanded | @@ -38,7 +38,7 @@ //! through one shared `iris::sense::DragArbiter` //! (`Selection::drag`, `selection.rs`), which decides pan vs. select the //! 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 //! below, `CursorSense::Scroll`), and now via a touch pan starting on a //! row's own text too. @@ -55,10 +55,10 @@ use selection::Selection; use std::{cell::RefCell, rc::Rc}; 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 /// crate does not already wrap. - pub list: WeakWidget, + pub list: WeakWidget, pub composer: composer::Composer, selection: Rc>, /// How many times [`Self::apply`] has fallen back to a full rebuild -- @@ -83,7 +83,7 @@ pub struct TranscriptScreen { impl TranscriptScreen { /// 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 - /// 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). pub fn push_row(&self, rsc: &mut Rsc, row: &FoldedRow) where @@ -96,7 +96,7 @@ impl TranscriptScreen { row, 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)); } @@ -218,7 +218,7 @@ impl TranscriptScreen { /// - **only the last row's content changed** (the common case: a delta /// folded into a still-open assistant message): that one row is /// 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 /// further new rows are appended after it, for the (also common) /// 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 /// run's calls that used to be separate rows join once the run closes) /// -- 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 /// often the fallback actually fires rather than assuming it never /// does. @@ -285,7 +285,7 @@ impl TranscriptScreen { &new_rows[common], 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 *self.tail.borrow_mut() = kept.map(|t| (new_key, t)); for row in &new_rows[common + 1..] { @@ -295,7 +295,7 @@ impl TranscriptScreen { RowDiff::Rebuild => { // A row before the tail changed (a regroup) -- nothing // 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 // re-`register`s whatever survives as it rebuilds each // row (docs/REVIEW-2026-09-06.md finding 1: a key that @@ -355,7 +355,7 @@ where Rsc::State: FocusHost + OpenUrl, { 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 // `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 // opening is exactly the inferred-as-measured mistake. 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)); } // Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s - // `scrollable()` uses for `Scroll`, applied directly to `List` since - // `List` already does its own placement and needs no `Scroll` wrapper. + // `scrollable()` uses for `Scroll`, applied directly to `LazySpan` since + // `LazySpan` already does its own placement and needs no `Scroll` wrapper. // Real touch-drag panning is the known gap in this module's doc. list.on(CursorSense::Scroll, |ctx, rsc| { 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 // already relative to `list`'s own on-screen box (this is what it was // 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. { let selection = selection.clone(); @@ -423,11 +423,11 @@ where let (composer, composer_bar) = composer::build_composer(rsc); // `.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 // there, which on the phone is the header bar (docs/IRIS_TODO.md, // 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) .span(Dir::DOWN) .add_strong(rsc) @@ -591,7 +591,7 @@ mod diff_tests { /// `Selection`, the gap docs/REVIEW-2026-09-06.md finding 8 named: the /// pure `diff_rows` decision above and `selection.rs`'s own registration /// 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()` /// existed and the `Rebuild` arm called it, with a panic from /// `TextEditable::edit` resolving the freed slot. diff --git a/iris/transcript-ui/src/row.rs b/iris/transcript-ui/src/row.rs index fdd439a..8812833 100644 --- a/iris/transcript-ui/src/row.rs +++ b/iris/transcript-ui/src/row.rs @@ -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 //! one `TextEdit` per top-level markdown block** (paragraph, heading, //! fence, list, table -- `client_core::markdown_blocks`), each rendered @@ -17,11 +17,11 @@ //! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by //! `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 -//! header calls `List::note_tap` at the row's own on-screen position -//! (read back from `List::extent`, since the tap event only knows its +//! header calls `LazySpan::note_tap` at the row's own on-screen position +//! (read back from `LazySpan::extent`, since the tap event only knows its //! position *within* this row) before toggling a `WidgetPtr` between the //! 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::selection::{SelKey, Selection}; @@ -43,7 +43,7 @@ const BLOCK_GAP_DP: f32 = 8.0; /// regardless of which row's base size surrounds it. 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 /// one -- collisions are not a correctness risk worth guarding against here /// (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. fn build_block( rsc: &mut Rsc, - list: WeakWidget, + list: WeakWidget, selection: Rc>, key: SelKey, block: &Block, @@ -225,7 +225,7 @@ where // id and every further frame, including the terminal `Drop`, // reaches `lib.rs`'s list-level registration instead -- see // `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. // // `Cancel` is the one that is *not* optional, and leaving it out @@ -315,7 +315,7 @@ where /// than a row (`selection::SelKey`). fn build_text_row( rsc: &mut Rsc, - list: WeakWidget, + list: WeakWidget, selection: Rc>, key: RowKey, sender: Option<&str>, @@ -385,7 +385,7 @@ impl RowBlocks { pub fn apply_delta( &mut self, rsc: &mut Rsc, - list: WeakWidget, + list: WeakWidget, selection: Rc>, key: RowKey, sender: Option<&str>, @@ -459,7 +459,7 @@ impl RowBlocks { fn build_single( rsc: &mut Rsc, - list: WeakWidget, + list: WeakWidget, selection: Rc>, key: RowKey, item: &TranscriptItem, @@ -489,7 +489,7 @@ pub enum TailRow { pub fn build_row( rsc: &mut Rsc, - list: WeakWidget, + list: WeakWidget, selection: Rc>, row: &FoldedRow, working: bool, diff --git a/iris/transcript-ui/src/selection.rs b/iris/transcript-ui/src/selection.rs index 1f537a1..63b4c07 100644 --- a/iris/transcript-ui/src/selection.rs +++ b/iris/transcript-ui/src/selection.rs @@ -5,7 +5,7 @@ //! `xilem` (RUST.md's E2 box, citing //! `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 -//! 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 //! visible row's *own* selection primitives (`TextEditCtx::select`/ //! `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 /// 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 - /// are actually loaded. `List::place` guards the twin of this same - /// class of bug on the list's own side (`list.rs`'s `slot_exists` + /// are actually loaded. `LazySpan::place` guards the twin of this same + /// class of bug on the list's own side (`lazy_span.rs`'s `slot_exists` /// assertion) -- a derived handle that silently outlives what it /// points to; the next caller adding a third row-keyed side table /// should read both. @@ -83,12 +83,12 @@ impl Selection { 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 /// 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 /// 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 /// new ones -- pointing at a widget the list had just freed, so the /// 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. /// `None` once the gesture is pointer-captured (`iris::sense`'s /// 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- /// 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 @@ -252,7 +252,7 @@ impl Selection { pub fn drag( &mut self, ui: &mut impl UiRsc, - list: WeakWidget, + list: WeakWidget, row: Option<(SelKey, Vec2, Vec2)>, pos_window: Vec2, sense: CursorSense, @@ -260,7 +260,7 @@ impl Selection { pointer: &PointerRequests, ) -> GestureOutcome { // 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 // 2026-09-07, *tells the gesture there was one*. A press that // caught moving content is a catch: it pans from this very sample @@ -312,7 +312,7 @@ impl Selection { GestureOutcome::Released(Some(v)) => { list(ui).fling(-v); // 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. // // Only when `fling` actually took it: below Compose's @@ -427,7 +427,11 @@ mod tests { EditMode::MultiLine, )) .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(); sel.register((1, 0), field); diff --git a/iris/transcript-ui/src/tool.rs b/iris/transcript-ui/src/tool.rs index e7ca8e2..9498f1b 100644 --- a/iris/transcript-ui/src/tool.rs +++ b/iris/transcript-ui/src/tool.rs @@ -153,7 +153,7 @@ struct Shared { /// Filled in immediately after construction -- the `WidgetPtr` cannot /// exist before the `Rc` every handler inside it captures. content: RefCell>>, - list: WeakWidget, + list: WeakWidget, selection: Rc>, key: RowKey, /// Whether a call in this row could still be running -- the caller's @@ -214,9 +214,9 @@ fn on_tap( /// 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 -/// knows its own box, so `List::extent` (last frame's on-screen box for -/// this key) turns the two into the position `list.rs`'s hold-the-edge +/// `LazySpan::note_tap` wants a viewport-relative position and this row only +/// knows its own box, so `LazySpan::extent` (last frame's on-screen box for +/// 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 /// describes for `AGENTS.md`'s `holdTopEdge`. 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. pub fn build_tool_row( rsc: &mut Rsc, - list: WeakWidget, + list: WeakWidget, selection: Rc>, key: RowKey, calls: Vec, @@ -795,7 +795,7 @@ impl ToolRow { /// Exists because the expanded appearance is otherwise unreachable /// from anything that cannot press the screen -- a headless /// 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. pub fn set_group_expanded(&self, rsc: &mut Rsc, expanded: bool) where