iris: List becomes LazySpan, and takes a Dir

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

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

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

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

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

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

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

No files matched your search

+48
View File
@@ -7,6 +7,54 @@ order and what "done" looks like. Tick and date them in place.
## Fix
- [ ] **In progress (2026-09-08): scrolling moves out of the list.**
Agreed with Iris over the design exchange that followed the overscroll
clamp. The list stays -- a lazy layout is a real thing that `Span`
cannot be -- but everything about *scrolling* leaves it, so that
`.scrollable()` is the one way anything in iris scrolls. Three steps,
each independently verifiable:
1. **Rename and `Dir`.** `List` -> `LazySpan` (it is what `Span` is,
laid out lazily from an anchor; it also stops colliding with
`BlockKind::List` in the markdown code), `ListRow` -> `LazyItem`,
`RowKey` kept, `Axis` -> `Dir`. Direction (which end item 0 sits at)
and pin (which end the view clings to) are **separate**: a
transcript is `Dir::DOWN` with the pin at the end, and conflating
them would stand it on its head.
2. **Delete the physics from `LazySpan`.** Its `Flinger`, `density`,
`Arc<dyn RequestRedraw>`, `tick` and the whole `fling`/
`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity` surface
go; `Scroll` is then the only `Flinger` user and `sense.rs` already
holds the genuinely shared parts. Add to `Widget`:
`fn scrolls_itself(&self) -> bool` (a `&self` capability flag read
through `get_dyn`, which does **not** mark dirty) and
`fn apply_scroll(&mut self, delta: &mut f32)` (takes what it can,
leaves the rest).
3. **`Scroll` wraps it**, owning `amt` and the pin: measure the child,
`apply_scroll`, place it again -- the same measure-then-place idiom
`Scroll::draw` and `LazySpan::place` already use. The measuring call
is free in the common case (unchanged region, not dirty, so
`draw_inner` skips it) and really walks exactly when the content
changed, which is when its walls need re-reading. Reaching the child
through `get_dyn_mut` marks it dirty by itself, so the second call
really draws -- no `Painter::draw_again` and nothing marked by hand.
`transcript-ui`'s `Selection` retargets to the `Scroll`.
Decisions taken along the way, with their reasons, so they are not
re-litigated: the **height cache stays in the container** (Iris:
widgets may render to two places at once, so a size keyed by
`WidgetId` would break; and the framework's own `ActiveData::size` is
freed by `remove_rec` the moment a row is virtualised away, which is
exactly when it is needed). **No `redraw_on_move` flag** -- the child
returning from `apply_scroll` is already the signal. **`amt` for a lazy
child is accumulated actual movement, not a distance from the top of
the content**, since paging rows in above shifts the origin; that is
honest for every current use and must be written at the field so
nobody builds a scrollbar on it.
Step 1 is done. Steps 2 and 3 are not.
- [x] **`List::clamp_to_content` still corrects on the next frame
(2026-09-08).** Iris's rule, stated while the composer's caret was
being fixed: "nothing in the framework should ever self heal because
+3 -3
View File
@@ -883,7 +883,7 @@ set once from `DisplayMetrics.density` in `android::view::new_peer`; the
desktop backend has no per-monitor density wired up yet and stays at
`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.
+2 -2
View File
@@ -48,7 +48,7 @@ Iris's two screenshots of the top edge -- rows drawn over the header in
one, a blank band in the other -- were **three** faults, and the rule
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
+6 -6
View File
@@ -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<iris::prelude::List>, rsc: &mut Rsc) {
/// `LazySpan::fling` sets a velocity and drives nothing by itself.
fn animate_list(list: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
let id = list.id();
rsc.ui_mut().animate(id);
}
@@ -991,7 +991,7 @@ fn animate_list(list: iris::prelude::WeakWidget<iris::prelude::List>, rsc: &mut
/// spline-decided `duration()` already caps how long it can run.
///
/// **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.
+16 -16
View File
@@ -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<List>, StrongWidget) {
let mut list = List::new(Axis::Y);
) -> (WeakWidget<LazySpan>, 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);
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -25,7 +25,7 @@ pub struct UiData {
/// never goes stale -- see LAYOUT.md section 2.
pub move_offsets: TrackedArena<MoveOffset, u32>,
/// 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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+5 -5
View File
@@ -1,4 +1,4 @@
//! RUST.md's I3: `iris::widget::List` with 800 rows of varied-length
//! RUST.md's I3: `iris::widget::LazySpan` with 800 rows of varied-length
//! wrapped text, one in twelve carrying a small image, scrollable with the
//! 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<Self>,
_: Proxy<Self::Event>,
) -> 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
+1 -1
View File
@@ -476,7 +476,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
// 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
+4 -4
View File
@@ -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;
+3 -3
View File
@@ -602,7 +602,7 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
/// A parent that both `mov`s a child (its own layout moved the box it
/// 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
+20 -20
View File
@@ -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<f32>),
/// 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]
+1 -1
View File
@@ -453,7 +453,7 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
};
// The bystander *contains* the capturer, which is the real shape: a
// 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));
-2
View File
@@ -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::*;
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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::*;
+7 -7
View File
@@ -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<f32>,
/// Touch panning, from the same `DragGesture` `List` is driven by
/// Touch panning, from the same `DragGesture` `LazySpan` is driven by
/// (`transcript-ui::Selection::drag`) rather than a second copy of its
/// 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).
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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)
@@ -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) {
@@ -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.
+4 -4
View File
@@ -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<MaskIdx> {
///
/// 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);
+2 -2
View File
@@ -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
+18 -18
View File
@@ -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<List>,
pub list: WeakWidget<LazySpan>,
pub composer: composer::Composer,
selection: Rc<RefCell<Selection>>,
/// 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<Rsc: HasEvents>(&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.
+11 -11
View File
@@ -1,4 +1,4 @@
//! One `iris::widget::list::ListRow` per folded transcript row
//! One `iris::widget::list::LazyItem` per folded transcript row
//! (`client_core::transcript_fold::TranscriptRow`). A row is a **column of
//! 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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>,
@@ -385,7 +385,7 @@ impl RowBlocks {
pub fn apply_delta<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
list: WeakWidget<List>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>,
@@ -459,7 +459,7 @@ impl RowBlocks {
fn build_single<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
item: &TranscriptItem,
@@ -489,7 +489,7 @@ pub enum TailRow {
pub fn build_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
row: &FoldedRow,
working: bool,
+15 -11
View File
@@ -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>,
list: WeakWidget<LazySpan>,
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);
+6 -6
View File
@@ -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<Option<WeakWidget<WidgetPtr>>>,
list: WeakWidget<List>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
/// Whether a call in this row could still be running -- the caller's
@@ -214,9 +214,9 @@ fn on_tap<Rsc: HasEvents>(
/// Hold the edge the reader is looking at while this row changes height.
///
/// `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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
calls: Vec<TranscriptItem>,
@@ -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<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
where