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:
1 parent
76fcbdccb9
commit
8e5928cc6a
30 files changed
+526
-329
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user