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
+535
-30
No files matched your search
+299
-29
@@ -94,11 +94,22 @@
|
||||
//! row that has never been measured, so this stays independent of how many
|
||||
//! rows exist outside the loaded window.
|
||||
//!
|
||||
//! **What is deliberately not solved here.** No overscroll clamping: a
|
||||
//! `scroll()` past the first or last row leaves a gap rather than rubber-
|
||||
//! banding back (mirrors `Scroll`'s own documented one-frame-lag
|
||||
//! tolerance in LAYOUT.md, just not even auto-corrected -- there is
|
||||
//! nothing to measure "how much content is left" without walking it).
|
||||
//! **Only what overlaps the viewport is drawn, and it is drawn whole.**
|
||||
//! One rule, `intersects_viewport`, used by both halves of that sentence:
|
||||
//! a row straddling either edge is drawn in full and clipped by the
|
||||
//! `.masked()` its caller must place it in (`List::draw` asserts that),
|
||||
//! 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 iris_core::util::HashMap;
|
||||
@@ -415,8 +426,9 @@ impl List {
|
||||
|
||||
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
||||
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
||||
/// Deliberately unclamped -- see the module doc's "what is not
|
||||
/// solved here."
|
||||
/// Unclamped here, on purpose: it is one write, and there is nothing
|
||||
/// 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) {
|
||||
if let Some(a) = &mut self.anchor {
|
||||
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) {
|
||||
self.snap_end = match self.anchor {
|
||||
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 {
|
||||
let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end));
|
||||
UiRegion::from_axis(axis, span, UiSpan::FULL)
|
||||
@@ -865,6 +1002,26 @@ impl List {
|
||||
let key = self.slot_key(slot);
|
||||
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) {
|
||||
(Placement::Top(top), Some(h)) => {
|
||||
// Offered a box sized to the *cached* height (cheap to
|
||||
@@ -928,7 +1085,13 @@ impl List {
|
||||
};
|
||||
if let Some(k) = key {
|
||||
self.heights.insert(k, height);
|
||||
self.extents.insert(k, RowExtent { slot, top, bottom });
|
||||
// `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 });
|
||||
}
|
||||
}
|
||||
(top, bottom)
|
||||
}
|
||||
@@ -961,6 +1124,21 @@ impl Widget for List {
|
||||
// density, and `draw` is where this widget meets the only thing
|
||||
// that knows it. See `fling`.
|
||||
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);
|
||||
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_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();
|
||||
Size::REST
|
||||
}
|
||||
@@ -1068,9 +1263,59 @@ mod tests {
|
||||
/// calling `List`'s own methods through `Widgets::get`/`get_mut`, which
|
||||
/// need a `Sized` widget type) and the erased root `UiRenderState::update`
|
||||
/// 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) {
|
||||
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]
|
||||
@@ -1142,7 +1387,7 @@ mod tests {
|
||||
bg_ids.push(bg_id);
|
||||
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();
|
||||
render.resize((100.0, 100.0));
|
||||
@@ -1323,7 +1568,12 @@ mod tests {
|
||||
render.update(&root, &mut rsc);
|
||||
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);
|
||||
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
|
||||
/// room to travel before `at_start` clamps it -- shared by the fling
|
||||
/// 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) {
|
||||
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 mut render = UiRenderState::new();
|
||||
render.resize((100.0, 600.0));
|
||||
@@ -1764,32 +2033,22 @@ mod tests {
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
||||
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();
|
||||
for step in 1..600 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
let Some(top) = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.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;
|
||||
let at = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
|
||||
deltas.push((at - prev).abs());
|
||||
prev = at;
|
||||
if !still {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
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
|
||||
// applies is a `List::fling`-adjacent concern, not this curve,
|
||||
@@ -1871,15 +2130,26 @@ mod tests {
|
||||
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();
|
||||
assert!(
|
||||
list_ref.at_start,
|
||||
"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];
|
||||
assert!(
|
||||
first.top >= -0.5,
|
||||
"clamped fling overshot the first row's top: {}",
|
||||
first.top.abs() < 0.5,
|
||||
"a fling stopped at the start must leave the first row flush with the top, not {}px \
|
||||
from it",
|
||||
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 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)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
|
||||
Reference in new issue
Block a user