iris: a transcript row is drawn if it overlaps the viewport, and clipped to it
Iris's phone, 2026-09-07, two screenshots of the transcript at its top edge wrong in opposite directions: rows already scrolled past still drawn, over the header bar (`version = "0.1.0"` behind "Run benchmark"), and a blank band where the row straddling the edge should be. Three faults, one rule -- `List::intersects_viewport`: a row is drawn if any part of it is inside the list's own box, and nothing outside that box reaches the screen. 1. **The walk drew everything between the anchor and the viewport.** `scroll` moves the anchor's offset and nothing else, so panning leaves the anchor's own row further and further outside the viewport, and every row in between was placed *and drawn* on every frame. Measured on the bench fixture: 8 scrolls of 3000px left 64 rows drawn for a 2012px viewport, ~59 of them off screen. `place` now skips a row whose height is already known and whose box does not overlap; `rehome_anchor` moves the anchor onto a visible row each frame, without moving anything drawn, so the walk is O(visible) again whatever distance was travelled. `extents` holds only what is on screen, which is what `key_at` already claimed of it, asserted at the end of every draw. 2. **Nothing clipped the list.** A straddling row is drawn in full -- that is the rule -- so the part above the list was on screen. The transcript's list is `.masked()` now (the mechanism `examples/ message_list.rs` and the composer already use, and one that nests as of the previous commit), and `List::draw` asserts it has a mask rather than leaving that to each caller to remember. 3. **A fling past the first row stayed past it.** `tick_fling` stops a fling that has reached an end, wherever the spline's last step had put it: `fling_toward_the_start_stops_at_the_first_row` was leaving the first row 1398px below a 600px viewport -- a blank screen -- and its assertion could not see it, since `extents` then held off-screen rows too and `top >= -0.5` is satisfied by +1398. `clamp_to_content` gives the gap back from the ends the walk already placed. Only when the opposite end is not also in the viewport, so a list shorter than its viewport stays bottom-anchored as before. Layer 1 of the test rig throughout (`transcript-fixture/tests/ top_edge.rs`, the real screen under a bench-app-shaped header): each of the five fails on its own subject and no other -- culling on the row's top instead of its bottom fails only `the_row_across_the_top_edge_is_ drawn`, the pre-fix walk fails only the two about what is placed, dropping `.masked()` fails only `the_list_is_clipped_to_its_own_box`, dropping the clamp fails only `scrolling_past_the_first_row_settles_on_ it`. The bottom edge and a list shorter than the viewport are the ends none of this had a reason to touch and are covered too.
This commit is contained in:
1 parent
d507ae4c96
commit
e922b73d7a
3 files changed
+534
-29
No files matched your search
+298
-28
@@ -94,11 +94,22 @@
|
|||||||
//! row that has never been measured, so this stays independent of how many
|
//! row that has never been measured, so this stays independent of how many
|
||||||
//! rows exist outside the loaded window.
|
//! rows exist outside the loaded window.
|
||||||
//!
|
//!
|
||||||
//! **What is deliberately not solved here.** No overscroll clamping: a
|
//! **Only what overlaps the viewport is drawn, and it is drawn whole.**
|
||||||
//! `scroll()` past the first or last row leaves a gap rather than rubber-
|
//! One rule, `intersects_viewport`, used by both halves of that sentence:
|
||||||
//! banding back (mirrors `Scroll`'s own documented one-frame-lag
|
//! a row straddling either edge is drawn in full and clipped by the
|
||||||
//! tolerance in LAYOUT.md, just not even auto-corrected -- there is
|
//! `.masked()` its caller must place it in (`List::draw` asserts that),
|
||||||
//! nothing to measure "how much content is left" without walking it).
|
//! and a row that has left the viewport is not drawn at all. The walk
|
||||||
|
//! still traverses whatever lies between the anchor and the viewport, and
|
||||||
|
//! `rehome_anchor` moves the anchor back onto a visible row every frame so
|
||||||
|
//! that "whatever lies between" stays empty however far the list is
|
||||||
|
//! panned.
|
||||||
|
//!
|
||||||
|
//! **Overscroll is taken back on the next frame, never rubber-banded.** A
|
||||||
|
//! `scroll()` or a fling past the first or last row leaves a gap for one
|
||||||
|
//! frame; `clamp_to_content` measures it from the ends the walk already
|
||||||
|
//! placed and gives it back (the same one-frame-lag `Scroll`'s content
|
||||||
|
//! length has, per LAYOUT.md). 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;
|
||||||
@@ -415,8 +426,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).
|
||||||
/// Deliberately unclamped -- see the module doc's "what is not
|
/// Unclamped here, on purpose: it is one write, and there is nothing
|
||||||
/// solved here."
|
/// at this point that knows where the content ends. The next `draw`
|
||||||
|
/// gives back whatever this moved past ([`Self::clamp_to_content`]).
|
||||||
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;
|
||||||
@@ -790,6 +802,115 @@ impl List {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move the anchor onto a row that is actually on screen, without
|
||||||
|
/// moving anything that is drawn: the row it re-homes to keeps the
|
||||||
|
/// exact top edge this frame's layout gave it.
|
||||||
|
///
|
||||||
|
/// [`Self::scroll`] moves the anchor's *offset* and nothing else, so
|
||||||
|
/// panning away from the anchor's own row leaves that row further and
|
||||||
|
/// further outside the viewport, and every row between it and the
|
||||||
|
/// viewport has to be walked on every frame from then on -- before
|
||||||
|
/// `place`'s intersection test, drawn too. Measured on the bench
|
||||||
|
/// fixture before this: 8 scrolls of 3000px left **64 rows** placed in
|
||||||
|
/// a 2012px viewport, ~59 of them off-screen, and the ones above it
|
||||||
|
/// drawn straight over the header (docs/IRIS_TODO.md, 2026-09-07).
|
||||||
|
/// Re-homing each frame makes the walk O(visible) again whatever
|
||||||
|
/// distance was travelled, which is what the module doc claims.
|
||||||
|
///
|
||||||
|
/// Only when the anchor's own row has left the viewport, so
|
||||||
|
/// `update_snap_end`'s pinned-to-newest anchor -- last slot, bottom
|
||||||
|
/// edge at the viewport's own bottom, which intersects it -- is left
|
||||||
|
/// exactly as it is rather than rewritten into a top-edge anchor that
|
||||||
|
/// no longer reads as flush with the end.
|
||||||
|
fn rehome_anchor(&mut self) {
|
||||||
|
let Some(anchor) = self.anchor else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.extents.values().any(|e| e.slot == anchor.slot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The topmost row on screen, so the anchor's offset stays a small
|
||||||
|
// number near the viewport's own leading edge rather than
|
||||||
|
// whatever the last row's bottom happens to be.
|
||||||
|
let Some(first) = self
|
||||||
|
.extents
|
||||||
|
.values()
|
||||||
|
.min_by(|a, b| a.top.total_cmp(&b.top))
|
||||||
|
.copied()
|
||||||
|
else {
|
||||||
|
// Nothing on screen at all -- a list scrolled past its own
|
||||||
|
// content (`scroll` is deliberately unclamped). There is no
|
||||||
|
// on-screen row to re-home to, and inventing one would move
|
||||||
|
// the list; leave the anchor where it is and let the next
|
||||||
|
// scroll or `repair_anchor` bring content back.
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.anchor = Some(Anchor {
|
||||||
|
slot: first.slot,
|
||||||
|
edge: Edge::Top,
|
||||||
|
offset: first.top,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take back an empty band at one edge that content on the other side
|
||||||
|
/// of the viewport could fill -- the correction that 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
|
||||||
|
/// placed, so the gap is already measured: `at_start` means nothing is
|
||||||
|
/// above `top`, and if `top` is nevertheless below the viewport's own
|
||||||
|
/// 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
|
||||||
|
/// unsolved ("no overscroll clamping ... nothing to measure how much
|
||||||
|
/// content is left without walking it") -- true of *total* content
|
||||||
|
/// height, but the walk hands back both ends of the loaded run for
|
||||||
|
/// free, which is all a clamp needs. `tick_fling` stops a fling that
|
||||||
|
/// has reached an end, but stops it wherever the spline's last step
|
||||||
|
/// had already put it: a hard fling to the top of the bench fixture
|
||||||
|
/// left the first row **1398px below** a 600px viewport, i.e. the
|
||||||
|
/// whole screen blank, and it stayed there (docs/IRIS_TODO.md,
|
||||||
|
/// 2026-09-07: "black from the header down").
|
||||||
|
///
|
||||||
|
/// **Only when the opposite end is not also inside the viewport.**
|
||||||
|
/// Both at once means the content is shorter than the viewport, where
|
||||||
|
/// 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
|
||||||
|
/// widget rejecting its own default (`repair_anchor`).
|
||||||
|
///
|
||||||
|
/// Applied to the anchor, so it lands on the *next* frame rather than
|
||||||
|
/// re-running this one: the same one-frame-lag `Scroll` accepts for
|
||||||
|
/// its content length, and one frame is 8ms on the phone.
|
||||||
|
fn clamp_to_content(&mut self, painter: &mut Painter, top: f32, bottom: f32) {
|
||||||
|
if self.at_start == self.at_end {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `at_start`/`at_end` already carry the sign of their own gap
|
||||||
|
// (`top >= 0.0`, `bottom <= viewport_len`), so this is the gap
|
||||||
|
// itself, positive to move content toward the leading edge.
|
||||||
|
let gap = if self.at_start {
|
||||||
|
top
|
||||||
|
} else {
|
||||||
|
bottom - self.viewport_len
|
||||||
|
};
|
||||||
|
// Sub-pixel gaps are what floating-point row heights leave behind
|
||||||
|
// every frame; correcting one would ask for another frame, which
|
||||||
|
// would leave another, and the list would never stop redrawing.
|
||||||
|
if gap.abs() < 0.5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.scroll(gap);
|
||||||
|
// Nothing else will ask: the frame this correction was discovered
|
||||||
|
// in has already been laid out, and a fling that ran out at an end
|
||||||
|
// (`tick_fling`'s `hit_bound`) has stopped requesting frames --
|
||||||
|
// which is exactly the case that left the list parked past its own
|
||||||
|
// first row.
|
||||||
|
painter.draw_again();
|
||||||
|
if let Some(redraw) = &self.redraw {
|
||||||
|
redraw.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn update_snap_end(&mut self) {
|
fn update_snap_end(&mut self) {
|
||||||
self.snap_end = match self.anchor {
|
self.snap_end = match self.anchor {
|
||||||
Some(a) => {
|
Some(a) => {
|
||||||
@@ -801,6 +922,22 @@ impl List {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The one rule for what this list draws**: a row is on screen if
|
||||||
|
/// any part of it is, so a row straddling either edge is drawn *in
|
||||||
|
/// full* and one that has left the viewport entirely is not drawn at
|
||||||
|
/// all. Both halves matter and they failed in opposite directions on
|
||||||
|
/// Iris's phone (docs/IRIS_TODO.md, 2026-09-07): rows already scrolled
|
||||||
|
/// past were still being drawn, over the header above the list, and
|
||||||
|
/// the part of a straddling row above the viewport had nothing
|
||||||
|
/// clipping it. The viewport here is the list's own box -- `0 ..
|
||||||
|
/// viewport_len`, `painter.region()` in window terms -- which is the
|
||||||
|
/// same box `List::draw` requires a mask on, so that what this test
|
||||||
|
/// admits and what the clip keeps are one region rather than two that
|
||||||
|
/// can disagree.
|
||||||
|
fn intersects_viewport(&self, top: f32, bottom: f32) -> bool {
|
||||||
|
bottom > 0.0 && top < self.viewport_len
|
||||||
|
}
|
||||||
|
|
||||||
fn abs_region(axis: Axis, start: f32, end: f32) -> UiRegion {
|
fn abs_region(axis: Axis, start: f32, end: f32) -> UiRegion {
|
||||||
let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end));
|
let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end));
|
||||||
UiRegion::from_axis(axis, span, UiSpan::FULL)
|
UiRegion::from_axis(axis, span, UiSpan::FULL)
|
||||||
@@ -865,6 +1002,26 @@ impl List {
|
|||||||
let key = self.slot_key(slot);
|
let key = self.slot_key(slot);
|
||||||
let cached = key.and_then(|k| self.heights.get(&k).copied());
|
let cached = key.and_then(|k| self.heights.get(&k).copied());
|
||||||
|
|
||||||
|
// A row entirely outside the viewport is traversed but not drawn
|
||||||
|
// -- see `intersects_viewport`. The walk still has to *pass
|
||||||
|
// through* it, because its height is what says where the rows
|
||||||
|
// behind it land, but nothing about it reaches the screen, so
|
||||||
|
// drawing it costs a redraw (and, unclipped, paints over whatever
|
||||||
|
// is above the list) for content nobody can see. Only possible
|
||||||
|
// for a row whose height is already known: a first-time row has
|
||||||
|
// to be drawn to be measured at all, which is why the extent
|
||||||
|
// below is recorded from the intersection test rather than from
|
||||||
|
// "was this drawn".
|
||||||
|
if let Some(h) = cached {
|
||||||
|
let (top, bottom) = match placement {
|
||||||
|
Placement::Top(top) => (top, top + h),
|
||||||
|
Placement::Bottom(bottom) => (bottom - h, bottom),
|
||||||
|
};
|
||||||
|
if !self.intersects_viewport(top, bottom) {
|
||||||
|
return (top, bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (top, bottom, height) = match (placement, cached) {
|
let (top, bottom, height) = match (placement, cached) {
|
||||||
(Placement::Top(top), Some(h)) => {
|
(Placement::Top(top), Some(h)) => {
|
||||||
// Offered a box sized to the *cached* height (cheap to
|
// Offered a box sized to the *cached* height (cheap to
|
||||||
@@ -928,8 +1085,14 @@ impl List {
|
|||||||
};
|
};
|
||||||
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
|
||||||
|
// `rehome_anchor` below reads it as exactly that), so a
|
||||||
|
// first-time row that had to be drawn to be measured and
|
||||||
|
// turned out to be off-screen does not go in it.
|
||||||
|
if self.intersects_viewport(top, bottom) {
|
||||||
self.extents.insert(k, RowExtent { slot, top, bottom });
|
self.extents.insert(k, RowExtent { slot, top, bottom });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
(top, bottom)
|
(top, bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -961,6 +1124,21 @@ impl Widget for List {
|
|||||||
// 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. See `fling`.
|
// that knows it. See `fling`.
|
||||||
self.density = painter.density();
|
self.density = painter.density();
|
||||||
|
// A row that straddles either edge is drawn in full
|
||||||
|
// (`intersects_viewport`), so the part of it outside this list's
|
||||||
|
// box is on screen unless something clips it -- and with nothing
|
||||||
|
// clipping it, a transcript panned to its top edge drew code and
|
||||||
|
// paragraphs straight through the header bar above it on Iris's
|
||||||
|
// phone (docs/IRIS_TODO.md, 2026-09-07). Clipping is `.masked()`,
|
||||||
|
// one mechanism, applied by whoever places the list -- a `List`
|
||||||
|
// cannot set the mask itself, since `Painter::set_mask` allows one
|
||||||
|
// mask per widget and rows of this list already use their own
|
||||||
|
// (`transcript-ui`'s `row.rs`, `tool.rs`). So it checks instead.
|
||||||
|
debug_assert!(
|
||||||
|
painter.is_masked(),
|
||||||
|
"a `List` must be drawn inside something `.masked()`: it draws rows straddling both \
|
||||||
|
edges in full, so the parts outside its own box reach the screen otherwise",
|
||||||
|
);
|
||||||
let output_len = painter.output_size().axis(axis);
|
let output_len = painter.output_size().axis(axis);
|
||||||
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
||||||
|
|
||||||
@@ -1013,6 +1191,23 @@ impl Widget for List {
|
|||||||
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
|
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;
|
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.
|
||||||
|
debug_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.clamp_to_content(painter, top, bottom);
|
||||||
self.update_snap_end();
|
self.update_snap_end();
|
||||||
Size::REST
|
Size::REST
|
||||||
}
|
}
|
||||||
@@ -1068,9 +1263,59 @@ mod tests {
|
|||||||
/// calling `List`'s own methods through `Widgets::get`/`get_mut`, which
|
/// calling `List`'s own methods through `Widgets::get`/`get_mut`, which
|
||||||
/// need a `Sized` widget type) and the erased root `UiRenderState::update`
|
/// need a `Sized` widget type) and the erased root `UiRenderState::update`
|
||||||
/// draws.
|
/// draws.
|
||||||
|
///
|
||||||
|
/// The root is a `Masked` around the list rather than the list
|
||||||
|
/// itself, because that is what every real caller has to do -- a
|
||||||
|
/// `List` draws the row straddling each edge in full and asserts
|
||||||
|
/// something is clipping it (`List::draw`). The mask is the full
|
||||||
|
/// window here, which is also the list's own box.
|
||||||
fn add_list(rsc: &mut TestRsc, list: List) -> (WeakWidget<List>, StrongWidget) {
|
fn add_list(rsc: &mut TestRsc, list: List) -> (WeakWidget<List>, StrongWidget) {
|
||||||
let strong = rsc.ui.widgets.add_strong(list);
|
let strong = rsc.ui.widgets.add_strong(list);
|
||||||
(strong.weak(), strong.any())
|
let weak = strong.weak();
|
||||||
|
let root = rsc.ui.widgets.add_strong(Masked {
|
||||||
|
inner: strong.any(),
|
||||||
|
});
|
||||||
|
(weak, root.any())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// (nothing here is outside the viewport), and `clamp_to_content`
|
||||||
|
/// leaves the list bottom-anchored -- the gap above the first row is
|
||||||
|
/// not overscroll, it is where this widget puts a short list, and
|
||||||
|
/// pulling it to the top would be the clamp overriding
|
||||||
|
/// `repair_anchor`'s own default.
|
||||||
|
#[test]
|
||||||
|
fn a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let mut list = List::new(Axis::Y);
|
||||||
|
push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
|
||||||
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
// Several frames, since the clamp acts on the frame *after* the
|
||||||
|
// one that measured a gap: a wrong one would walk the rows up the
|
||||||
|
// screen 40px at a time rather than settle.
|
||||||
|
for _ in 0..4 {
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
list_ref.extents.len(),
|
||||||
|
3,
|
||||||
|
"every row of a short list is on screen"
|
||||||
|
);
|
||||||
|
let first = list_ref.extents[&0];
|
||||||
|
let last = list_ref.extents[&2];
|
||||||
|
assert!(
|
||||||
|
(first.top - 40.0).abs() < 0.01 && (last.bottom - 100.0).abs() < 0.01,
|
||||||
|
"a 60px list in a 100px viewport moved off the bottom: rows {}..{}",
|
||||||
|
first.top,
|
||||||
|
last.bottom,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1142,7 +1387,7 @@ mod tests {
|
|||||||
bg_ids.push(bg_id);
|
bg_ids.push(bg_id);
|
||||||
list.push_back(ListRow::new(key, row));
|
list.push_back(ListRow::new(key, row));
|
||||||
}
|
}
|
||||||
let root = rsc.ui.widgets.add_strong(list).any();
|
let (_, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
let mut render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((100.0, 100.0));
|
render.resize((100.0, 100.0));
|
||||||
@@ -1323,7 +1568,12 @@ mod tests {
|
|||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
render.take_counters();
|
render.take_counters();
|
||||||
|
|
||||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
|
// Backwards, into content that exists: a list opens flush with
|
||||||
|
// its newest end, so scrolling *forward* from there is
|
||||||
|
// overscroll, and `clamp_to_content` lays out a second time to
|
||||||
|
// give it back -- a correct extra pass, but not the ordinary
|
||||||
|
// scroll tick whose cost this test is about.
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(-5.0);
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
|
||||||
@@ -1616,9 +1866,28 @@ mod tests {
|
|||||||
/// Enough rows, tall enough, that a fling toward the start has real
|
/// Enough rows, tall enough, that a fling toward the start has real
|
||||||
/// room to travel before `at_start` clamps it -- shared by the fling
|
/// room to travel before `at_start` clamps it -- shared by the fling
|
||||||
/// tests below.
|
/// tests below.
|
||||||
|
/// How far a `build_flingable_list` list has scrolled from its very
|
||||||
|
/// first row, in pixels: read off the topmost row on screen, whose
|
||||||
|
/// content position is exactly `slot * ROW_H` because every row there
|
||||||
|
/// is that tall. Measures the list's own accumulated movement (the
|
||||||
|
/// thing `scroll`/`tick_fling` write) rather than the spline's
|
||||||
|
/// bookkeeping, and unlike a single row's extent it stays defined
|
||||||
|
/// however far the list travels -- `extents` holds only what is
|
||||||
|
/// on screen (`List::intersects_viewport`).
|
||||||
|
fn scroll_position(list: &List) -> f32 {
|
||||||
|
let top = list
|
||||||
|
.extents
|
||||||
|
.values()
|
||||||
|
.min_by(|a, b| a.top.total_cmp(&b.top))
|
||||||
|
.expect("something is on screen");
|
||||||
|
top.slot as f32 * FLING_ROW_H - top.top
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLING_ROW_H: f32 = 20.0;
|
||||||
|
|
||||||
fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget<List>, StrongWidget, UiRenderState) {
|
fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget<List>, StrongWidget, UiRenderState) {
|
||||||
let mut list = List::new(Axis::Y);
|
let mut list = List::new(Axis::Y);
|
||||||
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), 20.0);
|
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), FLING_ROW_H);
|
||||||
let (list_weak, root) = add_list(rsc, list);
|
let (list_weak, root) = add_list(rsc, list);
|
||||||
let mut render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((100.0, 600.0));
|
render.resize((100.0, 600.0));
|
||||||
@@ -1764,32 +2033,22 @@ mod tests {
|
|||||||
|
|
||||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut prev_top = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0].top;
|
let mut prev = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
|
||||||
let mut deltas = Vec::new();
|
let mut deltas = Vec::new();
|
||||||
for step in 1..600 {
|
for step in 1..600 {
|
||||||
let now = start + std::time::Duration::from_millis(step * 16);
|
let now = start + std::time::Duration::from_millis(step * 16);
|
||||||
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let Some(top) = rsc
|
let at = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
|
||||||
.ui
|
deltas.push((at - prev).abs());
|
||||||
.widgets
|
prev = at;
|
||||||
.get(&list_weak)
|
|
||||||
.unwrap()
|
|
||||||
.extents
|
|
||||||
.get(&0)
|
|
||||||
.map(|e| e.top)
|
|
||||||
else {
|
|
||||||
break; // row 0 scrolled out of the loaded extents
|
|
||||||
};
|
|
||||||
deltas.push((prev_top - top).abs());
|
|
||||||
prev_top = top;
|
|
||||||
if !still {
|
if !still {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
deltas.len() >= 3,
|
deltas.len() >= 3,
|
||||||
"fling settled or left row 0's extent before collecting enough samples"
|
"fling settled before collecting enough samples"
|
||||||
);
|
);
|
||||||
// Skip the first tick (the slop-transition jump the arbiter
|
// Skip the first tick (the slop-transition jump the arbiter
|
||||||
// applies is a `List::fling`-adjacent concern, not this curve,
|
// applies is a `List::fling`-adjacent concern, not this curve,
|
||||||
@@ -1871,15 +2130,26 @@ mod tests {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The frame that gives back whatever the fling's last step spent
|
||||||
|
// past the first row -- `clamp_to_content` writes the anchor at
|
||||||
|
// the end of a draw, so it lands on the next one.
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
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,
|
||||||
"fling should have clamped at the first row"
|
"fling should have clamped at the first row"
|
||||||
);
|
);
|
||||||
|
// Not `>= -0.5`: `extents` used to hold every row the walk placed,
|
||||||
|
// on screen or not, so that read was satisfied by a first row
|
||||||
|
// sitting *1398px below* a 600px viewport with the whole screen
|
||||||
|
// blank -- the assertion could not fail in the direction the bug
|
||||||
|
// actually went. Both edges, so neither an overshoot past the top
|
||||||
|
// nor one left uncorrected can pass.
|
||||||
let first = list_ref.extents[&0];
|
let first = list_ref.extents[&0];
|
||||||
assert!(
|
assert!(
|
||||||
first.top >= -0.5,
|
first.top.abs() < 0.5,
|
||||||
"clamped fling overshot the first row's top: {}",
|
"a fling stopped at the start must leave the first row flush with the top, not {}px \
|
||||||
|
from it",
|
||||||
first.top
|
first.top
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! Layer 1 of docs/RUST.md's "Three test layers", for the transcript's
|
||||||
|
//! own edges: the real screen over the real fixture, under a header bar
|
||||||
|
//! like the bench app's, driven by `iris::harness`.
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
//! 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
|
||||||
|
//! it is inside the list's own box, and nothing outside that box reaches
|
||||||
|
//! the screen.
|
||||||
|
|
||||||
|
use iris::harness::Harness;
|
||||||
|
use iris::prelude::*;
|
||||||
|
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
|
||||||
|
/// A header band above the transcript, as `bench_client.rs` puts one --
|
||||||
|
/// the surface the rows were drawing over on the phone. Its exact height
|
||||||
|
/// does not matter; what matters is that the list's own box does not
|
||||||
|
/// start at the top of the window, so "above the viewport" and "off the
|
||||||
|
/// screen" are different places.
|
||||||
|
const HEADER_H: f32 = 300.0;
|
||||||
|
const HEADER: UiColor = UiColor::new(28, 28, 34, 255);
|
||||||
|
|
||||||
|
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let (opened, tree) = transcript_fixture::build_screen(&mut h.rsc).expect("the fixture folds");
|
||||||
|
let content = WidgetPtr::new().add(&mut h.rsc);
|
||||||
|
content(&mut h.rsc).set(tree);
|
||||||
|
let root = (rect(HEADER).height(abs(HEADER_H)), content.height(rest(1)))
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(&mut h.rsc)
|
||||||
|
.any();
|
||||||
|
h.state.set_root(root);
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list's own on-screen box, in window pixels.
|
||||||
|
fn list_box(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> PixelRegion {
|
||||||
|
h.render
|
||||||
|
.window_region(&screen.list.id(), &h.rsc)
|
||||||
|
.expect("the list is on screen")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every row the list drew this frame, as `(top, bottom)` window pixels,
|
||||||
|
/// topmost first. A `List`'s direct children are exactly its rows, and
|
||||||
|
/// `draw_inner`'s old-children diffing means a row it did not place this
|
||||||
|
/// frame is not among them.
|
||||||
|
fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32, f32)> {
|
||||||
|
let mut rows: Vec<(f32, f32)> = h
|
||||||
|
.render
|
||||||
|
.active
|
||||||
|
.get(&screen.list.id())
|
||||||
|
.expect("the list is drawn")
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| h.render.window_region(id, &h.rsc))
|
||||||
|
.map(|px| (px.top_left.y, px.bot_right.y))
|
||||||
|
.collect();
|
||||||
|
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls `amount` (negative walks back through older rows) and runs the
|
||||||
|
/// frame it asks for, returning the time of the next one.
|
||||||
|
fn scrolled(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
|
||||||
|
(screen.list)(&mut h.rsc).scroll(amount);
|
||||||
|
h.frame(t);
|
||||||
|
t + PHONE_FRAME_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (a) of docs/IRIS_TODO.md's reproduction: with a row across the top
|
||||||
|
/// edge, that row is placed -- the viewport's first pixel belongs to
|
||||||
|
/// something. A rule that culled a row once its *top* left the viewport
|
||||||
|
/// would leave a blank band here, which is the second of Iris's two
|
||||||
|
/// screenshots.
|
||||||
|
#[test]
|
||||||
|
fn the_row_across_the_top_edge_is_drawn() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let top = list_box(&h, &screen).top_left.y;
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
// 40px a frame, the shape a finger pan arrives in, through a straddle
|
||||||
|
// and out the other side of it many times over.
|
||||||
|
for _ in 0..60 {
|
||||||
|
t = scrolled(&mut h, &screen, -40.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let first = *rows.first().expect("something is on screen");
|
||||||
|
assert!(
|
||||||
|
first.0 <= top + 0.5,
|
||||||
|
"a band of {:.1}px under the header belongs to no row: rows start at {:.1}, the list \
|
||||||
|
at {top:.1}",
|
||||||
|
first.0 - top,
|
||||||
|
first.0,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
first.1 > top,
|
||||||
|
"the row across the top edge was culled: it ends at {:.1}, above the list's own \
|
||||||
|
{top:.1}",
|
||||||
|
first.1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (b): what falls outside the list's box is clipped rather than drawn
|
||||||
|
/// over whatever is there. The straddling row above is drawn *in full*,
|
||||||
|
/// so the only thing between its earlier lines and the header bar is this
|
||||||
|
/// mask -- with none, the phone drew `version = "0.1.0"` behind the "Run
|
||||||
|
/// benchmark" button.
|
||||||
|
#[test]
|
||||||
|
fn the_list_is_clipped_to_its_own_box() {
|
||||||
|
let (h, screen) = opened();
|
||||||
|
let active = h.render.active.get(&screen.list.id()).expect("drawn");
|
||||||
|
assert!(
|
||||||
|
active.mask != MaskIdx::NONE,
|
||||||
|
"the transcript's list is drawn with nothing clipping it",
|
||||||
|
);
|
||||||
|
let clip = h.rsc.ui.masks[active.mask.idx()].region.to_px(h.size());
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
assert!(
|
||||||
|
clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5,
|
||||||
|
"the clip {clip:?} reaches outside the list's own box {list:?}, so a row straddling an \
|
||||||
|
edge still draws past it",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row that has left the viewport entirely is not drawn at all. Before
|
||||||
|
/// the fix the walk ran from the anchor -- which `scroll` leaves wherever
|
||||||
|
/// it was, however far outside the viewport that ends up -- and drew
|
||||||
|
/// every row on the way: 8 scrolls of 3000px left **64 rows** placed for
|
||||||
|
/// a 2012px viewport, ~59 of them off screen and painting over the
|
||||||
|
/// header.
|
||||||
|
///
|
||||||
|
/// Asserted strictly on the way *back*, because a row whose height has
|
||||||
|
/// never been measured has to be drawn to be measured (`List::place`'s
|
||||||
|
/// doc), which on the outbound leg is every row entering from the top.
|
||||||
|
/// The return leg crosses the same rows with every height already known,
|
||||||
|
/// which is also the ordinary state of a transcript being panned around
|
||||||
|
/// in. The bound on how many rows are placed at once holds on both.
|
||||||
|
#[test]
|
||||||
|
fn rows_that_have_left_the_viewport_are_not_drawn() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| {
|
||||||
|
// A handful of rows whatever distance has been travelled -- the
|
||||||
|
// module doc's own claim about this widget.
|
||||||
|
assert!(
|
||||||
|
rows.len() <= 24,
|
||||||
|
"{leg} {step}: {} rows drawn for one 2012px viewport",
|
||||||
|
rows.len(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
for step in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, -400.0, t);
|
||||||
|
bounded(&drawn_rows(&h, &screen), "back", step);
|
||||||
|
}
|
||||||
|
for step in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, 400.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
bounded(&rows, "forward", step);
|
||||||
|
for &(top, bottom) in &rows {
|
||||||
|
assert!(
|
||||||
|
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
|
||||||
|
"forward {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
|
||||||
|
{list:?} and was drawn anyway",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end the fix had no reason to touch: the row across the *bottom*
|
||||||
|
/// edge, where the composer starts. Same rule, other direction -- and the
|
||||||
|
/// list opens pinned there, so this is the ordinary state of the screen
|
||||||
|
/// rather than a scrolled-to one.
|
||||||
|
#[test]
|
||||||
|
fn the_row_across_the_bottom_edge_is_drawn() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
for _ in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, -37.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let last = *rows.last().expect("something is on screen");
|
||||||
|
assert!(
|
||||||
|
last.1 >= list.bot_right.y - 0.5,
|
||||||
|
"a band of {:.1}px above the composer belongs to no row",
|
||||||
|
list.bot_right.y - last.1,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
last.0 < list.bot_right.y,
|
||||||
|
"the row across the bottom edge was culled: it starts at {:.1}, below the list's own \
|
||||||
|
{:.1}",
|
||||||
|
last.0,
|
||||||
|
list.bot_right.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// what a hard fling toward the top does; before `List::clamp_to_content`
|
||||||
|
/// it stayed wherever that left it -- the phone's "black from the header
|
||||||
|
/// down", and a whole blank screen in `iris`'s own
|
||||||
|
/// `fling_toward_the_start_stops_at_the_first_row`.
|
||||||
|
#[test]
|
||||||
|
fn scrolling_past_the_first_row_settles_on_it() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
for _ in 0..60 {
|
||||||
|
t = scrolled(&mut h, &screen, -100_000.0, t);
|
||||||
|
}
|
||||||
|
// The correction is written at the end of a draw and lands on the
|
||||||
|
// next one.
|
||||||
|
h.frame(t);
|
||||||
|
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let first = *rows.first().expect("the first row is on screen");
|
||||||
|
assert!(
|
||||||
|
(first.0 - list.top_left.y).abs() < 0.5,
|
||||||
|
"the transcript is parked {:.1}px past its own first row, so the top of the list is blank",
|
||||||
|
first.0 - list.top_left.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -422,7 +422,13 @@ where
|
|||||||
|
|
||||||
let (composer, composer_bar) = composer::build_composer(rsc);
|
let (composer, composer_bar) = composer::build_composer(rsc);
|
||||||
|
|
||||||
let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
|
// `.masked()`: the list draws the row straddling each of its edges in
|
||||||
|
// full (`List::intersects_viewport`), so without a clip the top of
|
||||||
|
// 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,
|
||||||
|
// 2026-09-07: "code and a paragraph visible behind Run benchmark").
|
||||||
|
// The same clip is what `List::draw` asserts it has.
|
||||||
|
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
|
||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any();
|
.any();
|
||||||
|
|||||||
Reference in new issue
Block a user