2 Commits
Author SHA1 Message Date
irisandClaude Opus 5 b7474f61b0 iris: scrolling belongs to Scroll, and a LazySpan only lays out
Steps 2 and 3 of the plan in docs/IRIS_TODO.md, together because
deleting the fling before `Scroll` could drive it would leave the app
unable to scroll at all. IRIS.md has the account and the measurements.

`LazySpan` loses its `Flinger`, its `density`, its
`Arc<dyn RequestRedraw>` -- which had no business existing in a
single-threaded frame loop -- its `tick`, and the whole
`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity`
surface. `Scroll` was the only other `Flinger` user, so there is now one
implementation of the physics rather than two, and a transcript is
`list.scrollable_to_end()` like anything else.

Three new `Widget` methods carry the handoff:

    fn scrolls_itself(&self) -> bool { false }
    fn apply_scroll(&mut self, delta: &mut f32) {}
    fn scroll_offset(&self) -> f32 { 0.0 }

`Scroll` asks the first, and a child that says yes is handed deltas
instead of being slid about as a lump -- which a lazy layout cannot be,
since which rows exist at all is a function of where it is scrolled to,
and it has no content length to be clamped against. `scrolls_itself` is
`&self` deliberately: `Widgets::get_dyn_mut` marks a widget dirty, so
asking through `apply_scroll` would dirty every ordinary child on every
tick and cost exactly the O(1) move the scheme exists for.

