2 Commits
Author SHA1 Message Date
iris e922b73d7a 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.
2026-09-07 16:05:31 -04:00
iris d507ae4c96 iris-core: masks nest instead of aborting, and a widget can ask to be drawn again
`Painter::set_mask` refused a widget any mask of its own once an
ancestor had set one -- `assertion failed: self.mask == MaskIdx::NONE`
-- so clipping was one level deep wherever it was used at all. That is
what stopped the transcript's `List` from being clipped to its own box:
its rows already use `.masked()` themselves (a code fence, a tool card's
one-line title), and giving the list one aborted on the first fence
drawn.

A mask now carries the mask it was set inside (`Mask::parent`) and the
fragment stage walks that chain, so a pixel has to be inside every mask
on it. Chained rather than intersected on the CPU because each mask
moves with its own widget: a fence inside a transcript row carries the
row's scroll and the list's box does not, and one region resolved when
the fence was last drawn gets the second of those wrong as soon as the
row is moved rather than redrawn -- which is every scroll frame. The
child holds one ref on its parent's slot, released where the child's own
slot is, so a chain cannot outlive what it points at. The old assert
survives as the case that is still wrong: the same widget setting two
masks, which since a mask now chains would be a clip loop.

Also `Painter::draw_again`, for a layout that can only discover a
correction to itself by laying out once -- `List::clamp_to_content`, in
the commit after this -- and `Painter::is_masked`, which is how a widget
that draws outside its own box can require something to be clipping it.
2026-09-07 16:05:13 -04:00
9 changed files with 633 additions and 48 deletions

No files matched your search

