From a2cd119985a8579227f8efd5af04f72fd51d09cc Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 05:35:12 -0400 Subject: [PATCH] iris: add List, a virtualised bottom-anchored list (RUST.md I3, part 1) Variable-height rows, keyed by a u64, composed only while visible via the existing draw_inner old-children diff (LAYOUT.md), moved not re-laid-out on scroll (Painter::widget_within/reposition, an O(1) offset write), a scroll anchor named by slot index so a row inserted above costs one index increment rather than a content-offset recompute, "more" sentinels as two ordinary optional widgets, and "hold the edge nearest the tap" resolved in the layout pass before any primitive is written for the frame. cargo test -p iris (24 passed, 5 new), cargo clippy --all-targets and cargo fmt --all -- --check clean. Co-Authored-By: Claude Sonnet --- iris/src/widget/list.rs | 851 ++++++++++++++++++++++++++++++++++++++++ iris/src/widget/mod.rs | 2 + 2 files changed, 853 insertions(+) create mode 100644 iris/src/widget/list.rs diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs new file mode 100644 index 0000000..188f51d --- /dev/null +++ b/iris/src/widget/list.rs @@ -0,0 +1,851 @@ +//! `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. +//! +//! **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). No +//! height estimation for unmeasured, off-screen rows: the walk only ever +//! measures the row it is about to place, one at a time outward from the +//! anchor, so there is no average-height table to keep consistent -- the +//! simplest thing that works, and it is what keeps every operation here +//! independent of how many rows exist off-screen. + +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, + pending_tap: Option, + extents: 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, + pending_tap: None, + extents: 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 popped.is_some() { + if let Some(a) = &mut self.anchor { + if a.slot == 0 { + self.anchor = None; + } else if a.slot > 0 { + a.slot -= 1; + } + } + 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 popped.is_some() { + if let Some(a) = &mut self.anchor + && a.slot == old_len - 1 + { + self.anchor = None; + } + 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); + } + + 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.anchor.as_mut().unwrap().offset = 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) + } + + /// Convert a `draw`-returned `Len` (possibly `rel`/`rest`, though every + /// row in practice reports a plain `abs` height) into pixels along + /// `axis`, the same formula `Scroll::draw` uses for its own content + /// length. + fn resolve_len_px(painter: &Painter, axis: Axis, len: Len) -> f32 { + let output_len = painter.output_size().axis(axis); + let container_len = painter.region().axis(axis).len(); + len.apply_rest() + .within_len(container_len) + .to_abs(output_len) + } + + /// Place one slot (a real row or a sentinel) per `placement`, caching + /// its resolved extent for the next frame's `note_tap` resolution, and + /// return its resolved `(leading, trailing)` edges in viewport pixels. + fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) { + let axis = self.axis; + // 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), bounded so the region's abs + // values never approach f32 imprecision at the sizes this list + // ever sees. + let generous = self.viewport_len.max(64.0) * 8.0; + let (top, bottom) = match placement { + Placement::Top(top) => { + let region = Self::abs_region(axis, top, top + generous); + let used = painter.widget_within(self.slot_widget(slot), region); + let h = Self::resolve_len_px(painter, axis, used.axis(axis)); + (top, top + h) + } + Placement::Bottom(bottom) => { + let used = painter.widget(self.slot_widget(slot)); + let h = Self::resolve_len_px(painter, axis, used.axis(axis)); + let top = bottom - h; + let region = Self::abs_region(axis, top, bottom); + painter.reposition(self.slot_widget(slot), region); + (top, bottom) + } + }; + if let Some(key) = self.slot_key(slot) { + self.extents.insert(key, RowExtent { slot, top, bottom }); + } + (top, bottom) + } +} + +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)); + } + + #[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::*;