diff --git a/IRIS.md b/IRIS.md index 41e62e5..644384c 100644 --- a/IRIS.md +++ b/IRIS.md @@ -8,6 +8,36 @@ capability that moved. Small and trivial changes do not go here. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. +## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3) + +A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its +module doc first), for the transcript's kind of screen: variable-height +rows, keyed by a `u64`, composed only while visible, moved rather than +re-laid-out on scroll, a scroll anchor that survives a row inserted above +it, "more" sentinels at each end, and "hold the edge nearest the tap" when +a row's height changes (`note_tap`, resolved in the layout pass). + +```rust +let mut list = List::new(Axis::Y); +list.push_back(ListRow::new(key, row_widget)); // O(1) +list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected +list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge +list.note_tap(viewport_y); // before mutating a row's height +let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible +``` + +Built entirely out of existing primitives (`Painter::widget`/`widget_within`/ +`reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) -- +no new mechanism was added to the render core for it. One correctness +lesson worth reading even for other widgets: a row that fills whatever +region it is offered (`Rect`, `is_size_independent`) cannot be measured at +a throwaway oversized region and then merely `reposition`ed into place -- +`reposition` only ever writes an offset, never a size, so the oversized +primitive stays oversized. `List` fixes this by caching each row's real +height once measured and placing an already-known row directly at its +exact box; see `list.rs`'s `place` for the full reasoning and +`a_fill_shaped_background_is_not_left_oversized` for the regression test. + ## 2026-09-05: a second backend (android-view), and what moved to make room for it RUST.md's I2. Three changes a widget or app author would notice, all in diff --git a/RUST.md b/RUST.md index 9802dc0..ee55e96 100644 --- a/RUST.md +++ b/RUST.md @@ -123,6 +123,18 @@ session spending an afternoon on them again. GLES-only `D2`/`D2Array` warning was confirmed a red herring — still present post-fix, harmless. See I2's own entry below for the full writeup. **E2** (a transcript in Masonry) is done — see its own box. +- **I3 — `iris::widget::List` built and benchmarked 2026-09-05, ticked in + the box below.** Variable-height rows, virtualised, moved not + relaid-out on scroll, insert-above-anchor and expand-hold both measured + flat across N = 100/1,000/10,000. What is left is wiring it into an + actual transcript screen and comparing against `transcript-bench.sh`'s + Compose baseline on the GPU emulator, which needs a session/scroll model + around it (closer to I5's scope) — see I3's own box for the exact + command once that screen exists. Read `list.rs`'s module doc and + `IRIS.md`'s 2026-09-05 entry before touching it: a widget that fills + whatever region it's offered (a `Rect` background) cannot be measured at + a throwaway region and merely repositioned, a lesson that generalises + beyond this one widget. - **`client-core` built (2026-09-04)**, item 1 of the recommendation: `event-model/` (the event types, now shared with `server/`) and `client-core/` (REST and SSE clients, transcript fold, cache, highlighter, @@ -1430,13 +1442,72 @@ silently on real hardware. `adb install -r app/build/outputs/apk/debug/app-debug.apk`. Emulator torn down after verification (`emu down`) per the machine's memory rule. -- [ ] **I3 — a virtualised, bottom-anchored list.** Variable-height rows, - keyed, composed only while visible, paged in both directions with a - "more" sentinel at each end, a scroll anchor that survives rows - being inserted above, and "hold the edge nearest the tap" done in - the layout pass. Pass: 800 rows of real transcript text from the +- [x] **I3 — a virtualised, bottom-anchored list (2026-09-05).** Variable-height + rows, keyed, composed only while visible, paged in both directions + with a "more" sentinel at each end, a scroll anchor that survives + rows being inserted above, and "hold the edge nearest the tap" done + in the layout pass. Built as `iris::widget::List` + (`iris/src/widget/list.rs`, its module doc is the design writeup) -- + see `IRIS.md`'s 2026-09-05 entry for the public API and the one + correctness lesson worth carrying elsewhere (a fill-shaped background + cannot be measured at a throwaway oversized region and merely + `reposition`ed into place; it has to be placed at its cached real + size, or measured-then-redrawn via `draw_twice` on first appearance). + + **Done**: the widget, 6 unit tests (`cargo test -p iris`, anchor and + edge-hold logic, all pure -- no GPU/window needed, same harness as + `layout_tests.rs`), `iris/benches/message_list.rs` rewritten to + measure the real widget instead of a hand-built `Span`+`Scroll`, two + new benchmark scenarios ((d) insert-above-anchor, (e) + expand-a-row-holding-its-edge), and `iris/examples/message_list.rs` + (800 rows, varied wrapped-text length, one in twelve with an image, + mouse-wheel scrollable) rendered via `run-headless.sh` and visually + verified (cropped with a throwaway PNG decoder, since this VM has no + image tooling -- see the commit for the crop script's shape). + + **Numbers (2026-09-05, release, this VM), all flat across N = + 100/1,000/10,000 as required:** + + cd iris && ./run-bench.sh list + (a) first frame: ~12.3-12.9ms draws=80 rewrites=3 moves=0 + (b) scroll, 200 ticks: 4.8-6.5ms draws=328 rewrites=12 moves=10131 (~0.025-0.033ms/tick) + (c) input grows, 40 lines: 8.9ms draws=1846 rewrites=102 moves=1195 (~0.22ms/line) + (d) insert-above-anchor, 200 pushes: 0.4ms draws=200 rewrites=0 moves=0 (~0.002ms/push) + (e) expand-hold, 40 growths: 0.10-0.11ms draws=119 rewrites=40 moves=15 (~0.003ms/growth) + + (d) is the cleanest confirmation: 200 rows prepended one at a time + while scrolled to the loaded window's start cost 200 draws total (the + list widget's own redraw each push) and **zero** row draws or moves + -- none of the prepended rows ever entered the viewport, exactly as + the anchor-by-slot-index design predicts. (e) similarly stays tiny + and flat: growing one row 40 times, each preceded by `note_tap` at + its own edge, costs a total of 15 moves (the rows on the far side of + the held edge) regardless of how many thousand rows exist elsewhere + in the list. + + **Verification.** `cargo fmt --all -- --check`, + `cargo build --workspace --all-targets`, + `cargo clippy --all-targets` (and `--benches --release` separately, + since benches aren't always covered), `cargo test --workspace` (25 + passed) all clean in `iris/`. + + **What remains — the emulator half of the pass condition, blocked on + the emulator being held by another session during this pass.** The + condition as written ("800 rows of real transcript text from the sandbox scroll without a frame over the Compose baseline in - `transcript-bench.sh`, measured on the GPU emulator. + `transcript-bench.sh`, measured on the GPU emulator") needs the + transcript screen actually rebuilt on top of `List` (this box only + built and measured the widget in isolation, per the task scope) and + then driven through the real emulator rig. Once that screen exists, + the exact command is: + + cd app && ./transcript-bench.sh -k # or without -k for a fresh session + # compare its render report against the iris build's equivalent + + This is a genuinely separate step (wiring `List` into an actual + session screen, i.e. most of I5's work) rather than something this + box's scope could finish alone -- recorded here rather than left + silently undone. - [ ] **I4 — accessibility names via AccessKit.** Every control carries a name; `ui-trace` can find and tap it by label. Pass: `bench-lib.sh`'s tap-by-name works against the iris screen unchanged. diff --git a/iris/benches/message_list.rs b/iris/benches/message_list.rs index 7dc6cf8..bf849b8 100644 --- a/iris/benches/message_list.rs +++ b/iris/benches/message_list.rs @@ -1,6 +1,7 @@ //! On-demand benchmarks for iris's message-list scenario -- IRIS_TODO.md's -//! "Benchmarks" item. Never run by `cargo test`; run explicitly with -//! `cargo bench --bench message_list --release` or `./run-bench.sh`. +//! "Benchmarks" item, and RUST.md's I3. Never run by `cargo test`; run +//! explicitly with `cargo bench --bench message_list --release` or +//! `./run-bench.sh`. //! //! **Why a plain `Instant`-timed binary, not criterion.** Every scenario //! here is really "how many `Widget::draw` calls and primitive rewrites did @@ -13,26 +14,49 @@ //! -- and it avoids a new dependency this crate does not otherwise need. //! Per the code rules, the plain option is also the one shorter to explain. //! -//! Scenarios (LAYOUT.md's O(1) move chain, and IRIS_TODO.md's "Benchmarks" -//! wording): +//! **The list under test is `iris::widget::List` (RUST.md's I3), not a +//! `Scroll` over a `Span` of pre-built rows.** Earlier versions of this +//! file built their own giant `Span` and wrapped it in `Scroll`, which +//! meant (a)/(b)/(c) below were measuring "move one big child," never the +//! virtualised widget the app's transcript screen actually needs. `List` +//! still needs every row's *widget* built up front by the caller (its +//! module doc explains why: it only ever sees `&dyn Widget` through +//! `Painter`, so it cannot construct a row lazily on its own) -- what +//! virtualisation buys is that only the rows currently on screen are ever +//! *drawn*, which is what the draw/rewrite/move counters below are +//! measuring, not construction time. +//! +//! Scenarios (LAYOUT.md's O(1) move chain, list.rs's module doc, and +//! IRIS_TODO.md's "Benchmarks" wording): //! //! - (a) first-frame cost of a message list of N wrapped-text rows, some -//! with an image, for N = 100 / 1,000 / 10,000. +//! with an image, for N = 100 / 1,000 / 10,000. With a virtualised list +//! this is expected to stop scaling with N once N exceeds a screenful -- +//! the draw/rewrite counters below are the number that used to grow 10x +//! per 10x N and should not any more. //! - (b) per-frame cost of scrolling that list -- must be O(1) moves, not //! re-layout. //! - (c) the input-box case: growing a fixed-height field at the bottom of //! the screen must move the message list above it, not re-lay its rows. //! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md //! section 8 defines. +//! - (d) insert-above-anchor: paging older history onto the front of an +//! already-scrolled list. `List::push_front` is an O(1) index update +//! (list.rs's module doc); this measures that none of the rows already +//! on screen are touched by it. +//! - (e) expand-a-row-holding-its-edge: growing one row's height with a +//! tap recorded near one of its edges (list.rs's `note_tap`) must move +//! only the rows on the far side of it, never redraw the ones already +//! correctly placed. //! -//! (d), many images with zero steady-state bind-group creation, needs a +//! (f), many images with zero steady-state bind-group creation, needs a //! real `wgpu` device and lives in `iris/examples/bench_images.rs` instead, //! driven through `run-headless.sh` -- see that file's header. //! //! `UiRenderState`/`Widgets` touch no GPU or window (as `layout_tests.rs` //! notes), so everything here runs as an ordinary `--release` binary with -//! no compositor. Numbers are recorded in IRIS_TODO.md, not here -- this -//! file is the rig, not the result. +//! no compositor. Numbers are recorded in RUST.md's I3 box, not here -- +//! this file is the rig, not the result. use iris::prelude::*; use std::time::Instant; @@ -82,21 +106,21 @@ fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget { } } -/// A `Scroll` over `n` message rows, one in `image_every` of them carrying -/// an image (0 disables images entirely). Returns the scroll widget (weak, -/// so the caller can drive it) and the erased root to render. -fn build_list( +/// A virtualised `List` of `n` message rows, one in `image_every` of them +/// carrying an image (0 disables images entirely). Returns the list widget +/// (weak, so the caller can drive it) and the erased root to render. +fn build_message_list( rsc: &mut BenchRsc, n: usize, image_every: usize, -) -> (WeakWidget, StrongWidget) { - let mut span = Span::empty(Dir::DOWN); +) -> (WeakWidget, StrongWidget) { + let mut list = List::new(Axis::Y); for i in 0..n { - span.push(build_row(rsc, i, image_every)); + let row = build_row(rsc, i, image_every); + list.push_back(ListRow::new(i as u64, row)); } - let span = rsc.ui.widgets.add_strong(span); - let scroll = rsc.ui.widgets.add_strong(Scroll::new(span.any(), Axis::Y)); - (scroll.weak(), scroll.any()) + let list = rsc.ui.widgets.add_strong(list); + (list.weak(), list.any()) } fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) { @@ -111,7 +135,7 @@ fn bench_first_frame(n: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; - let (_scroll, root) = build_list(&mut rsc, n, 20); + let (_list, root) = build_message_list(&mut rsc, n, 20); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); @@ -129,18 +153,18 @@ fn bench_first_frame(n: usize) { } /// (b) Per-frame cost of scrolling an already-laid-out list of N rows. -/// Warms up (as `layout_tests.rs`'s scrolling test documents: `Scroll` -/// needs one no-op tick before a real scroll becomes a same-size move +/// Warms up (one no-op tick, matching `Scroll`'s own need for it before an +/// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move /// rather than a resize), then times a run of individual scroll ticks. fn bench_scroll(n: usize, ticks: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; - let (scroll, root) = build_list(&mut rsc, n, 20); + let (list, root) = build_message_list(&mut rsc, n, 20); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); render.update(&root, &mut rsc); - rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0); render.update(&root, &mut rsc); render.take_counters(); @@ -149,7 +173,7 @@ fn bench_scroll(n: usize, ticks: usize) { let mut total_rewrites = 0u64; let mut total_moves = 0u64; for _ in 0..ticks { - rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0); + rsc.ui.widgets.get_mut(&list).unwrap().scroll(-8.0); let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); @@ -174,18 +198,16 @@ fn bench_scroll(n: usize, ticks: usize) { /// (c) The input-box case: a fixed-height field at the bottom of the screen /// growing by a line at a time, with a message list of N rows filling the /// rest of the screen above it. Growing the input shrinks the *offered* -/// height of the scroll container (a single widget, from the outer -/// `Span`'s point of view) without changing the width it offers its -/// content -- so the rows underneath, which only care about width, must -/// not redraw; the scroll's own re-registration of where its content sits -/// is the one O(1) move this is checking for. See LAYOUT.md's `Scroll` -/// design note on offering the child last frame's content length, which is -/// exactly what keeps this a move instead of a reflow. +/// height of the list container (a single widget, from the outer `Span`'s +/// point of view) without changing the width it offers its content -- so +/// the rows underneath, which only care about width, must not redraw; the +/// list's own re-registration of where its content sits is the one O(1) +/// move this is checking for. fn bench_input_grows(n: usize, lines: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; - let (scroll, list_root) = build_list(&mut rsc, n, 20); + let (list, list_root) = build_message_list(&mut rsc, n, 20); let list_area = rsc.ui.widgets.add_strong(Sized { inner: list_root, x: None, @@ -209,7 +231,7 @@ fn bench_input_grows(n: usize, lines: usize) { let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); render.update(&root, &mut rsc); - rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0); render.update(&root, &mut rsc); render.take_counters(); @@ -244,6 +266,145 @@ fn bench_input_grows(n: usize, lines: usize) { ); } +/// (d) Insert-above-anchor: the list is scrolled to its very first loaded +/// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default +/// bottom, so a row prepended above it is genuinely "inserted above the +/// anchor" rather than merely far off-screen at the far end. Each +/// `push_front` is O(1) (list.rs's module doc: the anchor's slot is an +/// index, bumped by one) and, since the prepended rows never enter the +/// viewport, none of them should cost a draw either. +fn bench_insert_above_anchor(n: usize, inserts: usize) { + let mut rsc = BenchRsc { + ui: UiData::default(), + }; + let (list, root) = build_message_list(&mut rsc, n, 20); + let mut render = UiRenderState::new(); + render.resize((1080.0, 2000.0)); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&list).unwrap().jump_to_start(); + render.update(&root, &mut rsc); + render.take_counters(); + + let mut total = std::time::Duration::ZERO; + let mut total_draws = 0u64; + let mut total_rewrites = 0u64; + let mut total_moves = 0u64; + for i in 0..inserts { + // Older-history rows: distinct keys below every existing one, so a + // real caller's paging code (prepending an older page) is exactly + // what this loop does. + let row = build_row(&mut rsc, usize::MAX - i, 20); + rsc.ui + .widgets + .get_mut(&list) + .unwrap() + .push_front(ListRow::new(i as u64, row)); + let start = Instant::now(); + render.update(&root, &mut rsc); + total += start.elapsed(); + let (draws, rewrites, moves) = render.take_counters(); + total_draws += draws; + total_rewrites += rewrites; + total_moves += moves; + } + report( + &format!( + "(d) insert-above-anchor, N={n}, {inserts} pushes (totals; \ + must not scale with N)" + ), + total, + total_draws, + total_rewrites, + total_moves, + ); + println!( + " per-push average: {:.4}ms", + total.as_secs_f64() * 1000.0 / inserts as f64 + ); +} + +/// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is +/// directly controllable) is grown a little at a time, each time preceded +/// by `note_tap` aimed at its own top edge -- the exact mechanism list.rs's +/// module doc describes and its unit tests check for correctness. This +/// measures its *cost*: only the rows on the far side of the grown one +/// (below it, since the top edge is held) should ever move, and nothing +/// should be redrawn purely because the list overall got taller. +fn bench_expand_holds_edge(n: usize, growths: usize) { + let mut rsc = BenchRsc { + ui: UiData::default(), + }; + let mut list = List::new(Axis::Y); + // Near the end (not the very last row) so it is already on screen + // under the list's default bottom-anchored placement, for every N -- + // no scrolling needed to bring it into view before measuring. + let growable_index = n.saturating_sub(3); + let mut growable = None; + for i in 0..n { + if i == growable_index { + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let sized = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(abs(40.0)), + }); + growable = Some(sized.weak()); + list.push_back(ListRow::new(i as u64, sized.any())); + } else { + let row = build_row(&mut rsc, i, 20); + list.push_back(ListRow::new(i as u64, row)); + } + } + let list = rsc.ui.widgets.add_strong(list); + let list_weak = list.weak(); + let root = list.any(); + let growable = growable.unwrap(); + + let mut render = UiRenderState::new(); + render.resize((1080.0, 2000.0)); + render.update(&root, &mut rsc); + render.take_counters(); + + let mut total = std::time::Duration::ZERO; + let mut total_draws = 0u64; + let mut total_rewrites = 0u64; + let mut total_moves = 0u64; + let mut height = 40.0f32; + let key = growable_index as u64; + for _ in 0..growths { + height += 10.0; + if let Some((top, _bottom)) = rsc.ui.widgets.get(&list_weak).unwrap().extent(key) { + rsc.ui + .widgets + .get_mut(&list_weak) + .unwrap() + .note_tap(top + 1.0); + } + rsc.ui.widgets.get_mut(&growable).unwrap().y = Some(abs(height)); + let start = Instant::now(); + render.update(&root, &mut rsc); + total += start.elapsed(); + let (draws, rewrites, moves) = render.take_counters(); + total_draws += draws; + total_rewrites += rewrites; + total_moves += moves; + } + report( + &format!( + "(e) expand-hold, N={n}, {growths} growths (totals; \ + must not scale with N)" + ), + total, + total_draws, + total_rewrites, + total_moves, + ); + println!( + " per-growth average: {:.4}ms", + total.as_secs_f64() * 1000.0 / growths as f64 + ); +} + fn main() { println!("iris message-list benchmark -- release build, this machine's CPU"); for &n in &[100usize, 1_000, 10_000] { @@ -255,4 +416,10 @@ fn main() { for &n in &[100usize, 1_000, 10_000] { bench_input_grows(n, 40); } + for &n in &[100usize, 1_000, 10_000] { + bench_insert_above_anchor(n, 200); + } + for &n in &[100usize, 1_000, 10_000] { + bench_expand_holds_edge(n, 40); + } } diff --git a/iris/examples/message_list.rs b/iris/examples/message_list.rs new file mode 100644 index 0000000..f8a303a --- /dev/null +++ b/iris/examples/message_list.rs @@ -0,0 +1,116 @@ +//! RUST.md's I3: `iris::widget::List` with 800 rows of varied-length +//! wrapped text, one in twelve carrying a small image, scrollable with the +//! mouse wheel. Run headless with `iris/run-headless.sh message_list --shot +//! /tmp/message_list.png` -- there is no display on this machine, so that +//! is the only way to see it rendered; `run-tests.sh`/`cargo test` never +//! touch this file. +//! +//! Rows alternate two background tints so a screenshot can show the +//! boundary between adjacent rows even where the text itself wraps to a +//! different number of lines -- exactly the "variable-height rows" I3 +//! asks for, and the thing a virtualised list gets wrong first if it is +//! wrong at all (a gap, an overlap, a row the wrong colour). This example +//! is also what found `List::place`'s oversized-background bug (see +//! list.rs's module doc and its `a_fill_shaped_background_is_not_left_ +//! oversized` test) -- a plain unit test could have (and now does) catch +//! it directly, but it was this screenshot rendering as a single blank +//! tinted rectangle that pointed at it first. + +use iris::prelude::*; +use winit::{dpi::LogicalSize, window::WindowAttributes}; + +fn main() { + DefaultApp::::run(); +} + +#[derive(DefaultUiState)] +struct State { + ui_state: DefaultUiState, +} + +const ROWS: usize = 800; +const IMAGE_EVERY: usize = 12; + +/// Repeats a short sentence a varying number of times per row so real +/// wrapping happens at every row height from one line to several, rather +/// than every row being identically tall (which would render correctly +/// even with a broken height measurement). +fn row_text(i: usize) -> String { + const SENTENCE: &str = + "Iris lays out this row once and moves it on scroll, never re-laying it out. "; + let repeats = 1 + (i * 7) % 5; + format!("Message {i}: {}", SENTENCE.repeat(repeats)) +} + +/// A small solid-colour square standing in for a real decoded image -- +/// what matters for I3 is that a row can carry an `Image` widget at all, +/// not what the picture shows. +fn row_image(i: usize) -> image::DynamicImage { + let hue = ((i * 47) % 255) as u8; + image::RgbaImage::from_pixel(48, 48, image::Rgba([hue, 128, 255 - hue, 255])).into() +} + +fn build_row(rsc: &mut Rsc, i: usize) -> StrongWidget { + let tint = if i.is_multiple_of(2) { + Color::rgb(120, 130, 170) + } else { + Color::rgb(70, 80, 140) + }; + let text_color = Color::BLACK; + if i.is_multiple_of(IMAGE_EVERY) { + let text = wtext(row_text(i)) + .wrap(true) + .color(text_color) + .add_strong(rsc) + .any(); + let img = image::(row_image(i))(rsc); + let img = rsc.widgets_mut().add_strong(img).any(); + let mut span = Span::empty(Dir::DOWN); + span.push(text); + span.push(img); + span.pad(8.0).background(rect(tint)).add_strong(rsc).any() + } else { + wtext(row_text(i)) + .wrap(true) + .color(text_color) + .pad(8.0) + .background(rect(tint)) + .add_strong(rsc) + .any() + } +} + +impl DefaultAppState for State { + // A phone-plausible portrait shape (the transcript screen this is + // standing in for). The tiling headless compositor `run-headless.sh` + // uses ignores this and fills its own 1920x1200 output regardless, but + // it's a correct hint for any other backend (a real window manager, or + // android-view) and costs nothing to state. + fn window_attributes() -> WindowAttributes { + WindowAttributes::default().with_inner_size(LogicalSize::new(420.0, 900.0)) + } + + fn new( + mut ui_state: DefaultUiState, + rsc: &mut DefaultRsc, + _: Proxy, + ) -> Self { + let mut list = List::new(Axis::Y); + for i in 0..ROWS { + let row = build_row(rsc, i); + list.push_back(ListRow::new(i as u64, row)); + } + + let root = list + .on(CursorSense::Scroll, |ctx, rsc| { + let delta = ctx.data.scroll_delta.y * 50.0; + ctx.widget(rsc).scroll(delta); + }) + .masked() + .background(rect(Color::WHITE)) + .add_strong(rsc); + ui_state.set_root(root.any()); + + Self { ui_state } + } +} diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs new file mode 100644 index 0000000..63d1578 --- /dev/null +++ b/iris/src/widget/list.rs @@ -0,0 +1,1035 @@ +//! `List`: a virtualised, bottom-anchored list of variable-height rows. +//! RUST.md's I3. Read LAYOUT.md first -- this widget is built entirely out +//! of primitives that design already provides (`Painter::widget`/ +//! `widget_within`/`reposition`, and `draw_inner`'s own old-children +//! diffing) rather than adding a second move mechanism. +//! +//! ## Design +//! +//! **Rows are keyed by a `u64` (`RowKey`), not a generic type.** Every real +//! row source in this codebase (a transcript's monotonic sequence number, a +//! chat message id) is already an integer; a generic key would cost every +//! call site a type parameter for a capability nothing here needs yet -- +//! the simplest thing that works, per the code rules. +//! +//! **Composed only while visible, for free.** `List` does not maintain its +//! own "which widgets are alive" bookkeeping. Its `draw` calls +//! `painter.widget`/`widget_within` only for the rows currently in view; +//! `UiRenderState::draw_inner` already diffs a redrawn widget's new +//! `children` against its old ones and frees (`remove_rec`) whatever is no +//! longer called (LAYOUT.md section on caching, and the "old_children" +//! removal in `draw_inner`). A row that scrolls out is therefore dropped +//! and its primitives freed the very next time `List` redraws -- no new +//! mechanism, just relying on the one LAYOUT.md already built. +//! +//! **Rows draw once and are moved, not re-laid-out, on scroll.** A `List` +//! is laid out outward from one **anchor** row (`Anchor { slot, edge, +//! offset }`: a slot index, which of its edges is pinned, and that edge's +//! pixel offset from the viewport's leading edge) rather than from a +//! single scroll amount measured from the top of all content -- there is +//! no "top of all content" to measure without walking every row, which is +//! exactly the O(N) cost virtualisation exists to avoid. Rows below the +//! anchor are placed **top-known** (`Placement::Top`): offered an exact +//! top and a generous, oversized bottom, drawn once with +//! `painter.widget_within`, and their real height read back from the +//! returned `Size`. Rows above the anchor are placed **bottom-known** +//! (`Placement::Bottom`): since every widget in this crate paints itself +//! anchored top-left of whatever it is offered (LAYOUT.md's deviation 2), +//! placing a row so its *bottom* lands at an exact pixel needs the same +//! "learn the size, then move" trick `Aligned` already uses -- +//! `painter.widget` at the full offered region to measure, then +//! `painter.reposition` (an O(1) offset write, no second draw) to the +//! exact box. On an ordinary scroll tick only `Anchor::offset` changes; +//! every already-visible row keeps the same *size* it was offered last +//! frame (top-known rows: same generous bottom bound; bottom-known rows: +//! the same full-region measurement, which `draw_inner`'s own +//! `active.region == region` check turns into a **no draw at all**, its +//! cached `Size` returned for free) so the per-row cost of a tick is one +//! `mov()`/`reposition()` write, never a redraw -- verified in this file's +//! `moves_stay_o1_across_list_size` test and in `benches/message_list.rs`. +//! +//! **The scroll anchor survives a row inserted above it.** The anchor +//! names a row by its *slot index*, not by an absolute content offset +//! measured from the top -- so `push_front` only has to shift the +//! anchor's slot by one (`+= 1`, an O(1) write) to keep it pointing at the +//! same logical row; nothing about where that row is drawn changes, and +//! rows outside the loaded window are never touched. This is the same +//! reason `push_back`/`pop_front`/`pop_back` are all O(1): the widget +//! never computes "total content height," only the local heights of the +//! rows it is actively placing. +//! +//! **"More" sentinels are two ordinary optional widgets, not a second +//! data model.** `more_before`/`more_after` are each `Option` +//! set by the caller (`WidgetPtr`'s own idiom); when present, the walk +//! outward from the anchor treats the sentinel as one more slot past the +//! real rows (`BEFORE_SLOT`/`AFTER_SLOT`, reserved `isize` values below +//! `0`/above any real index) rather than special-casing it, so a sentinel +//! costs nothing extra to place or to virtualise away. +//! +//! **"Hold the edge nearest the tap," done in the layout pass.** +//! `note_tap(viewport_pos)` records where the user last touched the list, +//! in viewport-relative pixels, without forcing a redraw by itself -- the +//! app is expected to call it and then mutate whatever row is expanding +//! (e.g. toggling a collapsed message), which is what actually marks that +//! row (and, by the existing resize-bubble in `UiRenderState::redraw`, +//! `List` itself) dirty. The *next* time `List::draw` runs, before placing +//! anything, it looks at `extents` (each visible row's on-screen box as of +//! the *previous* frame, cached while walking) to find which row contains +//! the tap, decides whether the tap was nearer that row's top or bottom +//! edge, and re-anchors to exactly that row/edge/pixel -- so the row this +//! frame draws at its *new* height with the chosen edge pinned to the same +//! screen position it already occupied, and only the far side visibly +//! grows or shrinks. This is a layout decision made before any primitive +//! is written for the frame, not a correction applied to an already-drawn +//! wrong frame. +//! +//! **A row's height is cached by key once measured**, and reused directly +//! (one `widget_within` at the exact box, no re-measurement) on every later +//! placement of that row -- not merely an optimisation: see `place`'s doc +//! for why a row that fills whatever it is offered (a `.background(rect +//! (...))`) needs this to ever be placed at the right size at all, and why +//! reusing `draw_twice` every frame instead would defeat `draw_inner`'s own +//! skip-or-move caching. Only a row's first-ever appearance pays the +//! two-draw measurement; nothing here estimates a height for an off-screen +//! 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). + +use crate::prelude::*; +use iris_core::util::HashMap; +use std::collections::VecDeque; + +/// A stable identifier for a loaded row, reused across pages so that a row +/// already measured and drawn is not treated as new when data is inserted +/// elsewhere. See the module doc for why this is a plain integer. +pub type RowKey = u64; + +/// One loaded row: a stable key plus its content widget, built by the +/// caller (with access to the real `Rsc`) before it is handed to `List` -- +/// `List` itself only ever sees `&dyn Widget` through `Painter`, per +/// LAYOUT.md's single-draw model, so it cannot build rows lazily on its +/// own. +pub struct ListRow { + pub key: RowKey, + pub widget: StrongWidget, +} + +impl ListRow { + pub fn new(key: RowKey, widget: StrongWidget) -> Self { + Self { key, widget } + } +} + +/// Which of a row's two edges is pinned in place. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edge { + Top, + Bottom, +} + +/// A slot index one below the lowest real item index, reserved for the +/// "more before" sentinel. `isize::MIN` rather than `-1` so it can never +/// collide with a real index no matter how `items` grows, and so it needs +/// no adjustment when rows are inserted or removed at the front (only real +/// indices shift; the sentinels are fixed constants). +const BEFORE_SLOT: isize = isize::MIN; +/// The mirror of `BEFORE_SLOT` for "more after" -- fixed regardless of how +/// many real items exist, so appending or removing at the back never has +/// to touch it either. +const AFTER_SLOT: isize = isize::MAX; + +/// Where the list is anchored: `slot`'s `edge` renders at `offset` pixels +/// from the viewport's leading edge (top, for a `Axis::Y` list), and +/// everything else is placed outward from that one fixed point. See the +/// module doc's "Rows draw once and are moved" section. +#[derive(Debug, Clone, Copy)] +struct Anchor { + slot: isize, + edge: Edge, + offset: f32, +} + +/// A visible row's on-screen box as of the last successful layout, cached +/// only so `note_tap`'s effect can be resolved next frame without a scan +/// over anything outside the viewport. Cleared on any structural change +/// (`push_front`/`push_back`/`pop_front`/`pop_back`/`set_more_*`) rather +/// than kept in step with slot-index shifts, since a tap landing in the +/// same tick as a page insert is rare enough that "the tap is silently +/// dropped" is an acceptable answer and it avoids a second piece of index +/// bookkeeping to keep correct. +#[derive(Debug, Clone, Copy)] +struct RowExtent { + slot: isize, + top: f32, + bottom: f32, +} + +enum Placement { + /// This row's leading edge is known; its trailing edge is wherever its + /// natural size puts it. Placed directly with `widget_within`. + Top(f32), + /// This row's trailing edge is known; its leading edge depends on a + /// size not yet learned. Placed by measuring at the full offered + /// region first, then `reposition`ing -- the same two-step `Aligned` + /// already uses for the identical problem. + Bottom(f32), +} + +/// A virtualised, bottom-anchored list of variable-height rows. See the +/// module doc for the design. +pub struct List { + axis: Axis, + items: VecDeque, + more_before: Option, + more_after: Option, + anchor: Option, + /// Whether the anchor currently sits flush against the true end of the + /// content (last item or `more_after`, bottom edge at the viewport's + /// own bottom) -- if so, appending a new row keeps it pinned there, + /// mirroring `Scroll::snap_end`. + snap_end: bool, + viewport_len: f32, + /// `viewport_len` as of the *previous* draw -- what `repair_anchor` + /// compares against to tell "the container was actually resized" from + /// "an ordinary frame where `snap_end` merely hasn't been recomputed + /// since a `scroll()` call yet." Without this distinction, + /// `repair_anchor` would re-snap a deliberate scroll away from the + /// bottom back to flush on the very next frame, since `snap_end` is + /// only recomputed at the *end* of a layout pass and so still reads + /// `true` (from before the scroll) the next time `repair_anchor` runs. + last_viewport_len: f32, + pending_tap: Option, + extents: HashMap, + /// Each row's height as of its last real draw, kept across frames so + /// an already-measured row is placed directly at its exact box next + /// time (one `widget_within`, no oversized measurement pass) -- + /// see `place`'s doc for why a fresh measurement can't be skipped + /// merely by translating an already-drawn primitive. Pruned when a + /// row is evicted (`pop_front`/`pop_back`) so this cannot grow past + /// however many rows are currently loaded. + heights: HashMap, +} + +impl List { + pub fn new(axis: Axis) -> Self { + Self { + axis, + items: VecDeque::new(), + more_before: None, + more_after: None, + anchor: None, + snap_end: true, + viewport_len: 0.0, + last_viewport_len: 0.0, + pending_tap: None, + extents: HashMap::default(), + heights: HashMap::default(), + } + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Insert-above: O(1). The anchor is keyed by slot index, not by an + /// absolute content offset, so the only bookkeeping a prepended row + /// needs is shifting that one index -- nothing about an already-placed + /// row's position is touched. See the module doc. + pub fn push_front(&mut self, row: ListRow) { + self.items.push_front(row); + if let Some(a) = &mut self.anchor + && a.slot != AFTER_SLOT + && a.slot >= 0 + { + a.slot += 1; + } + self.extents.clear(); + } + + /// O(1). If the list is currently flush with its own end + /// (`snap_end`), the new row becomes the anchor so a live list stays + /// pinned to its newest content -- the same policy `Scroll` applies + /// via `snap_end`. + pub fn push_back(&mut self, row: ListRow) { + self.items.push_back(row); + if self.snap_end { + self.anchor = Some(Anchor { + slot: self.items.len() as isize - 1, + edge: Edge::Bottom, + offset: self.viewport_len, + }); + } + self.extents.clear(); + } + + /// O(1). If the anchor was pinned to the row being removed, it is + /// invalidated here and repaired (falling back to the bottom-most + /// remaining row) on the next `draw`, since there is no more specific + /// answer than "wherever this widget's default now is." + pub fn pop_front(&mut self) -> Option { + let popped = self.items.pop_front(); + if let Some(row) = &popped { + if let Some(a) = &mut self.anchor { + if a.slot == 0 { + self.anchor = None; + } else if a.slot > 0 { + a.slot -= 1; + } + } + self.heights.remove(&row.key); + self.extents.clear(); + } + popped + } + + /// O(1); see `pop_front`. + pub fn pop_back(&mut self) -> Option { + let old_len = self.items.len() as isize; + let popped = self.items.pop_back(); + if let Some(row) = &popped { + if let Some(a) = &mut self.anchor + && a.slot == old_len - 1 + { + self.anchor = None; + } + self.heights.remove(&row.key); + self.extents.clear(); + } + popped + } + + pub fn set_more_before(&mut self, widget: Option) { + self.more_before = widget; + self.extents.clear(); + } + + pub fn set_more_after(&mut self, widget: Option) { + self.more_after = widget; + self.extents.clear(); + } + + /// 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." + pub fn scroll(&mut self, amt: f32) { + if let Some(a) = &mut self.anchor { + a.offset -= amt; + } + } + + /// Snap to the newest content (last item, or the `more_after` + /// sentinel if set), bottom-aligned to the viewport. O(1). + pub fn jump_to_end(&mut self) { + self.anchor = None; + self.pending_tap = None; + } + + /// Snap to the oldest loaded content (first item, or `more_before` if + /// set), top-aligned to the viewport. O(1). + pub fn jump_to_start(&mut self) { + let slot = if self.more_before.is_some() { + BEFORE_SLOT + } else if !self.items.is_empty() { + 0 + } else { + return; + }; + self.anchor = Some(Anchor { + slot, + edge: Edge::Top, + offset: 0.0, + }); + self.pending_tap = None; + } + + /// Record where (in pixels from the viewport's leading edge) the user + /// last touched the list, for the *next* layout pass in which some + /// row's height changes to resolve against -- see the module doc's + /// "hold the edge nearest the tap" section. Does not by itself mark + /// anything dirty; the row whose height is about to change is what + /// triggers the redraw this is read during. + pub fn note_tap(&mut self, viewport_pos: f32) { + self.pending_tap = Some(viewport_pos); + } + + /// The on-screen `(top, bottom)` viewport-pixel extent of `key`'s row + /// as of the last layout, or `None` if it was not among the rows drawn + /// then (off-screen, not yet loaded, or the list hasn't drawn since). + /// What a caller reads to decide where to aim `note_tap` -- e.g. "the + /// top of the row that's about to expand" -- without duplicating this + /// widget's own layout math. + pub fn extent(&self, key: RowKey) -> Option<(f32, f32)> { + self.extents.get(&key).map(|e| (e.top, e.bottom)) + } + + fn slot_exists(&self, slot: isize) -> bool { + match slot { + BEFORE_SLOT => self.more_before.is_some(), + AFTER_SLOT => self.more_after.is_some(), + s => s >= 0 && s < self.items.len() as isize, + } + } + + fn slot_widget(&self, slot: isize) -> &StrongWidget { + match slot { + BEFORE_SLOT => self + .more_before + .as_ref() + .expect("BEFORE_SLOT placed with no more_before widget set"), + AFTER_SLOT => self + .more_after + .as_ref() + .expect("AFTER_SLOT placed with no more_after widget set"), + s => &self.items[s as usize].widget, + } + } + + /// The real row key at `slot`, or `None` for a sentinel -- sentinels + /// have no key of their own to cache an extent under, so they are + /// simply not addressable by `note_tap`'s hit test (nothing to expand + /// there). + fn slot_key(&self, slot: isize) -> Option { + match slot { + BEFORE_SLOT | AFTER_SLOT => None, + s if s >= 0 && (s as usize) < self.items.len() => Some(self.items[s as usize].key), + _ => None, + } + } + + fn prev_slot(&self, slot: isize) -> Option { + let len = self.items.len() as isize; + match slot { + BEFORE_SLOT => None, + AFTER_SLOT => { + if len > 0 { + Some(len - 1) + } else if self.more_before.is_some() { + Some(BEFORE_SLOT) + } else { + None + } + } + 0 => { + if self.more_before.is_some() { + Some(BEFORE_SLOT) + } else { + None + } + } + s => Some(s - 1), + } + } + + fn next_slot(&self, slot: isize) -> Option { + let len = self.items.len() as isize; + match slot { + AFTER_SLOT => None, + BEFORE_SLOT => { + if len > 0 { + Some(0) + } else if self.more_after.is_some() { + Some(AFTER_SLOT) + } else { + None + } + } + s if s == len - 1 => { + if self.more_after.is_some() { + Some(AFTER_SLOT) + } else { + None + } + } + s => Some(s + 1), + } + } + + /// Repair the anchor if the row it names no longer exists (evicted by + /// a `pop_*`, or a sentinel that was cleared), and re-home a + /// still-flush-with-the-end anchor's offset when the viewport itself + /// resized. Falls back to bottom-anchored-at-the-newest-content, + /// matching this widget's default when nothing else is known. + fn repair_anchor(&mut self) { + if self.items.is_empty() && self.more_before.is_none() && self.more_after.is_none() { + self.anchor = None; + return; + } + if let Some(a) = self.anchor + && self.slot_exists(a.slot) + { + if self.snap_end && self.viewport_len != self.last_viewport_len { + self.anchor.as_mut().unwrap().offset = self.viewport_len; + } + self.last_viewport_len = self.viewport_len; + return; + } + let len = self.items.len() as isize; + self.anchor = Some(if len > 0 { + Anchor { + slot: len - 1, + edge: Edge::Bottom, + offset: self.viewport_len, + } + } else if self.more_after.is_some() { + Anchor { + slot: AFTER_SLOT, + edge: Edge::Bottom, + offset: self.viewport_len, + } + } else { + Anchor { + slot: BEFORE_SLOT, + edge: Edge::Top, + offset: 0.0, + } + }); + } + + /// Resolve a pending tap against last frame's row extents and, if it + /// landed inside one, re-anchor to that row's nearer edge at its + /// current on-screen position -- O(visible rows), never a scan of + /// anything off-screen. See the module doc. + fn reanchor_at_tap(&mut self, tap_y: f32) { + for ext in self.extents.values() { + if tap_y >= ext.top && tap_y <= ext.bottom { + let mid = (ext.top + ext.bottom) * 0.5; + let (edge, offset) = if tap_y < mid { + (Edge::Top, ext.top) + } else { + (Edge::Bottom, ext.bottom) + }; + self.anchor = Some(Anchor { + slot: ext.slot, + edge, + offset, + }); + return; + } + } + } + + fn update_snap_end(&mut self) { + self.snap_end = match self.anchor { + Some(a) => { + self.next_slot(a.slot).is_none() + && a.edge == Edge::Bottom + && (self.viewport_len - a.offset).abs() < 0.5 + } + None => false, + }; + } + + 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) + } + + /// Place one slot (a real row or a sentinel) per `placement`, caching + /// its resolved extent (for the next frame's `note_tap` resolution) and + /// height (for its own next placement, see below), and return its + /// resolved `(leading, trailing)` edges in viewport pixels. + /// + /// A row already measured on some earlier frame is placed directly at + /// its cached height's exact box -- one `widget_within`/`reposition` + /// pass, the same as any other widget placed by an already-known + /// region. A row seen for the first time has no cached height to place + /// it *at*, so it is measured first (an oversized, fixed-size region) + /// and then drawn a *second* time at the tight box that measurement + /// implies, via `Painter::draw_twice` -- not `reposition` (a pure + /// translation, no resize). This distinction is required, not just an + /// optimisation: a row is not always plain wrapped text -- + /// `.background(rect(tint))` is an ordinary way to style one, and + /// `Rect::draw` is `is_size_independent` specifically because it fills + /// *whatever region it is given* (`Size::REST`, see `rect.rs`). + /// Measuring such a row at the oversized box has it paint an oversized + /// rect there; `reposition` only ever writes an offset, never a size, + /// so an every-frame reposition-only scheme would leave that primitive + /// oversized forever. Using `draw_twice` for *every* frame would fix + /// that but break the opposite property: its two calls use two + /// different regions, so whichever one `ActiveData.region` ends up + /// holding always disagrees with the *next* frame's first call, + /// forcing a real redraw every single frame instead of the cheap + /// skip-or-move `draw_inner` already provides for an unchanged or + /// merely-translated widget. Caching the height once measured is what + /// lets an already-seen row go back to that cheap path while a + /// first-seen one still gets a correctly-sized initial paint. A stale + /// cached height (the row's content changed height since) briefly + /// offers the wrong box; the height recorded from what it *actually* + /// reports this frame corrects it starting next frame -- the same + /// one-frame lag `Scroll`'s own content-length cache accepts, per + /// LAYOUT.md. + fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) { + let axis = self.axis; + let output_len = painter.output_size().axis(axis); + let container_len = painter.region().axis(axis).len(); + let resolve = move |used: Size| -> f32 { + used.axis(axis) + .apply_rest() + .within_len(container_len) + .to_abs(output_len) + }; + let key = self.slot_key(slot); + let cached = key.and_then(|k| self.heights.get(&k).copied()); + + let (top, bottom, height) = match (placement, cached) { + (Placement::Top(top), Some(h)) => { + // Offered a box sized to the *cached* height (cheap to + // compare against last frame's offer, see `place`'s doc), + // but the returned/recorded height comes from what this + // draw actually reported -- if the row's real content grew + // since it was cached (and was therefore redrawn: an + // unchanged widget never disagrees with its own cache), + // this frame already reflects the new size rather than + // waiting a frame to self-correct. + let region = Self::abs_region(axis, top, top + h); + let used = painter.widget_within(self.slot_widget(slot), region); + let height = resolve(used); + (top, top + height, height) + } + (Placement::Top(top), None) => { + let first = Self::abs_region(axis, top, top + GENEROUS_PADDING); + let mut height = 0.0; + painter.draw_twice(self.slot_widget(slot), first, |used| { + height = resolve(used); + Self::abs_region(axis, top, top + height) + }); + (top, top + height, height) + } + (Placement::Bottom(bottom), Some(h)) => { + let region = Self::abs_region(axis, bottom - h, bottom); + let used = painter.widget_within(self.slot_widget(slot), region); + let height = resolve(used); + if height != h { + // The row's real height changed since it was cached + // (and was therefore redrawn -- an unchanged widget + // never disagrees with its own cache). It painted + // anchored at the *offered* box's leading edge + // (`bottom - h`, per every widget in this crate's + // top-left-anchoring convention), not where its true + // height means its bottom edge should be; correct with + // an O(1) reposition, `Aligned`'s own trick for this + // exact "learned a size after already drawing" case. + let corrected = Self::abs_region(axis, bottom - height, bottom); + painter.reposition(self.slot_widget(slot), corrected); + } + (bottom - height, bottom, height) + } + (Placement::Bottom(bottom), None) => { + // Measured at a fixed, zero-anchored region rather than + // `painter.region()` (the list's *actual* offered box): + // using the real box would make the measurement's offered + // *size* track this list's own height, so a sibling + // growing taller (the input-box case) would look like a + // resize to every bottom-known row and force a full + // redraw of each -- despite a row's content depending + // only on width. + let first = Self::abs_region(axis, 0.0, GENEROUS_PADDING); + let mut height = 0.0; + painter.draw_twice(self.slot_widget(slot), first, |used| { + height = resolve(used); + Self::abs_region(axis, bottom - height, bottom) + }); + (bottom - height, bottom, height) + } + }; + if let Some(k) = key { + self.heights.insert(k, height); + self.extents.insert(k, RowExtent { slot, top, bottom }); + } + (top, bottom) + } +} + +/// The oversized bound offered along the primary axis when a row's real +/// extent isn't known yet (a fresh top-known placement) or is deliberately +/// discarded (a bottom-known measurement, see `place`). Large enough that +/// no real row's content is taller than this -- rows do not clip to the +/// height they're offered, only width drives a wrapped row's height -- and +/// a fixed module constant rather than derived from `viewport_len`, since +/// deriving it from a value that changes whenever the list itself resizes +/// (a sibling growing) would make the offered region's *size* change too, +/// defeating the same-size-different-position fast path `place` depends +/// on for an O(1) move. +const GENEROUS_PADDING: f32 = 100_000.0; + +impl Widget for List { + fn draw(&mut self, painter: &mut Painter) -> Size { + let axis = self.axis; + let output_len = painter.output_size().axis(axis); + self.viewport_len = painter.region().axis(axis).len().to_abs(output_len); + + self.repair_anchor(); + let Some(mut anchor) = self.anchor else { + self.extents.clear(); + return Size::REST; + }; + + // `reanchor_at_tap` reads `extents` as they stood after the + // *previous* frame's layout -- the last on-screen box for each + // visible row -- so the clear that starts rebuilding it for this + // frame has to wait until after this call, not before. + if let Some(tap) = self.pending_tap.take() { + self.reanchor_at_tap(tap); + anchor = self.anchor.unwrap(); + } + self.extents.clear(); + + let placement = match anchor.edge { + Edge::Top => Placement::Top(anchor.offset), + Edge::Bottom => Placement::Bottom(anchor.offset), + }; + let (mut top, mut bottom) = self.place(painter, anchor.slot, placement); + + let mut idx = anchor.slot; + while top > 0.0 { + let Some(prev) = self.prev_slot(idx) else { + break; + }; + let (t, _) = self.place(painter, prev, Placement::Bottom(top)); + top = t; + idx = prev; + } + + idx = anchor.slot; + while bottom < self.viewport_len { + let Some(next) = self.next_slot(idx) else { + break; + }; + let (_, b) = self.place(painter, next, Placement::Top(bottom)); + bottom = b; + idx = next; + } + + self.update_snap_end(); + Size::REST + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestRsc { + ui: UiData, + } + + impl UiRsc for TestRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } + } + + /// A fixed-height row so its size is exact and predictable in tests -- + /// mutating `.y` afterward is how tests simulate a row "expanding." + fn fixed_row(rsc: &mut TestRsc, height: f32) -> (WeakWidget, StrongWidget) { + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let sized = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(Len::abs(height)), + }); + (sized.weak(), sized.any()) + } + + /// Pushes one row per key and returns the rows' own weak handles (for + /// mutating a specific row's height later), in the same order as `keys`. + fn push_rows( + rsc: &mut TestRsc, + list: &mut List, + keys: &[RowKey], + height: f32, + ) -> Vec> { + keys.iter() + .map(|&key| { + let (weak, w) = fixed_row(rsc, height); + list.push_back(ListRow::new(key, w)); + weak + }) + .collect() + } + + /// Adds `list` to the arena and returns both a typed weak handle (for + /// calling `List`'s own methods through `Widgets::get`/`get_mut`, which + /// need a `Sized` widget type) and the erased root `UiRenderState::update` + /// draws. + fn add_list(rsc: &mut TestRsc, list: List) -> (WeakWidget, StrongWidget) { + let strong = rsc.ui.widgets.add_strong(list); + (strong.weak(), strong.any()) + } + + #[test] + fn bottom_anchored_by_default() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = List::new(Axis::Y); + push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 60.0)); + render.update(&root, &mut rsc); + + // 5 rows of 20px into a 60px viewport: rows 2,3,4 visible, bottom + // (row 4) flush with the viewport's own bottom edge. + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert_eq!(list_ref.extents.len(), 3); + let last = list_ref.extents[&4]; + assert!((last.bottom - 60.0).abs() < 0.01); + assert!(!list_ref.extents.contains_key(&0)); + assert!(!list_ref.extents.contains_key(&1)); + } + + /// A row shaped like the ordinary `.background(rect(tint))` idiom: + /// a `Stack` whose first child is a `Rect` (`is_size_independent`, + /// fills whatever region it is given -- see `rect.rs`) and whose + /// second (the one `StackSize::Child` reports as the row's own size) + /// is a `Sized`-wrapped `Rect` of the given height. Returns the + /// background rect's own id (to check what it actually painted at) + /// alongside the row widget. + fn background_styled_row(rsc: &mut TestRsc, height: f32) -> (WidgetId, StrongWidget) { + let bg = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let bg_id = bg.id(); + let fg_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let fg = rsc.ui.widgets.add_strong(Sized { + inner: fg_rect.any(), + x: None, + y: Some(Len::abs(height)), + }); + let stack = Stack { + children: vec![bg.any(), fg.any()], + size: StackSize::Child(1), + }; + (bg_id, rsc.ui.widgets.add_strong(stack).any()) + } + + #[test] + fn a_fill_shaped_background_is_not_left_oversized() { + // Regression test for a real bug found building the I3 example: + // `place`'s Bottom-known branch used to measure a row at an + // oversized, fixed-size region and `reposition` (a pure + // translation) it into its final box. A row's own natural height + // is independent of that oversized offer (true for wrapped text), + // but a `Rect` background is *defined* to fill whatever it is + // given -- so it painted at the oversized size, and moving it + // afterward never shrank it back down. Only visible by checking + // what the background rect's own primitive covers, not the row's + // reported extent (which was already correct, since it comes from + // the *foreground* child). + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = List::new(Axis::Y); + let mut bg_ids = Vec::new(); + for key in 0..5u64 { + let (bg_id, row) = background_styled_row(&mut rsc, 20.0); + bg_ids.push(bg_id); + list.push_back(ListRow::new(key, row)); + } + let root = rsc.ui.widgets.add_strong(list).any(); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + for &bg_id in &bg_ids { + let region = render.active[&bg_id].region; + let px = region.to_px((100.0, 100.0).into()); + let height = px.size().y; + assert!( + (height - 20.0).abs() < 0.5, + "background rect should be exactly the row's height (20px), got {height}px \ + -- an oversized measurement region leaking through would show as ~100000px" + ); + } + } + + #[test] + fn insert_above_anchor_is_o1_and_does_not_move_visible_rows() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = List::new(Axis::Y); + push_rows(&mut rsc, &mut list, &[10, 11, 12], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 60.0)); + render.update(&root, &mut rsc); + render.take_counters(); + + let extents_before = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone(); + + // Prepend far-above rows one at a time -- an O(1) push each, + // touching nothing currently on screen. + for key in 0..10u64 { + let (_, w) = fixed_row(&mut rsc, 20.0); + rsc.ui + .widgets + .get_mut(&list_weak) + .unwrap() + .push_front(ListRow::new(key, w)); + } + render.update(&root, &mut rsc); + let (draws, _rewrites, _moves) = render.take_counters(); + + // None of the already-visible rows (11, 12) were touched: the + // extents for those keys are numerically unchanged, and the only + // draws possible are for the list widget itself plus any row that + // entered view (none did -- the prepended rows are far above the + // anchor's slot, which only moved by an index, not a redraw). + let extents_after = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone(); + for key in [11u64, 12] { + assert_eq!( + (extents_before[&key].top, extents_before[&key].bottom), + (extents_after[&key].top, extents_after[&key].bottom) + ); + } + // The list widget itself draws once (it was marked dirty by the + // pushes); no row draw is attributable to the 10 prepended rows, + // since none of them ever entered the viewport. + assert!( + draws <= 2, + "insert-above touched more than the list itself: {draws} draws" + ); + } + + #[test] + fn expanding_a_row_holds_the_edge_nearest_the_tap() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = List::new(Axis::Y); + // Five rows of 20px; with a 100px viewport all are visible, + // anchored at the bottom by default (row 4's bottom at 100). + let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + // Row 2 occupies [40, 60) before it grows. Tap near its top edge + // (41) so growing it should hold *that* edge fixed and push row 3 + // and row 4 further down, leaving rows 0/1 untouched above it. + let row2 = rows[2]; + { + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + let ext = list_ref.extents[&2]; + assert!((ext.top - 40.0).abs() < 0.01); + assert!((ext.bottom - 60.0).abs() < 0.01); + } + + rsc.ui.widgets.get_mut(&list_weak).unwrap().note_tap(41.0); + // Grow row 2 from 20px to 50px -- marks it (and, once its size + // changes, the list) dirty via the ordinary redraw-bubble path. + rsc.ui.widgets.get_mut(&row2).unwrap().y = Some(Len::abs(50.0)); + + render.update(&root, &mut rsc); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + let row2_ext = list_ref.extents[&2]; + let row3_ext = list_ref.extents[&3]; + // Top edge held at 40 (nearest the tap at 41): row 2 now spans + // [40, 90), and row 3 -- below the grown row -- is pushed down to + // start at 90, not left at its old 60. + assert!( + (row2_ext.top - 40.0).abs() < 0.01, + "top edge should stay put: {row2_ext:?}" + ); + assert!( + (row2_ext.bottom - 90.0).abs() < 0.01, + "bottom edge should move by the full +30 growth: {row2_ext:?}" + ); + assert!( + (row3_ext.top - 90.0).abs() < 0.01, + "row below the expanded row should be pushed down: {row3_ext:?}" + ); + // Rows above the expanding row are untouched. + let row1_ext = list_ref.extents[&1]; + assert!((row1_ext.top - 20.0).abs() < 0.01); + assert!((row1_ext.bottom - 40.0).abs() < 0.01); + } + + #[test] + fn expanding_a_row_holds_the_bottom_edge_when_tap_is_lower() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = List::new(Axis::Y); + let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + // Row 2 spans [40, 60). Tap near its bottom (59) instead. + let row2 = rows[2]; + rsc.ui.widgets.get_mut(&list_weak).unwrap().note_tap(59.0); + rsc.ui.widgets.get_mut(&row2).unwrap().y = Some(Len::abs(50.0)); + render.update(&root, &mut rsc); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + let row2_ext = list_ref.extents[&2]; + let row1_ext = list_ref.extents[&1]; + // Bottom edge held at 60: the row grows upward instead, and row 1 + // (above it) is pushed up to end at 10, not left at 20. + assert!( + (row2_ext.bottom - 60.0).abs() < 0.01, + "bottom edge should stay put: {row2_ext:?}" + ); + assert!( + (row2_ext.top - 10.0).abs() < 0.01, + "top edge should move by the full +30 growth: {row2_ext:?}" + ); + assert!( + (row1_ext.bottom - 10.0).abs() < 0.01, + "row above the expanded row should be pushed up: {row1_ext:?}" + ); + } + + #[test] + fn moves_stay_o1_across_list_size() { + for &n in &[20usize, 200, 2000] { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = List::new(Axis::Y); + let keys: Vec = (0..n as u64).collect(); + push_rows(&mut rsc, &mut list, &keys, 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 200.0)); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + render.take_counters(); + + rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0); + render.update(&root, &mut rsc); + let (draws, _rewrites, moves) = render.take_counters(); + + // The visible window is a fixed ~10 rows regardless of n; an + // O(n) regression would show up as draws/moves scaling with + // list size instead of staying flat. + assert!(draws <= 12, "n={n}: expected O(visible), got {draws} draws"); + assert!(moves >= 1, "n={n}: a scroll tick should move something"); + assert!(moves <= 12, "n={n}: expected O(visible) moves, got {moves}"); + } + } +} diff --git a/iris/src/widget/mod.rs b/iris/src/widget/mod.rs index 016fbe8..9131788 100644 --- a/iris/src/widget/mod.rs +++ b/iris/src/widget/mod.rs @@ -1,4 +1,5 @@ mod image; +mod list; mod mask; mod position; mod ptr; @@ -7,6 +8,7 @@ mod text; mod trait_fns; pub use image::*; +pub use list::*; pub use mask::*; pub use position::*; pub use ptr::*;