A mask is about to reference a primitive already drawn and evaluate it at the masked pixel (docs/LAYOUT.md's "Masks with a shape"), which the data layout could not answer: a primitive's placement lived in its layer's *vertex* buffer, invisible to the fragment stage, and `rects`/`glyphs` were per layer too -- so a mask whose shape is a rounded container in one layer, clipping content a `Stack` put in another, would have read the wrong layer's rect with nothing on screen to say so. So the instances and the per-primitive data become one arena (`UiRenderState::primitives`), bound once per frame; a layer keeps only its draw *order*, which is what its vertex buffer now is -- one `u32` slot per instance instead of eight attributes. The vertex stage reads the placement it is drawing from `instances[slot]`; the fragment stage can read any other primitive's from the same buffer, which is what the mask work needs and the reason there is no second copy for masks. Arena slots are stable (nothing is compacted), so a `Mask` can hold one across frames. A slot freed during a redraw is therefore not reusable until every layer's order has been compacted around it -- otherwise the reused slot would draw twice, once through the stale order entry -- which is what `Primitives::freed` and `UiRenderState::apply_free` are. That compaction moved out of `UiRenderNode::update` into `UiRenderState:: update`: it is bookkeeping over `active`, not GPU work, and the harness (which has no renderer) needs it too. Same 164 tests, the `--phone` screenshot unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
332 lines
12 KiB
Rust
332 lines
12 KiB
Rust
//! 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",
|
|
);
|
|
|
|
// And the mask has to *reach* what the rows draw. The two above say a
|
|
// mask exists and sits in the right place; neither says any primitive
|
|
// references it, so a broken `Mask::parent` chain -- what d507ae4
|
|
// introduced -- would leave them green while a code fence inside a row
|
|
// drew unclipped again (docs/REVIEW-2026-09-07.md's T3).
|
|
let rows = h
|
|
.render
|
|
.active
|
|
.get(&screen.list.id())
|
|
.expect("the list is drawn")
|
|
.children
|
|
.clone();
|
|
let mut checked = 0;
|
|
for row in rows {
|
|
for prim in primitives_under(&h, row) {
|
|
assert!(
|
|
mask_chain(&h, prim).contains(&active.mask),
|
|
"a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \
|
|
own mask {:?}",
|
|
mask_chain(&h, prim),
|
|
active.mask,
|
|
);
|
|
checked += 1;
|
|
}
|
|
}
|
|
assert!(
|
|
checked > 0,
|
|
"no row primitive was checked, so this test asserted nothing",
|
|
);
|
|
}
|
|
|
|
/// Every primitive `id` and its descendants drew, as `MaskIdx`es -- images
|
|
/// excluded, since they live in a separate instance array with their own
|
|
/// indices (`Primitives::free`).
|
|
fn primitives_under(h: &Harness, id: WidgetId) -> Vec<MaskIdx> {
|
|
let Some(active) = h.render.active.get(&id) else {
|
|
return Vec::new();
|
|
};
|
|
let mut out: Vec<MaskIdx> = active
|
|
.primitives
|
|
.iter()
|
|
.filter(|p| p.binding != IMAGE_BINDING)
|
|
.map(|p| h.render.primitives.instance(p.slot).mask_idx)
|
|
.collect();
|
|
for child in &active.children {
|
|
out.extend(primitives_under(h, *child));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The chain the fragment stage walks from `mask`, outermost last.
|
|
fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
|
|
let mut chain = Vec::new();
|
|
let mut at = mask;
|
|
while at != MaskIdx::NONE {
|
|
assert!(
|
|
!chain.contains(&at),
|
|
"the mask chain from {mask:?} loops back to {at:?}",
|
|
);
|
|
chain.push(at);
|
|
at = h.rsc.ui.masks[at.idx()].parent;
|
|
}
|
|
chain
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// The box is asserted on every leg *except the first*, because a row
|
|
/// whose height has never been measured has to be drawn to be measured
|
|
/// (`List::place`'s doc), which on the first walk back is every row
|
|
/// entering from the top. Every later leg crosses the same rows with
|
|
/// every height already known -- including the second walk *back*, which
|
|
/// is there because a regression that draws rows in the wrong place while
|
|
/// travelling backwards would otherwise be checked only by the row count
|
|
/// (docs/REVIEW-2026-09-07.md's T2). That is also the ordinary state of a
|
|
/// transcript being panned around in. The bound on how many rows are
|
|
/// placed at once holds on all three.
|
|
#[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(),
|
|
);
|
|
};
|
|
let inside = |rows: &[(f32, f32)], leg: &str, step: usize| {
|
|
for &(top, bottom) in rows {
|
|
assert!(
|
|
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
|
|
"{leg} {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
|
|
{list:?} and was drawn anyway",
|
|
);
|
|
}
|
|
};
|
|
|
|
for step in 0..40 {
|
|
t = scrolled(&mut h, &screen, -400.0, t);
|
|
bounded(&drawn_rows(&h, &screen), "measuring", step);
|
|
}
|
|
for step in 0..40 {
|
|
t = scrolled(&mut h, &screen, 400.0, t);
|
|
let rows = drawn_rows(&h, &screen);
|
|
bounded(&rows, "forward", step);
|
|
inside(&rows, "forward", step);
|
|
}
|
|
for step in 0..40 {
|
|
t = scrolled(&mut h, &screen, -400.0, t);
|
|
let rows = drawn_rows(&h, &screen);
|
|
bounded(&rows, "back", step);
|
|
inside(&rows, "back", step);
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
);
|
|
}
|
|
|
|
/// The same clamp at the other end, which is where Iris met it second
|
|
/// ("you shouldn't be able to scroll below the bottom (or above top)").
|
|
/// The list opens flush with its newest row, so this drags *forward* off
|
|
/// the end of the content and back.
|
|
#[test]
|
|
fn scrolling_past_the_last_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..20 {
|
|
t = scrolled(&mut h, &screen, 100_000.0, t);
|
|
}
|
|
h.frame(t);
|
|
|
|
let rows = drawn_rows(&h, &screen);
|
|
let last = *rows.last().expect("the last row is on screen");
|
|
assert!(
|
|
(last.1 - list.bot_right.y).abs() < 0.5,
|
|
"the transcript is parked {:.1}px past its own last row, so the bottom of the list is \
|
|
blank",
|
|
list.bot_right.y - last.1,
|
|
);
|
|
}
|