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::*;