use crate::prelude::*; use iris_core::util::HashMap; use std::collections::VecDeque; use std::time::Instant; 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 `LazySpan` -- /// `LazySpan` 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 LazyItem { pub key: RowKey, pub widget: StrongWidget, } impl LazyItem { pub fn new(key: RowKey, widget: StrongWidget) -> Self { Self { key, widget } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Edge { Leading, Trailing, } 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; const GENEROUS_PADDING: f32 = 100_000.0; const CONTENT_OFFSET_REBASE: f32 = 65_536.0; #[derive(Debug, Clone, Copy)] struct Anchor { slot: isize, edge: Edge, offset: f32, } #[derive(Debug, Clone, Copy)] struct RowExtent { slot: isize, lead: f32, trail: f32, } #[derive(Clone, Copy)] enum Placement { Leading(f32), Trailing(f32), } impl Placement { fn edges(self, height: f32) -> (f32, f32) { match self { Placement::Leading(lead) => (lead, lead + height), Placement::Trailing(trail) => (trail - height, trail), } } } pub struct LazySpan { dir: Dir, items: VecDeque, more_before: Option, more_after: Option, anchor: Option, ctl: ScrollController, viewport_len: f32, last_viewport_len: f32, pending_tap: Option, extents: HashMap, heights: HashMap, content_offset: f32, at_start: bool, at_end: bool, content_lead: f32, content_trail: f32, no_more_before: bool, no_more_after: bool, } impl LazySpan { pub fn new(dir: Dir, pin: Pin) -> Self { Self { dir, ctl: ScrollController::new(dir, pin), items: VecDeque::new(), more_before: None, more_after: None, anchor: None, viewport_len: 0.0, last_viewport_len: 0.0, at_start: false, at_end: false, content_lead: 0.0, content_trail: 0.0, no_more_before: false, no_more_after: false, pending_tap: None, extents: HashMap::default(), heights: HashMap::default(), content_offset: 0.0, } } pub fn len(&self) -> usize { self.items.len() } pub fn is_empty(&self) -> bool { self.items.is_empty() } pub fn push_front(&mut self, row: LazyItem) { 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(); } pub fn push_back(&mut self, row: LazyItem) { self.items.push_back(row); if self.ctl.pinned_to_end() { self.anchor = Some(Anchor { slot: self.items.len() as isize - 1, edge: Edge::Trailing, offset: self.viewport_len, }); } self.extents.clear(); } 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 } 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(); } /// Swap the last row's widget for a new one **without moving it**: the /// slot index is unchanged, so an anchor already pointing at this slot /// (in particular `snap_end`'s pinned-to-newest case) stays pinned, and /// an anchor pointing anywhere else -- this row scrolled out of view -- /// is untouched, so nothing currently on screen moves. This is what a /// streamed reply needs: the row whose *content* keeps changing after /// it first appears is still the same row by position, even if its /// `RowKey` happens to change too (rare -- only `heights`/`extents` care /// about the key, and both are invalidated here the same way /// `pop_back` already invalidates them for the row it removes). /// `None` if the list is empty. O(1), same as `push_back`/`pop_back`. pub fn replace_back(&mut self, row: LazyItem) -> Option { let idx = self.items.len().checked_sub(1)?; let old = std::mem::replace(&mut self.items[idx], row); self.heights.remove(&old.key); self.extents.clear(); Some(old) } pub fn clear(&mut self) { self.items.clear(); self.anchor = None; self.ctl.set_pinned_to_end(true); self.heights.clear(); self.extents.clear(); self.content_offset = 0.0; } fn move_anchor(&mut self, amt: f32) { if self.anchor.is_none() { return; } self.anchor.as_mut().unwrap().offset -= amt; // Converted into the screen-space convention the controller and // every caller outside this widget speak in. Every move this span // makes goes through here, including the ones `overscroll_gap` // gives back, so `amt` is what actually happened rather than what // was asked for -- see `ScrollController::moved_by`. Jumps // (`jump_to_end`/`jump_to_start`) deliberately do not: they are // not travel across the content. let moved = self.flip_delta(amt); self.ctl.moved_by(moved); } pub fn anchor_position_display(&self) -> String { match self.anchor { None => "idx=none".to_string(), Some(a) if a.slot == BEFORE_SLOT => "idx=more-before".to_string(), Some(a) if a.slot == AFTER_SLOT => "idx=more-after".to_string(), Some(a) => format!("idx={}/off={}px", a.slot, a.offset.round() as i64), } } pub fn jump_to_end(&mut self) { self.anchor = None; self.pending_tap = None; } 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::Leading, offset: 0.0, }); self.pending_tap = None; } fn flip_pos(&self, pos: f32) -> f32 { match self.dir.sign { Sign::Pos => pos, Sign::Neg => self.viewport_len - pos, } } fn flip_delta(&self, amt: f32) -> f32 { match self.dir.sign { Sign::Pos => -amt, Sign::Neg => amt, } } pub fn note_tap(&mut self, viewport_pos: f32) { self.pending_tap = Some(self.flip_pos(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 it 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. Ordered top-then-bottom on screen /// whichever way `dir` runs, since that is what a caller comparing it /// against a pointer position needs. pub fn extent(&self, key: RowKey) -> Option<(f32, f32)> { self.extents.get(&key).map(|e| { let (a, b) = (self.flip_pos(e.lead), self.flip_pos(e.trail)); (a.min(b), a.max(b)) }) } /// The row whose on-screen box (as of the last layout) contains /// `viewport_pos`, or `None` if it falls outside every row currently /// drawn (a gap, a header, or off the loaded content entirely). O /// (visible rows), same as `reanchor_at_tap`. What a caller resolves a /// pointer-captured gesture's row-under-the-finger against once the /// gesture is no longer being delivered through any one row's own hit /// region -- see `iris::sense`'s pointer-capture doc. pub fn key_at(&self, viewport_pos: f32) -> Option { let pos = self.flip_pos(viewport_pos); self.extents .iter() .find(|(_, ext)| pos >= ext.lead && pos <= ext.trail) .map(|(&key, _)| key) } 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, } } 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), } } 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.ctl.pinned_to_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::Trailing, offset: self.viewport_len, } } else if self.more_after.is_some() { Anchor { slot: AFTER_SLOT, edge: Edge::Trailing, offset: self.viewport_len, } } else { Anchor { slot: BEFORE_SLOT, edge: Edge::Leading, 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. `tap` is already in the /// walk's direction-relative space (`note_tap` converted it), so this /// compares like with like whichever way `dir` runs. fn reanchor_at_tap(&mut self, tap: f32) { for ext in self.extents.values() { if tap >= ext.lead && tap <= ext.trail { let mid = (ext.lead + ext.trail) * 0.5; let (edge, offset) = if tap < mid { (Edge::Leading, ext.lead) } else { (Edge::Trailing, ext.trail) }; self.anchor = Some(Anchor { slot: ext.slot, edge, offset, }); return; } } } fn rehome_anchor(&mut self) { let Some(anchor) = self.anchor else { return; }; if self.extents.values().any(|e| e.slot == anchor.slot) { return; } let Some(first) = self .extents .values() .min_by(|a, b| a.lead.total_cmp(&b.lead)) .copied() else { // Nothing on screen at all -- a list scrolled past its own // content (`scroll` is deliberately unclamped). There is no // on-screen row to re-home to, and inventing one would move // the list; leave the anchor where it is and let the next // scroll or `repair_anchor` bring content back. return; }; self.anchor = Some(Anchor { slot: first.slot, edge: Edge::Leading, offset: first.lead, }); } fn overscroll_gap(&self, lead: f32, trail: f32) -> Option { if self.at_start == self.at_end { return None; } let gap = if self.at_start { lead } else { trail - self.viewport_len }; // Sub-pixel gaps are what floating-point row heights leave behind // every frame; laying out again for one would leave another, and // the list would never settle. (gap.abs() >= 0.5).then_some(gap) } fn lay_out(&mut self, painter: &mut Painter, stable_anchor_lead: Option) -> (f32, f32) { let anchor = self .anchor .expect("lay_out with no anchor: `draw` returns before this without one"); let placement = match anchor.edge { Edge::Leading => Placement::Leading(anchor.offset), Edge::Trailing => Placement::Trailing(anchor.offset), }; let (mut lead, mut trail) = self.place(painter, anchor.slot, placement, stable_anchor_lead, true); let mut idx_lead = anchor.slot; while lead > 0.0 { let Some(prev) = self.prev_slot(idx_lead) else { break; }; let (l, _) = self.place(painter, prev, Placement::Trailing(lead), None, false); lead = l; idx_lead = prev; } let mut idx_trail = anchor.slot; while trail < self.viewport_len { let Some(next) = self.next_slot(idx_trail) else { break; }; let (_, t) = self.place(painter, next, Placement::Leading(trail), None, false); trail = t; idx_trail = next; } // What a fling is clamped against -- see `at_start`'s field doc. // `lead`/`trail` are the extreme edges actually placed this frame, // and `prev_slot`/`next_slot` returning `None` is what "no more // content" means everywhere else in this widget. self.at_start = self.prev_slot(idx_lead).is_none() && lead >= 0.0; self.at_end = self.next_slot(idx_trail).is_none() && trail <= self.viewport_len; // The structural half of the same two questions, kept apart from // `at_start`/`at_end` because they mean different things: // "there is nothing loaded past this edge" is what bounds a // scroll, while `at_start`/`at_end` add "and there is a gap to // give back", which is what the overscroll clamp acts on. self.no_more_before = self.prev_slot(idx_lead).is_none(); self.no_more_after = self.next_slot(idx_trail).is_none(); self.content_lead = lead; self.content_trail = trail; assert!( self.extents .values() .all(|e| self.intersects_viewport(e.lead, e.trail)), "a row outside the viewport (0..{}) is recorded as on screen: {:?}", self.viewport_len, self.extents .values() .find(|e| !self.intersects_viewport(e.lead, e.trail)), ); (lead, trail) } fn update_snap_end(&mut self) { let pinned = match self.anchor { Some(a) => { self.next_slot(a.slot).is_none() && a.edge == Edge::Trailing && (self.viewport_len - a.offset).abs() < 0.5 } None => false, }; self.ctl.set_pinned_to_end(pinned); } fn intersects_viewport(&self, lead: f32, trail: f32) -> bool { trail > 0.0 && lead < self.viewport_len } fn abs_region(dir: Dir, start: f32, end: f32) -> UiRegion { let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end)); let mut region = UiRegion::from_axis(dir.axis, span, UiSpan::FULL); if dir.sign == Sign::Neg { region.flip(dir.axis); } region } fn row_region(&self, lead: f32, trail: f32) -> UiRegion { Self::abs_region( self.dir, lead - self.content_offset, trail - self.content_offset, ) } fn apply_content_offset(&self, painter: &mut Painter) { let screen_offset = match self.dir.sign { Sign::Pos => self.content_offset, Sign::Neg => -self.content_offset, }; painter.set_child_offset(Vec2::from_axis(self.dir.axis, screen_offset, 0.0)); } fn rebase_content_offset(&mut self) { if self.content_offset.abs() >= CONTENT_OFFSET_REBASE { self.content_offset = 0.0; } } fn stabilize_lead(&mut self, painter: &mut Painter, from: f32, to: f32) { self.content_offset += to - from; self.apply_content_offset(painter); } fn place( &mut self, painter: &mut Painter, slot: isize, placement: Placement, stable_lead: Option, is_anchor: bool, ) -> (f32, f32) { debug_assert!( self.slot_exists(slot), "place() called with a slot that doesn't exist: {slot:?}" ); let dir = self.dir; let axis = dir.axis; let output_len = painter.output_size().axis(axis); let container_len = painter.region().axis(axis).len(); let density = painter.density(); let resolve = move |used: Size| -> f32 { used.axis(axis) .apply_rest(density) .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 known_anchor_height = (is_anchor && stable_lead.is_some()).then(|| { painter.known_len(self.slot_widget(slot), axis).map(|len| { len.apply_rest(density) .within_len(container_len) .to_abs(output_len) }) }); let known_anchor_height = known_anchor_height.flatten(); if let Some(h) = cached { let (lead, trail) = placement.edges(known_anchor_height.unwrap_or(h)); if let Some(old_lead) = stable_lead { self.stabilize_lead(painter, old_lead, lead); } if !self.intersects_viewport(lead, trail) { return (lead, trail); } } let height = match (cached, known_anchor_height) { (Some(_), Some(height)) => { let (lead, trail) = placement.edges(height); let region = self.row_region(lead, trail); painter.widget_within(self.slot_widget(slot), region); height } (Some(h), None) => { let (lead, trail) = placement.edges(h); let region = self.row_region(lead, trail); let used = painter.widget_within(self.slot_widget(slot), region).size(); let height = resolve(used); if height != h { let (new_lead, new_trail) = placement.edges(height); if stable_lead.is_some() { self.stabilize_lead(painter, lead, new_lead); } let region = self.row_region(new_lead, new_trail); painter.place(self.slot_widget(slot), region); } height } (None, None) => { let measure_from = match placement { Placement::Leading(lead) => lead, Placement::Trailing(_) => 0.0, }; let first = self.row_region(measure_from, measure_from + GENEROUS_PADDING); let height = resolve(painter.widget_within(self.slot_widget(slot), first).size()); let (lead, trail) = placement.edges(height); if is_anchor { self.stabilize_lead(painter, measure_from, lead); } let region = self.row_region(lead, trail); painter.place(self.slot_widget(slot), region); height } (None, Some(_)) => unreachable!("an uncached row has no retained height"), }; let (lead, trail) = placement.edges(height); if let Some(k) = key { self.heights.insert(k, height); if self.intersects_viewport(lead, trail) { self.extents.insert(k, RowExtent { slot, lead, trail }); } } (lead, trail) } /// **Inherent, and it shadows `WidgetLike::scrollable` on purpose.** /// That one wraps its widget in a `ScrollArea`, which is exactly what /// must not happen here -- a lump slid about by a parent would never /// update which rows it shows -- and this span already owns the /// controller such an area would have brought. Rust resolves an /// inherent method before a trait one, so `list.scrollable()` finds /// this, and it needs neither of the other's arguments: the axis is /// `dir`'s and the pin was chosen at construction. /// /// A caller with a drag arbiter of its own registers the wheel and /// leaves the drag out rather than calling this: one gesture, one /// arbiter (`transcript_ui`'s `Selection`, and `DragGesture`'s doc). pub fn scrollable(self) -> impl WidgetIdFn { let axis = self.dir.axis; scroll_senses(self, axis) } fn travel(&self) -> Travel { let forward = if self.no_more_after { (self.content_trail - self.viewport_len).max(0.0) } else { f32::INFINITY }; let backward = if self.no_more_before { (-self.content_lead).max(0.0) } else { f32::INFINITY }; match self.dir.sign { Sign::Pos => Travel { back: backward, fwd: forward, }, Sign::Neg => Travel { back: forward, fwd: backward, }, } } } impl Scrollable for LazySpan { fn controller(&self) -> &ScrollController { &self.ctl } fn controller_mut(&mut self) -> &mut ScrollController { &mut self.ctl } } impl Widget for LazySpan { fn child_order(&self) -> ChildOrder { ChildOrder::Axis(self.dir.axis) } /// A lazy span animates exactly one thing, its fling -- and it drives /// its own rather than being handed deltas by a `ScrollArea` around /// it, since which rows exist at all is a function of where it is /// scrolled to and a moved lump would never update them. fn tick(&mut self, now: Instant) -> bool { self.tick_fling(now) } fn draw(&mut self, painter: &mut Painter) { let axis = self.dir.axis; let output_len = painter.output_size().axis(axis); self.viewport_len = painter.region().axis(axis).len().to_abs(output_len); self.ctl.set_density(painter.density()); self.repair_anchor(); if self.anchor.is_none() { self.extents.clear(); painter.set_size(Size::REST); return; } // What a wheel, a drag or a fling asked for since the last frame, // already clamped to the travel that frame reported -- the walls // it placed are the freshest answer available, and where they are // stale (the content changed under a settled anchor) the walk // below finds the real ones and `overscroll_gap` gives back the // difference before this frame ends. let delta = self.ctl.take_delta(); if delta != 0.0 { let amt = self.flip_delta(delta); self.move_anchor(amt); } if let Some(tap) = self.pending_tap.take() { self.reanchor_at_tap(tap); } self.rebase_content_offset(); let stable_anchor_lead = self.anchor.and_then(|anchor| { self.extents .values() .find(|extent| extent.slot == anchor.slot) .map(|extent| extent.lead) }); self.extents.clear(); self.apply_content_offset(painter); let (lead, trail) = self.lay_out(painter, stable_anchor_lead); if let Some(gap) = self.overscroll_gap(lead, trail) { let stable_anchor_lead = self.anchor.and_then(|anchor| { self.extents .values() .find(|extent| extent.slot == anchor.slot) .map(|extent| extent.lead) }); self.move_anchor(gap); self.extents.clear(); self.lay_out(painter, stable_anchor_lead); } self.rehome_anchor(); self.update_snap_end(); self.ctl.set_travel(self.travel()); painter.set_size(Size::REST); } fn size_hint(&self, _axis: Axis) -> Option { Some(Len::REST) } } #[cfg(test)] mod tests { use super::*; use std::time::Instant; const FLING_ROW_H: f32 = 20.0; fn scroll_position(list: &LazySpan) -> f32 { let first = list .extents .values() .min_by(|a, b| a.lead.total_cmp(&b.lead)) .expect("something is on screen"); first.slot as f32 * FLING_ROW_H - first.lead } struct TestRsc { ui: Ui, } impl UiRsc for TestRsc { fn ui(&self) -> &Ui { &self.ui } fn ui_mut(&mut self) -> &mut Ui { &mut self.ui } } fn fixed_row(rsc: &mut TestRsc, height: f32) -> (WeakWidget, StrongWidget) { let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); let sized = rsc.ui.widgets.add_strong(Sized { inner: rect.any(), x: None, y: Some(Len::abs(height)), }); (sized.weak(), sized.any()) } fn push_rows( rsc: &mut TestRsc, list: &mut LazySpan, keys: &[RowKey], height: f32, ) -> Vec> { keys.iter() .map(|&key| { let (weak, w) = fixed_row(rsc, height); list.push_back(LazyItem::new(key, w)); weak }) .collect() } fn add_list(rsc: &mut TestRsc, list: LazySpan) -> (WeakWidget, StrongWidget) { let strong = rsc.ui.widgets.add_strong(list); let weak = strong.weak(); let root = rsc.ui.widgets.add_strong(Masked { shape: None, inner: strong.any(), }); (weak, root.any()) } #[test] fn a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); let (list_weak, root) = add_list(&mut rsc, list); let mut render = UiRenderState::new(); render.resize((100.0, 100.0)); for _ in 0..4 { render.update(&root, &mut rsc); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert_eq!( list_ref.extents.len(), 3, "every row of a short list is on screen" ); let first = list_ref.extents[&0]; let last = list_ref.extents[&2]; assert!( (first.lead - 40.0).abs() < 0.01 && (last.trail - 100.0).abs() < 0.01, "a 60px list in a 100px viewport moved off the bottom: rows {}..{}", first.lead, last.trail, ); } } #[test] fn a_dir_up_span_grows_upward_from_item_zero() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::UP, Pin::End); let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); let (list_weak, root) = add_list(&mut rsc, list); let mut render = UiRenderState::new(); render.resize((100.0, 100.0)); render.update(&root, &mut rsc); let drawn = |render: &UiRenderState, row: &WeakWidget| { let px = render .window_region(row, &rsc) .expect("every short-list row is drawn"); (px.top_left.y, px.bot_right.y) }; assert_eq!( drawn(&render, &rows[2]), (0.0, 20.0), "the newest row of a Dir::UP span is drawn at the top of the screen" ); assert_eq!(drawn(&render, &rows[1]), (20.0, 40.0)); assert_eq!( drawn(&render, &rows[0]), (40.0, 60.0), "item 0 is drawn furthest down" ); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert_eq!(list_ref.extent(2), Some((0.0, 20.0))); assert_eq!(list_ref.extent(0), Some((40.0, 60.0))); } #[test] fn a_delta_moves_both_directions_the_same_way_on_screen() { let moved_by = |dir: Dir| { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(dir, Pin::End); let keys: Vec = (0..10).collect(); let rows = 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, 100.0)); render.update(&root, &mut rsc); let push = |rsc: &mut TestRsc, render: &mut UiRenderState, delta: f32| { let before = rsc.ui.widgets.get(&list_weak).unwrap().amt(); rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(delta); render.update(&root, rsc); let moved = before - rsc.ui.widgets.get(&list_weak).unwrap().amt(); assert!( (moved - delta).abs() < 0.01, "there was content to take the whole delta: asked {delta}, moved {moved}", ); }; push( &mut rsc, &mut render, match dir.sign { Sign::Pos => 60.0, Sign::Neg => -60.0, }, ); let top = |render: &UiRenderState, rsc: &TestRsc| { render .window_region(&rows[4], rsc) .expect("row 4 is still drawn") .top_left .y }; let before = top(&render, &rsc); push(&mut rsc, &mut render, -10.0); top(&render, &rsc) - before }; for dir in [Dir::DOWN, Dir::UP] { let moved = moved_by(dir); assert!( (moved + 10.0).abs() < 0.5, "a negative delta must move the content 10px up the screen, not {moved}px", ); } } #[test] fn a_reversed_span_hit_tests_in_screen_space() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::UP, Pin::End); push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); let (list_weak, root) = add_list(&mut rsc, list); let mut render = UiRenderState::new(); render.resize((100.0, 100.0)); render.update(&root, &mut rsc); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert_eq!( list_ref.key_at(10.0), Some(2), "10px down the screen is the newest row" ); assert_eq!(list_ref.key_at(50.0), Some(0), "50px down is item 0"); assert_eq!( list_ref.key_at(90.0), None, "below the content there is no row" ); } #[test] fn bottom_anchored_by_default() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); 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); 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.trail - 60.0).abs() < 0.01); assert!(!list_ref.extents.contains_key(&0)); assert!(!list_ref.extents.contains_key(&1)); } fn background_styled_row(rsc: &mut TestRsc, height: f32) -> (WidgetId, StrongWidget) { let (bg_id, _, row) = resizable_background_row(rsc, height); (bg_id, row) } fn resizable_background_row( rsc: &mut TestRsc, height: f32, ) -> (WidgetId, WeakWidget, StrongWidget) { let bg = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); let bg_id = bg.id(); let fg_rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); let fg = rsc.ui.widgets.add_strong(Sized { inner: fg_rect.any(), x: None, y: Some(Len::abs(height)), }); let fg_weak = fg.weak(); let stack = Stack { children: vec![bg.any(), fg.any()], size: StackSize::Child(1), }; (bg_id, fg_weak, rsc.ui.widgets.add_strong(stack).any()) } #[test] fn a_row_that_changes_height_draws_its_background_at_the_new_height_immediately() { for key_to_change in 0..5u64 { for new_height in [50.0f32, 8.0] { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); let mut rows = Vec::new(); for key in 0..5u64 { let (bg_id, fg, row) = resizable_background_row(&mut rsc, 20.0); rows.push((bg_id, fg)); list.push_back(LazyItem::new(key, row)); } let (_, root) = add_list(&mut rsc, list); let mut render = UiRenderState::new(); render.resize((100.0, 100.0)); render.update(&root, &mut rsc); render.update(&root, &mut rsc); let (bg_id, fg) = rows[key_to_change as usize]; rsc.ui.widgets.get_mut(&fg).unwrap().y = Some(Len::abs(new_height)); render.update(&root, &mut rsc); let px = render .active .get(&bg_id) .unwrap_or_else(|| { panic!("row {key_to_change} resized to {new_height}px lost its background") }) .region .to_px((100.0, 100.0).into()); let drawn = px.size().y; assert!( (drawn - new_height).abs() < 0.5, "row {key_to_change} resized to {new_height}px drew its background at {drawn}px on the same frame" ); } } } #[test] fn a_fill_shaped_background_is_not_left_oversized() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); 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(LazyItem::new(key, row)); } let (_, root) = add_list(&mut rsc, list); 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 provisional 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: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); 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(); for key in 0..10u64 { let (_, w) = fixed_row(&mut rsc, 20.0); rsc.ui .widgets .get_mut(&list_weak) .unwrap() .push_front(LazyItem::new(key, w)); } render.update(&root, &mut rsc); let (draws, _rewrites, _moves, _shapes) = render.take_counters(); let extents_after = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone(); for key in [11u64, 12] { assert_eq!( (extents_before[&key].lead, extents_before[&key].trail), (extents_after[&key].lead, extents_after[&key].trail) ); } 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: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); 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); let row2 = rows[2]; { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let ext = list_ref.extents[&2]; assert!((ext.lead - 40.0).abs() < 0.01); assert!((ext.trail - 60.0).abs() < 0.01); } rsc.ui.widgets.get_mut(&list_weak).unwrap().note_tap(41.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 row3_ext = list_ref.extents[&3]; assert!( (row2_ext.lead - 40.0).abs() < 0.01, "top edge should stay put: {row2_ext:?}" ); assert!( (row2_ext.trail - 90.0).abs() < 0.01, "bottom edge should move by the full +30 growth: {row2_ext:?}" ); assert!( (row3_ext.lead - 90.0).abs() < 0.01, "row below the expanded row should be pushed down: {row3_ext:?}" ); let row1_ext = list_ref.extents[&1]; assert!((row1_ext.lead - 20.0).abs() < 0.01); assert!((row1_ext.trail - 40.0).abs() < 0.01); } #[test] fn expanding_a_row_holds_the_bottom_edge_when_tap_is_lower() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); 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); 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]; assert!( (row2_ext.trail - 60.0).abs() < 0.01, "bottom edge should stay put: {row2_ext:?}" ); assert!( (row2_ext.lead - 10.0).abs() < 0.01, "top edge should move by the full +30 growth: {row2_ext:?}" ); assert!( (row1_ext.trail - 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: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); 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); render.take_counters(); rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(1.0); render.update(&root, &mut rsc); let (draws, _rewrites, moves, _shapes) = render.take_counters(); assert_eq!(draws, 1, "n={n}: only the list should really draw"); assert_eq!(moves, 1, "n={n}: the whole retained run should move once"); } } #[test] fn a_large_accumulated_offset_rebases_without_moving_the_content() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); let keys: Vec = (0..100).collect(); let rows = push_rows(&mut rsc, &mut list, &keys, 1_000.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(CONTENT_OFFSET_REBASE + 1.0); render.update(&root, &mut rsc); let anchor_key = { let list = rsc.ui.widgets.get(&list_weak).unwrap(); let anchor = list.anchor.expect("the populated list has an anchor"); list.slot_key(anchor.slot) .expect("the anchor is a real row away from the sentinels") }; let anchor_row = &rows[anchor_key as usize]; let before = render .window_region(anchor_row, &rsc) .expect("the rehomed anchor is retained") .top_left .y; rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(1.0); render.update(&root, &mut rsc); let after = render .window_region(anchor_row, &rsc) .expect("the anchor remains retained after the rebase") .top_left .y; assert!( (after - before - 1.0).abs() < 0.5, "the rebase must be invisible; expected +1px, got {}px", after - before, ); } #[test] fn replacing_the_last_row_stays_pinned_to_the_bottom() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); 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); { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert!((list_ref.extents[&4].trail - 60.0).abs() < 0.01); } let (_weak, new_row) = fixed_row(&mut rsc, 40.0); let old = rsc .ui .widgets .get_mut(&list_weak) .unwrap() .replace_back(LazyItem::new(4, new_row)); assert!( old.is_some(), "replace_back should hand back the row it evicted" ); render.update(&root, &mut rsc); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let row4 = list_ref.extents[&4]; assert!( (row4.trail - 60.0).abs() < 0.01, "still pinned to the newest end after the replace: {row4:?}" ); assert!( (row4.lead - 20.0).abs() < 0.01, "grew upward, from the pinned bottom edge: {row4:?}" ); } #[test] fn replace_back_forgets_the_evicted_keys_own_height() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); 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); assert!( rsc.ui .widgets .get(&list_weak) .unwrap() .heights .contains_key(&4) ); let (_weak, new_row) = fixed_row(&mut rsc, 40.0); let old = rsc .ui .widgets .get_mut(&list_weak) .unwrap() .replace_back(LazyItem::new(100, new_row)); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert_eq!(old.map(|o| o.key), Some(4)); assert!( !list_ref.heights.contains_key(&4), "the evicted key's cached height must not outlive the row it measured" ); } #[test] fn replacing_the_last_row_out_of_view_does_not_move_visible_rows() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); 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); rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start(); render.update(&root, &mut rsc); let (before0, before1, before2) = { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert!(!list_ref.extents.contains_key(&4)); ( list_ref.extents[&0], list_ref.extents[&1], list_ref.extents[&2], ) }; let (_weak, new_row) = fixed_row(&mut rsc, 999.0); rsc.ui .widgets .get_mut(&list_weak) .unwrap() .replace_back(LazyItem::new(4, new_row)); render.update(&root, &mut rsc); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); for (key, before) in [(0u64, before0), (1, before1), (2, before2)] { let after = list_ref.extents[&key]; assert_eq!( (after.lead, after.trail), (before.lead, before.trail), "row {key} moved after an off-screen replace" ); } } #[test] fn replacing_the_last_row_many_times_does_not_leak_primitives() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); for key in 0..5u64 { let (_bg_id, row) = background_styled_row(&mut rsc, 20.0); list.push_back(LazyItem::new(key, row)); } 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); let before = render.active_widgets(); for i in 0..400u32 { let (_bg_id, new_row) = background_styled_row(&mut rsc, 20.0 + (i % 3) as f32); rsc.ui .widgets .get_mut(&list_weak) .unwrap() .replace_back(LazyItem::new(4, new_row)); render.update(&root, &mut rsc); } let after = render.active_widgets(); assert_eq!( before, after, "400 replaces of the last row must leave exactly the same \ number of active widgets as before a leaked id (and the \ primitives that live as long as its ActiveData does) would \ show up here as growth" ); } #[test] fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() { let mut rsc = TestRsc { ui: Ui::default() }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); let mut rows = Vec::new(); for key in 0..5u64 { let (bg_id, row) = background_styled_row(&mut rsc, 20.0); rows.push((row.id(), bg_id)); list.push_back(LazyItem::new(key, row)); } 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); assert!(render.orphaned_primitives().is_empty()); let (row2, row2_bg) = rows[2]; rsc.ui.widgets.get_dyn_mut(row2).unwrap(); rsc.ui.widgets.get_dyn_mut(row2_bg).unwrap(); render.redraw(list_weak.id(), &mut rsc); let orphans = render.orphaned_primitives(); assert!( orphans.is_empty(), "{} primitive(s) survived their own widget's redraw: {orphans:?}", orphans.len(), ); } fn build_flingable_list( rsc: &mut TestRsc, ) -> (WeakWidget, StrongWidget, UiRenderState) { let mut list = LazySpan::new(Dir::DOWN, Pin::End); push_rows(rsc, &mut list, &(0..200).collect::>(), FLING_ROW_H); let list = rsc.ui.widgets.add_strong(list); let list_weak = list.weak(); let root = rsc.ui.widgets.add_strong(Masked { shape: None, inner: list.any(), }); let root = root.any(); let mut render = UiRenderState::new(); render.resize((100.0, 600.0)); render.update(&root, rsc); (list_weak, root, render) } fn fling_frame( rsc: &mut TestRsc, scroll: &WeakWidget, root: &StrongWidget, render: &mut UiRenderState, now: Instant, ) -> bool { let still = rsc.ui.widgets.get_mut(scroll).unwrap().tick(now); render.update(root, rsc); still } #[test] fn a_fling_moves_the_list_and_then_settles() { let mut rsc = TestRsc { ui: Ui::default() }; let (scroll, root, mut render) = build_flingable_list(&mut rsc); let list_weak = scroll; assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0)); assert!(rsc.ui.widgets.get(&scroll).unwrap().is_scrolling()); let start = Instant::now(); let mut still = true; for step in 0..600 { let now = start + std::time::Duration::from_millis(step * 16); still = fling_frame(&mut rsc, &scroll, &root, &mut render, now); if !still { break; } } assert!(!still, "fling never settled within 600 steps"); assert!(!rsc.ui.widgets.get(&scroll).unwrap().is_scrolling()); assert!( scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()) < 200.0 * FLING_ROW_H, "a fling toward the start should have moved the list back through its rows" ); } #[test] fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() { let mut rsc = TestRsc { ui: Ui::default() }; let (scroll, root, mut render) = build_flingable_list(&mut rsc); let list_weak = scroll; let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) { let id = scroll.id(); rsc.ui.animate(id); } let start = Instant::now(); let mut steps = 0; let mut animating = true; while animating && steps < 600 { let now = start + std::time::Duration::from_millis(steps * 16); animating = rsc.ui.tick_animations(now); render.update(&root, &mut rsc); steps += 1; } assert!(!animating, "the driver never stopped within 600 frames"); assert!(steps > 1, "the fling settled without ever moving"); assert_ne!( scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()), before, "the fling was registered but never applied" ); let now = start + std::time::Duration::from_millis(steps * 16); assert!(!rsc.ui.tick_animations(now)); } #[test] fn a_negative_delta_moves_toward_the_end() { let mut rsc = TestRsc { ui: Ui::default() }; let (scroll, root, mut render) = build_flingable_list(&mut rsc); let list_weak = scroll; rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(2000.0); render.update(&root, &mut rsc); let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-500.0); render.update(&root, &mut rsc); let after = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); assert!( after > before, "a negative delta should move toward the end: {before} -> {after}" ); assert!( (after - before - 500.0).abs() < 0.5, "and by exactly what was asked for, away from a wall: {before} -> {after}" ); } #[test] fn amt_counts_only_what_the_child_could_take() { let mut rsc = TestRsc { ui: Ui::default() }; let (scroll, root, mut render) = build_flingable_list(&mut rsc); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100.0); render.update(&root, &mut rsc); assert!( (rsc.ui.widgets.get(&scroll).unwrap().amt() + 100.0).abs() < 0.5, "amt counts forward through the content, so 100px back is -100: {}", rsc.ui.widgets.get(&scroll).unwrap().amt() ); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0); render.update(&root, &mut rsc); let amt = rsc.ui.widgets.get(&scroll).unwrap().amt(); assert!( (amt + 3400.0).abs() < 0.5, "amt should equal the content's real travel, not what was asked for: {amt}" ); } #[test] fn a_fling_stops_at_the_first_row() { let mut rsc = TestRsc { ui: Ui::default() }; let (scroll, root, mut render) = build_flingable_list(&mut rsc); let list_weak = scroll; rsc.ui.widgets.get_mut(&scroll).unwrap().fling(50_000.0); let start = Instant::now(); for step in 0..2000 { let now = start + std::time::Duration::from_millis(step * 16); if !fling_frame(&mut rsc, &scroll, &root, &mut render, now) { break; } } let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert!(list_ref.at_start, "the fling should have reached the start"); let first = list_ref.extents[&0]; assert!( first.lead.abs() < 0.5, "a fling stopped at the start must leave the first row flush with the top, not {}px \ from it", first.lead ); } #[test] fn scrolling_past_the_start_lands_on_it_in_the_same_frame() { let mut rsc = TestRsc { ui: Ui::default() }; let (scroll, root, mut render) = build_flingable_list(&mut rsc); let list_weak = scroll; rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0); render.update(&root, &mut rsc); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let first = list_ref.extents[&0]; assert!( first.lead.abs() < 0.5, "the frame that overscrolled should end flush with the first row, not {}px from it", first.lead, ); } #[test] fn anchor_position_display_before_any_draw_is_none() { let list = LazySpan::new(Dir::DOWN, Pin::End); assert_eq!(list.anchor_position_display(), "idx=none"); } #[test] fn anchor_position_display_reports_slot_and_offset() { let mut rsc = TestRsc { ui: Ui::default() }; let (list_weak, root, mut render) = build_flingable_list(&mut rsc); let _ = (&root, &mut render); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert!(list_ref.anchor_position_display().starts_with("idx=")); assert!(!list_ref.anchor_position_display().contains("none")); } }