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