Files
iris/src/widget/position/lazy_span.rs
T
2026-09-10 23:58:43 -04:00

1699 lines
59 KiB
Rust

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<LazyItem>,
more_before: Option<StrongWidget>,
more_after: Option<StrongWidget>,
anchor: Option<Anchor>,
ctl: ScrollController,
viewport_len: f32,
last_viewport_len: f32,
pending_tap: Option<f32>,
extents: HashMap<RowKey, RowExtent>,
heights: HashMap<RowKey, f32>,
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<LazyItem> {
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<LazyItem> {
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<StrongWidget>) {
self.more_before = widget;
self.extents.clear();
}
pub fn set_more_after(&mut self, widget: Option<StrongWidget>) {
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<LazyItem> {
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<RowKey> {
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<RowKey> {
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<isize> {
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<isize> {
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<f32> {
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, 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<f32>,
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<Rsc: HasEvents>(self) -> impl WidgetIdFn<Rsc, LazySpan> {
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<Len> {
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<Sized>, 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<WeakWidget<Sized>> {
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<LazySpan>, 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<Sized>| {
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<RowKey> = (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<Sized>, 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<RowKey> = (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<RowKey> = (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<LazySpan>, StrongWidget, UiRenderState) {
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), 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<LazySpan>,
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"));
}
}