iris: List becomes LazySpan, and takes a Dir

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

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

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

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

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

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

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

No files matched your search

+48
View File
@@ -7,6 +7,54 @@ order and what "done" looks like. Tick and date them in place.
## Fix
- [ ] **In progress (2026-09-08): scrolling moves out of the list.**
Agreed with Iris over the design exchange that followed the overscroll
clamp. The list stays -- a lazy layout is a real thing that `Span`
cannot be -- but everything about *scrolling* leaves it, so that
`.scrollable()` is the one way anything in iris scrolls. Three steps,
each independently verifiable:
1. **Rename and `Dir`.** `List` -> `LazySpan` (it is what `Span` is,
laid out lazily from an anchor; it also stops colliding with
`BlockKind::List` in the markdown code), `ListRow` -> `LazyItem`,
`RowKey` kept, `Axis` -> `Dir`. Direction (which end item 0 sits at)
and pin (which end the view clings to) are **separate**: a
transcript is `Dir::DOWN` with the pin at the end, and conflating
them would stand it on its head.
2. **Delete the physics from `LazySpan`.** Its `Flinger`, `density`,
`Arc<dyn RequestRedraw>`, `tick` and the whole `fling`/
`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity` surface
go; `Scroll` is then the only `Flinger` user and `sense.rs` already
holds the genuinely shared parts. Add to `Widget`:
`fn scrolls_itself(&self) -> bool` (a `&self` capability flag read
through `get_dyn`, which does **not** mark dirty) and
`fn apply_scroll(&mut self, delta: &mut f32)` (takes what it can,
leaves the rest).
3. **`Scroll` wraps it**, owning `amt` and the pin: measure the child,
`apply_scroll`, place it again -- the same measure-then-place idiom
`Scroll::draw` and `LazySpan::place` already use. The measuring call
is free in the common case (unchanged region, not dirty, so
`draw_inner` skips it) and really walks exactly when the content
changed, which is when its walls need re-reading. Reaching the child
through `get_dyn_mut` marks it dirty by itself, so the second call
really draws -- no `Painter::draw_again` and nothing marked by hand.
`transcript-ui`'s `Selection` retargets to the `Scroll`.
Decisions taken along the way, with their reasons, so they are not
re-litigated: the **height cache stays in the container** (Iris:
widgets may render to two places at once, so a size keyed by
`WidgetId` would break; and the framework's own `ActiveData::size` is
freed by `remove_rec` the moment a row is virtualised away, which is
exactly when it is needed). **No `redraw_on_move` flag** -- the child
returning from `apply_scroll` is already the signal. **`amt` for a lazy
child is accumulated actual movement, not a distance from the top of
the content**, since paging rows in above shifts the origin; that is
honest for every current use and must be written at the field so
nobody builds a scrollbar on it.
Step 1 is done. Steps 2 and 3 are not.
- [x] **`List::clamp_to_content` still corrects on the next frame
(2026-09-08).** Iris's rule, stated while the composer's caret was
being fixed: "nothing in the framework should ever self heal because
+3 -3
View File
@@ -883,7 +883,7 @@ set once from `DisplayMetrics.density` in `android::view::new_peer`; the
desktop backend has no per-monitor density wired up yet and stays at
`1.0`. Every layout call site that used to call `.apply_rest()`/
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `List::place`, and
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `LazySpan::place`, and
`UiRenderState::reposition` itself). This also meant the Android
boundary's global logical-space stopgap could come out entirely: window
size, touch coordinates and insets are physical pixels again, matching
@@ -1095,7 +1095,7 @@ cost of a tool group's 4dp inset).
**A widget offered a box it does not fit is drawn again at the box its
own reported size implies, in the same frame.** Not next frame. The
temptation to defer is real — `List::place` offers a row its *cached*
temptation to defer is real — `LazySpan::place` offers a row its *cached*
height precisely so that an unchanged row hits `draw_inner`'s cheap
skip-or-move path, and `Scroll` sizes its child region from last frame's
content length for the same reason. But a `Rect` fills whatever region it
@@ -1112,5 +1112,5 @@ safe to apply everywhere: the second draw happens only on the frame a
widget's own size actually changes, which is a frame that was already
redrawing it. A widget whose reported size is a function of the box it
was *offered* would disagree every frame and redraw every frame — which
is why `List` requires content-sized rows, and has since long before
is why `LazySpan` requires content-sized rows, and has since long before
this.
+2 -2
View File
@@ -48,7 +48,7 @@ Iris's two screenshots of the top edge -- rows drawn over the header in
one, a blank band in the other -- were **three** faults, and the rule
that fixes all three is the one the IRIS_TODO entry asked for: *a row is
drawn if any part of it overlaps the list's own box, and nothing outside
that box reaches the screen* (`List::intersects_viewport`). Neither
that box reaches the screen* (`LazySpan::intersects_viewport`). Neither
suspected cause was right, which is worth reading before trusting the
next suspicion in this file: there was no visible-range test comparing a
row's top against the viewport's, and `03c6be8`'s header duplicate is
@@ -654,7 +654,7 @@ a change landed the way it did.
`fonts.xml` monospace declaration against fontique's actually-scanned
families, Android-only, verified `mono=Some("Droid Sans Mono")` on this
checkout's emulator.
- [x] Scroll clamped at both ends (e922b73, `List`'s overscroll clamp)
- [x] Scroll clamped at both ends (e922b73, `LazySpan`'s overscroll clamp)
and Compose's velocity estimator (docs/IRIS_TODO.md, 2026-09-07
later). Ticked 2026-09-08 against those entries, which were already
`[x]` while this box was not. Two things this box's own wording had