1699 lines
59 KiB
Rust
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"));
|
|
}
|
|
}
|