diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index 80069d0..2c57452 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -934,7 +934,7 @@ do not duplicate it there. ## From the phone, 2026-09-07 (build from ed04d4c) -- [ ] **"Some transcript blocks will be hidden until I uncover enough of +- [x] **"Some transcript blocks will be hidden until I uncover enough of them."** Two screenshots of the bench app's transcript at the top edge, both wrong in opposite directions: in one, rows scrolled above the viewport are still drawn and bleed *through* the header bar @@ -956,10 +956,62 @@ do not duplicate it there. drawn if any part of it intersects the viewport, and the viewport is the list's own region. + **Done, e922b73 + d507ae4.** Three causes, and the rule above is what + they are all fixed with (`List::intersects_viewport`). + `iris/transcript-fixture/tests/top_edge.rs` is the layer-1 + reproduction -- the real screen under a bench-app-shaped header -- + and each test was confirmed to fail on its own subject and no other. + + 1. *Drawn over the header*: **nothing was clipping the list at all**, + and a row straddling an edge is drawn in full, so the part above + the list was on screen. It could not be `.masked()` before, either: + `Painter::set_mask` aborted when an ancestor already had a mask, + and the list's own rows use `.masked()` (a code fence, a tool + card's title). So masks nest now -- `Mask::parent`, walked in the + fragment stage, chained rather than intersected on the CPU because + each mask moves with its own widget. `the_list_is_clipped_to_its_ + own_box`. + 2. *Rows already scrolled past still drawn*: the layout walk runs from + the anchor, `scroll` moves the anchor's offset and nothing else, so + panning leaves the anchor's row further and further outside the + viewport and **every row between it and the viewport was drawn, + every frame** -- measured at 64 rows for a 2012px viewport after 8 + scrolls of 3000px. `place` skips a row whose known box does not + overlap, and `rehome_anchor` puts the anchor back on a visible row + each frame without moving anything drawn. + `rows_that_have_left_the_viewport_are_not_drawn`. + 3. *The blank band*: not a culling rule at all -- the list could rest + **past its own first row** (`fling_toward_the_start_stops_at_the_ + first_row` was leaving it 1398px below a 600px viewport, a blank + screen, and that test's own assertion could not see it). + `clamp_to_content` gives the gap back. Both ends: + `scrolling_past_the_first_row_settles_on_it`, + `scrolling_past_the_last_row_settles_on_it`. This is also the first + item of the later report below. + + What was suspected and is *not* what happened: the visible-range test + never compared a row's top against the viewport's top (there was no + culling test at all), and `03c6be8`'s header duplicate is untouched by + any of this -- it stays open. A row straddling the top edge is drawn + both before and after; the test that would catch that mistake + (`the_row_across_the_top_edge_is_drawn`) is in place, and fails if the + rule is written against the row's top instead of its bottom. + ## From the phone, 2026-09-07, later (build from 4274b8b, ai-app-bench b47eb73) -- [ ] **"You shouldn't be able to scroll below the bottom (or above - top)."** The list's offset is not clamped to its content range while +- [x] **"You shouldn't be able to scroll below the bottom (or above + top)."** Done in e922b73, as `List::clamp_to_content` rather than as a + clamp inside the scroll setter: nothing at the moment of a `scroll` + call knows where the content ends (that is what walking the rows finds + out), so the correction is measured from the ends the layout walk + already placed and written to the anchor. In the app that lands in the + same frame -- a scrolled list is dirty, and `redraw_updates` drains + the mark the correction sets before the frame is submitted -- so + nothing displaced is displayed; only a full-tree redraw (a resize) + could show one frame of it. A fling that reaches an end already ends + there (`tick_fling`'s `hit_bound`), and now stops *on* the end rather + than wherever the spline's last step had put it. Layer-1 tests at both + ends, listed in the item above. The list's offset is not clamped to its content range while dragging and/or flinging. Compose's `LazyColumn` never moves content past its ends -- the overscroll *effect* on Android 12+ is a stretch drawn over clamped content, not a displacement. Clamp the offset in diff --git a/docs/RUST.md b/docs/RUST.md index ef6a394..652438f 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -43,6 +43,52 @@ gated on her verdict**, so this pass works the P0 defects and the pure prerequisites in this order. Each item is ticked here by the agent that closes it. +### The transcript's edges (2026-09-07, e922b73 + d507ae4) + +Iris's two screenshots of the top edge -- rows drawn over the header in +one, a blank band in the other -- were **three** faults, and the rule +that fixes all three is the one the IRIS_TODO entry asked for: *a row is +drawn if any part of it overlaps the list's own box, and nothing outside +that box reaches the screen* (`List::intersects_viewport`). Neither +suspected cause was right, which is worth reading before trusting the +next suspicion in this file: there was no visible-range test comparing a +row's top against the viewport's, and `03c6be8`'s header duplicate is +untouched and still open. + +What was actually wrong: **nothing clipped the list at all** (and it +could not be `.masked()`, because `Painter::set_mask` aborted whenever +an ancestor had a mask and the list's own rows use one -- so masks +nest now, `Mask::parent`, walked in the fragment stage); **the layout +walk drew every row between the anchor and the viewport**, which after +panning is however far you have panned, measured at 64 rows placed for +a 2012px viewport; and **the list could rest past its own first row**, +which is the blank band, and is also the first item of Iris's later +report the same day. Details and the six layer-1 test names are in +docs/IRIS_TODO.md's 2026-09-07 entry. + +Three things this says about the rig, since the rig is new: + +- **Layer 1 found all of it, in seconds.** The tests open the real + screen over the real fixture under a bench-app-shaped header + (`iris/transcript-fixture/tests/top_edge.rs`, `cargo test -p + transcript-fixture`, ~5s), and each was confirmed to fail on its own + subject and no other by breaking that subject on purpose. The + emulator was not used, and the phone will only be asked to confirm. +- **Layer 2 is where the clip is visible.** `iris/run-headless.sh phone + --phone --replay transcript-fixture/touch/flick-120hz.touch --shot + /tmp/p.png -- -p transcript-fixture`, run with `.masked()` removed, + draws the bottom row's text over the composer bar; with it, the same + flick clips cleanly at the bar. The window has no header, so the top + edge is the window edge there -- the header case is layer 1's. +- **An assertion that reads the wrong thing hides the bug it is for.** + `fling_toward_the_start_stops_at_the_first_row` asserted the first + row's top was `>= -0.5` while that row sat 1398px *below* a 600px + viewport with the screen blank: `extents` then held rows that were + not on screen, so the read was satisfied by the failure. `extents` + now holds only what is on screen -- which is what `key_at` always + claimed of it -- asserted at the end of every draw, and the test + checks both directions. + ### APK size (2026-09-07) Iris's question: the iris bench APK is about double the Compose bench APK @@ -223,9 +269,10 @@ closes it. - [x] Test rig, layers 1 and 2 ("Three test layers" below), landed 2026-09-07. - [ ] Fling parity with Compose, and the phone's keyboard push-up, with insets shown in the diagnostics overlay. Running, in a worktree. -- [ ] Rows at the transcript's top edge: culled too early in one state, +- [x] Rows at the transcript's top edge: culled too early in one state, drawn through the header in the other (docs/IRIS_TODO.md, 2026-09-07). - First after the rig lands, using its layer-1 harness. + Done 2026-09-07, e922b73 + d507ae4; the root causes and the test names + are in that IRIS_TODO entry, and the short version is below. - [ ] Phone logging through Dev Updater (Iris has no logcat; see docs/TODO.md and the memory note): research how Dev Updater shows an app's runtime log, design the smallest route (the app keeps its own @@ -249,6 +296,11 @@ closes it. 2026-09-07)". A mask references a primitive already drawn (rect SDF, texture or glyph alpha), chained and multiplied; `.masked()` points at the widget's own primitives; hit-testing applies the shape. + **Chaining landed early**, 2026-09-07 (d507ae4): a mask carries the + mask it was set inside and the fragment stage walks that chain, so + nesting works and `Painter::set_mask` no longer aborts on it. Still + rectangles only -- the shape half, and the hit-testing half, are what + is left of this item. - [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted` (docs/TODO.md). Main branch, not rustify. diff --git a/iris/transcript-fixture/tests/top_edge.rs b/iris/transcript-fixture/tests/top_edge.rs index b619236..90d0202 100644 --- a/iris/transcript-fixture/tests/top_edge.rs +++ b/iris/transcript-fixture/tests/top_edge.rs @@ -227,3 +227,28 @@ fn scrolling_past_the_first_row_settles_on_it() { 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, + ); +}