+13
View File
@@ -56,6 +56,19 @@ pub struct Mask {
/// primitive's own corners, so a mask and the content clipped by it
/// can move independently. See LAYOUT.md section 2b.
pub move_idx: MoveIdx,
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
/// clipping nests: the fragment stage walks the chain and a pixel has
/// to be inside every mask on it. Chained rather than intersected on
/// the CPU because each mask moves with its own widget -- a code fence
/// inside a transcript row carries the row's scroll, the list's own
/// box does not, and one region resolved when the fence was last drawn
/// gets the second of those wrong as soon as the row moves.
///
/// A child holds one ref on its parent's slot (`Painter::set_mask`),
/// released when the child's own slot goes
/// (`UiRenderState::remove`), so the chain cannot outlive what it
/// points at.
pub parent: MaskIdx,
}
/// One widget's cumulative on-screen translation, and the slot of the
+14 -2
View File
@@ -34,6 +34,10 @@ struct Mask {
x: UiSpan,
y: UiSpan,
move_idx: u32,
/// The mask this one is nested inside, or `4294967295u`. Mirrors
/// `Mask::parent` in data.rs; walked below with the same bound the
/// move chain uses.
parent: u32,
}
/// One widget's cumulative on-screen translation and the slot of the
@@ -196,8 +200,15 @@ fn fs_main(
color = vec4(1.0, 0.0, 1.0, 1.0);
}
}
if in.mask_idx != 4294967295u {
let mask = masks[in.mask_idx];
// Every mask on the chain, not just the innermost: a widget that set
// its own mask inside another is clipped by both, and each carries its
// own move slot (`Mask::parent` in data.rs).
var mask_idx = in.mask_idx;
for (var step = 0u; step < MOVE_CHAIN_LIMIT; step++) {
if mask_idx == 4294967295u {
break;
}
let mask = masks[mask_idx];
let mask_delta = resolve_move(mask.move_idx);
let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs));
let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs));
@@ -207,6 +218,7 @@ fn fs_main(
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
color *= 0.0;
}
mask_idx = mask.parent;
}
return color;
}
+52 -4
View File
@@ -53,8 +53,11 @@ impl<'a> Painter<'a> {
}
/// Clip everything this widget draws, itself and its descendants, to
/// `region`. One per widget: a second call would need the two to be
/// intersected, which nothing here does.
/// `region`. One call per widget; a widget drawn inside another
/// widget's mask nests instead -- the new mask chains to the inherited
/// one (`Mask::parent`) and the fragment stage requires a pixel to be
/// inside both, which is what lets a transcript row's code fence clip
/// to itself *and* to the list it scrolls inside.
///
/// The slot is allocated once and **rewritten in place** on every
/// later draw rather than pushed again, because a descendant whose own
@@ -62,24 +65,69 @@ impl<'a> Painter<'a> {
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) {
assert!(self.mask == MaskIdx::NONE);
debug_assert!(
self.own_mask == MaskIdx::NONE || self.mask != self.own_mask,
"set_mask called twice while drawing one widget: the second would replace the first \
rather than nest inside it",
);
let parent = self.mask;
let mask = Mask {
region,
move_idx: self.move_slot,
parent,
};
if self.own_mask == MaskIdx::NONE {
let old_parent = if self.own_mask == MaskIdx::NONE {
let slot = self.rsc.ui_mut().masks.push(mask);
// The one ref this widget holds on its own slot, so the slot
// outlives any single frame's primitives; released in
// `UiRenderState::remove`'s `undraw` branch.
self.rsc.ui_mut().masks.push_ref(slot);
self.own_mask = slot;
MaskIdx::NONE
} else {
let old = self.rsc.ui().masks[self.own_mask.idx()].parent;
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
old
};
// The chain link's own ref, taken before the old one is dropped so
// that re-chaining to the same slot cannot free it in between.
// Released here when the link changes, and in
// `UiRenderState::remove` when this widget's slot goes.
if old_parent != parent {
if parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(parent);
}
if old_parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.remove(old_parent);
}
}
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
/// [`Self::set_mask`], or one an ancestor set that it inherited. What
/// a widget whose contents may legitimately extend past its own box
/// (`iris::widget::List`, which draws a row straddling an edge in
/// full) asserts before relying on being cut off there.
pub fn is_masked(&self) -> bool {
self.mask != MaskIdx::NONE
}
/// Draws a widget within this widget's region, returning the size it
/// reported using.
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
+11 -5
View File
@@ -324,10 +324,9 @@ impl UiRenderState {
// own new one -- and `ActiveData::mask`'s only consumer is
// `redraw`, which feeds it back in as the *inherited* mask. Storing
// the set one instead handed a `Masked` its own mask on every
// targeted redraw, tripping `set_mask`'s nested-mask assert:
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
// first time the composer's scroll area was redrawn on the
// emulator.
// targeted redraw -- an abort the first time the composer's scroll
// area was redrawn on the emulator, and now (masks nest) a mask
// whose parent is itself, which `set_mask`'s own assert names.
let inherited_mask = mask;
let mut painter = Painter {
state: self,
@@ -514,8 +513,15 @@ impl UiRenderState {
// section 2's lifecycle note).
if active.own_mask != MaskIdx::NONE {
// The self-ownership ref `Painter::set_mask` took when
// it allocated this widget's own mask slot.
// it allocated this widget's own mask slot, and the
// chain link's ref on the mask this one nests inside
// -- read from the arena entry, for the same reason
// the move slot's parent is.
let outer = rsc.ui().masks[active.own_mask.idx()].parent;
rsc.ui_mut().masks.remove(active.own_mask);
if outer != MaskIdx::NONE {
rsc.ui_mut().masks.remove(outer);
}
}
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
rsc.ui_mut().move_offsets.remove(active.move_slot);
+2 -2
View File
@@ -150,8 +150,8 @@ fn hit_testing_follows_a_scrolled_widget() {
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
/// it set for itself -- `redraw` feeds it straight back in as the inherited
/// mask, so storing the set one hands a `Masked` its own mask the second
/// time round and trips `Painter::set_mask`'s nested-mask assert. That was
/// an abort (`assertion failed: self.mask == MaskIdx::NONE`) the first time
/// time round -- which `Painter::set_mask` asserts against, since a mask
/// that chains to itself is a clip loop. That was an abort the first time
/// the composer's new scroll area was redrawn on the emulator; a targeted
/// redraw of a `Masked` is what any real screen does whenever anything
/// inside it changes.
+299 -29
View File
@@ -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
);
}
+229
View File
@@ -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,
);
}
+6 -5
View File
@@ -90,11 +90,12 @@ where
// where the bar actually was.
// `.scrollable().masked()`: the finger pan (`Scroll::drag`) plus the
// clip that keeps six lines' worth of a longer message inside the
// bar. The mask is the caller's job rather than `Scroll`'s own,
// because `Painter::set_mask` allows exactly one mask per widget and
// a `Scroll` nested under another masked area would abort on the
// second -- `.masked()` is the one mechanism for clipping and this is
// one more use of it (tabs-ui's message area is the other).
// bar. The mask is the caller's job rather than `Scroll`'s own:
// `.masked()` is the one mechanism for clipping and this is one more
// use of it (tabs-ui's message area is the other). A `Scroll` nested
// under another masked area used to abort here; since 2026-09-07 the
// inner mask chains to the outer one (`Mask::parent`) and the content
// is clipped by both.
// Without it the overflow paints *above* the bar, over the
// transcript: measured before this change at 58px of stray text for a
// 475px message in a 417px box.
+7 -1
View File
@@ -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();