Compare commits
2
Commits
76fcbdccb9
...
b7474f61b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7474f61b0 | ||
|
|
8e5928cc6a |
No files matched your search
@@ -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
|
for iris API changes); this file is only the summary. Newest first. Items
|
||||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
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)
|
## 2026-09-08 (later still: the list's overscroll clamp, in frame)
|
||||||
|
|
||||||
Finishes the item the previous entry deferred. IRIS.md has the account.
|
Finishes the item the previous entry deferred. IRIS.md has the account.
|
||||||
|
|||||||
+105
@@ -12,6 +12,111 @@ things still stay out.
|
|||||||
An entry gives the date, what changed, why, and a short before/after where
|
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.
|
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
|
## 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
|
The last place in iris that corrected itself on a later frame. `List`'s
|
||||||
|
|||||||
@@ -7,6 +7,92 @@ order and what "done" looks like. Tick and date them in place.
|
|||||||
|
|
||||||
## Fix
|
## 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
|
- [x] **`List::clamp_to_content` still corrects on the next frame
|
||||||
(2026-09-08).** Iris's rule, stated while the composer's caret was
|
(2026-09-08).** Iris's rule, stated while the composer's caret was
|
||||||
being fixed: "nothing in the framework should ever self heal because
|
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
|
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()`/
|
`1.0`. Every layout call site that used to call `.apply_rest()`/
|
||||||
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
|
`.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
|
`UiRenderState::reposition` itself). This also meant the Android
|
||||||
boundary's global logical-space stopgap could come out entirely: window
|
boundary's global logical-space stopgap could come out entirely: window
|
||||||
size, touch coordinates and insets are physical pixels again, matching
|
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
|
**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
|
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
|
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
|
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
|
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
|
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
|
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
|
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.
|
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
|
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
|
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
|
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
|
suspected cause was right, which is worth reading before trusting the
|
||||||
next suspicion in this file: there was no visible-range test comparing a
|
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
|
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
|
`fonts.xml` monospace declaration against fontique's actually-scanned
|
||||||
families, Android-only, verified `mono=Some("Droid Sans Mono")` on this
|
families, Android-only, verified `mono=Some("Droid Sans Mono")` on this
|
||||||
checkout's emulator.
|
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
|
and Compose's velocity estimator (docs/IRIS_TODO.md, 2026-09-07
|
||||||
later). Ticked 2026-09-08 against those entries, which were already
|
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
|
`[x]` while this box was not. Two things this box's own wording had
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const STREAM_SECONDS: u64 = 20;
|
|||||||
/// swipe with these any more.
|
/// swipe with these any more.
|
||||||
const LEGACY_CYCLES: usize = 6;
|
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
|
/// 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
|
/// 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.
|
/// 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
|
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
|
||||||
/// from it (toward older messages) through `List::fling`, then
|
/// from it (toward older messages) through `Scroll::fling`, then
|
||||||
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
|
/// `FLING_COUNT` back. Outward is *positive* in `Scroll::scroll`'s
|
||||||
/// convention (`List::scroll`'s own doc: positive moves *later* content
|
/// convention, which is the finger's: a finger dragged down the screen
|
||||||
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
|
/// brings earlier content into view. It was negative here until
|
||||||
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
|
/// 2026-09-08, when the transcript's scroll position moved out of the
|
||||||
/// the other way around; the two apps' *travel* is still directly
|
/// `LazySpan` -- whose anchor offset ran the other way -- and into the
|
||||||
/// comparable because both report it as a row index + pixel offset, not a
|
/// `Scroll` around it. The two apps' *travel* is directly comparable
|
||||||
/// signed distance.
|
/// 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(
|
async fn run_fling_phase(
|
||||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
redraw: &Arc<dyn RequestRedraw>,
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
@@ -937,8 +938,8 @@ async fn run_fling_phase(
|
|||||||
for _ in 0..FLING_COUNT {
|
for _ in 0..FLING_COUNT {
|
||||||
ctx.update(|state: &mut BenchClient, rsc| {
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
if let Some(screen) = &state.screen {
|
if let Some(screen) = &state.screen {
|
||||||
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
(screen.scroll)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||||
animate_list(screen.list, rsc);
|
animate_scroll(screen.scroll, rsc);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
@@ -950,8 +951,8 @@ async fn run_fling_phase(
|
|||||||
for _ in 0..FLING_COUNT {
|
for _ in 0..FLING_COUNT {
|
||||||
ctx.update(|state: &mut BenchClient, rsc| {
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
if let Some(screen) = &state.screen {
|
if let Some(screen) = &state.screen {
|
||||||
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
(screen.scroll)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||||
animate_list(screen.list, rsc);
|
animate_scroll(screen.scroll, rsc);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
@@ -977,11 +978,11 @@ async fn read_anchor_position(
|
|||||||
.await
|
.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) --
|
/// release does (`transcript_ui::Selection::drag`'s `Released` arm) --
|
||||||
/// `List::fling` sets a velocity and drives nothing by itself.
|
/// `Scroll::fling` sets a velocity and drives nothing by itself.
|
||||||
fn animate_list(list: iris::prelude::WeakWidget<iris::prelude::List>, rsc: &mut Rsc) {
|
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::Scroll>, rsc: &mut Rsc) {
|
||||||
let id = list.id();
|
let id = scroll.id();
|
||||||
rsc.ui_mut().animate(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.
|
/// spline-decided `duration()` already caps how long it can run.
|
||||||
///
|
///
|
||||||
/// **It observes; it does not drive.** Until 2026-09-08 this loop called
|
/// **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
|
/// 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
|
/// redrew the list at a position it had already drawn, and the benchmark
|
||||||
/// looked distinctly less smooth than the same list under her finger.
|
/// 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();
|
let started = Instant::now();
|
||||||
while started.elapsed() < cap {
|
while started.elapsed() < cap {
|
||||||
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
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,
|
None => false,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -14,11 +14,11 @@
|
|||||||
//! -- and it avoids a new dependency this crate does not otherwise need.
|
//! -- 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.
|
//! 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
|
//! `Scroll` over a `Span` of pre-built rows.** Earlier versions of this
|
||||||
//! file built their own giant `Span` and wrapped it in `Scroll`, which
|
//! 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
|
//! 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
|
//! still needs every row's *widget* built up front by the caller (its
|
||||||
//! module doc explains why: it only ever sees `&dyn Widget` through
|
//! module doc explains why: it only ever sees `&dyn Widget` through
|
||||||
//! `Painter`, so it cannot construct a row lazily on its own) -- what
|
//! `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
|
//! *drawn*, which is what the draw/rewrite/move counters below are
|
||||||
//! measuring, not construction time.
|
//! 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):
|
//! IRIS_TODO.md's "Benchmarks" wording):
|
||||||
//!
|
//!
|
||||||
//! - (a) first-frame cost of a message list of N wrapped-text rows, some
|
//! - (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
|
//! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md
|
||||||
//! section 8 defines.
|
//! section 8 defines.
|
||||||
//! - (d) insert-above-anchor: paging older history onto the front of an
|
//! - (d) insert-above-anchor: paging older history onto the front of an
|
||||||
//! already-scrolled list. `List::push_front` is an O(1) index update
|
//! already-scrolled list. `LazySpan::push_front` is an O(1) index update
|
||||||
//! (list.rs's module doc); this measures that none of the rows already
|
//! (lazy_span.rs's module doc); this measures that none of the rows already
|
||||||
//! on screen are touched by it.
|
//! on screen are touched by it.
|
||||||
//! - (e) expand-a-row-holding-its-edge: growing one row's height with a
|
//! - (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
|
//! only the rows on the far side of it, never redraw the ones already
|
||||||
//! correctly placed.
|
//! 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
|
/// carrying an image (0 disables images entirely). Returns the list widget
|
||||||
/// (weak, so the caller can drive it) and the erased root to render.
|
/// (weak, so the caller can drive it) and the erased root to render.
|
||||||
fn build_message_list(
|
fn build_message_list(
|
||||||
rsc: &mut BenchRsc,
|
rsc: &mut BenchRsc,
|
||||||
n: usize,
|
n: usize,
|
||||||
image_every: usize,
|
image_every: usize,
|
||||||
) -> (WeakWidget<List>, StrongWidget) {
|
) -> (WeakWidget<LazySpan>, WeakWidget<Scroll>, StrongWidget) {
|
||||||
let mut list = List::new(Axis::Y);
|
let mut list = LazySpan::new(Dir::DOWN, true);
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let row = build_row(rsc, i, image_every);
|
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);
|
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) {
|
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 {
|
let mut rsc = BenchRsc {
|
||||||
ui: UiData::default(),
|
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();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((1080.0, 2000.0));
|
render.resize((1080.0, 2000.0));
|
||||||
|
|
||||||
@@ -160,11 +168,11 @@ fn bench_scroll(n: usize, ticks: usize) {
|
|||||||
let mut rsc = BenchRsc {
|
let mut rsc = BenchRsc {
|
||||||
ui: UiData::default(),
|
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();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((1080.0, 2000.0));
|
render.resize((1080.0, 2000.0));
|
||||||
render.update(&root, &mut rsc);
|
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.update(&root, &mut rsc);
|
||||||
render.take_counters();
|
render.take_counters();
|
||||||
|
|
||||||
@@ -173,7 +181,7 @@ fn bench_scroll(n: usize, ticks: usize) {
|
|||||||
let mut total_rewrites = 0u64;
|
let mut total_rewrites = 0u64;
|
||||||
let mut total_moves = 0u64;
|
let mut total_moves = 0u64;
|
||||||
for _ in 0..ticks {
|
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();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
@@ -207,7 +215,7 @@ fn bench_input_grows(n: usize, lines: usize) {
|
|||||||
let mut rsc = BenchRsc {
|
let mut rsc = BenchRsc {
|
||||||
ui: UiData::default(),
|
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 {
|
let list_area = rsc.ui.widgets.add_strong(Sized {
|
||||||
inner: list_root,
|
inner: list_root,
|
||||||
x: None,
|
x: None,
|
||||||
@@ -231,7 +239,7 @@ fn bench_input_grows(n: usize, lines: usize) {
|
|||||||
let mut render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((1080.0, 2000.0));
|
render.resize((1080.0, 2000.0));
|
||||||
render.update(&root, &mut rsc);
|
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.update(&root, &mut rsc);
|
||||||
render.take_counters();
|
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
|
/// 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
|
/// bottom, so a row prepended above it is genuinely "inserted above the
|
||||||
/// anchor" rather than merely far off-screen at the far end. Each
|
/// 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
|
/// index, bumped by one) and, since the prepended rows never enter the
|
||||||
/// viewport, none of them should cost a draw either.
|
/// viewport, none of them should cost a draw either.
|
||||||
fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
||||||
let mut rsc = BenchRsc {
|
let mut rsc = BenchRsc {
|
||||||
ui: UiData::default(),
|
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();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((1080.0, 2000.0));
|
render.resize((1080.0, 2000.0));
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
@@ -298,7 +306,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
|||||||
.widgets
|
.widgets
|
||||||
.get_mut(&list)
|
.get_mut(&list)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.push_front(ListRow::new(i as u64, row));
|
.push_front(LazyItem::new(i as u64, row));
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
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
|
/// (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
|
/// 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
|
/// 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
|
/// 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
|
/// (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 {
|
let mut rsc = BenchRsc {
|
||||||
ui: UiData::default(),
|
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
|
// 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 --
|
// under the list's default bottom-anchored placement, for every N --
|
||||||
// no scrolling needed to bring it into view before measuring.
|
// 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)),
|
y: Some(abs(40.0)),
|
||||||
});
|
});
|
||||||
growable = Some(sized.weak());
|
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 {
|
} else {
|
||||||
let row = build_row(&mut rsc, i, 20);
|
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);
|
let list = rsc.ui.widgets.add_strong(list);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ pub struct ActiveData {
|
|||||||
/// the same answer rather than drifting), and both then rewrite the
|
/// 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
|
/// 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
|
/// 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
|
/// row's blocks wrap. Reset to zero on a real redraw, with
|
||||||
/// `move_applied` and the slot itself.
|
/// `move_applied` and the slot itself.
|
||||||
pub repositioned: Vec2,
|
pub repositioned: Vec2,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pub struct UiData {
|
|||||||
/// never goes stale -- see LAYOUT.md section 2.
|
/// never goes stale -- see LAYOUT.md section 2.
|
||||||
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
||||||
/// Every widget whose [`crate::Widget::tick`] should run before the
|
/// 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::animate`] when the animation starts and removed by
|
||||||
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
|
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
|
||||||
/// a stopped animation costs nothing and a dropped widget cannot be
|
/// a stopped animation costs nothing and a dropped widget cannot be
|
||||||
|
|||||||
@@ -166,10 +166,47 @@ impl<'a> Painter<'a> {
|
|||||||
self.mask = self.own_mask;
|
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
|
/// Whether anything is clipping what this widget draws -- its own
|
||||||
/// [`Self::set_mask`], or one an ancestor set that it inherited. What
|
/// [`Self::set_mask`], or one an ancestor set that it inherited. What
|
||||||
/// a widget whose contents may legitimately extend past its own box
|
/// 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.
|
/// full) asserts before relying on being cut off there.
|
||||||
pub fn is_masked(&self) -> bool {
|
pub fn is_masked(&self) -> bool {
|
||||||
self.mask != MaskIdx::NONE
|
self.mask != MaskIdx::NONE
|
||||||
|
|||||||
@@ -417,13 +417,13 @@ impl UiRenderState {
|
|||||||
// Consumed here, not merely read: this call *is* the redraw the mark
|
// 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
|
// asked for, and leaving the mark set is what stranded a widget's
|
||||||
// primitives. `Painter::draw_twice` calls this twice for the same id
|
// 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
|
// call the still-set mark took the whole `if let` below -- including
|
||||||
// the `remove` that frees the first draw's primitives -- out of play,
|
// the `remove` that frees the first draw's primitives -- out of play,
|
||||||
// so `active.insert` at the end overwrote the only handles that could
|
// 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,
|
// 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
|
// 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 doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
|
||||||
// The same shape reaches any dirty widget an ancestor redraws first.
|
// The same shape reaches any dirty widget an ancestor redraws first.
|
||||||
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
|
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
|
||||||
@@ -700,7 +700,7 @@ impl UiRenderState {
|
|||||||
active.textures.clear();
|
active.textures.clear();
|
||||||
rsc.ui_mut().textures.free();
|
rsc.ui_mut().textures.free();
|
||||||
if undraw {
|
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
|
// virtualisation retiring a row, a rebuild) must not leave
|
||||||
// the pointer captured by an id nothing will ever draw
|
// the pointer captured by an id nothing will ever draw
|
||||||
// again. That path out is the sensor pass's, not this
|
// again. That path out is the sensor pass's, not this
|
||||||
|
|||||||
@@ -60,6 +60,72 @@ pub trait Widget: Any {
|
|||||||
fn tick(&mut self, now: std::time::Instant) -> bool {
|
fn tick(&mut self, now: std::time::Instant) -> bool {
|
||||||
false
|
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 () {
|
impl Widget for () {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that
|
//! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that
|
||||||
//! standalone-image bind-group *creation* -- a real `wgpu` resource, unlike
|
//! 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`,
|
//! image has loaded. This needs an actual `wgpu` device (`GpuTextures`,
|
||||||
//! `UiRenderNode`), so unlike the rest of the suite it cannot run as a
|
//! `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
|
//! plain binary; run it through `iris/run-headless.sh bench_images`, which
|
||||||
|
|||||||
@@ -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
|
//! wrapped text, one in twelve carrying a small image, scrollable with the
|
||||||
//! mouse wheel. Run headless with `iris/run-headless.sh message_list --shot
|
//! 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
|
//! /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
|
//! different number of lines -- exactly the "variable-height rows" I3
|
||||||
//! asks for, and the thing a virtualised list gets wrong first if it is
|
//! 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
|
//! 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
|
//! is also what found `LazySpan::place`'s oversized-background bug (see
|
||||||
//! list.rs's module doc and its `a_fill_shaped_background_is_not_left_
|
//! 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
|
//! oversized` test) -- a plain unit test could have (and now does) catch
|
||||||
//! it directly, but it was this screenshot rendering as a single blank
|
//! it directly, but it was this screenshot rendering as a single blank
|
||||||
//! tinted rectangle that pointed at it first.
|
//! tinted rectangle that pointed at it first.
|
||||||
@@ -98,17 +98,18 @@ impl DefaultAppState for State {
|
|||||||
rsc: &mut DefaultRsc<Self>,
|
rsc: &mut DefaultRsc<Self>,
|
||||||
_: Proxy<Self::Event>,
|
_: Proxy<Self::Event>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut list = List::new(Axis::Y);
|
let mut list = LazySpan::new(Dir::DOWN, true);
|
||||||
for i in 0..ROWS {
|
for i in 0..ROWS {
|
||||||
let row = build_row(rsc, i);
|
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
|
let root = list
|
||||||
.on(CursorSense::Scroll, |ctx, rsc| {
|
.scrollable_to_end(Axis::Y)
|
||||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
|
||||||
ctx.widget(rsc).scroll(delta);
|
|
||||||
})
|
|
||||||
.masked()
|
.masked()
|
||||||
.background(rect(Color::WHITE))
|
.background(rect(Color::WHITE))
|
||||||
.add_strong(rsc);
|
.add_strong(rsc);
|
||||||
|
|||||||
@@ -476,7 +476,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
||||||
// what this does and does not measure.
|
// what this does and does not measure.
|
||||||
let frame_start = Instant::now();
|
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
|
// fling -- is advanced here, before the draw, and asks for the
|
||||||
// next frame at the end of this one. See
|
// next frame at the end of this one. See
|
||||||
// `UiData::tick_animations`; `default/mod.rs`'s
|
// `UiData::tick_animations`; `default/mod.rs`'s
|
||||||
|
|||||||
+4
-4
@@ -141,8 +141,8 @@ impl TouchScript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Counts the frames something asked for without drawing any -- the
|
/// Counts the frames something asked for without drawing any -- the
|
||||||
/// harness's `RequestRedraw`. A `List` coasting through a fling asks for
|
/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
|
||||||
/// the next frame through this (`List::set_redraw_handle`), so a test can
|
/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can
|
||||||
/// tell "nothing moved" from "nothing was even asked to move".
|
/// tell "nothing moved" from "nothing was even asked to move".
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct RedrawCounter(AtomicUsize);
|
pub struct RedrawCounter(AtomicUsize);
|
||||||
@@ -335,7 +335,7 @@ impl Harness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `Instant` this harness means by `t_ms`. Public because a
|
/// 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.
|
/// to date those calls on the same clock the touch samples use.
|
||||||
pub fn at(&self, t_ms: u64) -> Instant {
|
pub fn at(&self, t_ms: u64) -> Instant {
|
||||||
self.base + Duration::from_millis(t_ms)
|
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
|
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||||
/// fling needs, since it moves only while something ticks it
|
/// 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 {
|
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");
|
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
||||||
let mut t = from_ms;
|
let mut t = from_ms;
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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
|
/// 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").
|
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
|
||||||
struct MoveThenPlace {
|
struct MoveThenPlace {
|
||||||
@@ -1041,8 +1041,8 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
|
|||||||
dir: Dir::DOWN,
|
dir: Dir::DOWN,
|
||||||
gap: Len::ZERO,
|
gap: Len::ZERO,
|
||||||
});
|
});
|
||||||
let mut list = List::new(Axis::Y);
|
let mut list = LazySpan::new(Dir::DOWN, true);
|
||||||
list.push_back(ListRow::new(0, outer.any()));
|
list.push_back(LazyItem::new(0, outer.any()));
|
||||||
let list = rsc.ui.widgets.add_strong(list);
|
let list = rsc.ui.widgets.add_strong(list);
|
||||||
let root = rsc
|
let root = rsc
|
||||||
.ui
|
.ui
|
||||||
|
|||||||
+20
-20
@@ -334,7 +334,7 @@ pub struct PointerRequests {
|
|||||||
|
|
||||||
impl PointerRequests {
|
impl PointerRequests {
|
||||||
/// Give `id` exclusive pointer input from the next dispatch on. `id`
|
/// 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
|
/// not one of its virtualised rows, which can be retired mid-drag as
|
||||||
/// content scrolls. Overwrites any previous capture: a gesture that
|
/// content scrolls. Overwrites any previous capture: a gesture that
|
||||||
/// starts a new one has already decided the old one is over, and the
|
/// starts a new one has already decided the old one is over, and the
|
||||||
@@ -437,7 +437,7 @@ impl SensorUi for UiRenderState {
|
|||||||
// capture.
|
// capture.
|
||||||
if let Some(id) = requests.holder() {
|
if let Some(id) = requests.holder() {
|
||||||
// The capture's path out for a widget that stopped being
|
// 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
|
// a rebuilt subtree. Nothing can be delivered to an id with no
|
||||||
// region, so the gesture ends here for everyone.
|
// region, so the gesture ends here for everyone.
|
||||||
let Some(shape) = self.resolved_region(&id, rsc) else {
|
let Some(shape) = self.resolved_region(&id, rsc) else {
|
||||||
@@ -924,7 +924,7 @@ pub struct PressState {
|
|||||||
/// instead of waiting for a long-press.
|
/// instead of waiting for a long-press.
|
||||||
pub already_selected: bool,
|
pub already_selected: bool,
|
||||||
/// Whether the target was already moving under its own momentum (a
|
/// 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,
|
/// [`DragArbiter::press_start`]: a press on moving content is a catch,
|
||||||
/// and catches skip the slop entirely.
|
/// and catches skip the slop entirely.
|
||||||
pub scrolling: bool,
|
pub scrolling: bool,
|
||||||
@@ -1088,7 +1088,7 @@ impl DragArbiter {
|
|||||||
// in full on this one frame is a visible jump the
|
// in full on this one frame is a visible jump the
|
||||||
// instant `DRAG_SLOP` is crossed (IRIS_TODO.md's
|
// instant `DRAG_SLOP` is crossed (IRIS_TODO.md's
|
||||||
// "scrolling down sometimes jitters the text," root-
|
// "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
|
// a synthetic monotonic drag: the offset held flat for
|
||||||
// every `Undecided` frame, then stepped by several
|
// every `Undecided` frame, then stepped by several
|
||||||
// frames' worth of motion at once on the frame slop
|
// 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
|
/// Whether the arbiter's current gesture (if any) has committed to
|
||||||
/// panning -- what a caller checks at release time to decide whether
|
/// 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
|
/// 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
|
/// a pan, never a text selection that happened to end with the finger
|
||||||
/// still moving.
|
/// still moving.
|
||||||
@@ -1158,7 +1158,7 @@ impl DragArbiter {
|
|||||||
pub enum GestureOutcome {
|
pub enum GestureOutcome {
|
||||||
Undecided,
|
Undecided,
|
||||||
/// Same units and sign as [`DragOutcome::Pan`] -- the caller's own
|
/// 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),
|
Pan(f32),
|
||||||
SelectStart,
|
SelectStart,
|
||||||
SelectExtend,
|
SelectExtend,
|
||||||
@@ -1173,7 +1173,7 @@ pub enum GestureOutcome {
|
|||||||
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
|
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
|
||||||
/// `Some(velocity)` only if the gesture had committed to panning
|
/// `Some(velocity)` only if the gesture had committed to panning
|
||||||
/// (never a tap, a long-press selection, or one still `Undecided`);
|
/// (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`.
|
/// whatever sign flip it already applies to `Pan`.
|
||||||
Released(Option<f32>),
|
Released(Option<f32>),
|
||||||
/// Another widget took the pointer (`CursorSense::Cancel`), so this
|
/// 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
|
/// Feed one frame of a gesture through. `id` is the widget iris should
|
||||||
/// give exclusive pointer input to once this gesture commits to
|
/// give exclusive pointer input to once this gesture commits to
|
||||||
/// panning or selecting -- a stable widget that outlives the gesture
|
/// 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
|
/// retired mid-drag as content scrolls). `pointer` is `CursorData`'s
|
||||||
/// own field, already in hand at every call site. `press` only matters
|
/// own field, already in hand at every call site. `press` only matters
|
||||||
/// on the frames [`Self::starts_press`] answers true for -- see
|
/// on the frames [`Self::starts_press`] answers true for -- see
|
||||||
@@ -1478,9 +1478,9 @@ const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1;
|
|||||||
/// (`DragGestureNode.sendDragStopped` passes
|
/// (`DragGestureNode.sendDragStopped` passes
|
||||||
/// `LocalViewConfiguration.maximumFlingVelocity` into
|
/// `LocalViewConfiguration.maximumFlingVelocity` into
|
||||||
/// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it
|
/// `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
|
/// 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;
|
pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0;
|
||||||
|
|
||||||
/// Estimates a drag's speed along one axis the way Compose's touch
|
/// 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
|
/// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then
|
||||||
/// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum
|
/// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum
|
||||||
/// itself is applied by the caller that knows the density
|
/// 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
|
/// `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
|
/// 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
|
// `calculateVelocity(maximumVelocity)`'s first branch, kept as the
|
||||||
// outer guard even though the degenerate case is now detected
|
// outer guard even though the degenerate case is now detected
|
||||||
// rather than clamped: a fit can still overflow on inputs nothing
|
// 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 }
|
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
|
/// small, which put an `ln` of a 56x-too-large ratio through
|
||||||
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
|
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
|
||||||
/// instead of 1.6. Nothing could see it while a finger fling never
|
/// 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.
|
/// be fixed before either was visible.
|
||||||
const FLING_TUNING: f32 = 0.84;
|
const FLING_TUNING: f32 = 0.84;
|
||||||
fn deceleration_rate() -> f32 {
|
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
|
/// `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
|
/// wrong density changes how long a fling lasts exponentially rather than
|
||||||
/// scaling it. An earlier version of this comment claimed the opposite and
|
/// 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
|
/// `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). `List` reads
|
/// one-second flick a 45-second coast (measured 2026-09-07). `LazySpan` reads
|
||||||
/// its density from the painter now.
|
/// its density from the painter now.
|
||||||
pub struct FlingCalculator {
|
pub struct FlingCalculator {
|
||||||
physical_coefficient: f32,
|
physical_coefficient: f32,
|
||||||
@@ -1919,7 +1919,7 @@ impl FlingCalculator {
|
|||||||
/// Total signed distance the fling travels before settling, in the
|
/// Total signed distance the fling travels before settling, in the
|
||||||
/// same pixel units `velocity` was given in.
|
/// same pixel units `velocity` was given in.
|
||||||
pub fn distance(&self, velocity: f32) -> f32 {
|
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
|
// here silently produces a NaN distance rather than surfacing the
|
||||||
// bug that produced it (docs/REVIEW-2026-09-06.md finding 3).
|
// bug that produced it (docs/REVIEW-2026-09-06.md finding 3).
|
||||||
debug_assert!(velocity.is_finite());
|
debug_assert!(velocity.is_finite());
|
||||||
@@ -1946,7 +1946,7 @@ impl FlingCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The signed distance covered by `elapsed` into a fling of this
|
/// 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
|
/// find how far to have scrolled by now. Clamped to the full
|
||||||
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
|
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
|
||||||
/// not special-case "past the end."
|
/// not special-case "past the end."
|
||||||
@@ -1964,7 +1964,7 @@ impl FlingCalculator {
|
|||||||
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
|
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
|
||||||
/// to zero at `duration()`, which is the whole difference between a
|
/// to zero at `duration()`, which is the whole difference between a
|
||||||
/// fling and a constant-speed slide, so it is what
|
/// 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
|
/// a shrinking number is the evidence that the curve is being followed
|
||||||
/// at all.
|
/// at all.
|
||||||
pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
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
|
/// 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
|
/// 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
|
/// `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.
|
/// 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
|
/// 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
|
/// Summing the spline's own per-frame position deltas across the
|
||||||
/// whole fling has to land within 1% of the closed-form `distance()`
|
/// 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,
|
/// of `position_at` actually add up to the total the fling promised,
|
||||||
/// not merely that the two formulas look plausible independently.
|
/// not merely that the two formulas look plausible independently.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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
|
// 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
|
// press, and a `Stack`'s siblings would be on separate layers where
|
||||||
// only the topmost is dispatched to at all.
|
// only the topmost is dispatched to at all.
|
||||||
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
mod image;
|
mod image;
|
||||||
mod list;
|
|
||||||
mod mask;
|
mod mask;
|
||||||
mod position;
|
mod position;
|
||||||
mod ptr;
|
mod ptr;
|
||||||
@@ -8,7 +7,6 @@ mod text;
|
|||||||
mod trait_fns;
|
mod trait_fns;
|
||||||
|
|
||||||
pub use image::*;
|
pub use image::*;
|
||||||
pub use list::*;
|
|
||||||
pub use mask::*;
|
pub use mask::*;
|
||||||
pub use position::*;
|
pub use position::*;
|
||||||
pub use ptr::*;
|
pub use ptr::*;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
|||||||
mod align;
|
mod align;
|
||||||
mod layer;
|
mod layer;
|
||||||
|
mod lazy_span;
|
||||||
mod max_size;
|
mod max_size;
|
||||||
mod offset;
|
mod offset;
|
||||||
mod pad;
|
mod pad;
|
||||||
@@ -10,6 +11,7 @@ mod stack;
|
|||||||
|
|
||||||
pub use align::*;
|
pub use align::*;
|
||||||
pub use layer::*;
|
pub use layer::*;
|
||||||
|
pub use lazy_span::*;
|
||||||
pub use max_size::*;
|
pub use max_size::*;
|
||||||
pub use offset::*;
|
pub use offset::*;
|
||||||
pub use pad::*;
|
pub use pad::*;
|
||||||
|
|||||||
@@ -20,14 +20,35 @@ pub struct Scroll {
|
|||||||
/// therefore opened at the end of its longest line, mid-word
|
/// therefore opened at the end of its longest line, mid-word
|
||||||
/// (`iris/run-headless.sh phone`, 2026-09-08).
|
/// (`iris/run-headless.sh phone`, 2026-09-08).
|
||||||
content_len: Option<f32>,
|
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
|
/// (`transcript-ui::Selection::drag`) rather than a second copy of its
|
||||||
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
|
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
|
||||||
/// `sense.rs` and only what a committed pan *means* is decided here.
|
/// `sense.rs` and only what a committed pan *means* is decided here.
|
||||||
/// See [`Self::drag`].
|
/// See [`Self::drag`].
|
||||||
gesture: DragGesture,
|
gesture: DragGesture,
|
||||||
/// The momentum a release leaves behind, the same [`Flinger`] a
|
/// 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
|
/// with nothing to opt into -- Compose's `scrollable` attaches
|
||||||
/// `ScrollableDefaults.flingBehavior()` on every axis it is given,
|
/// `ScrollableDefaults.flingBehavior()` on every axis it is given,
|
||||||
/// and Iris asked for the same (2026-09-08: "flinging should be
|
/// 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);
|
let delta = self.fling.tick(now);
|
||||||
self.scroll(delta);
|
self.scroll(delta);
|
||||||
// A fling must not keep spending its distance on content that is
|
// A fling must not keep spending its distance on content that is
|
||||||
// not there. Unlike `List`, this widget knows exactly where its
|
// not there. With an ordinary child this widget knows exactly
|
||||||
// content ends -- `update_amt` has just clamped `amt` into it --
|
// where the content ends -- `update_amt` has just clamped `amt`
|
||||||
// so the wall is read after the move rather than from what the
|
// into it -- so the wall is read straight off `amt`. With a child
|
||||||
// last draw found.
|
// that positions itself there is no content length to read, and
|
||||||
if self.amt <= 0.0 || self.amt >= self.scroll_range() {
|
// 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.stop();
|
||||||
}
|
}
|
||||||
self.fling.is_flinging()
|
self.fling.is_flinging()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
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
|
// Every length here is resolved against the box this widget was
|
||||||
// **offered** (`px_size`), never `output_size`: a `Scroll` is
|
// **offered** (`px_size`), never `output_size`: a `Scroll` is
|
||||||
// routinely smaller than the window -- the composer's field is
|
// routinely smaller than the window -- the composer's field is
|
||||||
// capped at six lines by a `MaxSize` around it -- and measuring
|
// capped at six lines by a `MaxSize` around it -- and measuring
|
||||||
// the window instead would make the pan range, and so where the
|
// the window instead would make the pan range, and so where the
|
||||||
// content sits, a function of the screen rather than of the box.
|
// 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 axis = self.axis;
|
||||||
let container_len = painter.px_size().axis(axis);
|
let container_len = painter.px_size().axis(axis);
|
||||||
self.container_len = container_len;
|
self.container_len = container_len;
|
||||||
@@ -94,45 +105,56 @@ impl Widget for Scroll {
|
|||||||
// density, and `draw` is where this widget meets the only thing
|
// density, and `draw` is where this widget meets the only thing
|
||||||
// that knows it.
|
// that knows it.
|
||||||
self.density = painter.density();
|
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
|
impl Scroll {
|
||||||
// container's -- that is what a scroll viewport means, and it is
|
/// The ordinary case: the child is a fixed lump this widget slides
|
||||||
// why the offset is the only thing that moves. A length is not
|
/// about, and its position is `amt`.
|
||||||
// knowable without drawing (LAYOUT.md section 5), so this draws
|
///
|
||||||
// the child once to measure it and once to place it, the same
|
/// **The child is drawn twice, and only the second decides anything.**
|
||||||
// measure-then-place idiom `Span::draw` and `List::place`
|
/// The first is handed last frame's length as a *hint* -- nothing
|
||||||
// already use. Written out rather than through
|
/// about where the child ends up depends on it, and it exists only so
|
||||||
// `Painter::draw_twice`, which cannot take a closure needing
|
/// that the usual case, where the content's length did not change,
|
||||||
// `&mut self` while `self.inner` is borrowed.
|
/// 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
|
||||||
// **Both draws are in this frame, and only the second one
|
/// frame on which the content did grow or shrink pays one real extra
|
||||||
// decides anything.** The first is handed last frame's length as
|
/// draw, and that is a frame on which the content was being redrawn
|
||||||
// a *hint* -- nothing about where the child ends up depends on
|
/// anyway.
|
||||||
// it, and it exists only so that the usual case, where the
|
///
|
||||||
// content's length did not change, offers the same region twice:
|
/// The alternative -- place against the hint and let the next frame
|
||||||
// `draw_inner` then makes the first call an O(1) `mov` and
|
/// fix it -- is what Iris found on her phone (2026-09-08): every
|
||||||
// returns at the first line of the second. A frame on which the
|
/// newline typed into the composer drew the field in a box one line
|
||||||
// content did grow or shrink pays one real extra draw, and that
|
/// short of its text, and since that text is centred in its box it
|
||||||
// is a frame on which the content was being redrawn anyway.
|
/// 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
|
||||||
// The alternative -- place against the hint and let the next
|
/// drawn, until the keyboard closed and its inset rewrite forced a
|
||||||
// frame fix it -- is what Iris found on her phone (2026-09-08):
|
/// redraw ("it fixes itself"). **Layout is a pure function of the
|
||||||
// every newline typed into the composer drew the field in a box
|
/// state, not of how many frames have been drawn** (Iris, 2026-09-08)
|
||||||
// one line short of its text, and since that text is centred in
|
/// -- a correction that needs a second frame is a frame drawn wrong.
|
||||||
// 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
|
/// The container's own length stands in as the hint until anything has
|
||||||
// bottom. There was no next frame: nothing dirtied that subtree
|
/// been measured: a zero-length region on the first frame would place
|
||||||
// again, so the stale placement was the last one drawn, until
|
/// the child's primitives against a box of no size.
|
||||||
// the keyboard closed and its inset rewrite forced a redraw ("it
|
fn draw_moved_child(&mut self, painter: &mut Painter, container_len: f32) -> Size {
|
||||||
// fixes itself"). **Layout is a pure function of the state, not
|
let axis = self.axis;
|
||||||
// of how many frames have been drawn** (Iris, 2026-09-08) -- a
|
let density = self.density;
|
||||||
// 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.
|
|
||||||
let hint = self.content_len.unwrap_or(container_len);
|
let hint = self.content_len.unwrap_or(container_len);
|
||||||
let used = painter.widget_within(&self.inner, self.child_region(hint));
|
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.amt = measured - container_len;
|
||||||
}
|
}
|
||||||
self.update_amt();
|
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
|
// 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
|
// grow (the composer's bar) should hug the text until its own cap
|
||||||
// stops it, and reporting the container instead would make this
|
// stops it, and reporting the container instead would make this
|
||||||
// widget's answer a function of the answer -- the bar is sized
|
// widget's answer a function of the answer -- the bar is sized
|
||||||
// from what is reported here, so it collapses to nothing and
|
// from what is reported here, so it collapses to nothing and never
|
||||||
// never recovers. What keeps the content inside the offered box
|
// recovers. What keeps the content inside the offered box is the
|
||||||
// is the mask a caller puts around it (`.scrollable().masked()`),
|
// mask a caller puts around it (`.scrollable().masked()`), not
|
||||||
// not this number.
|
// 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
|
used
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Scroll {
|
|
||||||
/// `at_end` starts the area pinned to the end of its content and
|
/// `at_end` starts the area pinned to the end of its content and
|
||||||
/// keeps it there while the content grows -- see
|
/// keeps it there while the content grows -- see
|
||||||
/// `WidgetLike::scrollable_to_end`. `false` starts at the beginning,
|
/// `WidgetLike::scrollable_to_end`. `false` starts at the beginning,
|
||||||
@@ -180,6 +263,9 @@ impl Scroll {
|
|||||||
snap_end: at_end,
|
snap_end: at_end,
|
||||||
container_len: 0.0,
|
container_len: 0.0,
|
||||||
content_len: None,
|
content_len: None,
|
||||||
|
pending: 0.0,
|
||||||
|
hit_wall: false,
|
||||||
|
child_scrolls: false,
|
||||||
gesture: DragGesture::on(axis),
|
gesture: DragGesture::on(axis),
|
||||||
fling: Flinger::new(),
|
fling: Flinger::new(),
|
||||||
density: 1.0,
|
density: 1.0,
|
||||||
@@ -235,14 +321,13 @@ impl Scroll {
|
|||||||
.gesture
|
.gesture
|
||||||
.handle(pointer, id, sense, pos_window, now, press)
|
.handle(pointer, id, sense, pos_window, now, press)
|
||||||
{
|
{
|
||||||
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
|
// The content follows the finger: `dy` straight through, and
|
||||||
// `-dy` to `List::scroll` because a `List`'s anchor offset and
|
// the same `dy` a transcript's own arbiter (`Selection::drag`)
|
||||||
// this widget's `amt` run in *opposite* directions (offset is
|
// hands to this same method. There used to be two conventions
|
||||||
// where the anchored edge sits; `amt` is how far the content
|
// here -- this one and a `LazySpan::scroll` whose anchor offset
|
||||||
// has been pulled up past the top), even though `List::scroll`'s
|
// ran the opposite way while its doc claimed to mirror this
|
||||||
// own doc claims to mirror this one's convention. The rule that
|
// one -- so every call site had to remember which it was
|
||||||
// holds for both, and the one to check a sign against, is that
|
// talking to. There is one now.
|
||||||
// the content follows the finger.
|
|
||||||
GestureOutcome::Pan(dy) => self.scroll(dy),
|
GestureOutcome::Pan(dy) => self.scroll(dy),
|
||||||
// Same sign as `Pan`, since `tick` applies it through the
|
// Same sign as `Pan`, since `tick` applies it through the
|
||||||
// same `scroll`.
|
// same `scroll`.
|
||||||
@@ -288,7 +373,12 @@ impl Scroll {
|
|||||||
/// question that cannot be answered yet -- answering it anyway is
|
/// question that cannot be answered yet -- answering it anyway is
|
||||||
/// what `content_len`'s doc describes.
|
/// what `content_len`'s doc describes.
|
||||||
pub fn update_amt(&mut self) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let len = self.scroll_range();
|
let len = self.scroll_range();
|
||||||
@@ -304,7 +394,7 @@ impl Scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a fling is coasting here right now -- the same question
|
/// 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
|
/// 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
|
/// know whether this area is moving on its own (a test, and
|
||||||
/// [`PressState::scrolling`]'s own condition).
|
/// [`PressState::scrolling`]'s own condition).
|
||||||
@@ -312,6 +402,46 @@ impl Scroll {
|
|||||||
self.fling.is_flinging()
|
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
|
/// Which way this area pans. For a caller that found the widget
|
||||||
/// rather than built it -- a test walking what is drawn, a scroll
|
/// rather than built it -- a test walking what is drawn, a scroll
|
||||||
/// indicator asking which edge to sit on.
|
/// indicator asking which edge to sit on.
|
||||||
@@ -319,7 +449,22 @@ impl Scroll {
|
|||||||
self.axis
|
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) {
|
pub fn scroll(&mut self, amt: f32) {
|
||||||
|
if self.child_scrolls {
|
||||||
|
self.pending += amt;
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.amt -= amt;
|
self.amt -= amt;
|
||||||
self.update_amt();
|
self.update_amt();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ widget_trait! {
|
|||||||
ctx.widget(rsc).scroll(delta);
|
ctx.widget(rsc).scroll(delta);
|
||||||
})
|
})
|
||||||
// A finger drag, through the same `DragGesture` the
|
// 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
|
// has the arbitration and why there is no fling. The wheel
|
||||||
// above and this are the two inputs of one scroll, so they
|
// above and this are the two inputs of one scroll, so they
|
||||||
// are registered together rather than left to each caller.
|
// are registered together rather than left to each caller.
|
||||||
|
|||||||
@@ -125,11 +125,6 @@ impl DefaultAppState for Client {
|
|||||||
if let Some(inset) = ime_argv() {
|
if let Some(inset) = ime_argv() {
|
||||||
opened.screen.composer.set_bottom_inset(rsc, inset);
|
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)
|
Some(opened.screen)
|
||||||
}
|
}
|
||||||
// On screen rather than a panic: this window exists to be
|
// 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};
|
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
|
||||||
/// The screen open on the fixture, framed twice -- once to draw, once for
|
/// 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.
|
/// anchor, which is what every assertion about scroll position reads.
|
||||||
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
|
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
|
||||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
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
|
/// 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
|
/// 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.
|
/// 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) {
|
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,
|
PHONE_FRAME_MS,
|
||||||
);
|
);
|
||||||
assert!(
|
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"
|
"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,
|
PHONE_FRAME_MS,
|
||||||
);
|
);
|
||||||
assert!(
|
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"
|
"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);
|
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
||||||
None,
|
None,
|
||||||
"a press that stopped a fling and moved nothing must not start another"
|
"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,
|
PHONE_FRAME_MS,
|
||||||
);
|
);
|
||||||
assert!(
|
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"
|
"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);
|
h.replay(&flick);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
||||||
None,
|
None,
|
||||||
"a gesture the platform took away must not fling"
|
"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
|
after - before
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
||||||
None,
|
None,
|
||||||
"and it must not have flung either"
|
"and it must not have flung either"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use iris::prelude::*;
|
|||||||
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
|
||||||
/// The screen open on the fixture, framed twice: once to draw, once for
|
/// 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.
|
/// anchor, which is what every assertion about scroll position reads.
|
||||||
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
|
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
|
||||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
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
|
/// (a) and (b) together, because the second is only meaningful if the
|
||||||
/// first happened: the recorded flick must release with a real velocity
|
/// first happened: the recorded flick must release with a real velocity
|
||||||
/// (`GestureOutcome::Released(Some(v))`, which is the only thing that
|
/// (`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.
|
/// actually travel and stop on the spline's own schedule.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
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"));
|
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
|
||||||
h.replay(&flick);
|
h.replay(&flick);
|
||||||
|
|
||||||
let velocity = (screen.list)(&mut h.rsc)
|
let velocity = (screen.scroll)(&mut h.rsc)
|
||||||
.fling_velocity()
|
.fling_velocity()
|
||||||
.expect("the flick must release as a pan with a velocity, not a tap");
|
.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
|
// Compose's own answer for this recording's five samples, printed by
|
||||||
// `iris/benches/velocity_reference.py` -- not a number read off this
|
// `iris/benches/velocity_reference.py` -- not a number read off this
|
||||||
// code. Negative because the flick runs *down* the screen and
|
// code. **Positive** because the flick runs *down* the screen and a
|
||||||
// `Selection::drag` flings the list by `-v` (see its `Released` arm).
|
// 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
|
// 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!(
|
assert!(
|
||||||
(velocity + 15_250.0).abs() < 20.0,
|
(velocity - 15_250.0).abs() < 20.0,
|
||||||
"expected ~-15250px/s from velocity_reference.py, got {velocity}"
|
"expected ~15250px/s from velocity_reference.py, got {velocity}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// `iris/benches/fling_spline_reference.py`'s own line for this exact
|
// `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 settled_at = None;
|
||||||
let mut t = flick.end_ms();
|
let mut t = flick.end_ms();
|
||||||
// Travel in pixels, measured from a row's own on-screen extent, since
|
// 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
|
// row is under the viewport's middle until it leaves, then pick
|
||||||
// another. Deliberately an *under*-count -- the frame a row leaves on
|
// another. Deliberately an *under*-count -- the frame a row leaves on
|
||||||
// contributes nothing -- which is why it is only ever a lower bound.
|
// 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)
|
.key_at(middle)
|
||||||
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
|
.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);
|
settled_at = Some(t);
|
||||||
}
|
}
|
||||||
t += PHONE_FRAME_MS;
|
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")));
|
h.replay(&script("tap", include_str!("../touch/tap.touch")));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
(screen.scroll)(&mut h.rsc).fling_velocity(),
|
||||||
None,
|
None,
|
||||||
"a tap must not fling"
|
"a tap must not fling"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! What these are about is docs/IRIS_TODO.md's 2026-09-07 phone report --
|
//! 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
|
//! 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
|
//! 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
|
//! it is inside the list's own box, and nothing outside that box reaches
|
||||||
//! the screen.
|
//! 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,
|
/// 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
|
/// `draw_inner`'s old-children diffing means a row it did not place this
|
||||||
/// frame is not among them.
|
/// frame is not among them.
|
||||||
fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32, f32)> {
|
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
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scrolls `amount` (negative walks back through older rows) and runs the
|
/// Scrolls `amount` and runs the frame it asks for, returning the time of
|
||||||
/// frame it asks for, returning the time of the next one.
|
/// 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 {
|
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);
|
h.frame(t);
|
||||||
t + PHONE_FRAME_MS
|
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
|
// 40px a frame, the shape a finger pan arrives in, through a straddle
|
||||||
// and out the other side of it many times over.
|
// and out the other side of it many times over.
|
||||||
for _ in 0..60 {
|
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 rows = drawn_rows(&h, &screen);
|
||||||
let first = *rows.first().expect("something is on screen");
|
let first = *rows.first().expect("something is on screen");
|
||||||
assert!(
|
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
|
/// 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
|
/// 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
|
/// entering from the top. Every later leg crosses the same rows with
|
||||||
/// every height already known -- including the second walk *back*, which
|
/// every height already known -- including the second walk *back*, which
|
||||||
/// is there because a regression that draws rows in the wrong place while
|
/// 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 {
|
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);
|
bounded(&drawn_rows(&h, &screen), "measuring", step);
|
||||||
}
|
}
|
||||||
for step in 0..40 {
|
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);
|
let rows = drawn_rows(&h, &screen);
|
||||||
bounded(&rows, "forward", step);
|
bounded(&rows, "forward", step);
|
||||||
inside(&rows, "forward", step);
|
inside(&rows, "forward", step);
|
||||||
}
|
}
|
||||||
for step in 0..40 {
|
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);
|
let rows = drawn_rows(&h, &screen);
|
||||||
bounded(&rows, "back", step);
|
bounded(&rows, "back", step);
|
||||||
inside(&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;
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
for _ in 0..40 {
|
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 rows = drawn_rows(&h, &screen);
|
||||||
let last = *rows.last().expect("something is on screen");
|
let last = *rows.last().expect("something is on screen");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -290,10 +295,10 @@ fn scrolling_past_the_first_row_settles_on_it() {
|
|||||||
let mut t = PHONE_FRAME_MS * 2;
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
for _ in 0..60 {
|
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
|
// 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
|
// frame `scrolled` drew is already flush with the first row. Adding
|
||||||
// one here would hide a regression to the old next-frame correction.
|
// one here would hide a regression to the old next-frame correction.
|
||||||
let rows = drawn_rows(&h, &screen);
|
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;
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
for _ in 0..20 {
|
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);
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! The message composer at the bottom of the transcript screen: a
|
//! The message composer at the bottom of the transcript screen: a
|
||||||
//! multi-line editable field with a natural (not fixed) height, so it
|
//! multi-line editable field with a natural (not fixed) height, so it
|
||||||
//! grows as typed into -- IRIS_TODO.md's "input box" benchmark case
|
//! 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
|
//! 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
|
//! beside this widget in a `Span::down`, so the list's own draw already
|
||||||
//! measures whatever vertical space is left each frame -- nothing here
|
//! measures whatever vertical space is left each frame -- nothing here
|
||||||
//! computes a height by hand, and growing this field is exactly the
|
//! computes a height by hand, and growing this field is exactly the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```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 1: sender label + one TextEdit |
|
||||||
//! | row 2: sender label + one TextEdit |
|
//! | row 2: sender label + one TextEdit |
|
||||||
//! | row 3 (Tools): collapsed/expanded |
|
//! | row 3 (Tools): collapsed/expanded |
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
//! through one shared `iris::sense::DragArbiter`
|
//! through one shared `iris::sense::DragArbiter`
|
||||||
//! (`Selection::drag`, `selection.rs`), which decides pan vs. select the
|
//! (`Selection::drag`, `selection.rs`), which decides pan vs. select the
|
||||||
//! way Android itself does -- see `DragArbiter`'s own doc and
|
//! 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
|
//! driven programmatically (I3's benchmark), via the mouse wheel (wired
|
||||||
//! below, `CursorSense::Scroll`), and now via a touch pan starting on a
|
//! below, `CursorSense::Scroll`), and now via a touch pan starting on a
|
||||||
//! row's own text too.
|
//! row's own text too.
|
||||||
@@ -55,10 +55,15 @@ use selection::Selection;
|
|||||||
use std::{cell::RefCell, rc::Rc};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
pub struct TranscriptScreen {
|
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
|
/// `.extent()`/call `.jump_to_end()` etc. directly for anything this
|
||||||
/// crate does not already wrap.
|
/// 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,
|
pub composer: composer::Composer,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
/// How many times [`Self::apply`] has fallen back to a full rebuild --
|
/// How many times [`Self::apply`] has fallen back to a full rebuild --
|
||||||
@@ -83,7 +88,7 @@ pub struct TranscriptScreen {
|
|||||||
impl TranscriptScreen {
|
impl TranscriptScreen {
|
||||||
/// Append one more folded row at the live end of the transcript --
|
/// 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
|
/// 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).
|
/// newest content when it already was (I3).
|
||||||
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
|
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
|
||||||
where
|
where
|
||||||
@@ -96,7 +101,7 @@ impl TranscriptScreen {
|
|||||||
row,
|
row,
|
||||||
self.session_working.get(),
|
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));
|
*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
|
/// - **only the last row's content changed** (the common case: a delta
|
||||||
/// folded into a still-open assistant message): that one row is
|
/// folded into a still-open assistant message): that one row is
|
||||||
/// rebuilt (`row::build_row`, the same path a fresh row goes
|
/// 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
|
/// other row is untouched, so nothing else redraws or moves. Any
|
||||||
/// further new rows are appended after it, for the (also common)
|
/// further new rows are appended after it, for the (also common)
|
||||||
/// case of a delta that both finishes the open reply and starts the
|
/// 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
|
/// 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)
|
/// run's calls that used to be separate rows join once the run closes)
|
||||||
/// -- falls back to a full rebuild: every row is dropped
|
/// -- 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
|
/// [`Self::take_rebuilds`] so a caller (a report, a test) can see how
|
||||||
/// often the fallback actually fires rather than assuming it never
|
/// often the fallback actually fires rather than assuming it never
|
||||||
/// does.
|
/// does.
|
||||||
@@ -285,7 +290,7 @@ impl TranscriptScreen {
|
|||||||
&new_rows[common],
|
&new_rows[common],
|
||||||
self.session_working.get(),
|
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
|
drop(evicted); // frees the old row's widget, same as a pop would
|
||||||
*self.tail.borrow_mut() = kept.map(|t| (new_key, t));
|
*self.tail.borrow_mut() = kept.map(|t| (new_key, t));
|
||||||
for row in &new_rows[common + 1..] {
|
for row in &new_rows[common + 1..] {
|
||||||
@@ -295,7 +300,7 @@ impl TranscriptScreen {
|
|||||||
RowDiff::Rebuild => {
|
RowDiff::Rebuild => {
|
||||||
// A row before the tail changed (a regroup) -- nothing
|
// A row before the tail changed (a regroup) -- nothing
|
||||||
// short of a full rebuild expresses that. `Selection`
|
// 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
|
// rows it was pointing at go with it -- `push_row` below
|
||||||
// re-`register`s whatever survives as it rebuilds each
|
// re-`register`s whatever survives as it rebuilds each
|
||||||
// row (docs/REVIEW-2026-09-06.md finding 1: a key that
|
// row (docs/REVIEW-2026-09-06.md finding 1: a key that
|
||||||
@@ -355,7 +360,7 @@ where
|
|||||||
Rsc::State: FocusHost + OpenUrl,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let selection = Rc::new(RefCell::new(Selection::new()));
|
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
|
// The last row's block widgets are kept for the same reason
|
||||||
// `push_row` keeps them: a reply that is *already* streaming when the
|
// `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
|
// and claiming a call is running because the screen happens to be
|
||||||
// opening is exactly the inferred-as-measured mistake.
|
// opening is exactly the inferred-as-measured mistake.
|
||||||
let (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false);
|
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));
|
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
|
// 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
|
// taken pointer capture on `list`'s own id (`row.rs`'s registration is
|
||||||
// only ever the gesture's first frame) -- registered once here, not
|
// 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
|
// 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
|
// already relative to `list`'s own on-screen box (this is what it was
|
||||||
// registered against), which is exactly the viewport-pixel space
|
// 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.
|
// resolved from those instead of a per-row hit test.
|
||||||
{
|
{
|
||||||
let selection = selection.clone();
|
let selection = selection.clone();
|
||||||
@@ -420,21 +415,52 @@ where
|
|||||||
.add(rsc);
|
.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);
|
let (composer, composer_bar) = composer::build_composer(rsc);
|
||||||
|
|
||||||
// `.masked()`: the list draws the row straddling each of its edges in
|
// `.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
|
// 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,
|
// there, which on the phone is the header bar (docs/IRIS_TODO.md,
|
||||||
// 2026-09-07: "code and a paragraph visible behind Run benchmark").
|
// 2026-09-07: "code and a paragraph visible behind Run benchmark").
|
||||||
// The same clip is what `List::draw` asserts it has.
|
// The same clip is what `LazySpan::draw` asserts it has.
|
||||||
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
|
let tree = (scroll.width(rest(1)).height(rest(1)).masked(), composer_bar)
|
||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any();
|
.any();
|
||||||
|
|
||||||
(
|
(
|
||||||
TranscriptScreen {
|
TranscriptScreen {
|
||||||
|
scroll,
|
||||||
tail: RefCell::new(tail),
|
tail: RefCell::new(tail),
|
||||||
session_working: std::cell::Cell::new(false),
|
session_working: std::cell::Cell::new(false),
|
||||||
list,
|
list,
|
||||||
@@ -591,7 +617,7 @@ mod diff_tests {
|
|||||||
/// `Selection`, the gap docs/REVIEW-2026-09-06.md finding 8 named: the
|
/// `Selection`, the gap docs/REVIEW-2026-09-06.md finding 8 named: the
|
||||||
/// pure `diff_rows` decision above and `selection.rs`'s own registration
|
/// pure `diff_rows` decision above and `selection.rs`'s own registration
|
||||||
/// tests each pass in isolation, and neither alone catches finding 1 (a
|
/// 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()`
|
/// has already freed its widget). This fails before `Selection::clear()`
|
||||||
/// existed and the `Rebuild` arm called it, with a panic from
|
/// existed and the `Rebuild` arm called it, with a panic from
|
||||||
/// `TextEditable::edit` resolving the freed slot.
|
/// `TextEditable::edit` resolving the freed slot.
|
||||||
|
|||||||
@@ -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
|
//! (`client_core::transcript_fold::TranscriptRow`). A row is a **column of
|
||||||
//! one `TextEdit` per top-level markdown block** (paragraph, heading,
|
//! one `TextEdit` per top-level markdown block** (paragraph, heading,
|
||||||
//! fence, list, table -- `client_core::markdown_blocks`), each rendered
|
//! fence, list, table -- `client_core::markdown_blocks`), each rendered
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by
|
//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by
|
||||||
//! `client_core::transcript_fold::group_tool_runs`) is the row that proves
|
//! `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
|
//! 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
|
//! header calls `LazySpan::note_tap` at the row's own on-screen position
|
||||||
//! (read back from `List::extent`, since the tap event only knows its
|
//! (read back from `LazySpan::extent`, since the tap event only knows its
|
||||||
//! position *within* this row) before toggling a `WidgetPtr` between the
|
//! position *within* this row) before toggling a `WidgetPtr` between the
|
||||||
//! collapsed summary and the full detail -- the same two-step contract
|
//! 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::markdown::{BlockFrame, Link, frame_of, render_block};
|
||||||
use crate::selection::{SelKey, Selection};
|
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.
|
/// regardless of which row's base size surrounds it.
|
||||||
pub const BASE_SIZE: f32 = 16.0;
|
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
|
/// `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
|
/// one -- collisions are not a correctness risk worth guarding against here
|
||||||
/// (a `DefaultHasher` collision across the run ids one session produces is
|
/// (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.
|
/// them without rebuilding the handler.
|
||||||
fn build_block<Rsc: HasEvents>(
|
fn build_block<Rsc: HasEvents>(
|
||||||
rsc: &mut Rsc,
|
rsc: &mut Rsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<LazySpan>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
key: SelKey,
|
key: SelKey,
|
||||||
block: &Block,
|
block: &Block,
|
||||||
@@ -225,7 +225,7 @@ where
|
|||||||
// id and every further frame, including the terminal `Drop`,
|
// id and every further frame, including the terminal `Drop`,
|
||||||
// reaches `lib.rs`'s list-level registration instead -- see
|
// reaches `lib.rs`'s list-level registration instead -- see
|
||||||
// `iris::sense`'s pointer-capture doc for why that has to be a
|
// `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.
|
// drag as content scrolls.
|
||||||
//
|
//
|
||||||
// `Cancel` is the one that is *not* optional, and leaving it out
|
// `Cancel` is the one that is *not* optional, and leaving it out
|
||||||
@@ -315,7 +315,7 @@ where
|
|||||||
/// than a row (`selection::SelKey`).
|
/// than a row (`selection::SelKey`).
|
||||||
fn build_text_row<Rsc: HasEvents>(
|
fn build_text_row<Rsc: HasEvents>(
|
||||||
rsc: &mut Rsc,
|
rsc: &mut Rsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<LazySpan>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
key: RowKey,
|
key: RowKey,
|
||||||
sender: Option<&str>,
|
sender: Option<&str>,
|
||||||
@@ -385,7 +385,7 @@ impl RowBlocks {
|
|||||||
pub fn apply_delta<Rsc: HasEvents>(
|
pub fn apply_delta<Rsc: HasEvents>(
|
||||||
&mut self,
|
&mut self,
|
||||||
rsc: &mut Rsc,
|
rsc: &mut Rsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<LazySpan>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
key: RowKey,
|
key: RowKey,
|
||||||
sender: Option<&str>,
|
sender: Option<&str>,
|
||||||
@@ -459,7 +459,7 @@ impl RowBlocks {
|
|||||||
|
|
||||||
fn build_single<Rsc: HasEvents>(
|
fn build_single<Rsc: HasEvents>(
|
||||||
rsc: &mut Rsc,
|
rsc: &mut Rsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<LazySpan>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
key: RowKey,
|
key: RowKey,
|
||||||
item: &TranscriptItem,
|
item: &TranscriptItem,
|
||||||
@@ -489,7 +489,7 @@ pub enum TailRow {
|
|||||||
|
|
||||||
pub fn build_row<Rsc: HasEvents>(
|
pub fn build_row<Rsc: HasEvents>(
|
||||||
rsc: &mut Rsc,
|
rsc: &mut Rsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<LazySpan>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
row: &FoldedRow,
|
row: &FoldedRow,
|
||||||
working: bool,
|
working: bool,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! `xilem` (RUST.md's E2 box, citing
|
//! `xilem` (RUST.md's E2 box, citing
|
||||||
//! `masonry/src/widgets/text_area.rs:414-459`). Each transcript row here is
|
//! `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
|
//! 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
|
//! `PlainEditor`" either -- iris's answer is a coordinator that drives each
|
||||||
//! visible row's *own* selection primitives (`TextEditCtx::select`/
|
//! visible row's *own* selection primitives (`TextEditCtx::select`/
|
||||||
//! `select_all`/`deselect`, already built for a single field) from one
|
//! `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
|
/// in iris's default input layer, with only the pan-vs-select
|
||||||
/// *decision* staying here.
|
/// *decision* staying here.
|
||||||
gesture: DragGesture,
|
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 {
|
impl Default for Selection {
|
||||||
@@ -67,15 +76,24 @@ impl Selection {
|
|||||||
rows: BTreeMap::new(),
|
rows: BTreeMap::new(),
|
||||||
anchor: None,
|
anchor: None,
|
||||||
gesture: DragGesture::new(),
|
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
|
/// A row's selectable text became visible/known. Every addition here
|
||||||
/// needs its removal (`unregister`, or `clear` for all of them at
|
/// 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
|
/// `pop_back`/`clear`), so this map never outgrows however many rows
|
||||||
/// are actually loaded. `List::place` guards the twin of this same
|
/// are actually loaded. `LazySpan::place` guards the twin of this same
|
||||||
/// class of bug on the list's own side (`list.rs`'s `slot_exists`
|
/// class of bug on the list's own side (`lazy_span.rs`'s `slot_exists`
|
||||||
/// assertion) -- a derived handle that silently outlives what it
|
/// assertion) -- a derived handle that silently outlives what it
|
||||||
/// points to; the next caller adding a third row-keyed side table
|
/// points to; the next caller adding a third row-keyed side table
|
||||||
/// should read both.
|
/// should read both.
|
||||||
@@ -83,12 +101,12 @@ impl Selection {
|
|||||||
self.rows.insert(key, text);
|
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
|
/// clears the list, and what `TranscriptScreen::apply`'s `Rebuild` arm
|
||||||
/// calls right before it, since a full rebuild drops every row's old
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// new ones -- pointing at a widget the list had just freed, so the
|
||||||
/// next long-press anywhere panicked in `begin`'s deselect loop).
|
/// 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.
|
/// pointer is currently over -- row-local, as `begin`/`extend` want.
|
||||||
/// `None` once the gesture is pointer-captured (`iris::sense`'s
|
/// `None` once the gesture is pointer-captured (`iris::sense`'s
|
||||||
/// pointer-capture doc) and the current position falls outside every
|
/// 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-
|
/// `Pan` outcome never needs it, so this only actually matters mid-
|
||||||
/// selection, where it is rare and the frame is simply dropped.
|
/// 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
|
/// `pos_window` is in window space, since a pan's delta has to stay
|
||||||
@@ -252,7 +270,7 @@ impl Selection {
|
|||||||
pub fn drag(
|
pub fn drag(
|
||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut impl UiRsc,
|
ui: &mut impl UiRsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<LazySpan>,
|
||||||
row: Option<(SelKey, Vec2, Vec2)>,
|
row: Option<(SelKey, Vec2, Vec2)>,
|
||||||
pos_window: Vec2,
|
pos_window: Vec2,
|
||||||
sense: CursorSense,
|
sense: CursorSense,
|
||||||
@@ -260,7 +278,7 @@ impl Selection {
|
|||||||
pointer: &PointerRequests,
|
pointer: &PointerRequests,
|
||||||
) -> GestureOutcome {
|
) -> GestureOutcome {
|
||||||
// A fresh touch-down cancels any fling still coasting from the
|
// 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
|
// `Scroller::abortAnimation` for the same reason -- and, since
|
||||||
// 2026-09-07, *tells the gesture there was one*. A press that
|
// 2026-09-07, *tells the gesture there was one*. A press that
|
||||||
// caught moving content is a catch: it pans from this very sample
|
// 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
|
// finger is"). See `DragArbiter::press_start` for Compose's own
|
||||||
// mechanism. `starts_press` rather than a `PressStart` test of our
|
// mechanism. `starts_press` rather than a `PressStart` test of our
|
||||||
// own, so this fires on the recovered-press frames too.
|
// 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();
|
let mut press = PressState::default();
|
||||||
if self.gesture.starts_press(sense) {
|
if self.gesture.starts_press(sense) {
|
||||||
press.scrolling = list(ui).is_scrolling();
|
press.scrolling = self.scroll.is_some_and(|s| s(ui).is_scrolling());
|
||||||
list(ui).cancel_fling();
|
if let Some(scroll) = self.scroll {
|
||||||
|
scroll(ui).cancel_fling();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
press.already_selected = self.has_selection(ui);
|
press.already_selected = self.has_selection(ui);
|
||||||
let outcome = self
|
let outcome = self
|
||||||
@@ -286,7 +311,15 @@ impl Selection {
|
|||||||
// to undo either -- the point is that no tap, fling or
|
// to undo either -- the point is that no tap, fling or
|
||||||
// selection follows from a gesture that was never ours.
|
// selection follows from a gesture that was never ours.
|
||||||
GestureOutcome::Cancelled | GestureOutcome::Undecided => {}
|
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 => {
|
GestureOutcome::SelectStart => {
|
||||||
if let Some((key, pos_row, size)) = row {
|
if let Some((key, pos_row, size)) = row {
|
||||||
// Grep-able on "iris selection" the way the frame
|
// Grep-able on "iris selection" the way the frame
|
||||||
@@ -310,18 +343,19 @@ impl Selection {
|
|||||||
// tap/long-press that never left `Undecided` -- exactly what
|
// tap/long-press that never left `Undecided` -- exactly what
|
||||||
// `DragGesture`'s `Some(v)` already encodes.
|
// `DragGesture`'s `Some(v)` already encodes.
|
||||||
GestureOutcome::Released(Some(v)) => {
|
GestureOutcome::Released(Some(v)) => {
|
||||||
list(ui).fling(-v);
|
|
||||||
// The half that actually makes it move -- see
|
// 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.
|
// computed, stored, and never advanced by anything.
|
||||||
//
|
//
|
||||||
// Only when `fling` actually took it: below Compose's
|
// Only when `fling` actually took it: below Compose's
|
||||||
// `|v| <= 1.0`, or with no anchor, there is nothing to
|
// `|v| <= 1.0` there is nothing to tick, and registering
|
||||||
// tick, and registering an animation for a widget that is
|
// an animation for a widget that is not animating asks the
|
||||||
// not animating asks the next frame to find that out
|
// next frame to find that out (docs/REVIEW-2026-09-07.md's
|
||||||
// (docs/REVIEW-2026-09-07.md's second nit).
|
// second nit).
|
||||||
if list(ui).is_scrolling() {
|
if let Some(scroll) = self.scroll
|
||||||
let id = list.id();
|
&& scroll(ui).fling(v)
|
||||||
|
{
|
||||||
|
let id = scroll.id();
|
||||||
ui.ui_mut().animate(id);
|
ui.ui_mut().animate(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,9 +461,19 @@ mod tests {
|
|||||||
EditMode::MultiLine,
|
EditMode::MultiLine,
|
||||||
))
|
))
|
||||||
.weak();
|
.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();
|
let mut sel = Selection::new();
|
||||||
|
sel.set_scroll_area(scroll);
|
||||||
sel.register((1, 0), field);
|
sel.register((1, 0), field);
|
||||||
assert!(sel.gesture.is_idle());
|
assert!(sel.gesture.is_idle());
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ struct Shared {
|
|||||||
/// Filled in immediately after construction -- the `WidgetPtr` cannot
|
/// Filled in immediately after construction -- the `WidgetPtr` cannot
|
||||||
/// exist before the `Rc` every handler inside it captures.
|
/// exist before the `Rc` every handler inside it captures.
|
||||||
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
|
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<LazySpan>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
key: RowKey,
|
key: RowKey,
|
||||||
/// Whether a call in this row could still be running -- the caller's
|
/// 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.
|
/// 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
|
/// `LazySpan::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
|
/// knows its own box, so `LazySpan::extent` (last frame's on-screen box for
|
||||||
/// this key) turns the two into the position `list.rs`'s hold-the-edge
|
/// 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
|
/// pass resolves against -- the two-step contract that module's doc
|
||||||
/// describes for `AGENTS.md`'s `holdTopEdge`.
|
/// describes for `AGENTS.md`'s `holdTopEdge`.
|
||||||
fn note_tap(rsc: &mut impl UiRsc, shared: &Shared) {
|
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.
|
/// with no result never came back rather than still running.
|
||||||
pub fn build_tool_row<Rsc: HasEvents>(
|
pub fn build_tool_row<Rsc: HasEvents>(
|
||||||
rsc: &mut Rsc,
|
rsc: &mut Rsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<LazySpan>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
key: RowKey,
|
key: RowKey,
|
||||||
calls: Vec<TranscriptItem>,
|
calls: Vec<TranscriptItem>,
|
||||||
@@ -795,7 +795,7 @@ impl ToolRow {
|
|||||||
/// Exists because the expanded appearance is otherwise unreachable
|
/// Exists because the expanded appearance is otherwise unreachable
|
||||||
/// from anything that cannot press the screen -- a headless
|
/// from anything that cannot press the screen -- a headless
|
||||||
/// screenshot on this displayless machine, and a test. Same path a tap
|
/// 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.
|
/// reader would have got.
|
||||||
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
|
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
|
||||||
where
|
where
|
||||||
|
|||||||
Reference in new issue
Block a user