`Scroll::draw` is measure, apply, place -- the idiom it already used for
its own content length. The measuring draw is free in the common case
(unchanged region, nothing dirty, `draw_inner` returns immediately and
the child's stored walls are still correct) and really walks exactly
when the content changed. Nothing is marked by hand: reaching the child
to hand it the delta is what dirties it, which is why `draw_again` could
stay deleted.

`scroll_offset` was not in the plan and is needed. A lazy span usually
cannot say where its content ends until it has walked there, so it takes
a delta in full whenever the wall is not already in view and the walk
gives part of it back; the remainder is exact only when the wall was
already visible, and `Scroll` adding remainders up would over-count by
every overshoot and never correct. It reads the child's accumulated
movement after the placing draw instead, so `amt` equals what is on
screen. `amt_counts_only_what_the_child_could_take` is the test.

One convention for a scroll delta, the finger's. `Scroll::scroll(+)`
moved toward the start while `LazySpan::scroll(+)` moved toward the end,
with the latter's doc claiming to mirror the former -- so every call site
had to know which it was talking to. `LazySpan::scroll` is private now
and the single negation is inside its `apply_scroll`; call sites that
passed `-dy`/`-v` pass them through, and `phone_screen.rs`'s recorded
velocity flips sign with its magnitude unchanged.
`a_negative_delta_moves_toward_the_end` pins the sign across the whole
handoff, since nothing else can catch a list scrolling backwards.

The transcript builds its `Scroll` by hand rather than through
`.scrollable_to_end()`: that helper registers a finger drag, and
`Selection` is already the arbiter for those frames -- two `DragGesture`s
seeing one gesture is what its own doc rules out. Caught by
`a_long_press_and_drag_selects_text`, which failed when both were live.

Deferred, in DECISIONS.md and IRIS_TODO.md: the *pin* is still each
widget's own. Applying one happens when a row is appended, between
frames with no painter in hand, so moving it to `Scroll` needs a fourth
`Widget` method or a parameter on `apply_scroll`; nothing external edits
a pin today.

Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace green (21 suites), the arm64 release APK builds,
and the phone-shaped headless window replaying flick-120hz.touch scrolls
back through the transcript in the direction it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:39:33 -04:00
irisandClaude Opus 5 8e5928cc6a 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>
2026-09-08 20:17:57 -04:00
34 changed files with 1479 additions and 877 deletions

No files matched your search

+28
View File
@@ -5,6 +5,34 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-08 (last: scrolling moves out of the list)
Agreed with Iris in the exchange that followed, so most of this is her
call rather than mine. IRIS.md has the account. What I decided along the
way, and would flag for reversal:
- **A third `Widget` method, `scroll_offset`**, beyond the two we agreed.
`apply_scroll`'s remainder is exact only when the wall was already in
view, and a lazy span usually cannot see its wall until it has walked
there -- so `Scroll` reads the child's accumulated movement after the
placing draw instead of adding remainders up, which would drift.
- **One scroll-delta convention, the finger's.** The two widgets had
opposite ones under the same name; `LazySpan::scroll` is now private and
the single negation lives in its `apply_scroll`. Call sites that passed
`-dy`/`-v` pass them straight through, and one fixture's expected
velocity flipped sign with its magnitude unchanged.
- **The transcript builds its `Scroll` by hand rather than through
`.scrollable_to_end()`**, because that helper registers a finger drag
and `Selection` is already the arbiter for those frames -- two
`DragGesture`s seeing one gesture is what `DragGesture`'s own doc rules
out. The wheel is registered identically; only the drag differs.
- **DEFERRED: the pin stays in each widget.** Iris asked for `amt` and
the at-end control to live in `Scroll`; `amt` does, the pin does not,
because applying a pin happens when a row is appended -- between frames,
with no painter -- so moving it needs a fourth `Widget` method or a
parameter on `apply_scroll`. Nothing external edits a pin today.
docs/IRIS_TODO.md carries it.
## 2026-09-08 (later still: the list's overscroll clamp, in frame)
Finishes the item the previous entry deferred. IRIS.md has the account.
+105
View File
@@ -12,6 +12,111 @@ things still stay out.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-08 (last): `List` is `LazySpan`, and scrolling belongs to `Scroll`
From the design exchange after the overscroll fix, where you asked
whether `List` could just be `Span::scrollable()`. It cannot -- a lazy
layout is a real thing a `Span` is not, for reasons measured below -- but
almost everything you named as out of place was, and it has all moved.
**`List` -> `LazySpan`** (`ListRow` -> `LazyItem`, `RowKey` unchanged),
living beside `Span` under `widget/position/`. It is what `Span` is, laid
out lazily from an anchor rather than eagerly from the start, and the name
says so. It also stops colliding with `BlockKind::List` in the markdown
code.
**It takes a `Dir` instead of an `Axis`**, meaning what it means in `Span`:
which end 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 argument:
`LazySpan::new(dir, at_end)`, spelled like `Scroll::new`'s. `Dir::UP` is
real rather than nominal: the walk works in direction-relative pixels from
the leading edge, with `abs_region` flipping the box and `flip_pos`
converting the screen-space positions the hit-testing helpers speak in.
**Everything about scrolling left the list.** Its `Flinger`, its
`density`, its `Arc<dyn RequestRedraw>` (which had no business existing in
a single-threaded frame loop), its `tick`, and the whole
`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity`
surface are gone. `Scroll` was the only other `Flinger` user, so there is
now exactly one implementation of the physics and `sense.rs` keeps the
parts both ever shared. A transcript is `list.scrollable_to_end()` like
anything else.
### The new public surface: three `Widget` methods
```rust
fn scrolls_itself(&self) -> bool { false }
fn apply_scroll(&mut self, delta: &mut f32) {}
fn scroll_offset(&self) -> f32 { 0.0 }
```
`Scroll` asks the first, and if the child says yes it stops sliding the
child about as a lump and starts handing it deltas. Each method is `&self`
or `&mut self` for a reason worth keeping: reaching a widget through
`Widgets::get_dyn_mut` *marks it dirty*, so asking the capability question
through `apply_scroll` would dirty every ordinary child on every scroll
tick and cost exactly the O(1) move the whole scheme exists for.
`Scroll::draw` is then measure, apply, place -- the same measure-then-place
idiom it already used for its own content length. The measuring draw is
free in the common case (unchanged region, nothing dirty, so `draw_inner`
returns immediately and the child's stored walls are still correct) and
really walks exactly when the content changed, which is when they need
re-reading. **Nothing is marked by hand**: reaching the child to hand it
the delta is itself what dirties it, so the placing draw really draws.
That is why `Painter::draw_again` could stay deleted.
### Why `scroll_offset` exists
`apply_scroll` leaving a remainder was meant to be the whole story, and it
is not quite. A lazy span usually **cannot say where its content ends
until it has walked there**, so it takes a delta in full whenever the wall
is not already in view, and the walk that follows gives part of it back.
The remainder is exact only when the wall was already visible. `Scroll`
adding remainders up would over-count by every overshoot and never
correct, so it reads the child's accumulated movement after the placing
draw instead, and `amt` is set from that. `amt` therefore always equals
what is on screen.
For a self-positioning child `amt` is **movement, not position**: paging
rows in above moves the origin and the child cannot say by how much,
never having measured them. The direction is the same as an ordinary
child's; the absolute value is not comparable, and a scrollbar would need
a real content length before it could use either.
### One convention for a scroll delta
There were two, and they read alike: `Scroll::scroll(+)` moved toward the
*start* while `LazySpan::scroll(+)` moved toward the *end*, with the
latter's doc claiming to mirror the former. Every call site had to
remember which it was talking to, and `Selection::drag` negated on the way
in. There is one now -- the finger's, which is `Scroll`'s -- and
`LazySpan::scroll` is private with the single negation inside
`apply_scroll`. `a_negative_delta_moves_toward_the_end` pins it across the
whole handoff, since no type can catch a scroll running backwards.
### What the measurements said, for the record
- A `Span` is skipped entirely in the steady state (`(0,0,0)` counters),
but **when it is redrawn it costs two draws per child** -- 21 draws for
10 children -- because phase 1 offers each child the ambient region to
learn its length and phase 2 offers it its real share. Any mutation of a
`Span` therefore redraws all of it: 24 draws for 11 children after one
prepend. That is why a transcript cannot be one.
- A settled scroll tick of the lazy span with 31 rows on screen is
**1 real draw and 31 move-slot writes**, no primitive rewrites and no
text reshaped; an idle frame is `(0,0,0,0)`. That is the number against
which "store the edges and only recompute what changed" would be
judged, and it is why the walk was left alone.
- The framework's own `ActiveData::size` cannot serve as the row-height
cache: `remove_rec` frees it the moment a row is virtualised away,
which is exactly when the walk needs it. The cache stays in the
container, keyed by `RowKey` -- which is also right for the reason you
gave, that a widget may one day render in two places and a size keyed
by `WidgetId` would break.
## 2026-09-08 (later still): a `List` clamps its overscroll in the same frame, and `draw_again` is gone
The last place in iris that corrected itself on a later frame. `List`'s
+86
View File
@@ -7,6 +7,92 @@ 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.
Done 2026-09-08, in two commits (the rename, then steps 2 and 3
together -- deleting the fling before `Scroll` could drive it would
have left the app unable to scroll at all).
**Two things the plan did not anticipate, both settled in the code:**
- **`apply_scroll`'s remainder is not enough on its own, so `Widget`
gained a third method, `scroll_offset`.** A lazy span usually cannot
say where its content ends until it has walked there, so it takes a
delta in full whenever the wall is not already in view, and the walk
that follows gives part of it back. The remainder is therefore right
only when the wall was already visible, and `Scroll` adding
remainders up would over-count by every overshoot and never correct.
`scroll_offset` is the child's accumulated movement, read `&self`
after the placing draw, and `Scroll::amt` is set from it -- so `amt`
equals what is on screen rather than what was asked for. There is a
test, `amt_counts_only_what_the_child_could_take`.
- **There were two opposite scroll-delta conventions**, and the
handoff made keeping both impossible. `Scroll::scroll(+)` moved
toward the *start* while `LazySpan::scroll(+)` moved toward the
*end*, and `LazySpan::scroll`'s own doc claimed to mirror `Scroll`'s.
There is one now -- the finger's, which is `Scroll`'s -- and
`LazySpan::scroll` is private, with the single negation inside
`apply_scroll`. Call sites that used to pass `-dy`/`-v` pass them
through, and the fixture recordings' expected velocity flipped sign
with its magnitude unchanged.
**Still open, and the one thing to decide:** the *pin* ("stay at the
end as rows are appended") is still each widget's own -- `Scroll` has
`snap_end` for an ordinary child, `LazySpan` has one for itself, and
the constructor argument sets each. Iris asked for `amt` and "other
controls (iirc only at end for now)" to live in `Scroll` so a caller
always edits the `Scroll`; that half is done for `amt` and not for the
pin, because a pin has to be *applied* when a row is appended --
between frames, with no painter in hand -- so moving it needs either a
fourth `Widget` method or a parameter on `apply_scroll`. Nothing
external edits a pin today (the transcript sets it once at
construction and calls `jump_to_end` on the span for the rest), so
this is a design question rather than a missing capability.
- [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
+20 -19
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 `Scroll::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,14 +908,15 @@ where
}
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
/// from it (toward older messages) through `List::fling`, then
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
/// convention (`List::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
/// comparable because both report it as a row index + pixel offset, not a
/// signed distance.
/// from it (toward older messages) through `Scroll::fling`, then
/// `FLING_COUNT` back. Outward is *positive* in `Scroll::scroll`'s
/// convention, which is the finger's: a finger dragged down the screen
/// brings earlier content into view. It was negative here until
/// 2026-09-08, when the transcript's scroll position moved out of the
/// `LazySpan` -- whose anchor offset ran the other way -- and into the
/// `Scroll` around it. The two apps' *travel* is directly comparable
/// whichever way the signs run, because both report it as a row index plus
/// a pixel offset rather than a signed distance.
async fn run_fling_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
@@ -937,8 +938,8 @@ async fn run_fling_phase(
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
animate_list(screen.list, rsc);
(screen.scroll)(rsc).fling(FLING_VELOCITY_PX_S);
animate_scroll(screen.scroll, rsc);
}
});
redraw.request_redraw();
@@ -950,8 +951,8 @@ async fn run_fling_phase(
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
animate_list(screen.list, rsc);
(screen.scroll)(rsc).fling(-FLING_VELOCITY_PX_S);
animate_scroll(screen.scroll, rsc);
}
});
redraw.request_redraw();
@@ -977,11 +978,11 @@ async fn read_anchor_position(
.await
}
/// Register the list with the frame loop, exactly as a finger's own
/// Register the scroll area 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) {
let id = list.id();
/// `Scroll::fling` sets a velocity and drives nothing by itself.
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::Scroll>, rsc: &mut Rsc) {
let id = scroll.id();
rsc.ui_mut().animate(id);
}
@@ -991,7 +992,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
/// `Scroll::tick` 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.
@@ -1010,7 +1011,7 @@ async fn wait_for_fling_settle(
let started = Instant::now();
while started.elapsed() < cap {
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).is_scrolling(),
Some(screen) => (screen.scroll)(rsc).is_scrolling(),
None => false,
})
.await;
+32 -24
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,21 +106,29 @@ 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>, WeakWidget<Scroll>, 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())
let list_weak = list.weak();
// Scrolled through a `Scroll`, like every other scroll area in iris
// since the position moved out of the list: what this measures has to
// be the path the app actually takes.
let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(list.any(), Axis::Y, true));
(list_weak, scroll.weak(), scroll.any())
}
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
@@ -135,7 +143,7 @@ fn bench_first_frame(n: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (_list, root) = build_message_list(&mut rsc, n, 20);
let (_list, _scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
@@ -160,11 +168,11 @@ fn bench_scroll(n: usize, ticks: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (list, root) = build_message_list(&mut rsc, n, 20);
let (_list, scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
@@ -173,7 +181,7 @@ fn bench_scroll(n: usize, ticks: usize) {
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for _ in 0..ticks {
rsc.ui.widgets.get_mut(&list).unwrap().scroll(-8.0);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0);
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
@@ -207,7 +215,7 @@ fn bench_input_grows(n: usize, lines: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (list, list_root) = build_message_list(&mut rsc, n, 20);
let (_list, scroll, list_root) = build_message_list(&mut rsc, n, 20);
let list_area = rsc.ui.widgets.add_strong(Sized {
inner: list_root,
x: None,
@@ -231,7 +239,7 @@ fn bench_input_grows(n: usize, lines: usize) {
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
@@ -270,14 +278,14 @@ 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) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (list, root) = build_message_list(&mut rsc, n, 20);
let (list, _scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
@@ -298,7 +306,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 +333,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 +342,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 +357,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
+38 -1
View File
@@ -166,10 +166,47 @@ impl<'a> Painter<'a> {
self.mask = self.own_mask;
}
/// Ask a child whether it positions its own content
/// ([`Widget::scrolls_itself`]) -- read through `get_dyn`, which does
/// **not** mark it dirty, which is the whole reason this is a separate
/// question from [`Self::apply_scroll`] rather than something that
/// falls out of calling it. `false` for a child that has gone.
pub fn scrolls_itself<W: ?Sized>(&self, id: &StrongWidget<W>) -> bool {
self.rsc
.widgets()
.get_dyn(id.id())
.is_some_and(|w| w.scrolls_itself())
}
/// Hand a child a scroll delta to take what it can of
/// ([`Widget::apply_scroll`]), leaving the rest in `delta`.
///
/// Reaching the child mutably is what marks it for a real redraw
/// (`Widgets::get_dyn_mut`), so a caller that follows this with
/// another `widget_within` at the same region gets a genuine draw
/// rather than `draw_inner`'s unchanged-region skip -- which is
/// exactly the measure-then-place shape `Scroll` uses, and why there
/// is no "mark this for another frame" call in this type.
pub fn apply_scroll<W: ?Sized>(&mut self, id: &StrongWidget<W>, delta: &mut f32) {
if let Some(w) = self.rsc.widgets_mut().get_dyn_mut(id.id()) {
w.apply_scroll(delta);
}
}
/// Read a self-positioning child's accumulated content movement
/// ([`Widget::scroll_offset`]). Through `get_dyn`, so it does not mark
/// the child dirty. `0.0` for a child that has gone.
pub fn scroll_offset<W: ?Sized>(&self, id: &StrongWidget<W>) -> f32 {
self.rsc
.widgets()
.get_dyn(id.id())
.map_or(0.0, |w| w.scroll_offset())
}
/// 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
+66
View File
@@ -60,6 +60,72 @@ pub trait Widget: Any {
fn tick(&mut self, now: std::time::Instant) -> bool {
false
}
/// Whether this widget positions its own content and should be handed
/// scroll deltas ([`Self::apply_scroll`]) instead of being moved by
/// the `Scroll` around it. Default `false`: an ordinary child is a
/// fixed lump its parent slides about, which is what makes a scroll
/// tick an O(1) move of one subtree rather than a redraw.
///
/// A lazy layout has to answer `true`, because the two halves of a
/// scroll are not separable for it: which children exist at all is a
/// function of where it is scrolled to, so it cannot be a lump, and it
/// cannot report a content length for the parent to clamp against
/// either -- it has never measured the rows it has not drawn.
///
/// **`&self` on purpose.** Reaching a widget through
/// `Widgets::get_dyn_mut` marks it dirty, so asking the question
/// through [`Self::apply_scroll`] would dirty every ordinary child on
/// every scroll tick and cost exactly the redraw the move path exists
/// to avoid. This is read through `get_dyn`, which does not mark.
fn scrolls_itself(&self) -> bool {
false
}
/// Take as much of `delta` as this widget can actually move, and leave
/// the rest in it. Only called on a widget whose
/// [`Self::scrolls_itself`] is `true`.
///
/// The sign is `Scroll::scroll`'s, which is the finger's: a positive
/// delta moves the content in the positive direction of the axis, and
/// so brings *earlier* content into view.
///
/// What is left behind is how the caller learns it reached a wall --
/// asked for 300, got 50 back means the content ran out 250 short --
/// which is all a fling needs to know to stop, and all a pin needs to
/// know to re-pin. There is deliberately nothing here reporting an
/// absolute position: a lazy layout's origin moves when content is
/// loaded above it, so any such number would be a fiction.
///
/// Called between the two draws of `Scroll::draw`, so the walls this
/// answers against were measured by the first of them.
#[allow(unused_variables)]
fn apply_scroll(&mut self, delta: &mut f32) {}
/// How far this widget has moved its own content in total, in
/// [`Self::apply_scroll`]'s direction convention -- for a parent
/// keeping an account of where a self-positioning child has got to.
///
/// **Why this exists and `apply_scroll`'s remainder is not enough.**
/// A lazy layout usually cannot say where its content ends until it
/// has walked there, so `apply_scroll` takes a delta in full whenever
/// the wall is not already in view, and the wall is found by the walk
/// that follows -- which gives some of it back. The remainder is
/// therefore right only when the wall was already visible, and a
/// parent adding remainders up would over-count by every overshoot
/// and never correct. Read after the child has been placed, this is
/// what actually happened.
///
/// `&self`, so asking does not mark the child dirty
/// ([`Self::scrolls_itself`] has the reasoning).
///
/// Counts scrolling only: a jump straight to an item is not travel
/// across the content and does not appear here, because for a layout
/// whose origin moves as content is paged in there is no distance
/// between the two positions to report.
fn scroll_offset(&self) -> f32 {
0.0
}
}
impl Widget for () {
+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
+10 -9
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,17 +98,18 @@ 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));
}
// `.scrollable_to_end`, like anything else that scrolls: the
// wheel and the drag are the `Scroll`'s, and the list only lays
// out. Masked outside it, since a `LazySpan` draws the row
// straddling each edge in full and asserts something clips it.
let root = list
.on(CursorSense::Scroll, |ctx, rsc| {
let delta = ctx.data.scroll_delta.y * 50.0;
ctx.widget(rsc).scroll(delta);
})
.scrollable_to_end(Axis::Y)
.masked()
.background(rect(Color::WHITE))
.add_strong(rsc);
+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 (`UiData::animate` and `Widget::tick`), 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 `Scroll::tick` 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.
/// (`Scroll::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, `Scroll::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::Scroll::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 (`Scroll::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 `Scroll::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::Scroll::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 `Scroll::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::Scroll::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 `Scroll::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 (`Scroll::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
/// `Scroll::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 `Scroll::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 (`Scroll::tick`) 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
/// `Scroll::tick`'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 `Scroll::tick`'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::*;
+226 -81
View File
@@ -20,14 +20,35 @@ 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
/// A scroll delta this area has been handed but not yet passed to a
/// child that positions itself ([`Widget::scrolls_itself`]) -- how
/// far a wheel, a drag or a fling asked to move since the last draw.
/// Applied and cleared in `draw`, which is the only place the child's
/// walls are known.
///
/// Nothing accumulates here for an ordinary child, whose position is
/// this widget's own `amt` and is written the moment a delta arrives.
pending: f32,
/// Whether the last draw handed a self-positioning child more than it
/// could take -- the only way this widget learns where that child's
/// content ends, since it has no length to ask for. Reset every time a
/// delta is fully consumed.
hit_wall: bool,
/// Whether the child positions itself, from its own
/// [`Widget::scrolls_itself`], re-read every draw. Cached because it
/// is asked once per scroll delta as well as once per draw, and it is
/// read through `get_dyn` -- taking `&mut` to ask would mark every
/// ordinary child dirty on every tick and cost exactly the O(1) move
/// this widget exists for.
child_scrolls: bool,
/// 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,42 +71,32 @@ 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
// 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.
if self.amt <= 0.0 || self.amt >= self.scroll_range() {
// not there. With an ordinary child this widget knows exactly
// where the content ends -- `update_amt` has just clamped `amt`
// into it -- so the wall is read straight off `amt`. With a child
// that positions itself there is no content length to read, and
// the wall arrives instead as the part of a delta the child could
// not take (`hit_wall`, set in `draw`); it is one frame old, which
// costs a fling one extra tick and nothing on screen, since the
// child clamped its own position within the frame that found it.
let at_wall = if self.child_scrolls {
self.hit_wall
} else {
self.amt <= 0.0 || self.amt >= self.scroll_range()
};
if at_wall {
self.fling.stop();
}
self.fling.is_flinging()
}
fn draw(&mut self, painter: &mut Painter) -> Size {
// The region offered to the child is sized using *last* frame's
// content length, not a fresh measurement -- deliberately, so that
// an ordinary scroll tick (`amt` changes, content does not) offers
// the child the exact same size it was last drawn with, only
// shifted. That is what lets `draw_inner` dispatch this as an O(1)
// move (LAYOUT.md section 2) instead of a redraw: sizing the region
// to a *fresh* measurement would require drawing the child first to
// learn it, and a provisional draw almost never matches the
// previously active size, forcing a real redraw on every tick. A
// genuine content-size change (not just a scroll) therefore lags
// one frame before the container's clamp reflects it; the content
// length itself (read below from what was actually drawn) is never
// stale, so this self-corrects the next frame and never leaves the
// scroll range wrong for long. See LAYOUT.md section 4.
//
// Every length here is resolved against the box this widget was
// **offered** (`px_size`), never `output_size`: a `Scroll` is
// routinely smaller than the window -- the composer's field is
// capped at six lines by a `MaxSize` around it -- and measuring
// the window instead would make the pan range, and so where the
// content sits, a function of the screen rather than of the box.
// (What the previous arithmetic here computed came to the same
// number by a longer route, through a `within_len` against a
// window-relative scalar; it read as if the window were the
// container and cost a session working out that it was not.)
let axis = self.axis;
let container_len = painter.px_size().axis(axis);
self.container_len = container_len;
@@ -94,45 +105,56 @@ impl Widget for Scroll {
// density, and `draw` is where this widget meets the only thing
// that knows it.
self.density = painter.density();
let density = self.density;
let was_known = self.child_scrolls;
self.child_scrolls = painter.scrolls_itself(&self.inner);
// A delta that arrived before this widget had ever drawn went to
// `amt` (the ordinary child's path), because what kind of child
// this is cannot be asked until there is a painter to ask through.
// Hand it on rather than dropping it: `amt` means nothing to a
// self-positioning child, so the delta would simply never happen.
if self.child_scrolls && !was_known && self.amt != 0.0 {
self.pending -= self.amt;
self.amt = 0.0;
}
if self.child_scrolls {
self.draw_self_scrolling_child(painter)
} else {
self.draw_moved_child(painter, container_len)
}
}
}
// **The child's box along this axis is its own length**, not the
// container's -- that is what a scroll viewport means, and it is
// 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`
// already use. Written out rather than through
// `Painter::draw_twice`, which cannot take a closure needing
// `&mut self` while `self.inner` is borrowed.
//
// **Both draws are in this frame, and only the second one
// decides anything.** The first is handed last frame's length as
// a *hint* -- nothing about where the child ends up depends on
// it, and it exists only so that the usual case, where the
// content's length did not change, offers the same region twice:
// `draw_inner` then makes the first call an O(1) `mov` and
// returns at the first line of the second. A frame on which the
// content did grow or shrink pays one real extra draw, and that
// is a frame on which the content was being redrawn anyway.
//
// The alternative -- place against the hint and let the next
// frame fix it -- is what Iris found on her phone (2026-09-08):
// every newline typed into the composer drew the field in a box
// one line short of its text, and since that text is centred in
// its box it hung half a line past each end, putting the caret a
// whole 12dp below the bar's inside edge and flush with its
// bottom. There was no next frame: nothing dirtied that subtree
// again, so the stale placement was the last one drawn, until
// the keyboard closed and its inset rewrite forced a redraw ("it
// fixes itself"). **Layout is a pure function of the state, not
// of how many frames have been drawn** (Iris, 2026-09-08) -- a
// correction that needs a second frame is a frame drawn wrong.
//
// The container's own length stands in as the hint until
// anything has been measured: a zero-length region on the first
// frame would place the child's primitives against a box of no
// size.
impl Scroll {
/// The ordinary case: the child is a fixed lump this widget slides
/// about, and its position is `amt`.
///
/// **The child is drawn twice, and only the second decides anything.**
/// The first is handed last frame's length as a *hint* -- nothing
/// about where the child ends up depends on it, and it exists only so
/// that the usual case, where the content's length did not change,
/// offers the same region twice: `draw_inner` then makes the first
/// call an O(1) `mov` and returns at the first line of the second. A
/// frame on which the content did grow or shrink pays one real extra
/// draw, and that is a frame on which the content was being redrawn
/// anyway.
///
/// The alternative -- place against the hint and let the next frame
/// fix it -- is what Iris found on her phone (2026-09-08): every
/// newline typed into the composer drew the field in a box one line
/// short of its text, and since that text is centred in its box it
/// hung half a line past each end. There was no next frame: nothing
/// dirtied that subtree again, so the stale placement was the last one
/// drawn, until the keyboard closed and its inset rewrite forced a
/// redraw ("it fixes itself"). **Layout is a pure function of the
/// state, not of how many frames have been drawn** (Iris, 2026-09-08)
/// -- a correction that needs a second frame is a frame drawn wrong.
///
/// The container's own length stands in as the hint until anything has
/// been measured: a zero-length region on the first frame would place
/// the child's primitives against a box of no size.
fn draw_moved_child(&mut self, painter: &mut Painter, container_len: f32) -> Size {
let axis = self.axis;
let density = self.density;
let hint = self.content_len.unwrap_or(container_len);
let used = painter.widget_within(&self.inner, self.child_region(hint));
@@ -153,21 +175,82 @@ impl Widget for Scroll {
self.amt = measured - container_len;
}
self.update_amt();
let used = painter.widget_within(&self.inner, self.child_region(measured));
// The **content's** size, not the container's. A parent that can
// grow (the composer's bar) should hug the text until its own cap
// stops it, and reporting the container instead would make this
// widget's answer a function of the answer -- the bar is sized
// from what is reported here, so it collapses to nothing and
// never recovers. What keeps the content inside the offered box
// is the mask a caller puts around it (`.scrollable().masked()`),
// not this number.
// from what is reported here, so it collapses to nothing and never
// recovers. What keeps the content inside the offered box is the
// mask a caller puts around it (`.scrollable().masked()`), not
// this number.
painter.widget_within(&self.inner, self.child_region(measured))
}
/// The lazy case: the child positions its own content
/// ([`Widget::scrolls_itself`]), so this widget contributes the
/// gesture, the fling and the accounting, and the child contributes
/// the placement.
///
/// Measure, apply, place -- the same measure-then-place idiom
/// [`Self::draw_moved_child`] and `LazySpan::place` use, for the same
/// reason: the child cannot say how much of a delta it can take until
/// it has laid out, and a correction that waits for the next frame is
/// a frame drawn wrong.
///
/// **The measuring draw is free in the common case.** It offers the
/// child the same box as last frame, so with nothing dirty
/// `draw_inner` returns immediately and the walls the child answers
/// against are the ones it last measured -- still correct, because
/// nothing changed. When the content *did* change the child is dirty,
/// really walks, and the walls are fresh, which is exactly when they
/// need to be.
///
/// **Nothing marks the child by hand.** Reaching it through
/// `get_dyn_mut` to hand it the delta is itself what dirties it
/// (`Widgets::get_dyn_mut`), so the placing draw below really draws
/// rather than taking `draw_inner`'s unchanged-region skip.
fn draw_self_scrolling_child(&mut self, painter: &mut Painter) -> Size {
painter.widget_within(&self.inner, UiRegion::FULL);
let requested = std::mem::take(&mut self.pending);
let mut left = requested;
if requested != 0.0 {
painter.apply_scroll(&self.inner, &mut left);
}
let used = painter.widget_within(&self.inner, UiRegion::FULL);
// Read *after* the child has been placed, because that is the
// draw that finds the end of the content: a child that could not
// see its wall yet took the whole delta above and gave part of it
// back while walking. `amt` is therefore the movement that
// actually happened, not the movement that was asked for -- see
// `Widget::scroll_offset`.
//
// Deliberately **not** a distance from the start of the content:
// paging rows in above moves that origin and the child cannot say
// by how much, never having measured them. It is honest for what
// it is used for -- "how far has this moved", a fling's wall --
// and anything wanting an absolute position (a scrollbar) needs a
// real content length first.
// Negated: `scroll_offset` is in the finger's direction while
// `amt` counts *forward through the content*, so that `amt` means
// the same thing and moves the same way whichever kind of child
// this is. What differs, unavoidably, is the origin -- an ordinary
// child's `amt` is measured from the start of its content, and a
// lazy child has no start to measure from, so its `amt` is
// movement from wherever it happened to be when this area was
// built. Direction is comparable; absolute value is not.
let moved = -painter.scroll_offset(&self.inner);
// A delta that came back short, or one the child took and then
// gave part of back, is this widget's only way of hearing that the
// content ran out.
self.hit_wall = requested != 0.0 && (moved - self.amt + requested).abs() > 0.5;
self.amt = moved;
used
}
}
impl Scroll {
/// `at_end` starts the area pinned to the end of its content and
/// keeps it there while the content grows -- see
/// `WidgetLike::scrollable_to_end`. `false` starts at the beginning,
@@ -180,6 +263,9 @@ impl Scroll {
snap_end: at_end,
container_len: 0.0,
content_len: None,
pending: 0.0,
hit_wall: false,
child_scrolls: false,
gesture: DragGesture::on(axis),
fling: Flinger::new(),
density: 1.0,
@@ -235,14 +321,13 @@ impl Scroll {
.gesture
.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
// 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
// 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.
// The content follows the finger: `dy` straight through, and
// the same `dy` a transcript's own arbiter (`Selection::drag`)
// hands to this same method. There used to be two conventions
// here -- this one and a `LazySpan::scroll` whose anchor offset
// ran the opposite way while its doc claimed to mirror this
// one -- so every call site had to remember which it was
// talking to. There is one now.
GestureOutcome::Pan(dy) => self.scroll(dy),
// Same sign as `Pan`, since `tick` applies it through the
// same `scroll`.
@@ -288,7 +373,12 @@ impl Scroll {
/// question that cannot be answered yet -- answering it anyway is
/// what `content_len`'s doc describes.
pub fn update_amt(&mut self) {
if self.content_len.is_none() {
// A self-positioning child has no content length and no clamp of
// its own here: it does its own clamping in `apply_scroll`, and
// `amt` is a record of what it did rather than a position to
// correct. Clamping it against a `scroll_range` of zero (which is
// what no `content_len` computes to) would peg it at 0 forever.
if self.child_scrolls || self.content_len.is_none() {
return;
}
let len = self.scroll_range();
@@ -304,7 +394,7 @@ impl Scroll {
}
/// Whether a fling is coasting here right now -- the same question
/// `List::is_scrolling` answers for the other scrolling widget, under
/// `Scroll::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).
@@ -312,6 +402,46 @@ impl Scroll {
self.fling.is_flinging()
}
/// Start a fling at `velocity`, in the same direction convention as
/// [`Self::scroll`]. Answers whether one actually started, which is
/// the caller's cue to register this widget for frames
/// (`UiData::animate`) -- see [`Widget::tick`]. Cancels any fling
/// already in progress.
///
/// **Sets the fling; it does not drive it.** A fling moves only while
/// something calls `tick` once per frame, and what does that in a
/// running app is `UiData::tick_animations`, over the ids
/// `UiData::animate` was given. Split that way because the two halves
/// have different owners: the velocity is this widget's business and
/// whether anything animates at all is the frame loop's. Missing the
/// second call is what a finger fling did on Iris's phone for two
/// builds -- the velocity was right and nothing ever advanced it,
/// which looks exactly like a list that stops dead under the finger.
///
/// The density handed to `FlingCalculator` is this area's own, taken
/// from the painter in `draw`, not `1.0`: it does **not** cancel out
/// of the spline, and a hardcoded 1.0 against a 2.75-density screen
/// made a flick that should coast for about a second run for 45.
pub fn fling(&mut self, velocity: f32) -> bool {
self.fling.start(velocity, self.density)
}
/// Cancel any fling in progress with no further movement -- the next
/// touch-down's job, since `AndroidFlingSpline`'s curve has no idea a
/// finger came back down and Android's own `Scroller` relies on the
/// view calling `abortAnimation` for the same reason.
pub fn cancel_fling(&mut self) {
self.fling.stop();
}
/// The velocity a fling in progress is coasting at, `None` when
/// nothing is flinging. What a release's decision looks like from the
/// outside, so a test can read what the gesture measured rather than
/// re-timing the gesture itself.
pub fn fling_velocity(&self) -> Option<f32> {
self.fling.velocity()
}
/// Which way this area pans. For a caller that found the widget
/// rather than built it -- a test walking what is drawn, a scroll
/// indicator asking which edge to sit on.
@@ -319,7 +449,22 @@ impl Scroll {
self.axis
}
/// Pan by `amt`, in the finger's direction: positive moves the
/// content the positive way along the axis, which brings **earlier**
/// content into view. One convention, and the one
/// [`Widget::apply_scroll`] carries, so that a delta means the same
/// thing wherever it is handed on.
///
/// A child that positions itself cannot be moved by writing `amt`
/// here -- where it can actually go is a question only its own layout
/// can answer -- so the delta is banked until `draw`, which is where
/// that answer exists. `amt` is then written from what the child
/// really took.
pub fn scroll(&mut self, amt: f32) {
if self.child_scrolls {
self.pending += amt;
return;
}
self.amt -= amt;
self.update_amt();
}
+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.
@@ -125,11 +125,6 @@ impl DefaultAppState for Client {
if let Some(inset) = ime_argv() {
opened.screen.composer.set_bottom_inset(rsc, inset);
}
// 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).
let handle = rsc.tasks.redraw_handle();
(opened.screen.list)(rsc).set_redraw_handle(handle);
Some(opened.screen)
}
// On screen rather than a panic: this window exists to be
@@ -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) {
@@ -124,7 +124,7 @@ fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
PHONE_FRAME_MS,
);
assert!(
(screen.list)(&mut h.rsc).is_scrolling(),
(screen.scroll)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing"
);
@@ -151,7 +151,7 @@ fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
PHONE_FRAME_MS,
);
assert!(
(screen.list)(&mut h.rsc).is_scrolling(),
(screen.scroll)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing"
);
@@ -161,7 +161,7 @@ fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
(screen.scroll)(&mut h.rsc).fling_velocity(),
None,
"a press that stopped a fling and moved nothing must not start another"
);
@@ -195,7 +195,7 @@ fn the_same_small_drag_on_a_settled_list_moves_nothing() {
PHONE_FRAME_MS,
);
assert!(
!(screen.list)(&mut h.rsc).is_scrolling(),
!(screen.scroll)(&mut h.rsc).is_scrolling(),
"the fling must have stopped, or this is the same case as the test above"
);
@@ -60,7 +60,7 @@ fn a_cancelled_flick_does_not_fling() {
h.replay(&flick);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
(screen.scroll)(&mut h.rsc).fling_velocity(),
None,
"a gesture the platform took away must not fling"
);
@@ -110,7 +110,7 @@ fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
after - before
);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
(screen.scroll)(&mut h.rsc).fling_velocity(),
None,
"and it must not have flung either"
);
+16 -11
View File
@@ -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 `Scroll::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() {
@@ -44,18 +44,23 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
h.replay(&flick);
let velocity = (screen.list)(&mut h.rsc)
let velocity = (screen.scroll)(&mut h.rsc)
.fling_velocity()
.expect("the flick must release as a pan with a velocity, not a tap");
// Compose's own answer for this recording's five samples, printed by
// `iris/benches/velocity_reference.py` -- not a number read off this
// code. Negative because the flick runs *down* the screen and
// `Selection::drag` flings the list by `-v` (see its `Released` arm).
// code. **Positive** because the flick runs *down* the screen and a
// delta now carries the finger's own direction the whole way, from the
// gesture through `Selection::drag` (which passes it straight to
// `Scroll::fling`) to the anchor. It read -15250 while the transcript
// negated the velocity on its way into a `LazySpan` whose anchor
// offset ran the other way; the magnitude is the number that came from
// `velocity_reference.py` and it has not changed.
// The 2026-09-07 before/after: the old average estimator read
// -12250px/s here, which is the fling Iris reported as too slow.
// 12250px/s here, which is the fling Iris reported as too slow.
assert!(
(velocity + 15_250.0).abs() < 20.0,
"expected ~-15250px/s from velocity_reference.py, got {velocity}"
(velocity - 15_250.0).abs() < 20.0,
"expected ~15250px/s from velocity_reference.py, got {velocity}"
);
// `iris/benches/fling_spline_reference.py`'s own line for this exact
@@ -73,7 +78,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.
@@ -93,7 +98,7 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
.key_at(middle)
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
};
if settled_at.is_none() && !list.is_scrolling() {
if settled_at.is_none() && !(screen.scroll)(&mut h.rsc).is_scrolling() {
settled_at = Some(t);
}
t += PHONE_FRAME_MS;
@@ -137,7 +142,7 @@ fn a_tap_on_a_row_moves_nothing() {
h.replay(&script("tap", include_str!("../touch/tap.touch")));
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
(screen.scroll)(&mut h.rsc).fling_velocity(),
None,
"a tap must not fling"
);
+19 -14
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)> {
@@ -62,10 +62,15 @@ fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32
rows
}
/// Scrolls `amount` (negative walks back through older rows) and runs the
/// frame it asks for, returning the time of the next one.
/// Scrolls `amount` and runs the frame it asks for, returning the time of
/// the next one. **Positive walks back through older rows** -- the
/// finger's own direction, and `Scroll::scroll`'s, which is the one
/// convention a delta has anywhere in iris since the transcript's scroll
/// position moved out of the `LazySpan` and into the `Scroll` around it.
/// It used to be the opposite here, because a `LazySpan`'s anchor offset
/// ran the other way.
fn scrolled(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
(screen.list)(&mut h.rsc).scroll(amount);
(screen.scroll)(&mut h.rsc).scroll(amount);
h.frame(t);
t + PHONE_FRAME_MS
}
@@ -84,7 +89,7 @@ fn the_row_across_the_top_edge_is_drawn() {
// 40px a frame, the shape a finger pan arrives in, through a straddle
// and out the other side of it many times over.
for _ in 0..60 {
t = scrolled(&mut h, &screen, -40.0, t);
t = scrolled(&mut h, &screen, 40.0, t);
let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("something is on screen");
assert!(
@@ -198,7 +203,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
@@ -231,17 +236,17 @@ fn rows_that_have_left_the_viewport_are_not_drawn() {
};
for step in 0..40 {
t = scrolled(&mut h, &screen, -400.0, t);
t = scrolled(&mut h, &screen, 400.0, t);
bounded(&drawn_rows(&h, &screen), "measuring", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t);
t = scrolled(&mut h, &screen, -400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "forward", step);
inside(&rows, "forward", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, -400.0, t);
t = scrolled(&mut h, &screen, 400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "back", step);
inside(&rows, "back", step);
@@ -259,7 +264,7 @@ fn the_row_across_the_bottom_edge_is_drawn() {
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..40 {
t = scrolled(&mut h, &screen, -37.0, t);
t = scrolled(&mut h, &screen, 37.0, t);
let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("something is on screen");
assert!(
@@ -290,10 +295,10 @@ fn scrolling_past_the_first_row_settles_on_it() {
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..60 {
t = scrolled(&mut h, &screen, -100_000.0, t);
t = scrolled(&mut h, &screen, 100_000.0, t);
}
// No settling frame on purpose: the draw that discovers the gap gives
// 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);
@@ -316,7 +321,7 @@ fn scrolling_past_the_last_row_settles_on_it() {
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..20 {
t = scrolled(&mut h, &screen, 100_000.0, t);
t = scrolled(&mut h, &screen, -100_000.0, t);
}
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
+53 -27
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,15 @@ 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>,
/// The scroll area around it, which owns the position, the fling and
/// the pin -- what a caller drives to scroll the transcript, or reads
/// to ask whether it is coasting. The `LazySpan` above lays out; it
/// does not scroll.
pub scroll: WeakWidget<Scroll>,
pub composer: composer::Composer,
selection: Rc<RefCell<Selection>>,
/// How many times [`Self::apply`] has fallen back to a full rebuild --
@@ -83,7 +88,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 +101,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 +223,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 +233,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 +290,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 +300,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 +360,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,20 +376,10 @@ 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.
// 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;
ctx.widget(rsc).scroll(delta);
})
.add(rsc);
// The continuation of a row-started drag once it has committed and
// taken pointer capture on `list`'s own id (`row.rs`'s registration is
// only ever the gesture's first frame) -- registered once here, not
@@ -392,7 +387,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();
@@ -420,21 +415,52 @@ where
.add(rsc);
}
// The scroll area. Everything about *scrolling* is here rather than
// in the list: the wheel, the fling, `amt`, and the clamp against the
// ends. The `LazySpan` inside answers `Widget::scrolls_itself`, so
// this hands it deltas to apply itself (`apply_scroll`) instead of
// sliding it about as a lump -- which it cannot be, since which rows
// exist at all is a function of where it is scrolled to.
//
// Built out rather than through `.scrollable_to_end()`, and this is
// the reason: that helper registers a finger drag on the `Scroll`,
// driving `Scroll`'s own `DragGesture`, and the transcript already has
// an arbiter of its own -- `Selection`, which has to decide between
// panning and selecting text and so cannot let a second `DragGesture`
// see the same frames. `DragGesture`'s doc says it: one gesture, one
// arbiter, each frame delivered exactly once. So the wheel is
// registered here (identical to the helper's) and the finger arrives
// through `Selection::drag`, which hands committed pans and releases
// to this same `Scroll`.
//
// `at_end: true` -- a transcript opens at its newest message and stays
// there while replies arrive. That is the *pin*, and it is a different
// question from `Dir::DOWN` above, which says the oldest message is
// item 0 and sits at the top.
let scroll = Scroll::new(list.add_strong(rsc).any(), Axis::Y, true)
.on(CursorSense::Scroll, |ctx, rsc| {
let delta = ctx.data.scroll_delta.y * 50.0;
ctx.widget(rsc).scroll(delta);
})
.add(rsc);
selection.borrow_mut().set_scroll_area(scroll);
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.
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
// The same clip is what `LazySpan::draw` asserts it has.
let tree = (scroll.width(rest(1)).height(rest(1)).masked(), composer_bar)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
(
TranscriptScreen {
scroll,
tail: RefCell::new(tail),
session_working: std::cell::Cell::new(false),
list,
@@ -591,7 +617,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,
+65 -21
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
@@ -53,6 +53,15 @@ pub struct Selection {
/// in iris's default input layer, with only the pan-vs-select
/// *decision* staying here.
gesture: DragGesture,
/// The scroll area around the transcript's `LazySpan`, which owns the
/// position, the fling and `amt` -- what a committed pan and a release
/// are handed to. Set by `build_tree` the moment it exists, which is
/// after this (rows need a `Selection` to be built, and the `Scroll`
/// needs the rows), hence the `Option` rather than a constructor
/// argument. A `Selection` without one still selects text and still
/// reports taps; it simply cannot pan, which is what the `debug_assert`
/// in `drag` is there to catch in the tests that build one by hand.
scroll: Option<WeakWidget<Scroll>>,
}
impl Default for Selection {
@@ -67,15 +76,24 @@ impl Selection {
rows: BTreeMap::new(),
anchor: None,
gesture: DragGesture::new(),
scroll: None,
}
}
/// Hand this the scroll area that wraps the transcript, once it
/// exists -- see the `scroll` field. Called by `build_tree`; every
/// pan and fling this arbitrates goes there rather than to the
/// `LazySpan`, which no longer owns a position.
pub fn set_scroll_area(&mut self, scroll: WeakWidget<Scroll>) {
self.scroll = Some(scroll);
}
/// 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 +101,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 +255,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 +270,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 +278,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 -- `Scroll::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
@@ -270,10 +288,17 @@ impl Selection {
// finger is"). See `DragArbiter::press_start` for Compose's own
// mechanism. `starts_press` rather than a `PressStart` test of our
// own, so this fires on the recovered-press frames too.
debug_assert!(
self.scroll.is_some(),
"Selection::drag with no scroll area: a committed pan would be silently dropped -- \
call `set_scroll_area` after building the `Scroll` around the transcript"
);
let mut press = PressState::default();
if self.gesture.starts_press(sense) {
press.scrolling = list(ui).is_scrolling();
list(ui).cancel_fling();
press.scrolling = self.scroll.is_some_and(|s| s(ui).is_scrolling());
if let Some(scroll) = self.scroll {
scroll(ui).cancel_fling();
}
}
press.already_selected = self.has_selection(ui);
let outcome = self
@@ -286,7 +311,15 @@ impl Selection {
// to undo either -- the point is that no tap, fling or
// selection follows from a gesture that was never ours.
GestureOutcome::Cancelled | GestureOutcome::Undecided => {}
GestureOutcome::Pan(dy) => list(ui).scroll(-dy),
// `scroll(dy)`, not `scroll(-dy)`: since the position moved
// into `Scroll` there is one convention for a scroll delta in
// the crate -- the finger's -- rather than a `LazySpan` whose
// anchor offset ran the other way while claiming to mirror it.
GestureOutcome::Pan(dy) => {
if let Some(scroll) = self.scroll {
scroll(ui).scroll(dy);
}
}
GestureOutcome::SelectStart => {
if let Some((key, pos_row, size)) = row {
// Grep-able on "iris selection" the way the frame
@@ -310,18 +343,19 @@ impl Selection {
// tap/long-press that never left `Undecided` -- exactly what
// `DragGesture`'s `Some(v)` already encodes.
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
// `Scroll::fling`'s doc. Without it the velocity is
// computed, stored, and never advanced by anything.
//
// Only when `fling` actually took it: below Compose's
// `|v| <= 1.0`, or with no anchor, there is nothing to
// tick, and registering an animation for a widget that is
// not animating asks the next frame to find that out
// (docs/REVIEW-2026-09-07.md's second nit).
if list(ui).is_scrolling() {
let id = list.id();
// `|v| <= 1.0` there is nothing to tick, and registering
// an animation for a widget that is not animating asks the
// next frame to find that out (docs/REVIEW-2026-09-07.md's
// second nit).
if let Some(scroll) = self.scroll
&& scroll(ui).fling(v)
{
let id = scroll.id();
ui.ui_mut().animate(id);
}
}
@@ -427,9 +461,19 @@ 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));
let list_weak = list.weak();
// The scroll area the real screen puts around it: a committed pan
// goes there, and `Selection` asserts it was told about one.
let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(list.any(), Axis::Y, true))
.weak();
let list = list_weak;
let mut sel = Selection::new();
sel.set_scroll_area(scroll);
sel.register((1, 0), field);
assert!(sel.gesture.is_idle());
+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