diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs index 38f4706..b234345 100644 --- a/iris/src/widget/list.rs +++ b/iris/src/widget/list.rs @@ -94,11 +94,22 @@ //! row that has never been measured, so this stays independent of how many //! rows exist outside the loaded window. //! -//! **What is deliberately not solved here.** No overscroll clamping: a -//! `scroll()` past the first or last row leaves a gap rather than rubber- -//! banding back (mirrors `Scroll`'s own documented one-frame-lag -//! tolerance in LAYOUT.md, just not even auto-corrected -- there is -//! nothing to measure "how much content is left" without walking it). +//! **Only what overlaps the viewport is drawn, and it is drawn whole.** +//! One rule, `intersects_viewport`, used by both halves of that sentence: +//! a row straddling either edge is drawn in full and clipped by the +//! `.masked()` its caller must place it in (`List::draw` asserts that), +//! and a row that has left the viewport is not drawn at all. The walk +//! still traverses whatever lies between the anchor and the viewport, and +//! `rehome_anchor` moves the anchor back onto a visible row every frame so +//! that "whatever lies between" stays empty however far the list is +//! panned. +//! +//! **Overscroll is taken back on the next frame, never rubber-banded.** A +//! `scroll()` or a fling past the first or last row leaves a gap for one +//! frame; `clamp_to_content` measures it from the ends the walk already +//! placed and gives it back (the same one-frame-lag `Scroll`'s content +//! length has, per LAYOUT.md). A list shorter than its viewport is not +//! overscrolled and is left alone, still bottom-anchored. use crate::prelude::*; use iris_core::util::HashMap; @@ -415,8 +426,9 @@ impl List { /// Move the anchor's edge by `amt` pixels. Positive moves later /// content into view (mirrors `Scroll::scroll`'s sign convention). - /// Deliberately unclamped -- see the module doc's "what is not - /// solved here." + /// Unclamped here, on purpose: it is one write, and there is nothing + /// at this point that knows where the content ends. The next `draw` + /// gives back whatever this moved past ([`Self::clamp_to_content`]). pub fn scroll(&mut self, amt: f32) { if let Some(a) = &mut self.anchor { a.offset -= amt; @@ -790,6 +802,115 @@ impl List { } } + /// Move the anchor onto a row that is actually on screen, without + /// moving anything that is drawn: the row it re-homes to keeps the + /// exact top edge this frame's layout gave it. + /// + /// [`Self::scroll`] moves the anchor's *offset* and nothing else, so + /// panning away from the anchor's own row leaves that row further and + /// further outside the viewport, and every row between it and the + /// viewport has to be walked on every frame from then on -- before + /// `place`'s intersection test, drawn too. Measured on the bench + /// fixture before this: 8 scrolls of 3000px left **64 rows** placed in + /// a 2012px viewport, ~59 of them off-screen, and the ones above it + /// drawn straight over the header (docs/IRIS_TODO.md, 2026-09-07). + /// Re-homing each frame makes the walk O(visible) again whatever + /// distance was travelled, which is what the module doc claims. + /// + /// Only when the anchor's own row has left the viewport, so + /// `update_snap_end`'s pinned-to-newest anchor -- last slot, bottom + /// edge at the viewport's own bottom, which intersects it -- is left + /// exactly as it is rather than rewritten into a top-edge anchor that + /// no longer reads as flush with the end. + fn rehome_anchor(&mut self) { + let Some(anchor) = self.anchor else { + return; + }; + if self.extents.values().any(|e| e.slot == anchor.slot) { + return; + } + // The topmost row on screen, so the anchor's offset stays a small + // number near the viewport's own leading edge rather than + // whatever the last row's bottom happens to be. + let Some(first) = self + .extents + .values() + .min_by(|a, b| a.top.total_cmp(&b.top)) + .copied() + else { + // Nothing on screen at all -- a list scrolled past its own + // content (`scroll` is deliberately unclamped). There is no + // on-screen row to re-home to, and inventing one would move + // the list; leave the anchor where it is and let the next + // scroll or `repair_anchor` bring content back. + return; + }; + self.anchor = Some(Anchor { + slot: first.slot, + edge: Edge::Top, + offset: first.top, + }); + } + + /// Take back an empty band at one edge that content on the other side + /// of the viewport could fill -- the correction that makes a `scroll` + /// or a fling past the end of the content settle *on* the end rather + /// than beyond it. + /// + /// `top`/`bottom` are the extreme edges this frame's walk actually + /// placed, so the gap is already measured: `at_start` means nothing is + /// above `top`, and if `top` is nevertheless below the viewport's own + /// leading edge then those pixels are empty and always will be. This + /// is the whole of what the module doc used to list as deliberately + /// unsolved ("no overscroll clamping ... nothing to measure how much + /// content is left without walking it") -- true of *total* content + /// height, but the walk hands back both ends of the loaded run for + /// free, which is all a clamp needs. `tick_fling` stops a fling that + /// has reached an end, but stops it wherever the spline's last step + /// had already put it: a hard fling to the top of the bench fixture + /// left the first row **1398px below** a 600px viewport, i.e. the + /// whole screen blank, and it stayed there (docs/IRIS_TODO.md, + /// 2026-09-07: "black from the header down"). + /// + /// **Only when the opposite end is not also inside the viewport.** + /// Both at once means the content is shorter than the viewport, where + /// the space is not overscroll at all -- it is a bottom-anchored list + /// with three rows in it, and pulling those to the top would be this + /// widget rejecting its own default (`repair_anchor`). + /// + /// Applied to the anchor, so it lands on the *next* frame rather than + /// re-running this one: the same one-frame-lag `Scroll` accepts for + /// its content length, and one frame is 8ms on the phone. + fn clamp_to_content(&mut self, painter: &mut Painter, top: f32, bottom: f32) { + if self.at_start == self.at_end { + return; + } + // `at_start`/`at_end` already carry the sign of their own gap + // (`top >= 0.0`, `bottom <= viewport_len`), so this is the gap + // itself, positive to move content toward the leading edge. + let gap = if self.at_start { + top + } else { + bottom - self.viewport_len + }; + // Sub-pixel gaps are what floating-point row heights leave behind + // every frame; correcting one would ask for another frame, which + // would leave another, and the list would never stop redrawing. + if gap.abs() < 0.5 { + return; + } + self.scroll(gap); + // Nothing else will ask: the frame this correction was discovered + // in has already been laid out, and a fling that ran out at an end + // (`tick_fling`'s `hit_bound`) has stopped requesting frames -- + // which is exactly the case that left the list parked past its own + // first row. + painter.draw_again(); + if let Some(redraw) = &self.redraw { + redraw.request_redraw(); + } + } + fn update_snap_end(&mut self) { self.snap_end = match self.anchor { Some(a) => { @@ -801,6 +922,22 @@ impl List { }; } + /// **The one rule for what this list draws**: a row is on screen if + /// any part of it is, so a row straddling either edge is drawn *in + /// full* and one that has left the viewport entirely is not drawn at + /// all. Both halves matter and they failed in opposite directions on + /// Iris's phone (docs/IRIS_TODO.md, 2026-09-07): rows already scrolled + /// past were still being drawn, over the header above the list, and + /// the part of a straddling row above the viewport had nothing + /// clipping it. The viewport here is the list's own box -- `0 .. + /// viewport_len`, `painter.region()` in window terms -- which is the + /// same box `List::draw` requires a mask on, so that what this test + /// admits and what the clip keeps are one region rather than two that + /// can disagree. + fn intersects_viewport(&self, top: f32, bottom: f32) -> bool { + bottom > 0.0 && top < self.viewport_len + } + fn abs_region(axis: Axis, start: f32, end: f32) -> UiRegion { let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end)); UiRegion::from_axis(axis, span, UiSpan::FULL) @@ -865,6 +1002,26 @@ impl List { let key = self.slot_key(slot); let cached = key.and_then(|k| self.heights.get(&k).copied()); + // A row entirely outside the viewport is traversed but not drawn + // -- see `intersects_viewport`. The walk still has to *pass + // through* it, because its height is what says where the rows + // behind it land, but nothing about it reaches the screen, so + // drawing it costs a redraw (and, unclipped, paints over whatever + // is above the list) for content nobody can see. Only possible + // for a row whose height is already known: a first-time row has + // to be drawn to be measured at all, which is why the extent + // below is recorded from the intersection test rather than from + // "was this drawn". + if let Some(h) = cached { + let (top, bottom) = match placement { + Placement::Top(top) => (top, top + h), + Placement::Bottom(bottom) => (bottom - h, bottom), + }; + if !self.intersects_viewport(top, bottom) { + return (top, bottom); + } + } + let (top, bottom, height) = match (placement, cached) { (Placement::Top(top), Some(h)) => { // Offered a box sized to the *cached* height (cheap to @@ -928,7 +1085,13 @@ impl List { }; if let Some(k) = key { self.heights.insert(k, height); - self.extents.insert(k, RowExtent { slot, top, bottom }); + // `extents` is what is *on screen* (`key_at`'s doc, and + // `rehome_anchor` below reads it as exactly that), so a + // first-time row that had to be drawn to be measured and + // turned out to be off-screen does not go in it. + if self.intersects_viewport(top, bottom) { + self.extents.insert(k, RowExtent { slot, top, bottom }); + } } (top, bottom) } @@ -961,6 +1124,21 @@ impl Widget for List { // density, and `draw` is where this widget meets the only thing // that knows it. See `fling`. self.density = painter.density(); + // A row that straddles either edge is drawn in full + // (`intersects_viewport`), so the part of it outside this list's + // box is on screen unless something clips it -- and with nothing + // clipping it, a transcript panned to its top edge drew code and + // paragraphs straight through the header bar above it on Iris's + // phone (docs/IRIS_TODO.md, 2026-09-07). Clipping is `.masked()`, + // one mechanism, applied by whoever places the list -- a `List` + // cannot set the mask itself, since `Painter::set_mask` allows one + // mask per widget and rows of this list already use their own + // (`transcript-ui`'s `row.rs`, `tool.rs`). So it checks instead. + debug_assert!( + painter.is_masked(), + "a `List` must be drawn inside something `.masked()`: it draws rows straddling both \ + edges in full, so the parts outside its own box reach the screen otherwise", + ); let output_len = painter.output_size().axis(axis); self.viewport_len = painter.region().axis(axis).len().to_abs(output_len); @@ -1013,6 +1191,23 @@ impl Widget for List { self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0; self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len; + // Both halves of `intersects_viewport`'s rule, checked where they + // are cheap to check: what this frame put on screen is exactly + // what overlaps the viewport, and nothing above or below it can + // be seen. The first failed silently for a whole build -- an + // off-screen row draws correctly, it is just in the wrong place. + debug_assert!( + self.extents + .values() + .all(|e| self.intersects_viewport(e.top, e.bottom)), + "a row outside the viewport (0..{}) is recorded as on screen: {:?}", + self.viewport_len, + self.extents + .values() + .find(|e| !self.intersects_viewport(e.top, e.bottom)), + ); + self.rehome_anchor(); + self.clamp_to_content(painter, top, bottom); self.update_snap_end(); Size::REST } @@ -1068,9 +1263,59 @@ mod tests { /// calling `List`'s own methods through `Widgets::get`/`get_mut`, which /// need a `Sized` widget type) and the erased root `UiRenderState::update` /// draws. + /// + /// The root is a `Masked` around the list rather than the list + /// itself, because that is what every real caller has to do -- a + /// `List` draws the row straddling each edge in full and asserts + /// something is clipping it (`List::draw`). The mask is the full + /// window here, which is also the list's own box. fn add_list(rsc: &mut TestRsc, list: List) -> (WeakWidget, StrongWidget) { let strong = rsc.ui.widgets.add_strong(list); - (strong.weak(), strong.any()) + let weak = strong.weak(); + let root = rsc.ui.widgets.add_strong(Masked { + inner: strong.any(), + }); + (weak, root.any()) + } + + /// The case the top-edge cull and the overscroll clamp both had no + /// reason to touch: fewer rows than fit. Every one of them is drawn + /// (nothing here is outside the viewport), and `clamp_to_content` + /// leaves the list bottom-anchored -- the gap above the first row is + /// not overscroll, it is where this widget puts a short list, and + /// pulling it to the top would be the clamp overriding + /// `repair_anchor`'s own default. + #[test] + fn a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = List::new(Axis::Y); + push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + // Several frames, since the clamp acts on the frame *after* the + // one that measured a gap: a wrong one would walk the rows up the + // screen 40px at a time rather than settle. + for _ in 0..4 { + render.update(&root, &mut rsc); + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert_eq!( + list_ref.extents.len(), + 3, + "every row of a short list is on screen" + ); + let first = list_ref.extents[&0]; + let last = list_ref.extents[&2]; + assert!( + (first.top - 40.0).abs() < 0.01 && (last.bottom - 100.0).abs() < 0.01, + "a 60px list in a 100px viewport moved off the bottom: rows {}..{}", + first.top, + last.bottom, + ); + } } #[test] @@ -1142,7 +1387,7 @@ mod tests { bg_ids.push(bg_id); list.push_back(ListRow::new(key, row)); } - let root = rsc.ui.widgets.add_strong(list).any(); + let (_, root) = add_list(&mut rsc, list); let mut render = UiRenderState::new(); render.resize((100.0, 100.0)); @@ -1323,7 +1568,12 @@ mod tests { render.update(&root, &mut rsc); render.take_counters(); - rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0); + // Backwards, into content that exists: a list opens flush with + // its newest end, so scrolling *forward* from there is + // overscroll, and `clamp_to_content` lays out a second time to + // give it back -- a correct extra pass, but not the ordinary + // scroll tick whose cost this test is about. + rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(-5.0); render.update(&root, &mut rsc); let (draws, _rewrites, moves, _shapes) = render.take_counters(); @@ -1616,9 +1866,28 @@ mod tests { /// Enough rows, tall enough, that a fling toward the start has real /// room to travel before `at_start` clamps it -- shared by the fling /// tests below. + /// How far a `build_flingable_list` list has scrolled from its very + /// first row, in pixels: read off the topmost row on screen, whose + /// content position is exactly `slot * ROW_H` because every row there + /// is that tall. Measures the list's own accumulated movement (the + /// thing `scroll`/`tick_fling` write) rather than the spline's + /// bookkeeping, and unlike a single row's extent it stays defined + /// however far the list travels -- `extents` holds only what is + /// on screen (`List::intersects_viewport`). + fn scroll_position(list: &List) -> f32 { + let top = list + .extents + .values() + .min_by(|a, b| a.top.total_cmp(&b.top)) + .expect("something is on screen"); + top.slot as f32 * FLING_ROW_H - top.top + } + + const FLING_ROW_H: f32 = 20.0; + fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget, StrongWidget, UiRenderState) { let mut list = List::new(Axis::Y); - push_rows(rsc, &mut list, &(0..200).collect::>(), 20.0); + push_rows(rsc, &mut list, &(0..200).collect::>(), FLING_ROW_H); let (list_weak, root) = add_list(rsc, list); let mut render = UiRenderState::new(); render.resize((100.0, 600.0)); @@ -1764,32 +2033,22 @@ mod tests { rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0); let start = Instant::now(); - let mut prev_top = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0].top; + let mut prev = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); let mut deltas = Vec::new(); for step in 1..600 { let now = start + std::time::Duration::from_millis(step * 16); let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now); render.update(&root, &mut rsc); - let Some(top) = rsc - .ui - .widgets - .get(&list_weak) - .unwrap() - .extents - .get(&0) - .map(|e| e.top) - else { - break; // row 0 scrolled out of the loaded extents - }; - deltas.push((prev_top - top).abs()); - prev_top = top; + let at = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); + deltas.push((at - prev).abs()); + prev = at; if !still { break; } } assert!( deltas.len() >= 3, - "fling settled or left row 0's extent before collecting enough samples" + "fling settled before collecting enough samples" ); // Skip the first tick (the slop-transition jump the arbiter // applies is a `List::fling`-adjacent concern, not this curve, @@ -1871,15 +2130,26 @@ mod tests { break; } } + // The frame that gives back whatever the fling's last step spent + // past the first row -- `clamp_to_content` writes the anchor at + // the end of a draw, so it lands on the next one. + render.update(&root, &mut rsc); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert!( list_ref.at_start, "fling should have clamped at the first row" ); + // Not `>= -0.5`: `extents` used to hold every row the walk placed, + // on screen or not, so that read was satisfied by a first row + // sitting *1398px below* a 600px viewport with the whole screen + // blank -- the assertion could not fail in the direction the bug + // actually went. Both edges, so neither an overshoot past the top + // nor one left uncorrected can pass. let first = list_ref.extents[&0]; assert!( - first.top >= -0.5, - "clamped fling overshot the first row's top: {}", + first.top.abs() < 0.5, + "a fling stopped at the start must leave the first row flush with the top, not {}px \ + from it", first.top ); } diff --git a/iris/transcript-fixture/tests/top_edge.rs b/iris/transcript-fixture/tests/top_edge.rs new file mode 100644 index 0000000..b619236 --- /dev/null +++ b/iris/transcript-fixture/tests/top_edge.rs @@ -0,0 +1,229 @@ +//! 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.rsc.ui.masks[active.mask.idx()].region.to_px(h.size()); + 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", + ); +} + +/// 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. +/// +/// Asserted strictly on the way *back*, because a row whose height has +/// never been measured has to be drawn to be measured (`List::place`'s +/// doc), which on the outbound leg is every row entering from the top. +/// The return leg crosses the same rows with every height already known, +/// which is also the ordinary state of a transcript being panned around +/// in. The bound on how many rows are placed at once holds on both. +#[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(), + ); + }; + + for step in 0..40 { + t = scrolled(&mut h, &screen, -400.0, t); + bounded(&drawn_rows(&h, &screen), "back", step); + } + for step in 0..40 { + t = scrolled(&mut h, &screen, 400.0, t); + let rows = drawn_rows(&h, &screen); + bounded(&rows, "forward", step); + for &(top, bottom) in &rows { + assert!( + bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5, + "forward {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \ + {list:?} and was drawn anyway", + ); + } + } +} + +/// 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, + ); +} diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs index 3650532..0bd3ee7 100644 --- a/iris/transcript-ui/src/lib.rs +++ b/iris/transcript-ui/src/lib.rs @@ -422,7 +422,13 @@ where let (composer, composer_bar) = composer::build_composer(rsc); - let tree = (list.width(rest(1)).height(rest(1)), composer_bar) + // `.masked()`: the list draws the row straddling each of its edges in + // full (`List::intersects_viewport`), so without a clip the top of + // that row is drawn above the list -- through whatever the app put + // there, which on the phone is the header bar (docs/IRIS_TODO.md, + // 2026-09-07: "code and a paragraph visible behind Run benchmark"). + // The same clip is what `List::draw` asserts it has. + let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar) .span(Dir::DOWN) .add_strong(rsc) .any();