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>
336 lines
13 KiB
Rust
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,
|
|
);
|
|
}
|