Files
ai-app/iris/transcript-fixture/tests/top_edge.rs
T
irisandClaude Opus 5 4fdabc39d0 iris: one ScrollController, a Scrollable trait, and Pin
Iris's three points on docs/SCROLL.md, in the shape she proposed: a
controller both scrolling widgets *contain*, rather than a protocol
between them. "I don't like adding methods to widget, it seems like we
can structure things better instead."

`Scroll` becomes `ScrollArea`, because it only scrolls a predefined area.
`ScrollController` holds everything that is not a particular widget's
layout -- the position, the pending delta, the travel left each way, the
pin, the DragGesture and the Flinger -- and `Scrollable` is the trait over
it, one required pair of methods with the rest defaulted.

`Widget` loses `scrolls_itself`, `apply_scroll` and `scroll_offset`. They
existed only so a `Scroll` could drive a `LazySpan` it had no business
wrapping; the span owns its own controller now, so the wrapper, the
measure/apply/place dance between two widgets and `amt`'s two meanings all
go with them. The transcript's tree loses a node: `list` is the layout and
the position.

`.scrollable(axis, pin)` replaces `scrollable`/`scrollable_on`/
`scrollable_to_end` -- one mechanism whose arguments had been hidden in
three names. `LazySpan` has an inherent `scrollable()` that shadows it,
since Rust resolves inherent methods before trait ones: the same word at
the call site, and the wrapping version cannot reach the one widget that
must not be wrapped.

`Pin` says which end either way round: `Start`/`End` are content-relative
and `Neg`/`Pos` axis-absolute, so a caller can say "the bottom" and mean
it whichever way the content runs. They differ only for a reversed span,
which is the whole reason both exist.

One behaviour changes: a delta is applied by the next draw rather than
where it arrives, since the layout is the only thing that knows where the
content ends. Nothing on screen differs -- input is followed by a frame --
but `amt` no longer moves between draws, which several tests were reading.
This also closes SCROLL.md's open question about the pin living in two
places.

Verified: cargo test --workspace (all green, including the layer-1
transcript-fixture fling/selection/top-edge tests), clippy --all-targets
clean, fmt clean, `cargo ndk` check of android-app, and
`run-headless.sh phone --phone --replay flick-120hz.touch`, whose
before/after screenshots show the recorded flick carrying the transcript
back from turn 270 to turn 258 on the Vulkan adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:51:53 -04:00

336 lines
13 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 (`LazySpan::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 `LazySpan`'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` and runs the frame it asks for, returning the time of
/// the next one. **Positive walks back through older rows** -- the
/// finger's own direction, and `Scroll::scroll`'s, which is the one
/// convention a delta has anywhere in iris since the transcript's scroll
/// position lives in the `LazySpan`'s own `ScrollController`.
/// It used to be the opposite here, because a `LazySpan`'s anchor offset
/// ran the other way.
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
/// (`LazySpan::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 the clamp existed 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);
}
// No settling frame on purpose: the draw that discovers the gap gives
// it back inside that same frame (`LazySpan::overscroll_gap`), so the last
// frame `scrolled` drew is already flush with the first row. Adding
// one here would hide a regression to the old next-frame correction.
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);
}
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,
);
}