Files
ai-app/iris/transcript-fixture/tests/top_edge.rs
T
irisandClaude Opus 5 38bf6309cb iris: a mask is a shape, not a rectangle -- .masked_by, and touch obeys it
Iris, on the code fence: "the code block scrolling currently masks in an
inner rectangle. Ideally masks should have a shape associated with them,
rounded rectangle being one of them ... so that the mask becomes the
parent container with rounded edges. Make sure alpha works properly with
it, eg. on the corners where alpha should be decreased / multiplied."

`Mask` is now `{ primitive, parent }` -- the slot of a primitive already
written, plus the mask this one nests inside. The fragment stage
evaluates that primitive's own coverage at the masked pixel, through the
same `rounded_rect_coverage` a drawn rect goes through, and multiplies it
into the alpha along the whole `parent` chain. Nothing about the shape is
copied, so a rounded container's corner and its children's clipped corner
are one piece of arithmetic and cannot drift; two nested feathers dim a
pixel twice, which is the multiply she asked for.

`.masked()` is unchanged for callers: it writes an undrawn rect
(`Drawn::No`/`NOT_DRAWN` -- owned, moved, resized and freed like any
other primitive, simply never rasterized) and points at that, so square
clipping is the same mechanism rather than a special case. New
`.masked_by(shape)` draws `shape` behind the content in its own layer and
clips to the first primitive it drew, with no radius written twice; it
replaces `.masked().background(w)`, which drew both and clipped to the
box. `transcript-ui`'s `BlockFrame::Verbatim` is the first caller.

Hit-testing applies the shape (`SensorUi::run_sensors` ->
`UiRenderState::mask_admits`, coverage above one half, which is where the
drawn edge is), as well as the widget's own box -- the two ask different
questions and both have to hold. `primitive_corners` is a floor-for-floor
transliteration of the shader's `corners_of`, not `region.to_px()`: the
phone's 2.55 density puts nothing on a whole pixel, and skipping the
rounding disagrees with the pixels by up to one along each edge.

A mask's shape must be a rect, asserted by name in `set_mask_to`. A glyph
would need a CPU-side alpha plane before the hit test could agree with
the shader, and a standalone image a bind-group switch the fragment stage
cannot make. So no texture mask exists; the branch where one would go is
in both copies of `mask_coverage`. docs/LAYOUT.md's section end lists this
and the three other places the code is narrower than the design.

Tests. Layer 1, `layout_tests.rs`: the child's coverage swept across the
container's corner arc equals the container's own exactly; nested masks
multiply rather than intersect, asserted where both feathers are partial,
which is the only place the two differ; a press in a rounded-away corner
misses while one inside the curve and one on a straight edge hit; and
`a_plain_mask_still_clips_to_a_square_box`, the half this had no reason to
touch. The first version of the corner test swept the straight chord
between the arc's ends, which lies inside the circle everywhere -- it
proved nothing and said so, which is why it counts both sides now.

`iris/tests/mask_sdf.rs` is the only test here that needs a GPU: it lifts
`distance_from_rect` and `rounded_rect_coverage` out of
`iris_core::SHAPE_SHADER` by name -- lifted, not copied, since a copy
would be edited alongside the shader -- and runs them in a compute pass
over ~200k points at five radii against `iris_core::rounded_rect_coverage`.
Worst disagreement under 1e-5; the negative control (`+ 0.01` inside the
shader's smoothstep) fails it at 0.03.

Layer 2 for looking: `./run-headless.sh phone --phone --shot /tmp/mask.png
--seconds 6 -- -p transcript-fixture` draws the fixture's horizontally
scrolled code fence clipped on the curve at both top corners.

Two things found on the way and fixed here:

- The winit backend had the defect the Android one was fixed for in
  85869d0 -- `Backends::PRIMARY` and an `.expect` on the adapter. This
  VM's Venus device disappears when the host runs out of virgl contexts,
  which happened mid-task, and layer 2 aborted with `Could not get
  adapter!` while GL sat there working. It probes and rebuilds the
  instance on `Backends::GL` exactly as Android does now, and the request
  names the backends it tried. The rule had been written on one member of
  a set of two.
- `active_primitive_count` counted mask shapes, so `iris::frame`'s
  `primitives=` -- a number Iris reads off a phone report as "how much is
  on screen" -- would have gained one per masked widget.

`widget_trait!` now accepts a `///` doc comment on its functions, since
`masked_by` is public API and rustdoc is where a contract is read.

docs/LAYOUT.md, docs/RUST.md (both queue boxes, the commands, and where
the GPU test sits among the three layers), docs/IRIS.md and
docs/IRIS_TODO.md ("Masks defined relative to each other", now closed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:18:57 -04:00

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.render.mask_region(active.mask, &h.rsc);
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,
);
}