Iris: "the documentation is also pretty crazy too. Can you go through it
and remove everything that's already done and decided? There's entire md
files iirc for projects already complete. And many with checkboxes already
ticked off that just fill up context."
docs/RUST.md 8503 -> 905 the framework bake-off (options,
recommendation, twelve closed
experiment boxes) and two superseded
"where things stand" sections, out;
what the experiments settled kept as
one line each
docs/IRIS_TODO.md 1383 -> 229 fifty closed items and six
phone-report sections whose defects
are all fixed
docs/LAYOUT.md 1116 -> 829 the pre-implementation framing: the
old trait, the checklist, the
migration list, the pass conditions
docs/TEXTURES.md 496 -> 240 the prior-art survey, the proposal
and its review, all implemented
docs/REVIEW-*.md 673 -> 0 two completed review passes; the two
findings left open on purpose (mask
hit-testing, the phone's font set)
moved into RUST.md
What survives a prune is what cannot be cheaply re-derived: measurements
(the APK-size table, the phone bench reports), dead ends, invariants and
their reasons, and the design of what exists now rather than the route to
it. AGENTS.md now says that, so the next session prunes as it goes rather
than appending; docs/IRIS_TODO.md's header says items are deleted when
they land rather than ticked.
Deleting the two review files left eighteen citations dangling in code
comments that state their reason inline and cited the file for provenance
only — those now read "(review, 2026-09-06)" and carry no dead pointer.
The emulator's measured GPU capabilities moved to the this-machine-android
skill, where machine facts belong. IRIS.md and DECISIONS.md are dated
records and were not rewritten; each gained one note that paths in older
entries predate the 2026-09-08 crate merge, pointing at the mapping.
Not touched, deliberately: docs/DECISIONS.md's entries (that file *is* the
queue of things for Iris to review, so deleting decided items would remove
what it exists for) and iris/readme.md and iris/TODO, which are hers.
Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in every workspace, and every remaining docs/*.md cross-reference
resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
341 lines
13 KiB
Rust
341 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 ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
|
use iris::harness::Harness;
|
|
use iris::prelude::*;
|
|
|
|
/// 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, ai_app::ui::TranscriptScreen) {
|
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
|
let (opened, tree) = ai_app::ui::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: &ai_app::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: &ai_app::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: &ai_app::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.
|
|
///
|
|
/// The clip is one the screen **opted into** (`build_tree`'s `.masked()`),
|
|
/// so this reads the mask the list *inherited*. A `LazySpan` sets none of
|
|
/// its own -- masking is opt-in, like scrolling (Iris, 2026-09-08) -- so
|
|
/// this is also the test that the transcript is still asking for one.
|
|
#[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 (review, 2026-09-07'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
|
|
/// (review, 2026-09-07'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,
|
|
);
|
|
}
|