iris: a List gives back its overscroll in the frame that found it
The last place in iris that corrected itself on a later frame, and the item docs/IRIS_TODO.md carried from the Scroll change. Iris's rule: "nothing in the framework should ever self heal because it should not be drawn incorrectly in the first place. If you need 2 draws to get something into the correct position then that should happen within the same frame." `clamp_to_content` measured the gap past the end of the content from the edges the walk had just placed, wrote it to the anchor and asked for another frame -- so one frame was drawn with the list past its own end, and a fling that had already stopped was not going to ask for the frame that fixed it. Now the walk outward from the anchor is `List::lay_out`, `overscroll_gap` is a pure measurement of the same gap (no painter, no redraw handle), and `draw` moves the anchor and walks a second time inside the same frame. One further pass always settles it: the gap comes from the edges the first walk placed, so moving the anchor by it puts that edge exactly on the viewport's, and the opposite end can only open a new gap when the content is shorter than the viewport, which `overscroll_gap` declines to touch. The second walk is paid only on an overscrolled frame and re-offers every row the same cached-height box at a new offset, which `draw_inner` dispatches as an O(1) move. `Painter::draw_again` had no other caller and is removed with it, so the framework no longer offers a way to ask for a corrective frame at all. Simplification in the same change: a placement is one pinned edge plus a height, so `Placement::edges(height)` gives the box and `place`'s top-known and bottom-known cases stop being two copies of the same arithmetic -- four match arms down to two. Four tests draw no settling frame on purpose and fail without the change: `fling_toward_the_start_stops_at_the_first_row` and the new `scrolling_past_the_start_is_given_back_in_the_same_frame` (list.rs), and `scrolling_past_the_first_row_settles_on_it` / `scrolling_past_the_last_row_settles_on_it` (layer 1, top_edge.rs). Verified: cargo fmt --check, clippy --workspace --all-targets clean, cargo test --workspace and ./run-tests.sh green, the phone-shaped headless window replaying flick-120hz.touch draws the transcript correctly, and the arm64 release APK builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a00376994e
commit
76fcbdccb9
7 files changed
+307
-190
No files matched your search
@@ -5,6 +5,21 @@ 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 (later still: the list's overscroll clamp, in frame)
|
||||||
|
|
||||||
|
Finishes the item the previous entry deferred. IRIS.md has the account.
|
||||||
|
|
||||||
|
- **`List` lays out a second time within the frame** when its walk lands
|
||||||
|
off the end of the content, instead of writing the correction to the
|
||||||
|
anchor and asking for another frame. The extra walk is paid only on an
|
||||||
|
overscrolled frame, and it is mostly O(1) moves.
|
||||||
|
- **`Painter::draw_again` is removed**, `List` having been its only
|
||||||
|
caller -- so the framework no longer offers a way to ask for a
|
||||||
|
corrective frame at all.
|
||||||
|
- **`List::place`'s top-known and bottom-known cases are one path**
|
||||||
|
(`Placement::edges`), which is the "write the logic once" rule applied
|
||||||
|
to two symmetric directions rather than a behaviour change.
|
||||||
|
|
||||||
## 2026-09-08 (later: a scroll area measures and places in one frame)
|
## 2026-09-08 (later: a scroll area measures and places in one frame)
|
||||||
|
|
||||||
From Iris's phone report about the composer's padding while typing
|
From Iris's phone report about the composer's padding while typing
|
||||||
|
|||||||
@@ -12,6 +12,56 @@ 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 (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
|
||||||
|
walk outward from its anchor could end up off the end of its content --
|
||||||
|
a fling stops wherever the spline's last step left it, and a `scroll` is
|
||||||
|
deliberately unclamped because nothing at the moment of the call knows
|
||||||
|
where the content ends. `clamp_to_content` measured that gap from the
|
||||||
|
edges the walk had just placed, wrote it to the anchor, and asked for
|
||||||
|
another frame. So one frame was drawn with the content past its own end,
|
||||||
|
and on Iris's phone a hard fling to the top left the whole screen blank
|
||||||
|
until something asked for that frame -- which a fling that has stopped no
|
||||||
|
longer does.
|
||||||
|
|
||||||
|
It is the same shape as `Scroll`'s fix. The walk is now `List::lay_out`,
|
||||||
|
and `List::draw` runs it, asks `overscroll_gap` whether the layout landed
|
||||||
|
off the end, and on a gap moves the anchor and runs the walk **again,
|
||||||
|
inside the same frame**. `overscroll_gap` is a pure measurement -- no
|
||||||
|
painter, no redraw handle -- and the decision to lay out again is `draw`'s.
|
||||||
|
|
||||||
|
Three properties make the second pass cheap and correct:
|
||||||
|
|
||||||
|
- **It runs only on a frame that actually overscrolled.** An ordinary
|
||||||
|
scroll tick still walks once.
|
||||||
|
- **One further pass always settles it.** The gap is measured from the
|
||||||
|
edges the first walk placed, so moving the anchor by it puts that edge
|
||||||
|
exactly on the viewport's; the opposite end can only open a new gap if
|
||||||
|
the content is shorter than the viewport, which `overscroll_gap`
|
||||||
|
declines to touch at all (a short list is bottom-anchored on purpose).
|
||||||
|
- **The second walk is mostly moves.** Every row keeps the box its cached
|
||||||
|
height gives it and only its offset changes, which is `draw_inner`'s
|
||||||
|
O(1) `mov` path.
|
||||||
|
|
||||||
|
**Public surface: `Painter::draw_again` is removed.** `List` was its only
|
||||||
|
caller, so with this there is no "ask for a corrective frame" mechanism in
|
||||||
|
the framework -- which is the point, since reaching for one is the sign a
|
||||||
|
placement should have been redone inside the draw that discovered the
|
||||||
|
problem.
|
||||||
|
|
||||||
|
`List::place` also lost half its body to the same simplification the rule
|
||||||
|
suggests: a placement is one pinned edge plus a height, so `Placement::
|
||||||
|
edges(height)` gives the box and the top-known and bottom-known cases stop
|
||||||
|
being two copies of the same arithmetic.
|
||||||
|
|
||||||
|
Tests that draw no settling frame on purpose, and fail without the change:
|
||||||
|
`fling_toward_the_start_stops_at_the_first_row` and the new
|
||||||
|
`scrolling_past_the_start_is_given_back_in_the_same_frame` in `list.rs`,
|
||||||
|
and `scrolling_past_the_first_row_settles_on_it` /
|
||||||
|
`scrolling_past_the_last_row_settles_on_it` at layer 1
|
||||||
|
(`transcript-fixture/tests/top_edge.rs`).
|
||||||
|
|
||||||
## 2026-09-08 (later): a `Scroll` measures and places its content in one frame
|
## 2026-09-08 (later): a `Scroll` measures and places its content in one frame
|
||||||
|
|
||||||
Iris's phone: "when typing with the keyboard up and entering enough
|
Iris's phone: "when typing with the keyboard up and entering enough
|
||||||
|
|||||||
+23
-12
@@ -7,7 +7,7 @@ order and what "done" looks like. Tick and date them in place.
|
|||||||
|
|
||||||
## Fix
|
## Fix
|
||||||
|
|
||||||
- [ ] **`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
|
||||||
it should not be drawn incorrectly in the first place. If you need 2
|
it should not be drawn incorrectly in the first place. If you need 2
|
||||||
@@ -15,15 +15,26 @@ order and what "done" looks like. Tick and date them in place.
|
|||||||
happen within the same frame. Layout should never be frame dependent,
|
happen within the same frame. Layout should never be frame dependent,
|
||||||
it should be a pure function of the state." `Scroll::draw` was brought
|
it should be a pure function of the state." `Scroll::draw` was brought
|
||||||
to that rule the same day (it measures its content and places it
|
to that rule the same day (it measures its content and places it
|
||||||
again in the one frame, IRIS.md's entry). `List::clamp_to_content` is
|
again in the one frame, IRIS.md's entry). Done for `List` later the
|
||||||
the one place left that has not been: it discovers a fling has run
|
same day: the walk outward from the anchor is now `List::lay_out`, and
|
||||||
past the content's end, calls `scroll(gap)` and `Painter::draw_again`,
|
`draw` runs it, asks `overscroll_gap` (a pure measurement, no painter
|
||||||
and asks its own `RequestRedraw` handle for a frame -- so one frame is
|
and no redraw handle) whether the layout landed off the end of the
|
||||||
drawn with the content past its end and the next one snaps it back.
|
content, and on a gap moves the anchor and runs the walk a second time
|
||||||
The fix is the same shape as `Scroll`'s: re-place inside the draw that
|
**inside the same frame**. `Painter::draw_again` had no other caller
|
||||||
found the gap. Not done in the same change because `List::place` is a
|
and is gone with it, so there is now no "ask for a corrective frame"
|
||||||
larger piece of machinery than `Scroll::draw` and this deserves its
|
mechanism in the framework at all. One further pass always settles it:
|
||||||
own before/after on the phone.
|
the gap is measured from the edges the walk actually placed, so moving
|
||||||
|
the anchor by it puts that edge exactly on the viewport's, and the
|
||||||
|
opposite end cannot open a new gap without the content being shorter
|
||||||
|
than the viewport, which `overscroll_gap` declines to touch. The extra
|
||||||
|
walk is paid only on an overscrolled frame and re-offers every row the
|
||||||
|
same box at a new offset, which `draw_inner` dispatches as an O(1)
|
||||||
|
move. Three tests draw no settling frame on purpose and fail without
|
||||||
|
the change: `fling_toward_the_start_stops_at_the_first_row`,
|
||||||
|
`scrolling_past_the_start_is_given_back_in_the_same_frame` (both in
|
||||||
|
`list.rs`) and `scrolling_past_the_first_row_settles_on_it` /
|
||||||
|
`scrolling_past_the_last_row_settles_on_it` (layer 1,
|
||||||
|
`transcript-fixture/tests/top_edge.rs`).
|
||||||
|
|
||||||
- [x] **`request_device` asked for compute-shader limits it never uses
|
- [x] **`request_device` asked for compute-shader limits it never uses
|
||||||
(2026-09-05).** `Limits::default()` (both `iris/src/android/render.rs`
|
(2026-09-05).** `Limits::default()` (both `iris/src/android/render.rs`
|
||||||
@@ -1124,7 +1135,7 @@ do not duplicate it there.
|
|||||||
**past its own first row** (`fling_toward_the_start_stops_at_the_
|
**past its own first row** (`fling_toward_the_start_stops_at_the_
|
||||||
first_row` was leaving it 1398px below a 600px viewport, a blank
|
first_row` was leaving it 1398px below a 600px viewport, a blank
|
||||||
screen, and that test's own assertion could not see it).
|
screen, and that test's own assertion could not see it).
|
||||||
`clamp_to_content` gives the gap back. Both ends:
|
the overscroll clamp gives the gap back. Both ends:
|
||||||
`scrolling_past_the_first_row_settles_on_it`,
|
`scrolling_past_the_first_row_settles_on_it`,
|
||||||
`scrolling_past_the_last_row_settles_on_it`. This is also the first
|
`scrolling_past_the_last_row_settles_on_it`. This is also the first
|
||||||
item of the later report below.
|
item of the later report below.
|
||||||
@@ -1140,7 +1151,7 @@ do not duplicate it there.
|
|||||||
## From the phone, 2026-09-07, later (build from 4274b8b, ai-app-bench b47eb73)
|
## From the phone, 2026-09-07, later (build from 4274b8b, ai-app-bench b47eb73)
|
||||||
|
|
||||||
- [x] **"You shouldn't be able to scroll below the bottom (or above
|
- [x] **"You shouldn't be able to scroll below the bottom (or above
|
||||||
top)."** Done in e922b73, as `List::clamp_to_content` rather than as a
|
top)."** Done in e922b73, as a clamp in `List::draw` rather than as a
|
||||||
clamp inside the scroll setter: nothing at the moment of a `scroll`
|
clamp inside the scroll setter: nothing at the moment of a `scroll`
|
||||||
call knows where the content ends (that is what walking the rows finds
|
call knows where the content ends (that is what walking the rows finds
|
||||||
out), so the correction is measured from the ends the layout walk
|
out), so the correction is measured from the ends the layout walk
|
||||||
|
|||||||
+1
-1
@@ -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::clamp_to_content`)
|
- [x] Scroll clamped at both ends (e922b73, `List`'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
|
||||||
|
|||||||
@@ -166,21 +166,6 @@ impl<'a> Painter<'a> {
|
|||||||
self.mask = self.own_mask;
|
self.mask = self.own_mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask for this widget to be drawn again on the next frame, from
|
|
||||||
/// inside its own `draw` -- for a layout that can only discover a
|
|
||||||
/// correction to itself by laying out once (`List::clamp_to_content`,
|
|
||||||
/// which learns how far past its content the list is from the walk it
|
|
||||||
/// has just done). The mark is the same one `Widgets::get_dyn_mut`
|
|
||||||
/// sets, so `UiRenderState::update` picks it up exactly as it does any
|
|
||||||
/// other dirty widget; it does **not** by itself ask the platform for
|
|
||||||
/// a frame, which is the caller's own `RequestRedraw` handle.
|
|
||||||
///
|
|
||||||
/// The correction it asks for must converge, or this is a widget that
|
|
||||||
/// redraws forever.
|
|
||||||
pub fn draw_again(&mut self) {
|
|
||||||
self.rsc.widgets_mut().needs_redraw.insert(self.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
||||||
|
|||||||
+212
-155
@@ -104,12 +104,14 @@
|
|||||||
//! that "whatever lies between" stays empty however far the list is
|
//! that "whatever lies between" stays empty however far the list is
|
||||||
//! panned.
|
//! panned.
|
||||||
//!
|
//!
|
||||||
//! **Overscroll is taken back on the next frame, never rubber-banded.** A
|
//! **Overscroll is taken back within the frame, never rubber-banded.** A
|
||||||
//! `scroll()` or a fling past the first or last row leaves a gap for one
|
//! `scroll()` or a fling past the first or last row leaves a gap;
|
||||||
//! frame; `clamp_to_content` measures it from the ends the walk already
|
//! `overscroll_gap` measures it from the ends the walk already placed, and
|
||||||
//! placed and gives it back (the same one-frame-lag `Scroll`'s content
|
//! `draw` moves the anchor by it and walks a second time before the frame
|
||||||
//! length has, per LAYOUT.md). A list shorter than its viewport is not
|
//! ends -- layout is a pure function of the state, not of how many frames
|
||||||
//! overscrolled and is left alone, still bottom-anchored.
|
//! have been drawn (Iris, 2026-09-08), the same rule `Scroll::draw`
|
||||||
|
//! follows. A list shorter than its viewport is not overscrolled and is
|
||||||
|
//! left alone, still bottom-anchored.
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use iris_core::util::HashMap;
|
use iris_core::util::HashMap;
|
||||||
@@ -180,17 +182,31 @@ struct RowExtent {
|
|||||||
bottom: f32,
|
bottom: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which of a row's edges the walk already knows, and where it is. The
|
||||||
|
/// two are symmetric -- one pinned edge plus a height gives the box --
|
||||||
|
/// so `place` parameterises over them through [`Self::edges`] rather
|
||||||
|
/// than carrying a copy of the same logic per direction.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
enum Placement {
|
enum Placement {
|
||||||
/// This row's leading edge is known; its trailing edge is wherever its
|
/// This row's leading edge is known; its trailing edge is wherever its
|
||||||
/// natural size puts it. Placed directly with `widget_within`.
|
/// own height puts it.
|
||||||
Top(f32),
|
Top(f32),
|
||||||
/// This row's trailing edge is known; its leading edge depends on a
|
/// This row's trailing edge is known; its leading edge is that height
|
||||||
/// size not yet learned. Placed by measuring at the full offered
|
/// back from it.
|
||||||
/// region first, then `reposition`ing -- the same two-step `Aligned`
|
|
||||||
/// already uses for the identical problem.
|
|
||||||
Bottom(f32),
|
Bottom(f32),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Placement {
|
||||||
|
/// The `(leading, trailing)` edges this placement implies for a row
|
||||||
|
/// of `height`.
|
||||||
|
fn edges(self, height: f32) -> (f32, f32) {
|
||||||
|
match self {
|
||||||
|
Placement::Top(top) => (top, top + height),
|
||||||
|
Placement::Bottom(bottom) => (bottom - height, bottom),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A virtualised, bottom-anchored list of variable-height rows. See the
|
/// A virtualised, bottom-anchored list of variable-height rows. See the
|
||||||
/// module doc for the design.
|
/// module doc for the design.
|
||||||
pub struct List {
|
pub struct List {
|
||||||
@@ -406,8 +422,9 @@ impl List {
|
|||||||
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
||||||
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
||||||
/// Unclamped here, on purpose: it is one write, and there is nothing
|
/// Unclamped here, on purpose: it is one write, and there is nothing
|
||||||
/// at this point that knows where the content ends. The next `draw`
|
/// at this point that knows where the content ends. The `draw` that
|
||||||
/// gives back whatever this moved past ([`Self::clamp_to_content`]).
|
/// follows gives back whatever this moved past, in that same frame
|
||||||
|
/// ([`Self::overscroll_gap`]).
|
||||||
pub fn scroll(&mut self, amt: f32) {
|
pub fn scroll(&mut self, amt: f32) {
|
||||||
if let Some(a) = &mut self.anchor {
|
if let Some(a) = &mut self.anchor {
|
||||||
a.offset -= amt;
|
a.offset -= amt;
|
||||||
@@ -804,14 +821,15 @@ impl List {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Take back an empty band at one edge that content on the other side
|
/// The empty band at one edge that content on the other side of the
|
||||||
/// of the viewport could fill -- the correction that makes a `scroll`
|
/// viewport could fill -- positive to move content toward the leading
|
||||||
/// or a fling past the end of the content settle *on* the end rather
|
/// edge -- or `None` when the layout already sits on its content.
|
||||||
/// than beyond it.
|
/// This is what makes a `scroll` or a fling past the end of the
|
||||||
|
/// content settle *on* the end rather than beyond it.
|
||||||
///
|
///
|
||||||
/// `top`/`bottom` are the extreme edges this frame's walk actually
|
/// `top`/`bottom` are the extreme edges the walk actually placed, so
|
||||||
/// placed, so the gap is already measured: `at_start` means nothing is
|
/// the gap is already measured: `at_start` means nothing is above
|
||||||
/// above `top`, and if `top` is nevertheless below the viewport's own
|
/// `top`, and if `top` is nevertheless below the viewport's own
|
||||||
/// leading edge then those pixels are empty and always will be. This
|
/// leading edge then those pixels are empty and always will be. This
|
||||||
/// is the whole of what the module doc used to list as deliberately
|
/// is the whole of what the module doc used to list as deliberately
|
||||||
/// unsolved ("no overscroll clamping ... nothing to measure how much
|
/// unsolved ("no overscroll clamping ... nothing to measure how much
|
||||||
@@ -829,19 +847,9 @@ impl List {
|
|||||||
/// the space is not overscroll at all -- it is a bottom-anchored list
|
/// the space is not overscroll at all -- it is a bottom-anchored list
|
||||||
/// with three rows in it, and pulling those to the top would be this
|
/// with three rows in it, and pulling those to the top would be this
|
||||||
/// widget rejecting its own default (`repair_anchor`).
|
/// widget rejecting its own default (`repair_anchor`).
|
||||||
///
|
fn overscroll_gap(&self, top: f32, bottom: f32) -> Option<f32> {
|
||||||
/// Applied to the anchor, so it lands on the *next* frame rather than
|
|
||||||
/// re-running this one -- which means one frame is drawn with the
|
|
||||||
/// content past its own end, and **that is a deviation from the rule
|
|
||||||
/// that layout is a pure function of the state rather than of how
|
|
||||||
/// many frames have been drawn** (Iris, 2026-09-08; `Scroll::draw`
|
|
||||||
/// used to lag the same way and no longer does, so this is now the
|
|
||||||
/// only place left). docs/IRIS_TODO.md carries it; the fix is the
|
|
||||||
/// same shape as `Scroll`'s -- re-place within this frame instead of
|
|
||||||
/// marking the next one.
|
|
||||||
fn clamp_to_content(&mut self, painter: &mut Painter, top: f32, bottom: f32) {
|
|
||||||
if self.at_start == self.at_end {
|
if self.at_start == self.at_end {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
// `at_start`/`at_end` already carry the sign of their own gap
|
// `at_start`/`at_end` already carry the sign of their own gap
|
||||||
// (`top >= 0.0`, `bottom <= viewport_len`), so this is the gap
|
// (`top >= 0.0`, `bottom <= viewport_len`), so this is the gap
|
||||||
@@ -852,21 +860,76 @@ impl List {
|
|||||||
bottom - self.viewport_len
|
bottom - self.viewport_len
|
||||||
};
|
};
|
||||||
// Sub-pixel gaps are what floating-point row heights leave behind
|
// Sub-pixel gaps are what floating-point row heights leave behind
|
||||||
// every frame; correcting one would ask for another frame, which
|
// every frame; laying out again for one would leave another, and
|
||||||
// would leave another, and the list would never stop redrawing.
|
// the list would never settle.
|
||||||
if gap.abs() < 0.5 {
|
(gap.abs() >= 0.5).then_some(gap)
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
/// Place every row that reaches the viewport, outward from the
|
||||||
|
/// anchor, and return the extreme `(leading, trailing)` edges the walk
|
||||||
|
/// reached. Rebuilds `extents` and `at_start`/`at_end` from what it
|
||||||
|
/// placed; the caller clears `extents` first, since `reanchor_at_tap`
|
||||||
|
/// reads the previous frame's copy.
|
||||||
|
///
|
||||||
|
/// Called a second time in the same `draw` when the first pass lands
|
||||||
|
/// off the end of the content -- see [`Self::overscroll_gap`] and
|
||||||
|
/// `draw`.
|
||||||
|
fn lay_out(&mut self, painter: &mut Painter) -> (f32, f32) {
|
||||||
|
let anchor = self
|
||||||
|
.anchor
|
||||||
|
.expect("lay_out with no anchor: `draw` returns before this without one");
|
||||||
|
let placement = match anchor.edge {
|
||||||
|
Edge::Top => Placement::Top(anchor.offset),
|
||||||
|
Edge::Bottom => Placement::Bottom(anchor.offset),
|
||||||
|
};
|
||||||
|
let (mut top, mut bottom) = self.place(painter, anchor.slot, placement);
|
||||||
|
|
||||||
|
let mut idx_top = anchor.slot;
|
||||||
|
while top > 0.0 {
|
||||||
|
let Some(prev) = self.prev_slot(idx_top) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
|
||||||
|
top = t;
|
||||||
|
idx_top = prev;
|
||||||
}
|
}
|
||||||
self.scroll(gap);
|
|
||||||
// Nothing else will ask: the frame this correction was discovered
|
let mut idx_bottom = anchor.slot;
|
||||||
// in has already been laid out, and a fling that ran out at an end
|
while bottom < self.viewport_len {
|
||||||
// (`tick_fling`'s `hit_bound`) has stopped requesting frames --
|
let Some(next) = self.next_slot(idx_bottom) else {
|
||||||
// which is exactly the case that left the list parked past its own
|
break;
|
||||||
// first row.
|
};
|
||||||
painter.draw_again();
|
let (_, b) = self.place(painter, next, Placement::Top(bottom));
|
||||||
if let Some(redraw) = &self.redraw {
|
bottom = b;
|
||||||
redraw.request_redraw();
|
idx_bottom = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What `tick_fling` clamps a fling against -- see `at_start`'s
|
||||||
|
// field doc. `top`/`bottom` are the extreme edges actually placed
|
||||||
|
// this frame, and `prev_slot`/`next_slot` returning `None` is what
|
||||||
|
// "no more content" means everywhere else in this widget.
|
||||||
|
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
|
||||||
|
self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len;
|
||||||
|
|
||||||
|
// Both halves of `intersects_viewport`'s rule, checked where they
|
||||||
|
// are cheap to check: what this pass put on screen is exactly what
|
||||||
|
// overlaps the viewport, and nothing above or below it can be
|
||||||
|
// seen. The first failed silently for a whole build -- an
|
||||||
|
// off-screen row draws correctly, it is just in the wrong place.
|
||||||
|
// `assert!` for R1's reason: it walks the rows *on screen*, a
|
||||||
|
// handful, once per draw, and a release build is the only build
|
||||||
|
// this fault has ever been seen in.
|
||||||
|
assert!(
|
||||||
|
self.extents
|
||||||
|
.values()
|
||||||
|
.all(|e| self.intersects_viewport(e.top, e.bottom)),
|
||||||
|
"a row outside the viewport (0..{}) is recorded as on screen: {:?}",
|
||||||
|
self.viewport_len,
|
||||||
|
self.extents
|
||||||
|
.values()
|
||||||
|
.find(|e| !self.intersects_viewport(e.top, e.bottom)),
|
||||||
|
);
|
||||||
|
(top, bottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_snap_end(&mut self) {
|
fn update_snap_end(&mut self) {
|
||||||
@@ -985,71 +1048,60 @@ impl List {
|
|||||||
// below is recorded from the intersection test rather than from
|
// below is recorded from the intersection test rather than from
|
||||||
// "was this drawn".
|
// "was this drawn".
|
||||||
if let Some(h) = cached {
|
if let Some(h) = cached {
|
||||||
let (top, bottom) = match placement {
|
let (top, bottom) = placement.edges(h);
|
||||||
Placement::Top(top) => (top, top + h),
|
|
||||||
Placement::Bottom(bottom) => (bottom - h, bottom),
|
|
||||||
};
|
|
||||||
if !self.intersects_viewport(top, bottom) {
|
if !self.intersects_viewport(top, bottom) {
|
||||||
return (top, bottom);
|
return (top, bottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (top, bottom, height) = match (placement, cached) {
|
let widget = self.slot_widget(slot);
|
||||||
(Placement::Top(top), Some(h)) => {
|
let height = match cached {
|
||||||
// Offered a box sized to the *cached* height (cheap to
|
// Offered a box sized to the *cached* height (cheap to compare
|
||||||
// compare against last frame's offer, see `place`'s doc),
|
// against last frame's offer, see `place`'s doc), but the
|
||||||
// but the returned/recorded height comes from what this
|
// height kept is what this draw actually reported -- if the
|
||||||
// draw actually reported -- if the row's real content grew
|
// row's real content grew since it was cached (and was
|
||||||
// since it was cached (and was therefore redrawn: an
|
// therefore redrawn: an unchanged widget never disagrees with
|
||||||
// unchanged widget never disagrees with its own cache),
|
// its own cache), it is drawn again here, this frame, at the
|
||||||
// this frame already reflects the new size rather than
|
// box its own height implies rather than waiting a frame to
|
||||||
// waiting a frame to self-correct.
|
// self-correct.
|
||||||
let region = Self::abs_region(axis, top, top + h);
|
Some(h) => {
|
||||||
let used = painter.widget_within(self.slot_widget(slot), region);
|
let (top, bottom) = placement.edges(h);
|
||||||
|
let used = painter.widget_within(widget, Self::abs_region(axis, top, bottom));
|
||||||
let height = resolve(used);
|
let height = resolve(used);
|
||||||
if height != h {
|
if height != h {
|
||||||
let corrected = Self::abs_region(axis, top, top + height);
|
let (top, bottom) = placement.edges(height);
|
||||||
painter.widget_within(self.slot_widget(slot), corrected);
|
painter.widget_within(widget, Self::abs_region(axis, top, bottom));
|
||||||
}
|
}
|
||||||
(top, top + height, height)
|
height
|
||||||
}
|
}
|
||||||
(Placement::Top(top), None) => {
|
// Never measured, so there is no height to place it at: it is
|
||||||
let first = Self::abs_region(axis, top, top + GENEROUS_PADDING);
|
// measured at an oversized region first and drawn again at the
|
||||||
|
// box that measurement implies (`draw_twice`, not
|
||||||
|
// `reposition`, which writes an offset and never a size).
|
||||||
|
//
|
||||||
|
// A bottom-known row measures at a *zero-anchored* region
|
||||||
|
// rather than at its own box: using the real box would make
|
||||||
|
// the measurement's offered size track this list's own height,
|
||||||
|
// so a sibling growing taller (the input-box case) would look
|
||||||
|
// like a resize to every bottom-known row and force a full
|
||||||
|
// redraw of each -- despite a row's content depending only on
|
||||||
|
// width.
|
||||||
|
None => {
|
||||||
|
let measure_from = match placement {
|
||||||
|
Placement::Top(top) => top,
|
||||||
|
Placement::Bottom(_) => 0.0,
|
||||||
|
};
|
||||||
|
let first = Self::abs_region(axis, measure_from, measure_from + GENEROUS_PADDING);
|
||||||
let mut height = 0.0;
|
let mut height = 0.0;
|
||||||
painter.draw_twice(self.slot_widget(slot), first, |used| {
|
painter.draw_twice(widget, first, |used| {
|
||||||
height = resolve(used);
|
height = resolve(used);
|
||||||
Self::abs_region(axis, top, top + height)
|
let (top, bottom) = placement.edges(height);
|
||||||
|
Self::abs_region(axis, top, bottom)
|
||||||
});
|
});
|
||||||
(top, top + height, height)
|
height
|
||||||
}
|
|
||||||
(Placement::Bottom(bottom), Some(h)) => {
|
|
||||||
let region = Self::abs_region(axis, bottom - h, bottom);
|
|
||||||
let used = painter.widget_within(self.slot_widget(slot), region);
|
|
||||||
let height = resolve(used);
|
|
||||||
if height != h {
|
|
||||||
let corrected = Self::abs_region(axis, bottom - height, bottom);
|
|
||||||
painter.widget_within(self.slot_widget(slot), corrected);
|
|
||||||
}
|
|
||||||
(bottom - height, bottom, height)
|
|
||||||
}
|
|
||||||
(Placement::Bottom(bottom), None) => {
|
|
||||||
// Measured at a fixed, zero-anchored region rather than
|
|
||||||
// `painter.region()` (the list's *actual* offered box):
|
|
||||||
// using the real box would make the measurement's offered
|
|
||||||
// *size* track this list's own height, so a sibling
|
|
||||||
// growing taller (the input-box case) would look like a
|
|
||||||
// resize to every bottom-known row and force a full
|
|
||||||
// redraw of each -- despite a row's content depending
|
|
||||||
// only on width.
|
|
||||||
let first = Self::abs_region(axis, 0.0, GENEROUS_PADDING);
|
|
||||||
let mut height = 0.0;
|
|
||||||
painter.draw_twice(self.slot_widget(slot), first, |used| {
|
|
||||||
height = resolve(used);
|
|
||||||
Self::abs_region(axis, bottom - height, bottom)
|
|
||||||
});
|
|
||||||
(bottom - height, bottom, height)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let (top, bottom) = placement.edges(height);
|
||||||
if let Some(k) = key {
|
if let Some(k) = key {
|
||||||
self.heights.insert(k, height);
|
self.heights.insert(k, height);
|
||||||
// `extents` is what is *on screen* (`key_at`'s doc, and
|
// `extents` is what is *on screen* (`key_at`'s doc, and
|
||||||
@@ -1115,10 +1167,10 @@ impl Widget for List {
|
|||||||
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
||||||
|
|
||||||
self.repair_anchor();
|
self.repair_anchor();
|
||||||
let Some(mut anchor) = self.anchor else {
|
if self.anchor.is_none() {
|
||||||
self.extents.clear();
|
self.extents.clear();
|
||||||
return Size::REST;
|
return Size::REST;
|
||||||
};
|
}
|
||||||
|
|
||||||
// `reanchor_at_tap` reads `extents` as they stood after the
|
// `reanchor_at_tap` reads `extents` as they stood after the
|
||||||
// *previous* frame's layout -- the last on-screen box for each
|
// *previous* frame's layout -- the last on-screen box for each
|
||||||
@@ -1126,63 +1178,38 @@ impl Widget for List {
|
|||||||
// frame has to wait until after this call, not before.
|
// frame has to wait until after this call, not before.
|
||||||
if let Some(tap) = self.pending_tap.take() {
|
if let Some(tap) = self.pending_tap.take() {
|
||||||
self.reanchor_at_tap(tap);
|
self.reanchor_at_tap(tap);
|
||||||
anchor = self.anchor.unwrap();
|
|
||||||
}
|
}
|
||||||
self.extents.clear();
|
self.extents.clear();
|
||||||
|
|
||||||
let placement = match anchor.edge {
|
let (top, bottom) = self.lay_out(painter);
|
||||||
Edge::Top => Placement::Top(anchor.offset),
|
|
||||||
Edge::Bottom => Placement::Bottom(anchor.offset),
|
|
||||||
};
|
|
||||||
let (mut top, mut bottom) = self.place(painter, anchor.slot, placement);
|
|
||||||
|
|
||||||
let mut idx_top = anchor.slot;
|
// **The clamp is applied inside the frame that found it**, not
|
||||||
while top > 0.0 {
|
// marked for the next one: layout is a pure function of the state
|
||||||
let Some(prev) = self.prev_slot(idx_top) else {
|
// rather than of how many frames have been drawn (Iris,
|
||||||
break;
|
// 2026-09-08), and a correction that lands next frame is a frame
|
||||||
};
|
// drawn wrong -- with nothing guaranteed to ask for that next
|
||||||
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
|
// frame, since a fling that ran out at an end has already stopped
|
||||||
top = t;
|
// requesting them, which is exactly what left the list parked past
|
||||||
idx_top = prev;
|
// its own first row. Same shape as `Scroll::draw`, which measures
|
||||||
|
// its content and places it again in the one frame.
|
||||||
|
//
|
||||||
|
// One further pass settles it, always: the gap is measured from
|
||||||
|
// the edges this walk actually placed, so moving the anchor by it
|
||||||
|
// puts that edge exactly on the viewport's, and the rows the
|
||||||
|
// second walk brings into view are placed outward from there. The
|
||||||
|
// opposite end cannot open a new gap -- that would mean the
|
||||||
|
// content is shorter than the viewport, which `overscroll_gap`
|
||||||
|
// already declines to touch. The extra walk is paid only on a
|
||||||
|
// frame that was overscrolled, and it re-offers every row the same
|
||||||
|
// box at a new offset, which `draw_inner` dispatches as an O(1)
|
||||||
|
// move.
|
||||||
|
if let Some(gap) = self.overscroll_gap(top, bottom) {
|
||||||
|
self.scroll(gap);
|
||||||
|
self.extents.clear();
|
||||||
|
self.lay_out(painter);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut idx_bottom = anchor.slot;
|
|
||||||
while bottom < self.viewport_len {
|
|
||||||
let Some(next) = self.next_slot(idx_bottom) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let (_, b) = self.place(painter, next, Placement::Top(bottom));
|
|
||||||
bottom = b;
|
|
||||||
idx_bottom = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
// What `tick_fling` clamps a fling against -- see `at_start`'s
|
|
||||||
// field doc. `top`/`bottom` are the extreme edges actually placed
|
|
||||||
// this frame, and `prev_slot`/`next_slot` returning `None` is what
|
|
||||||
// "no more content" means everywhere else in this widget.
|
|
||||||
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
|
|
||||||
self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len;
|
|
||||||
|
|
||||||
// Both halves of `intersects_viewport`'s rule, checked where they
|
|
||||||
// are cheap to check: what this frame put on screen is exactly
|
|
||||||
// what overlaps the viewport, and nothing above or below it can
|
|
||||||
// be seen. The first failed silently for a whole build -- an
|
|
||||||
// off-screen row draws correctly, it is just in the wrong place.
|
|
||||||
// `assert!` for R1's reason: it walks the rows *on screen*, a
|
|
||||||
// handful, once per draw, and a release build is the only build
|
|
||||||
// this fault has ever been seen in.
|
|
||||||
assert!(
|
|
||||||
self.extents
|
|
||||||
.values()
|
|
||||||
.all(|e| self.intersects_viewport(e.top, e.bottom)),
|
|
||||||
"a row outside the viewport (0..{}) is recorded as on screen: {:?}",
|
|
||||||
self.viewport_len,
|
|
||||||
self.extents
|
|
||||||
.values()
|
|
||||||
.find(|e| !self.intersects_viewport(e.top, e.bottom)),
|
|
||||||
);
|
|
||||||
self.rehome_anchor();
|
self.rehome_anchor();
|
||||||
self.clamp_to_content(painter, top, bottom);
|
|
||||||
self.update_snap_end();
|
self.update_snap_end();
|
||||||
Size::REST
|
Size::REST
|
||||||
}
|
}
|
||||||
@@ -1256,7 +1283,7 @@ mod tests {
|
|||||||
|
|
||||||
/// The case the top-edge cull and the overscroll clamp both had no
|
/// The case the top-edge cull and the overscroll clamp both had no
|
||||||
/// reason to touch: fewer rows than fit. Every one of them is drawn
|
/// reason to touch: fewer rows than fit. Every one of them is drawn
|
||||||
/// (nothing here is outside the viewport), and `clamp_to_content`
|
/// (nothing here is outside the viewport), and `overscroll_gap`
|
||||||
/// leaves the list bottom-anchored -- the gap above the first row is
|
/// leaves the list bottom-anchored -- the gap above the first row is
|
||||||
/// not overscroll, it is where this widget puts a short list, and
|
/// not overscroll, it is where this widget puts a short list, and
|
||||||
/// pulling it to the top would be the clamp overriding
|
/// pulling it to the top would be the clamp overriding
|
||||||
@@ -1605,8 +1632,9 @@ mod tests {
|
|||||||
|
|
||||||
// Backwards, into content that exists: a list opens flush with
|
// Backwards, into content that exists: a list opens flush with
|
||||||
// its newest end, so scrolling *forward* from there is
|
// its newest end, so scrolling *forward* from there is
|
||||||
// overscroll, and `clamp_to_content` lays out a second time to
|
// overscroll, and the clamp lays out a second time within the
|
||||||
// give it back -- a correct extra pass, but not the ordinary
|
// frame to give it back -- a correct extra pass, but not the
|
||||||
|
// ordinary
|
||||||
// scroll tick whose cost this test is about.
|
// scroll tick whose cost this test is about.
|
||||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(-5.0);
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(-5.0);
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
@@ -2146,6 +2174,34 @@ mod tests {
|
|||||||
assert_eq!((before.top, before.bottom), (after.top, after.bottom));
|
assert_eq!((before.top, before.bottom), (after.top, after.bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The clamp on the frame that discovers it, for an ordinary
|
||||||
|
/// `scroll` rather than a fling: one `update` after moving 100,000px
|
||||||
|
/// past the first row, and the list is already flush with the top.
|
||||||
|
/// The old code left that frame drawn with the whole screen blank and
|
||||||
|
/// snapped back on the next one, so this fails on it -- it draws no
|
||||||
|
/// settling frame on purpose (the same shape as
|
||||||
|
/// `phone_screen.rs`'s composer test).
|
||||||
|
#[test]
|
||||||
|
fn scrolling_past_the_start_is_given_back_in_the_same_frame() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.scroll(-100_000.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||||
|
let first = list_ref.extents[&0];
|
||||||
|
assert!(
|
||||||
|
first.top.abs() < 0.5,
|
||||||
|
"the frame that overscrolled should end flush with the first row, not {}px from it",
|
||||||
|
first.top,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fling_toward_the_start_stops_at_the_first_row() {
|
fn fling_toward_the_start_stops_at_the_first_row() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
@@ -2165,10 +2221,11 @@ mod tests {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The frame that gives back whatever the fling's last step spent
|
// No settling frame: the draw that discovers the fling ran past
|
||||||
// past the first row -- `clamp_to_content` writes the anchor at
|
// the first row gives those pixels back inside that same frame
|
||||||
// the end of a draw, so it lands on the next one.
|
// (`overscroll_gap`), so the last frame the loop above drew is
|
||||||
render.update(&root, &mut rsc);
|
// already flush with the top. Drawing one more here would hide a
|
||||||
|
// regression to the old next-frame correction.
|
||||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
list_ref.at_start,
|
list_ref.at_start,
|
||||||
|
|||||||
@@ -279,8 +279,8 @@ fn the_row_across_the_bottom_edge_is_drawn() {
|
|||||||
|
|
||||||
/// Panning past the first row settles *on* it rather than beyond it. The
|
/// Panning past the first row settles *on* it rather than beyond it. The
|
||||||
/// list is scrolled far further back than the fixture is long, which is
|
/// list is scrolled far further back than the fixture is long, which is
|
||||||
/// what a hard fling toward the top does; before `List::clamp_to_content`
|
/// what a hard fling toward the top does; before the clamp existed it
|
||||||
/// it stayed wherever that left it -- the phone's "black from the header
|
/// stayed wherever that left it -- the phone's "black from the header
|
||||||
/// down", and a whole blank screen in `iris`'s own
|
/// down", and a whole blank screen in `iris`'s own
|
||||||
/// `fling_toward_the_start_stops_at_the_first_row`.
|
/// `fling_toward_the_start_stops_at_the_first_row`.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -292,10 +292,10 @@ fn scrolling_past_the_first_row_settles_on_it() {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
// The correction is written at the end of a draw and lands on the
|
// No settling frame on purpose: the draw that discovers the gap gives
|
||||||
// next one.
|
// it back inside that same frame (`List::overscroll_gap`), so the last
|
||||||
h.frame(t);
|
// frame `scrolled` drew is already flush with the first row. Adding
|
||||||
|
// one here would hide a regression to the old next-frame correction.
|
||||||
let rows = drawn_rows(&h, &screen);
|
let rows = drawn_rows(&h, &screen);
|
||||||
let first = *rows.first().expect("the first row is on screen");
|
let first = *rows.first().expect("the first row is on screen");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -318,7 +318,6 @@ fn scrolling_past_the_last_row_settles_on_it() {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
h.frame(t);
|
|
||||||
|
|
||||||
let rows = drawn_rows(&h, &screen);
|
let rows = drawn_rows(&h, &screen);
|
||||||
let last = *rows.last().expect("the last row is on screen");
|
let last = *rows.last().expect("the last row is on screen");
|
||||||
|
|||||||
Reference in new issue
Block a user