use std::cell::Cell;
use std::collections::HashMap;
use std::rc::Rc;
use frust_core::accesskit::Role;
use frust_core::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, FLING_STOP,
FrameTime, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
PointerPhase, ScrollDelta, SemanticsCtx, TOUCH_SLOP, VelocityTracker, View, WHEEL_LINE_PX,
Widget, fling_decay, fling_displacement,
};
use kurbo::{Point, Size};
use crate::ChildKey;
use crate::authoring::{ErasedCallback, presses};
use crate::physics::effect::OverscrollEffect;
use crate::physics::{
MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR, ScrollMetrics, ScrollPhysics, Simulation,
default_overscroll_effect, default_physics,
};
use crate::scroll::{
BallisticState, InnerScrollState, METRICS_FALLBACK_DPR, SETTLE_DECAY, SETTLE_STOP_PX,
ambient_scroll_claim, crossed_refresh_trigger, inner_claim_state, stretch_about_edge,
with_scroll_claim, with_scroll_veto,
};
const BUFFER: isize = 2;
const DUPLICATE_KEY_MSG: &str = "ListView::builder_keyed produced a duplicate key inside one \
window: row identity is ambiguous (release: the first slot claiming a key keeps the live \
row, later duplicates build fresh)";
const ESTIMATE_NEEDS_KEYS_MSG: &str = "ListView::estimated_item_extent requires \
ListView::builder_keyed: a measured extent is cached under the row's stable key, which a \
positional list has none of (release: the estimate is ignored and the uniform extent path \
runs)";
const MAX_PREFIX_STEP: usize = 512;
type OnNearStart<State> = Rc<dyn Fn(&mut State)>;
type OnNearEnd<State> = Rc<dyn Fn(&mut State)>;
type OnRefresh<State> = Rc<dyn Fn(&mut State)>;
type KeyOf = Rc<dyn Fn(usize) -> ChildKey>;
#[derive(Clone, Copy, Debug)]
struct Measured {
index: usize,
extent: f64,
}
#[derive(Clone, Copy, Debug)]
struct WindowPlan {
start: usize,
end: usize,
y_start: f64,
}
impl WindowPlan {
const EMPTY: Self = Self {
start: 0,
end: 0,
y_start: 0.0,
};
}
pub struct ListView<State: 'static> {
item_count: usize,
item_extent: f64,
builder: Rc<dyn Fn(usize) -> AnyView<State>>,
key_of: Option<KeyOf>,
estimated_item_extent: Option<f64>,
on_near_start: Option<OnNearStart<State>>,
near_start_threshold: f64,
on_near_end: Option<OnNearEnd<State>>,
near_end_threshold: f64,
on_refresh_release: Option<OnRefresh<State>>,
physics: Option<Rc<dyn ScrollPhysics>>,
pub(crate) effect: OverscrollEffect,
}
impl<State: 'static> ListView<State> {
pub fn builder(
item_count: usize,
item_extent: f64,
builder: impl Fn(usize) -> AnyView<State> + 'static,
) -> Self {
assert!(
item_extent > 0.0,
"ListView item_extent must be positive (uniform extent)"
);
Self {
item_count,
item_extent,
builder: Rc::new(builder),
key_of: None,
estimated_item_extent: None,
on_near_start: None,
near_start_threshold: 0.0,
on_near_end: None,
near_end_threshold: 0.0,
on_refresh_release: None,
physics: None,
effect: default_overscroll_effect(),
}
}
pub fn builder_keyed(
item_count: usize,
item_extent: f64,
key_of: impl Fn(usize) -> ChildKey + 'static,
builder: impl Fn(usize) -> AnyView<State> + 'static,
) -> Self {
assert!(
item_extent > 0.0,
"ListView item_extent must be positive (uniform extent)"
);
Self {
item_count,
item_extent,
builder: Rc::new(builder),
key_of: Some(Rc::new(key_of)),
estimated_item_extent: None,
on_near_start: None,
near_start_threshold: 0.0,
on_near_end: None,
near_end_threshold: 0.0,
on_refresh_release: None,
physics: None,
effect: default_overscroll_effect(),
}
}
pub fn estimated_item_extent(mut self, estimate_px: f64) -> Self {
assert!(
estimate_px > 0.0,
"ListView estimated_item_extent must be positive"
);
debug_assert!(self.key_of.is_some(), "{}", ESTIMATE_NEEDS_KEYS_MSG);
self.estimated_item_extent = Some(estimate_px);
self
}
fn variable_estimate(&self) -> Option<f64> {
match (self.key_of.as_ref(), self.estimated_item_extent) {
(Some(_), Some(estimate)) => Some(estimate),
_ => None,
}
}
pub fn on_near_start<F: Fn(&mut State) + 'static>(
mut self,
callback: F,
threshold_px: f64,
) -> Self {
self.on_near_start = Some(Rc::new(callback));
self.near_start_threshold = threshold_px;
self
}
pub fn on_near_end<F: Fn(&mut State) + 'static>(
mut self,
callback: F,
threshold_px: f64,
) -> Self {
self.on_near_end = Some(Rc::new(callback));
self.near_end_threshold = threshold_px;
self
}
pub fn on_refresh_release<F: Fn(&mut State) + 'static>(mut self, callback: F) -> Self {
self.on_refresh_release = Some(Rc::new(callback));
self
}
pub fn physics(mut self, physics: impl ScrollPhysics + 'static) -> Self {
self.physics = Some(Rc::new(physics));
self
}
pub fn overscroll_effect(mut self, effect: OverscrollEffect) -> Self {
self.effect = effect;
self
}
}
impl<State: 'static> ListView<State> {
fn reconcile_positional(
&self,
prev: &Self,
element: &mut ListViewWidget,
ctx: &mut BuildCtx<'_>,
plan: WindowPlan,
) -> ChangeFlags {
let (start, end) = (plan.start, plan.end);
let mut flags = ChangeFlags::NONE;
let old_keys = std::mem::take(&mut element.keys);
let old_children = std::mem::take(&mut element.children);
let mut old: HashMap<usize, ChildPod> = old_keys.into_iter().zip(old_children).collect();
let mut new_children = Vec::with_capacity(end.saturating_sub(start));
let mut new_keys = Vec::with_capacity(end.saturating_sub(start));
let mut structural = false;
for index in start..end {
if let Some(mut pod) = old.remove(&index) {
let prev_view = (prev.builder)(index);
let next_view = (self.builder)(index);
flags |= crate::authoring::rebuild_child(&prev_view, &next_view, &mut pod, ctx);
new_children.push(pod);
} else {
new_children.push(crate::authoring::build_child(&(self.builder)(index), ctx));
structural = true;
}
new_keys.push(index);
}
for (index, mut pod) in old.drain() {
crate::authoring::teardown_child(&(prev.builder)(index), &mut pod, ctx);
structural = true;
}
element.children = new_children;
element.keys = new_keys;
element.sync_child_origins();
if structural {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags
}
#[allow(clippy::too_many_arguments)]
fn reconcile_keyed(
&self,
prev: &Self,
element: &mut ListViewWidget,
ctx: &mut BuildCtx<'_>,
plan: WindowPlan,
key_of: &KeyOf,
delta: isize,
anchor_probe_missed: bool,
) -> ChangeFlags {
let (start, end) = (plan.start, plan.end);
let capacity = end.saturating_sub(start);
let mut slot_keys: Vec<ChildKey> = Vec::with_capacity(capacity);
let mut next_index_of: HashMap<ChildKey, usize> = HashMap::with_capacity(capacity);
let mut duplicate = false;
for index in start..end {
let key = key_of(index);
duplicate |= next_index_of.insert(key, index).is_some();
slot_keys.push(key);
}
debug_assert!(!duplicate, "{}", DUPLICATE_KEY_MSG);
let prev_index_of = std::mem::take(&mut element.key_index);
let old_keys = std::mem::take(&mut element.keys);
let old_children = std::mem::take(&mut element.children);
let window_shifted = old_keys.first().copied() != (capacity > 0).then_some(start)
|| old_keys.len() != capacity;
let mut old: HashMap<usize, ChildPod> = old_keys.into_iter().zip(old_children).collect();
let mut new_children = Vec::with_capacity(capacity);
let mut new_keys = Vec::with_capacity(capacity);
let mut flags = ChangeFlags::NONE;
let mut structural = window_shifted;
let mut any_survivor = false;
for (index, key) in (start..end).zip(slot_keys.iter().copied()) {
let survivor = prev_index_of
.get(&key)
.copied()
.and_then(|prev_index| old.remove(&prev_index).map(|pod| (prev_index, pod)));
match survivor {
Some((prev_index, mut pod)) => {
any_survivor = true;
let prev_view = (prev.builder)(prev_index);
let next_view = (self.builder)(index);
flags |= crate::authoring::rebuild_child(&prev_view, &next_view, &mut pod, ctx);
if prev_index != index {
structural = true;
}
new_children.push(pod);
}
None => {
new_children.push(crate::authoring::build_child(&(self.builder)(index), ctx));
structural = true;
}
}
new_keys.push(index);
}
if element.is_variable() {
if anchor_probe_missed && !any_survivor {
element.clear_measured();
} else {
let count = element.item_count;
for (key, prev_index) in prev_index_of.iter() {
if old.contains_key(prev_index)
&& !Self::key_survives(*key, *prev_index, delta, count, key_of)
{
element.forget_measured(key);
}
}
}
}
for (index, mut pod) in old.drain() {
crate::authoring::teardown_child(&(prev.builder)(index), &mut pod, ctx);
structural = true;
}
element.children = new_children;
element.keys = new_keys;
element.key_index = next_index_of;
if let Some(estimate) = element.variable_estimate() {
element.slot_keys = slot_keys;
element.set_window_geometry(plan, estimate);
}
element.sync_child_origins();
if structural {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags
}
fn anchor_shift_items(
prev: &Self,
element: &ListViewWidget,
old_item_count: usize,
key_of: &KeyOf,
) -> Option<isize> {
let prev_key_of = prev.key_of.as_ref()?;
let new_item_count = element.item_count;
let delta = new_item_count as isize - old_item_count as isize;
for &i_prev in &element.keys {
let anchor_key = prev_key_of(i_prev);
if i_prev < new_item_count && key_of(i_prev) == anchor_key {
return Some(0);
}
if delta != 0 {
let candidate = i_prev as isize + delta;
if candidate >= 0
&& (candidate as usize) < new_item_count
&& key_of(candidate as usize) == anchor_key
{
return Some(delta);
}
}
}
None
}
fn key_survives(
key: ChildKey,
prev_index: usize,
delta: isize,
item_count: usize,
key_of: &KeyOf,
) -> bool {
if prev_index < item_count && key_of(prev_index) == key {
return true;
}
if delta != 0 {
let candidate = prev_index as isize + delta;
if candidate >= 0
&& (candidate as usize) < item_count
&& key_of(candidate as usize) == key
{
return true;
}
}
false
}
}
pub fn list_view<State: 'static>(
item_count: usize,
item_extent: f64,
builder: impl Fn(usize) -> AnyView<State> + 'static,
) -> ListView<State> {
ListView::builder(item_count, item_extent, builder)
}
pub struct ListViewWidget {
children: Vec<ChildPod>,
keys: Vec<usize>,
key_index: HashMap<ChildKey, usize>,
key_of: Option<KeyOf>,
estimated_extent: Option<f64>,
measured: HashMap<ChildKey, Measured>,
measured_sum: f64,
slot_keys: Vec<ChildKey>,
slot_y: Vec<f64>,
anchor_index: usize,
anchor_y: f64,
pending_correction: f64,
item_count: usize,
item_extent: f64,
offset: f64,
drag_position: f64,
overscroll: f64,
settling: bool,
pub(crate) physics: Rc<dyn ScrollPhysics>,
pub(crate) effect: OverscrollEffect,
pub(crate) edge_pull: f64,
ballistic: Option<BallisticState>,
carried_velocity: f64,
viewport: Size,
scrolling: bool,
down_active: bool,
pub(crate) inner_at_down: InnerScrollState,
pub(crate) deferring: bool,
live_veto: Rc<Cell<bool>>,
down_start: Point,
last_drag: Point,
tracker: VelocityTracker,
fling: Option<f64>,
last_frame_time: FrameTime,
last_anim: Option<FrameTime>,
on_near_start: Option<ErasedCallback>,
near_start_threshold: f64,
near_start_armed: bool,
pending_near_start: bool,
on_near_end: Option<ErasedCallback>,
near_end_threshold: f64,
near_end_armed: bool,
pending_near_end: bool,
on_refresh_release: Option<ErasedCallback>,
}
impl ListViewWidget {
fn new(item_count: usize, item_extent: f64) -> Self {
Self {
children: Vec::new(),
keys: Vec::new(),
key_index: HashMap::new(),
key_of: None,
estimated_extent: None,
measured: HashMap::new(),
measured_sum: 0.0,
slot_keys: Vec::new(),
slot_y: Vec::new(),
anchor_index: 0,
anchor_y: 0.0,
pending_correction: 0.0,
item_count,
item_extent,
offset: 0.0,
drag_position: 0.0,
overscroll: 0.0,
settling: false,
physics: default_physics(),
effect: default_overscroll_effect(),
edge_pull: 0.0,
ballistic: None,
carried_velocity: 0.0,
viewport: Size::ZERO,
scrolling: false,
down_active: false,
inner_at_down: InnerScrollState::default(),
deferring: false,
live_veto: Rc::new(Cell::new(false)),
down_start: Point::ZERO,
last_drag: Point::ZERO,
tracker: VelocityTracker::new(),
fling: None,
last_frame_time: FrameTime::ZERO,
last_anim: None,
on_near_start: None,
near_start_threshold: 0.0,
near_start_armed: true,
pending_near_start: false,
on_near_end: None,
near_end_threshold: 0.0,
near_end_armed: true,
pending_near_end: false,
on_refresh_release: None,
}
}
fn evaluate_near_start(&mut self) -> bool {
if self.on_near_start.is_none() || self.item_count == 0 {
return false;
}
let threshold = self.near_start_threshold;
let position = self.placement_offset();
if position > 2.0 * threshold {
self.near_start_armed = true;
}
if self.near_start_armed && position <= threshold {
self.near_start_armed = false;
return true;
}
false
}
fn fire_near_start(&mut self, ctx: &mut EventCtx) {
if self.evaluate_near_start()
&& let Some(cb) = self.on_near_start.as_mut()
{
cb(ctx);
}
}
fn deliver_pending_near_start(&mut self, ctx: &mut EventCtx) {
if self.pending_near_start {
self.pending_near_start = false;
if let Some(cb) = self.on_near_start.as_mut() {
cb(ctx);
}
}
}
fn evaluate_near_end(&mut self) -> bool {
if self.on_near_end.is_none() || self.item_count == 0 {
return false;
}
let threshold = self.near_end_threshold;
let distance = self.max_offset() - self.placement_offset();
if distance > 2.0 * threshold {
self.near_end_armed = true;
}
if self.near_end_armed && distance <= threshold {
self.near_end_armed = false;
return true;
}
false
}
fn fire_near_end(&mut self, ctx: &mut EventCtx) {
if self.evaluate_near_end()
&& let Some(cb) = self.on_near_end.as_mut()
{
cb(ctx);
}
}
fn deliver_pending_near_end(&mut self, ctx: &mut EventCtx) {
if self.pending_near_end {
self.pending_near_end = false;
if let Some(cb) = self.on_near_end.as_mut() {
cb(ctx);
}
}
}
pub fn offset(&self) -> f64 {
self.offset
}
pub fn window(&self) -> &[usize] {
&self.keys
}
pub fn max_offset(&self) -> f64 {
(self.content_extent() - self.viewport.height).max(0.0)
}
fn placement_offset(&self) -> f64 {
if self.pending_correction == 0.0 {
return self.offset;
}
(self.offset + self.pending_correction).clamp(0.0, self.max_offset())
}
fn painted_offset(&self) -> f64 {
match self.effect {
OverscrollEffect::Translate => self.placement_offset() + self.overscroll,
OverscrollEffect::Stretch | OverscrollEffect::None => self.placement_offset(),
}
}
fn apply_pending_correction(&mut self) -> bool {
if self.pending_correction == 0.0 || self.is_flinging() {
return false;
}
let target = self.offset + self.pending_correction;
self.pending_correction = 0.0;
let before = self.offset;
self.set_offset(target);
self.offset != before
}
fn content_extent(&self) -> f64 {
match self.variable_estimate() {
Some(estimate) => {
let unmeasured = self.item_count.saturating_sub(self.measured.len());
self.measured_sum + estimate * unmeasured as f64
}
None => self.item_count as f64 * self.item_extent,
}
}
pub fn is_flinging(&self) -> bool {
self.fling.is_some() || self.ballistic.is_some()
}
fn metrics_at(&self, pixels: f64) -> ScrollMetrics {
ScrollMetrics {
pixels,
min_scroll_extent: 0.0,
max_scroll_extent: self.max_offset(),
viewport_dimension: self.viewport.height,
device_pixel_ratio: METRICS_FALLBACK_DPR,
}
}
fn metrics(&self) -> ScrollMetrics {
self.metrics_at(self.offset + self.overscroll)
}
fn live_velocity(&self) -> f64 {
if let Some(v) = self.fling {
return v;
}
match self.ballistic.as_ref() {
Some(state) => state.sim.dx(state.elapsed_secs(self.last_frame_time)),
None => 0.0,
}
}
fn fling_start_velocity(&self, release: f64) -> f64 {
let mapped = self.physics.carried_momentum(self.carried_velocity);
let continues_it = release.signum() == mapped.signum()
&& release.abs() > MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped.abs();
if continues_it {
release + mapped
} else {
release
}
}
fn release_simulation(&self) -> Option<Box<dyn Simulation>> {
let released = self.fling_start_velocity(-self.tracker.velocity());
let max = self.physics.max_fling_velocity();
let velocity = if released.abs() < self.physics.min_fling_velocity() {
0.0
} else {
released.clamp(-max, max)
};
self.physics
.create_ballistic_simulation(&self.metrics(), velocity)
}
fn event_time_ms(&self) -> f64 {
self.last_frame_time.as_secs_f64() * 1000.0
}
fn set_offset(&mut self, value: f64) {
self.offset = value.clamp(0.0, self.max_offset());
}
fn clamp_offset(&mut self) {
self.set_offset(self.offset);
}
fn apply_drag_offset(&mut self, delta: f64) {
let metrics = self.metrics();
let mapped = self.physics.apply_physics_to_user_offset(&metrics, delta);
self.drag_position += mapped;
let rejected = self
.physics
.apply_boundary_conditions(&metrics, self.drag_position);
let allowed = self.drag_position - rejected;
self.offset = allowed.clamp(0.0, self.max_offset());
self.overscroll = allowed - self.offset;
self.edge_pull = self.overscroll + rejected;
}
pub fn settle_tick(&mut self, dt_ms: f64) -> bool {
if !self.settling {
return false;
}
let rejected = self.edge_pull - self.overscroll;
if self.overscroll.abs() <= SETTLE_STOP_PX && rejected.abs() <= SETTLE_STOP_PX {
self.overscroll = 0.0;
self.edge_pull = 0.0;
self.settling = false;
self.sync_child_origins();
return false;
}
let retained = SETTLE_DECAY.powf(dt_ms);
self.overscroll *= retained;
self.edge_pull = self.overscroll + rejected * retained;
self.sync_child_origins();
true
}
fn desired_window(&self) -> WindowPlan {
if let Some(estimate) = self.variable_estimate() {
return if self.item_count == 0 {
WindowPlan::EMPTY
} else {
self.desired_window_variable(estimate)
};
}
if self.item_count == 0 || self.item_extent <= 0.0 {
return WindowPlan::EMPTY;
}
let vh = self.viewport.height;
let first = (self.offset / self.item_extent).floor() as isize - BUFFER;
let last = ((self.offset + vh) / self.item_extent).ceil() as isize + BUFFER;
let start = first.max(0) as usize;
let end = (last.max(0) as usize).min(self.item_count);
let start = start.min(end);
WindowPlan {
start,
end,
y_start: start as f64 * self.item_extent,
}
}
fn window_covers(&self, start: usize, end: usize) -> bool {
if start >= end {
return true;
}
match (self.keys.first(), self.keys.last()) {
(Some(&f), Some(&l)) => f <= start && l + 1 >= end,
_ => false,
}
}
fn sync_child_origins(&mut self) {
let painted = self.painted_offset();
if self.is_variable() {
for (y, pod) in self.slot_y.iter().zip(self.children.iter_mut()) {
pod.set_origin(Point::new(0.0, *y - painted));
}
return;
}
let extent = self.item_extent;
for (index, pod) in self.keys.iter().zip(self.children.iter_mut()) {
pod.set_origin(Point::new(0.0, *index as f64 * extent - painted));
}
}
fn variable_estimate(&self) -> Option<f64> {
match (self.key_of.as_ref(), self.estimated_extent) {
(Some(_), Some(estimate)) => Some(estimate),
_ => None,
}
}
fn is_variable(&self) -> bool {
self.variable_estimate().is_some()
}
fn unmeasured_extent(&self) -> f64 {
self.variable_estimate().unwrap_or(self.item_extent)
}
fn extent_at(&self, index: usize, estimate: f64) -> f64 {
let Some(key_of) = self.key_of.as_ref() else {
return estimate;
};
if index >= self.item_count {
return estimate;
}
self.measured
.get(&key_of(index))
.map(|m| m.extent)
.unwrap_or(estimate)
}
fn walk_to_offset(&self, estimate: f64) -> (usize, f64) {
let count = self.item_count;
debug_assert!(count > 0, "walk_to_offset needs a non-empty list");
let target = self.placement_offset();
let mut index = self.anchor_index.min(count - 1);
let mut y = self.anchor_y;
let gap = target - y;
if gap.abs() > estimate * MAX_PREFIX_STEP as f64 {
let jump = (gap / estimate).trunc();
let landed = (index as f64 + jump).clamp(0.0, (count - 1) as f64);
y += (landed - index as f64) * estimate;
index = landed as usize;
}
let mut steps = 0usize;
while y > target && index > 0 && steps < MAX_PREFIX_STEP {
index -= 1;
y -= self.extent_at(index, estimate);
steps += 1;
}
while index + 1 < count && steps < MAX_PREFIX_STEP {
let extent = self.extent_at(index, estimate);
if y + extent <= target {
y += extent;
index += 1;
steps += 1;
} else {
break;
}
}
if index == 0 {
y = 0.0;
}
(index, y)
}
fn desired_window_variable(&self, estimate: f64) -> WindowPlan {
let count = self.item_count;
let (first_visible, y_visible) = self.walk_to_offset(estimate);
let mut start = first_visible;
let mut y_start = y_visible;
for _ in 0..BUFFER {
if start == 0 {
break;
}
start -= 1;
y_start -= self.extent_at(start, estimate);
}
if start == 0 {
y_start = 0.0;
}
let bottom = self.placement_offset() + self.viewport.height;
let mut end = first_visible + 1;
let mut y_end = y_visible + self.extent_at(first_visible, estimate);
while end < count && y_end < bottom {
y_end += self.extent_at(end, estimate);
end += 1;
}
let end = end.saturating_add(BUFFER as usize).min(count);
WindowPlan {
start,
end: end.max(start),
y_start,
}
}
fn set_window_geometry(&mut self, plan: WindowPlan, estimate: f64) {
self.anchor_index = plan.start;
self.anchor_y = if plan.start == 0 { 0.0 } else { plan.y_start };
let keys = std::mem::take(&mut self.keys);
let mut ys = std::mem::take(&mut self.slot_y);
ys.clear();
let mut y = self.anchor_y;
for &index in &keys {
ys.push(y);
y += self.extent_at(index, estimate);
}
self.slot_y = ys;
self.keys = keys;
}
fn record_measurement(&mut self, key: ChildKey, index: usize, extent: f64) {
match self.measured.insert(key, Measured { index, extent }) {
Some(previous) => self.measured_sum += extent - previous.extent,
None => self.measured_sum += extent,
}
}
fn forget_measured(&mut self, key: &ChildKey) {
if let Some(previous) = self.measured.remove(key) {
self.measured_sum -= previous.extent;
}
}
fn clear_measured(&mut self) {
self.measured.clear();
self.measured_sum = 0.0;
self.pending_correction = 0.0;
}
fn evict_measured_stale_indices(&mut self) {
if self.measured.is_empty() {
return;
}
let count = self.item_count;
let mut dropped = 0.0;
self.measured.retain(|_, entry| {
let live = entry.index < count;
if !live {
dropped += entry.extent;
}
live
});
self.measured_sum -= dropped;
}
fn reset_variable_state(&mut self) {
self.clear_measured();
self.slot_keys.clear();
self.slot_y.clear();
self.anchor_index = 0;
self.anchor_y = 0.0;
}
fn reset_keyed_state(&mut self) {
self.key_index.clear();
self.reset_variable_state();
}
fn apply_anchor_shift(&mut self, items: isize) {
let step = self.unmeasured_extent();
self.set_offset(self.offset + items as f64 * step);
if self.is_variable() {
let index = (self.anchor_index as isize + items).max(0) as usize;
self.anchor_y = if index == 0 {
0.0
} else {
(self.anchor_y + items as f64 * step).max(0.0)
};
self.anchor_index = index;
}
}
fn layout_variable(&mut self, ctx: &mut LayoutCtx, vw: f64, estimate: f64) {
let child_bc = BoxConstraints::new(Size::new(vw, 0.0), Size::new(vw, f64::INFINITY));
let top = self.placement_offset();
let mut children = std::mem::take(&mut self.children);
let mut ys = std::mem::take(&mut self.slot_y);
ys.clear();
let mut y = self.anchor_y;
let mut y_assumed = self.anchor_y;
let mut correction = 0.0;
for (slot, pod) in children.iter_mut().enumerate() {
let key = self.slot_keys.get(slot).copied();
let index = self.keys.get(slot).copied();
let assumed = index.map_or(estimate, |index| self.extent_at(index, estimate));
let size = pod.layout_child(ctx, &child_bc);
ys.push(y);
if y_assumed + assumed <= top {
correction += size.height - assumed;
}
y += size.height;
y_assumed += assumed;
if let (Some(key), Some(index)) = (key, index) {
self.record_measurement(key, index, size.height);
}
}
self.slot_y = ys;
self.children = children;
self.pending_correction += correction;
self.clamp_offset();
}
pub fn tick(&mut self, dt_ms: f64) -> bool {
let Some(v) = self.fling else {
return false;
};
self.set_offset(self.offset + fling_displacement(v, dt_ms));
self.sync_child_origins();
let next_v = fling_decay(v, dt_ms);
let at_bound = self.offset <= 0.0 || self.offset >= self.max_offset();
if next_v.abs() < FLING_STOP || at_bound {
self.fling = None;
false
} else {
self.fling = Some(next_v);
true
}
}
fn drive_ballistic(&mut self, now: FrameTime) {
let Some((proposed, velocity, done)) = self.ballistic.as_ref().map(|state| {
let t = state.elapsed_secs(now);
(state.sim.x(t), state.sim.dx(t), state.sim.is_done(t))
}) else {
return;
};
let rejected = self
.physics
.apply_boundary_conditions(&self.metrics(), proposed);
let allowed = proposed - rejected;
self.offset = allowed.clamp(0.0, self.max_offset());
self.overscroll = allowed - self.offset;
self.edge_pull = self.overscroll + rejected;
self.sync_child_origins();
if done || self.ballistic_is_pinned_outward(proposed, rejected, velocity) {
self.ballistic = None;
self.settle_ballistic_residual();
}
}
fn ballistic_is_pinned_outward(&self, proposed: f64, rejected: f64, velocity: f64) -> bool {
let excess = proposed - proposed.clamp(0.0, self.max_offset());
excess != 0.0 && rejected == excess && velocity * excess > 0.0
}
fn settle_ballistic_residual(&mut self) {
if self.edge_pull.abs() > SETTLE_STOP_PX {
self.settling = true;
}
}
fn pump_fling(&mut self, ctx: &mut PaintCtx) {
if self.fling.is_none() && !self.settling && self.ballistic.is_none() {
self.last_anim = None;
return;
}
let now = ctx.frame_time();
let dt = match self.last_anim {
Some(t) => now.saturating_sub(t).as_secs_f64() * 1000.0,
None => 0.0,
};
self.last_anim = Some(now);
if let Some(state) = self.ballistic.as_mut() {
state.start.get_or_insert(now);
}
if dt > 0.0 {
if self.ballistic.is_some() {
self.drive_ballistic(now);
} else if self.fling.is_some() {
self.tick(dt);
} else {
self.settle_tick(dt);
}
if self.evaluate_near_start() {
self.pending_near_start = true;
}
if self.evaluate_near_end() {
self.pending_near_end = true;
}
}
if self.fling.is_some() || self.settling || self.ballistic.is_some() {
ctx.request_frame();
}
}
fn cancel_children(&mut self, ctx: &mut EventCtx, pos: Point) {
let cancel = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Cancel,
position: pos,
button: PointerButton::Primary,
});
if let Some(pod) = self.children.iter_mut().find(|pod| pod.is_active()) {
pod.event_child(ctx, &cancel);
ctx.release_captured_child(pod);
} else {
crate::authoring::route_event(&mut self.children, ctx, &cancel);
}
}
fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
if !matches!(
event,
InputEvent::Pointer(p) if p.phase == PointerPhase::Cancel
) {
self.deliver_pending_near_start(ctx);
self.deliver_pending_near_end(ctx);
}
match event {
InputEvent::Housekeeping | InputEvent::Overlay(_) => {
crate::authoring::route_event(&mut self.children, ctx, event)
}
InputEvent::Key(_) | InputEvent::Ime(_) | InputEvent::EditCommand(_) => {
crate::authoring::route_event(&mut self.children, ctx, event)
}
InputEvent::Scroll { delta, .. } => {
let dy = match delta {
ScrollDelta::Lines(_, y) => y * WHEEL_LINE_PX,
ScrollDelta::Pixels(_, y) => *y,
};
self.fling = None;
self.settling = false;
self.ballistic = None;
self.overscroll = 0.0;
self.edge_pull = 0.0;
self.set_offset(self.offset + dy);
self.sync_child_origins();
self.fire_near_start(ctx);
self.fire_near_end(ctx);
ctx.request_redraw();
EventResult::Handled
}
InputEvent::Pointer(p) => match p.phase {
PointerPhase::Down => {
if !presses(p) {
return crate::authoring::route_event(&mut self.children, ctx, event);
}
self.scrolling = false;
self.down_active = true;
self.inner_at_down = InnerScrollState::default();
self.deferring = false;
self.live_veto = Rc::new(Cell::new(false));
self.carried_velocity = self.live_velocity();
self.fling = None;
self.settling = false;
self.ballistic = None;
self.edge_pull = self.overscroll;
self.last_anim = None;
self.down_start = p.position;
self.last_drag = p.position;
self.tracker.clear();
self.tracker.record(t_ms, p.position.y);
ctx.capture_pointer();
if let Some(host) = ambient_scroll_claim() {
host.set(inner_claim_state(self.physics.as_ref(), &self.metrics()));
}
let claim = Rc::new(Cell::new(InnerScrollState::default()));
let veto = Rc::clone(&self.live_veto);
let children = &mut self.children;
with_scroll_claim(&claim, || {
with_scroll_veto(&veto, || {
crate::authoring::route_event(children, ctx, event)
})
});
self.inner_at_down = claim.get();
EventResult::Handled
}
PointerPhase::Move => {
if !self.down_active {
return crate::authoring::route_event(&mut self.children, ctx, event);
}
self.tracker.record(t_ms, p.position.y);
if self.scrolling {
let dy = p.position.y - self.last_drag.y;
self.last_drag = p.position;
self.apply_drag_offset(-dy);
self.sync_child_origins();
self.fire_near_start(ctx);
self.fire_near_end(ctx);
ctx.request_redraw();
} else if !self.deferring
&& !self.live_veto.get()
&& (p.position.y - self.down_start.y).abs() > TOUCH_SLOP
&& self.physics.should_accept_user_offset(&self.metrics())
{
if self.inner_at_down.defers(p.position.y - self.down_start.y) {
self.deferring = true;
crate::authoring::route_event(&mut self.children, ctx, event);
} else {
self.scrolling = true;
self.settling = false;
self.last_drag = p.position;
self.drag_position = self.offset + self.overscroll;
self.cancel_children(ctx, p.position);
ctx.request_redraw();
}
} else {
crate::authoring::route_event(&mut self.children, ctx, event);
}
EventResult::Handled
}
PointerPhase::Up => {
if self.scrolling {
if crossed_refresh_trigger(self.edge_pull)
&& let Some(cb) = self.on_refresh_release.as_mut()
{
cb(ctx);
}
if let Some(sim) = self.release_simulation() {
self.fling = None;
self.settling = false;
self.ballistic = Some(BallisticState { sim, start: None });
self.last_anim = None;
} else if self.edge_pull != 0.0 {
self.fling = None;
self.settling = true;
self.last_anim = None;
} else {
let finger_v = self.tracker.velocity();
if finger_v.abs() > FLING_STOP {
self.fling = Some(self.fling_start_velocity(-finger_v));
self.last_anim = None;
}
}
} else {
crate::authoring::route_event(&mut self.children, ctx, event);
}
self.scrolling = false;
self.down_active = false;
self.inner_at_down = InnerScrollState::default();
self.deferring = false;
self.live_veto = Rc::new(Cell::new(false));
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
crate::authoring::route_event(&mut self.children, ctx, event);
self.scrolling = false;
self.down_active = false;
self.inner_at_down = InnerScrollState::default();
self.deferring = false;
self.live_veto = Rc::new(Cell::new(false));
self.pending_near_start = false;
self.pending_near_end = false;
self.settling = false;
self.ballistic = None;
self.overscroll = 0.0;
self.edge_pull = 0.0;
self.sync_child_origins();
ctx.request_redraw();
EventResult::Handled
}
},
_ => crate::authoring::route_event(&mut self.children, ctx, event),
}
}
}
impl<State: 'static> View<State> for ListView<State> {
type Element = ListViewWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ListViewWidget {
let mut widget = ListViewWidget::new(self.item_count, self.item_extent);
widget.on_near_start = self
.on_near_start
.as_ref()
.map(crate::authoring::erase_callback);
widget.near_start_threshold = self.near_start_threshold;
widget.on_near_end = self
.on_near_end
.as_ref()
.map(crate::authoring::erase_callback);
widget.near_end_threshold = self.near_end_threshold;
widget.on_refresh_release = self
.on_refresh_release
.as_ref()
.map(crate::authoring::erase_callback);
if let Some(physics) = self.physics.clone() {
widget.physics = physics;
}
widget.effect = self.effect;
widget.key_of = self.key_of.clone();
widget.estimated_extent = self.variable_estimate();
let plan = widget.desired_window();
let variable = widget.is_variable();
let mut duplicate = false;
for index in plan.start..plan.end {
widget
.children
.push(crate::authoring::build_child(&(self.builder)(index), ctx));
widget.keys.push(index);
if let Some(key_of) = self.key_of.as_ref() {
let key = key_of(index);
duplicate |= widget.key_index.insert(key, index).is_some();
if variable {
widget.slot_keys.push(key);
}
}
}
debug_assert!(!duplicate, "{}", DUPLICATE_KEY_MSG);
if let Some(estimate) = widget.variable_estimate() {
widget.set_window_geometry(plan, estimate);
}
widget.sync_child_origins();
widget
}
fn rebuild(
&self,
prev: &Self,
element: &mut ListViewWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_near_start = self
.on_near_start
.as_ref()
.map(crate::authoring::erase_callback);
element.near_start_threshold = self.near_start_threshold;
element.on_near_end = self
.on_near_end
.as_ref()
.map(crate::authoring::erase_callback);
element.near_end_threshold = self.near_end_threshold;
element.on_refresh_release = self
.on_refresh_release
.as_ref()
.map(crate::authoring::erase_callback);
if let Some(physics) = self.physics.clone() {
element.physics = physics;
}
element.effect = self.effect;
element.key_of = self.key_of.clone();
let mut flags = ChangeFlags::NONE;
let old_item_count = element.item_count;
let mut item_count_changed = false;
if element.item_count != self.item_count {
element.item_count = self.item_count;
item_count_changed = true;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.item_extent != self.item_extent {
element.item_extent = self.item_extent;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let estimate = self.variable_estimate();
if element.estimated_extent != estimate {
element.estimated_extent = estimate;
if estimate.is_none() {
element.reset_variable_state();
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let mut prepend_correction = false;
let mut anchor_probe_missed = false;
if let Some(key_of) = self.key_of.as_ref() {
match Self::anchor_shift_items(prev, element, old_item_count, key_of) {
Some(shift_items) if shift_items != 0 => {
element.apply_anchor_shift(shift_items);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
prepend_correction = shift_items > 0;
}
Some(_) => {}
None => {
anchor_probe_missed = true;
element.pending_correction = 0.0;
}
}
}
if item_count_changed {
if !prepend_correction {
element.near_start_armed = true;
}
element.near_end_armed = true;
}
if self.item_count < old_item_count {
element.evict_measured_stale_indices();
}
if element.apply_pending_correction() {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let offset_before_clamp = element.offset;
element.clamp_offset();
if element.is_variable() && element.offset != offset_before_clamp {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let plan = element.desired_window();
let delta = self.item_count as isize - old_item_count as isize;
flags |= match self.key_of.as_ref() {
Some(key_of) => {
self.reconcile_keyed(prev, element, ctx, plan, key_of, delta, anchor_probe_missed)
}
None => {
element.reset_keyed_state();
self.reconcile_positional(prev, element, ctx, plan)
}
};
flags
}
fn teardown(&self, element: &mut ListViewWidget, ctx: &mut BuildCtx<'_>) {
for (index, pod) in element.keys.iter().zip(element.children.iter_mut()) {
crate::authoring::teardown_child(&(self.builder)(*index), pod, ctx);
}
}
}
impl Widget for ListViewWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let vw = if bc.max().width.is_finite() {
bc.max().width
} else {
0.0
};
let vh = if bc.max().height.is_finite() {
bc.max().height
} else {
self.content_extent()
};
self.viewport = Size::new(vw, vh);
self.clamp_offset();
if let Some(estimate) = self.variable_estimate() {
self.layout_variable(ctx, vw, estimate);
} else {
let child_bc = BoxConstraints::tight(Size::new(vw, self.item_extent));
for pod in &mut self.children {
pod.layout_child(ctx, &child_bc);
}
}
self.sync_child_origins();
bc.constrain(self.viewport)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.last_frame_time = ctx.frame_time();
self.pump_fling(ctx);
scene.push_clip(ctx.origin(), ctx.size());
self.sync_child_origins();
let stretch = match self.effect {
OverscrollEffect::Stretch => {
stretch_about_edge(ctx.origin(), ctx.size(), self.edge_pull)
}
OverscrollEffect::Translate | OverscrollEffect::None => None,
};
if let Some(transform) = stretch {
scene.push_transform(transform);
}
for pod in &mut self.children {
pod.paint_child(ctx, scene);
}
if stretch.is_some() {
scene.pop_transform();
}
scene.pop_clip();
let WindowPlan { start, end, .. } = self.desired_window();
let uncovered = self.item_count > 0 && !self.window_covers(start, end);
if uncovered || self.pending_correction != 0.0 {
ctx.request_frame();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let t = self.event_time_ms();
self.event_at(ctx, event, t)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let max_offset = self.max_offset();
let count = self.item_count;
let offset = self.painted_offset();
ctx.push_container(
Role::List,
move |node| {
node.set_size_of_set(count);
node.set_scroll_y(offset);
node.set_scroll_y_min(0.0);
node.set_scroll_y_max(max_offset);
},
|ctx| {
for pod in &self.children {
pod.semantics_child(ctx);
}
},
);
}
crate::authoring::visit_children!(children);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::physics::parity::{Bouncing, Clamping, DecelerationRate, NeverScrollable};
use crate::physics::rubber_band::RubberBand;
use crate::scroll::ScrollWidget;
use frust_core::{RenderRoot, any};
use std::any::Any;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
struct GenView {
gens: Rc<Cell<u64>>,
seen: Rc<GenLog>,
index: usize,
}
#[derive(Default)]
struct GenLog {
entries: std::cell::RefCell<std::collections::HashMap<usize, u64>>,
}
struct GenWidget {
generation: u64,
seen: Rc<GenLog>,
index: usize,
}
impl View<()> for GenView {
type Element = GenWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> GenWidget {
let generation = self.gens.get();
self.gens.set(generation + 1);
GenWidget {
generation,
seen: self.seen.clone(),
index: self.index,
}
}
fn rebuild(
&self,
_prev: &Self,
element: &mut GenWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.index = self.index;
element.seen = self.seen.clone();
ChangeFlags::NONE
}
}
impl Widget for GenWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(bc.max())
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
self.seen
.entries
.borrow_mut()
.insert(self.index, self.generation);
}
}
struct NullScene;
impl PaintScene for NullScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
}
fn list_widget(root: &RenderRoot<(), ListView<()>>) -> &ListViewWidget {
let id = root.root_id().expect("root built");
(root.tree().pod(id).expect("root pod").widget() as &dyn Any)
.downcast_ref::<ListViewWidget>()
.expect("root is a ListViewWidget")
}
fn frame(
root: &mut RenderRoot<(), ListView<()>>,
logic: &mut impl FnMut(&mut ()) -> ListView<()>,
state: &mut (),
window: Size,
ms: f64,
) -> ChangeFlags {
let flags = root.rebuild(logic, state);
root.layout(window);
let mut sink = NullScene;
root.paint(&mut sink, FrameTime::from_nanos((ms * 1_000_000.0) as u64));
flags
}
fn ev(phase: PointerPhase, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(10.0, y),
button: PointerButton::Primary,
})
}
#[test]
fn only_the_visible_window_materializes() {
fn logic(_: &mut ()) -> ListView<()> {
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
}
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
let w = list_widget(&root);
assert_eq!(w.window(), &[0, 1, 2, 3, 4, 5]);
assert!(
w.children.len() < 20,
"only a handful of the 1000 rows are materialized"
);
}
#[test]
fn window_shifts_to_the_scrolled_offset() {
fn logic(_: &mut ()) -> ListView<()> {
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
}
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(
&mut state,
&InputEvent::Scroll {
position: Point::new(10.0, 50.0),
delta: ScrollDelta::Pixels(0.0, 500.0),
},
);
frame(&mut root, &mut logic, &mut state, window, 32.0);
let w = list_widget(&root);
assert_eq!(w.offset(), 500.0);
assert_eq!(w.window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
}
#[test]
fn an_overlay_broadcast_reaches_every_realized_row() {
use frust_core::{OverlayEvent, OverlayEventKind, OverlayKey};
struct Owner(Rc<Cell<u32>>);
struct OwnerW(Rc<Cell<u32>>);
impl View<()> for Owner {
type Element = OwnerW;
fn build(&self, _c: &mut BuildCtx<'_>) -> OwnerW {
OwnerW(self.0.clone())
}
fn rebuild(&self, _p: &Self, e: &mut OwnerW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
e.0 = self.0.clone();
ChangeFlags::NONE
}
}
impl Widget for OwnerW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(200.0, 50.0))
}
fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
fn event(&mut self, _ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
if matches!(e, InputEvent::Overlay(_)) {
self.0.set(self.0.get() + 1);
return EventResult::Handled;
}
EventResult::Ignored
}
}
let seen = Rc::new(Cell::new(0u32));
let seen_l = seen.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let seen = seen_l.clone();
list_view(1000, 50.0, move |_| any::<(), _>(Owner(seen.clone())))
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
let realized = list_widget(&root).children.len();
assert!(realized > 1, "the fixture realizes a window of rows");
let outcome = root.event(
&mut state,
&InputEvent::Overlay(OverlayEvent {
key: OverlayKey::next(),
kind: OverlayEventKind::OutsideDown,
}),
);
assert_eq!(
seen.get() as usize,
realized,
"every realized row heard it, with no first-handler-wins short-circuit"
);
assert!(
!outcome.handled,
"a broadcast is never consumed, whatever a row returned"
);
}
#[test]
fn window_shift_relocates_survivors_preserving_state() {
let gens = Rc::new(Cell::new(0u64));
let seen = Rc::new(GenLog::default());
let gens_l = gens.clone();
let seen_l = seen.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let gens = gens_l.clone();
let seen = seen_l.clone();
list_view(1000, 50.0, move |i| {
any::<(), _>(GenView {
gens: gens.clone(),
seen: seen.clone(),
index: i,
})
})
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
let survivor_gen = seen.entries.borrow()[&3];
root.event(
&mut state,
&InputEvent::Scroll {
position: Point::new(10.0, 50.0),
delta: ScrollDelta::Pixels(0.0, 50.0),
},
);
frame(&mut root, &mut logic, &mut state, window, 32.0);
let entries = seen.entries.borrow();
assert_eq!(
entries[&3], survivor_gen,
"a surviving row keeps its widget (and state): relocated, not rebuilt"
);
assert!(
entries[&6] > survivor_gen,
"a freshly-entered row is built anew (higher generation)"
);
}
#[test]
fn fling_advances_the_window_across_frames() {
fn logic(_: &mut ()) -> ListView<()> {
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
}
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &ev(PointerPhase::Down, 180.0));
frame(&mut root, &mut logic, &mut state, window, 32.0);
root.event(&mut state, &ev(PointerPhase::Move, 120.0)); frame(&mut root, &mut logic, &mut state, window, 48.0);
root.event(&mut state, &ev(PointerPhase::Move, 60.0)); root.event(&mut state, &ev(PointerPhase::Up, 60.0));
assert!(list_widget(&root).is_flinging(), "release starts a fling");
let start_first = list_widget(&root).window()[0];
for k in 0..8 {
frame(
&mut root,
&mut logic,
&mut state,
window,
64.0 + 16.0 * k as f64,
);
}
assert!(
list_widget(&root).window()[0] > start_first,
"the fling carried the window to higher indices across frames"
);
}
#[test]
fn extent_is_exact_and_offset_clamps_with_no_overscroll() {
let mut w = ListViewWidget::new(100, 40.0);
w.viewport = Size::new(200.0, 300.0);
assert_eq!(w.max_offset(), 3700.0);
w.set_offset(10_000.0);
assert_eq!(w.offset(), 3700.0, "clamped to the end, no overscroll");
w.set_offset(-50.0);
assert_eq!(w.offset(), 0.0, "clamped to the top");
let mut short = ListViewWidget::new(2, 40.0);
short.viewport = Size::new(200.0, 300.0);
assert_eq!(short.max_offset(), 0.0);
}
#[test]
fn empty_list_materializes_nothing() {
fn logic(_: &mut ()) -> ListView<()> {
list_view(0, 50.0, |i| any::<(), _>(gen_stub(i)))
}
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
assert!(list_widget(&root).window().is_empty());
}
pub(super) fn gen_stub(_index: usize) -> impl View<()> {
crate::test_support::leaf(200.0, 50.0)
}
struct RoleListItemRow;
struct RoleListItemRowWidget;
impl View<()> for RoleListItemRow {
type Element = RoleListItemRowWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> RoleListItemRowWidget {
RoleListItemRowWidget
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut RoleListItemRowWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for RoleListItemRowWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(300.0, 56.0))
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(frust_core::accesskit::Role::ListItem, |_node| {});
}
}
#[test]
fn semantics_is_a_list_container_over_the_windowed_rows() {
use frust_core::accesskit::Role;
use frust_text::TextContext;
fn logic(_s: &mut ()) -> ListView<()> {
list_view(1000, 56.0, |_i| any::<(), _>(RoleListItemRow))
}
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(300.0, 200.0);
let mut tcx = TextContext::new();
for _ in 0..2 {
root.rebuild(&mut logic, &mut state);
root.layout_with_text(window, &mut tcx as &mut dyn Any);
let mut sink = NullScene;
root.paint(&mut sink, FrameTime::ZERO);
}
let update = root.semantics();
let materialized = list_widget(&root).window().len();
let (_, list) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::List)
.expect("the ListView contributes a Role::List container node");
assert_eq!(
list.size_of_set(),
Some(1000),
"the container advertises the full item count, not just the window"
);
assert_eq!(
list.children().len(),
materialized,
"only the materialized rows are semantics children of the list"
);
let list_items = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::ListItem)
.count();
assert_eq!(list_items, materialized);
}
#[derive(Default)]
struct Loads {
count: u32,
}
fn near_start_widget(threshold: f64) -> ListViewWidget {
let mut w = ListViewWidget::new(1000, 50.0);
w.viewport = Size::new(200.0, 200.0);
w.near_start_threshold = threshold;
let cb: Rc<dyn Fn(&mut Loads)> = Rc::new(|s: &mut Loads| s.count += 1);
w.on_near_start = Some(crate::authoring::erase_callback(&cb));
w.near_start_armed = true;
w
}
fn wheel(px: f64) -> InputEvent {
InputEvent::Scroll {
position: Point::new(10.0, 50.0),
delta: ScrollDelta::Pixels(0.0, px),
}
}
fn run_loads(w: &mut ListViewWidget, state: &mut Loads, e: &InputEvent) {
let sa: &mut dyn Any = state;
let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
w.event_at(&mut ctx, e, 0.0);
}
#[test]
fn on_near_start_fires_near_start_and_rearms_after_scrolling_away() {
let mut w = near_start_widget(100.0);
let mut state = Loads::default();
run_loads(&mut w, &mut state, &wheel(500.0));
assert_eq!(w.offset(), 500.0);
assert_eq!(state.count, 0, "away from the start does not fire");
run_loads(&mut w, &mut state, &wheel(-460.0));
assert_eq!(w.offset(), 40.0);
assert_eq!(
state.count, 1,
"nearing the start fires the load-older hook"
);
run_loads(&mut w, &mut state, &wheel(-20.0));
assert_eq!(w.offset(), 20.0);
assert_eq!(state.count, 1, "no re-fire while still near the start");
run_loads(&mut w, &mut state, &wheel(500.0));
assert_eq!(state.count, 1);
run_loads(&mut w, &mut state, &wheel(-480.0));
assert_eq!(w.offset(), 40.0);
assert_eq!(state.count, 2, "rearmed after scrolling away, fires again");
}
#[test]
fn on_near_start_does_not_fire_at_the_bottom() {
let mut w = near_start_widget(100.0);
let mut state = Loads::default();
run_loads(&mut w, &mut state, &wheel(1_000_000.0));
assert_eq!(w.offset(), w.max_offset());
assert_eq!(state.count, 0, "the bottom is not the load-older edge");
}
#[test]
fn near_start_content_growth_rearms_across_rebuild() {
let loads = Rc::new(Cell::new(0u32));
let loads_l = loads.clone();
let count = Rc::new(Cell::new(1000usize));
let count_l = count.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let loads = loads_l.clone();
list_view(count_l.get(), 50.0, |i| any::<(), _>(gen_stub(i)))
.on_near_start(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &wheel(10.0));
assert_eq!(loads.get(), 1, "near-start fires at the top");
root.event(&mut state, &wheel(5.0));
assert_eq!(loads.get(), 1);
count.set(2000);
frame(&mut root, &mut logic, &mut state, window, 32.0);
root.event(&mut state, &wheel(5.0));
assert_eq!(loads.get(), 2, "content growth rearms the load-older edge");
}
#[test]
fn cancel_clears_a_pending_near_start_without_firing() {
let mut w = near_start_widget(100.0);
let mut state = Loads::default();
w.pending_near_start = true;
run_loads(&mut w, &mut state, &ev(PointerPhase::Cancel, 50.0));
assert!(!w.pending_near_start);
assert_eq!(state.count, 0, "Cancel never fires the callback");
w.pending_near_start = true;
run_loads(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
assert!(!w.pending_near_start);
assert_eq!(
state.count, 1,
"a pending fire is delivered on the next event"
);
}
fn near_end_widget(threshold: f64) -> ListViewWidget {
let mut w = ListViewWidget::new(1000, 50.0);
w.viewport = Size::new(200.0, 200.0);
w.near_end_threshold = threshold;
let cb: Rc<dyn Fn(&mut Loads)> = Rc::new(|s: &mut Loads| s.count += 1);
w.on_near_end = Some(crate::authoring::erase_callback(&cb));
w.near_end_armed = true;
w.offset = w.max_offset();
w
}
#[test]
fn on_near_end_fires_near_end_and_rearms_after_scrolling_away() {
let mut w = near_end_widget(100.0);
let mut state = Loads::default();
let start_offset = w.offset();
run_loads(&mut w, &mut state, &wheel(-500.0));
assert_eq!(w.offset(), start_offset - 500.0);
assert_eq!(state.count, 0, "away from the end does not fire");
run_loads(&mut w, &mut state, &wheel(460.0));
assert_eq!(w.offset(), start_offset - 40.0);
assert_eq!(state.count, 1, "nearing the end fires the load-newer hook");
run_loads(&mut w, &mut state, &wheel(20.0));
assert_eq!(w.offset(), start_offset - 20.0);
assert_eq!(state.count, 1, "no re-fire while still near the end");
run_loads(&mut w, &mut state, &wheel(-500.0));
assert_eq!(state.count, 1);
run_loads(&mut w, &mut state, &wheel(480.0));
assert_eq!(w.offset(), start_offset - 40.0);
assert_eq!(state.count, 2, "rearmed after scrolling away, fires again");
}
#[test]
fn on_near_end_does_not_fire_at_the_top() {
let mut w = near_end_widget(100.0);
let mut state = Loads::default();
run_loads(&mut w, &mut state, &wheel(-1_000_000.0));
assert_eq!(w.offset(), 0.0);
assert_eq!(state.count, 0, "the top is not the load-newer edge");
}
#[test]
fn near_end_content_growth_rearms_across_rebuild() {
let loads = Rc::new(Cell::new(0u32));
let loads_l = loads.clone();
let count = Rc::new(Cell::new(1000usize));
let count_l = count.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let loads = loads_l.clone();
list_view(count_l.get(), 50.0, |i| any::<(), _>(gen_stub(i)))
.on_near_end(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &wheel(1_000_000.0));
assert_eq!(loads.get(), 1, "near-end fires at the bottom");
root.event(&mut state, &wheel(5.0));
assert_eq!(loads.get(), 1);
count.set(2000);
frame(&mut root, &mut logic, &mut state, window, 32.0);
root.event(&mut state, &wheel(1_000_000.0));
assert_eq!(loads.get(), 2, "content growth rearms the load-newer edge");
}
#[test]
fn cancel_clears_a_pending_near_end_without_firing() {
let mut w = near_end_widget(100.0);
let mut state = Loads::default();
w.pending_near_end = true;
run_loads(&mut w, &mut state, &ev(PointerPhase::Cancel, 50.0));
assert!(!w.pending_near_end);
assert_eq!(state.count, 0, "Cancel never fires the callback");
w.pending_near_end = true;
run_loads(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
assert!(!w.pending_near_end);
assert_eq!(
state.count, 1,
"a pending fire is delivered on the next event"
);
}
fn overscroll_logic(item_count: usize) -> impl FnMut(&mut ()) -> ListView<()> {
move |_: &mut ()| list_view(item_count, 50.0, |i| any::<(), _>(gen_stub(i)))
}
fn rubber_band_logic(item_count: usize) -> impl FnMut(&mut ()) -> ListView<()> {
move |_: &mut ()| {
list_view(item_count, 50.0, |i| any::<(), _>(gen_stub(i))).physics(RubberBand::new())
}
}
#[test]
fn top_overscroll_resists_never_moves_the_windowing_offset_and_settles_with_no_refresh() {
let refreshes = Rc::new(Cell::new(0u32));
let refreshes_l = refreshes.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let refreshes = refreshes_l.clone();
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
.physics(RubberBand::new())
.on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
frame(&mut root, &mut logic, &mut state, window, 32.0);
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); assert!(list_widget(&root).scrolling);
assert_eq!(
list_widget(&root).offset(),
0.0,
"the takeover move does not itself scroll"
);
frame(&mut root, &mut logic, &mut state, window, 48.0);
root.event(&mut state, &ev(PointerPhase::Move, 110.0));
let w = list_widget(&root);
assert_eq!(
w.offset(),
0.0,
"the windowing offset never leaves [0, max]"
);
assert_eq!(
w.overscroll, -10.0,
"overscroll is the raw excess (-20) * OVERSCROLL_RESISTANCE (0.5), \
matching ScrollView's own resistance exactly"
);
assert_eq!(w.window()[0], 0, "no row materializes before index 0");
root.event(&mut state, &ev(PointerPhase::Up, 110.0));
let w = list_widget(&root);
assert!(w.settling, "an overscrolled release settles, never flings");
assert!(!w.is_flinging());
assert_eq!(
refreshes.get(),
0,
"release under the trigger does not refresh"
);
let mut ms = 64.0;
for _ in 0..30 {
frame(&mut root, &mut logic, &mut state, window, ms);
ms += 16.0;
if !list_widget(&root).settling {
break;
}
}
let w = list_widget(&root);
assert!(!w.settling, "the settle terminated");
assert_eq!(
w.overscroll, 0.0,
"the surface settles back to the clamped edge"
);
assert_eq!(w.offset(), 0.0);
}
#[test]
fn on_refresh_release_fires_once_past_the_trigger_and_only_on_release() {
let refreshes = Rc::new(Cell::new(0u32));
let refreshes_l = refreshes.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let refreshes = refreshes_l.clone();
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
.physics(RubberBand::new())
.on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); frame(&mut root, &mut logic, &mut state, window, 32.0);
root.event(&mut state, &ev(PointerPhase::Move, 290.0));
let w = list_widget(&root);
assert_eq!(w.overscroll, -100.0, "past the trigger (|-100| > 64)");
assert_eq!(
w.offset(),
0.0,
"windowing offset stays 0 even far past the trigger"
);
assert_eq!(w.window()[0], 0, "no row materializes before index 0");
assert_eq!(refreshes.get(), 0, "no fire before release");
root.event(&mut state, &ev(PointerPhase::Up, 290.0));
assert_eq!(refreshes.get(), 1, "release past the trigger fires once");
assert!(list_widget(&root).settling);
}
#[test]
fn bottom_overscroll_displaces_and_settles_but_never_fires_refresh() {
let refreshes = Rc::new(Cell::new(0u32));
let refreshes_l = refreshes.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let refreshes = refreshes_l.clone();
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
.physics(RubberBand::new())
.on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &wheel(1_000_000.0));
frame(&mut root, &mut logic, &mut state, window, 32.0);
let max_offset = list_widget(&root).max_offset();
assert_eq!(list_widget(&root).offset(), max_offset);
root.event(&mut state, &ev(PointerPhase::Down, 200.0));
frame(&mut root, &mut logic, &mut state, window, 48.0);
root.event(&mut state, &ev(PointerPhase::Move, 160.0)); assert_eq!(
list_widget(&root).offset(),
max_offset,
"the takeover move does not itself scroll"
);
frame(&mut root, &mut logic, &mut state, window, 64.0);
root.event(&mut state, &ev(PointerPhase::Move, 100.0));
let w = list_widget(&root);
assert_eq!(
w.offset(),
max_offset,
"the windowing offset stays pinned at max_offset"
);
assert_eq!(
w.overscroll, 30.0,
"overscroll is the raw excess (60) * OVERSCROLL_RESISTANCE (0.5)"
);
assert_eq!(
*w.window().last().unwrap(),
999,
"no row materializes past the last item"
);
root.event(&mut state, &ev(PointerPhase::Up, 100.0));
let w = list_widget(&root);
assert!(
w.settling,
"a bottom overscroll release settles, never flings"
);
assert_eq!(
refreshes.get(),
0,
"a bottom overscroll never fires refresh"
);
let mut ms = 80.0;
for _ in 0..30 {
frame(&mut root, &mut logic, &mut state, window, ms);
ms += 16.0;
if !list_widget(&root).settling {
break;
}
}
let w = list_widget(&root);
assert!(!w.settling);
assert_eq!(w.overscroll, 0.0);
assert_eq!(w.offset(), max_offset);
assert_eq!(refreshes.get(), 0, "still never fired");
}
#[test]
fn cancel_during_overscroll_never_fires_refresh_and_snaps_back() {
let refreshes = Rc::new(Cell::new(0u32));
let refreshes_l = refreshes.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let refreshes = refreshes_l.clone();
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
.physics(RubberBand::new())
.on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); frame(&mut root, &mut logic, &mut state, window, 32.0);
root.event(&mut state, &ev(PointerPhase::Move, 290.0)); assert_eq!(list_widget(&root).overscroll, -100.0);
root.event(&mut state, &ev(PointerPhase::Cancel, 290.0));
let w = list_widget(&root);
assert_eq!(
refreshes.get(),
0,
"Cancel never fires the refresh callback"
);
assert_eq!(
w.overscroll, 0.0,
"Cancel snaps the surface back into range"
);
assert!(!w.settling);
assert_eq!(w.offset(), 0.0);
}
#[test]
fn wheel_never_overscrolls_past_either_edge_and_starts_no_settle() {
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
let mut logic = overscroll_logic(1000);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &wheel(-5000.0));
let w = list_widget(&root);
assert_eq!(w.offset(), 0.0);
assert_eq!(w.overscroll, 0.0, "wheel input never overscrolls the top");
assert!(!w.settling, "wheel input starts no settle animation");
root.event(&mut state, &wheel(1_000_000.0));
let w = list_widget(&root);
assert_eq!(w.offset(), w.max_offset());
assert_eq!(
w.overscroll, 0.0,
"wheel input never overscrolls the bottom"
);
assert!(!w.settling);
}
#[test]
fn near_start_and_refresh_fire_from_one_continuous_drag_sequence() {
let loads = Rc::new(Cell::new(0u32));
let refreshes = Rc::new(Cell::new(0u32));
let (loads_l, refreshes_l) = (loads.clone(), refreshes.clone());
let mut logic = move |_: &mut ()| -> ListView<()> {
let (loads, refreshes) = (loads_l.clone(), refreshes_l.clone());
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
.on_near_start(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
.on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &wheel(150.0));
frame(&mut root, &mut logic, &mut state, window, 32.0);
assert_eq!(list_widget(&root).offset(), 150.0);
assert_eq!(loads.get(), 0);
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); frame(&mut root, &mut logic, &mut state, window, 48.0);
root.event(&mut state, &ev(PointerPhase::Move, 170.0));
let w = list_widget(&root);
assert_eq!(w.offset(), 70.0);
assert_eq!(w.overscroll, 0.0);
assert_eq!(loads.get(), 1, "near-start fires on approach");
assert_eq!(refreshes.get(), 0, "still in range, no refresh yet");
root.event(&mut state, &ev(PointerPhase::Move, 500.0));
let w = list_widget(&root);
assert_eq!(w.offset(), 0.0, "windowing offset stays clamped");
assert!(
w.overscroll < -64.0,
"well past the refresh trigger (REFRESH_TRIGGER_PX)"
);
assert_eq!(loads.get(), 1, "near-start does not re-fire mid-overscroll");
root.event(&mut state, &ev(PointerPhase::Up, 500.0));
assert_eq!(
refreshes.get(),
1,
"refresh fires on release, from the same gesture"
);
assert_eq!(loads.get(), 1, "and near-start's single fire still stands");
}
#[test]
fn overscroll_resistance_tracks_a_max_offset_that_grows_mid_drag() {
let mut logic = rubber_band_logic(10);
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &wheel(1_000_000.0));
frame(&mut root, &mut logic, &mut state, window, 32.0);
assert_eq!(list_widget(&root).max_offset(), 300.0);
assert_eq!(list_widget(&root).offset(), 300.0);
root.event(&mut state, &ev(PointerPhase::Down, 200.0));
root.event(&mut state, &ev(PointerPhase::Move, 160.0)); frame(&mut root, &mut logic, &mut state, window, 48.0);
root.event(&mut state, &ev(PointerPhase::Move, 100.0));
let w = list_widget(&root);
assert_eq!(w.offset(), 300.0);
assert_eq!(
w.overscroll, 30.0,
"resisted against the old max_offset (300)"
);
logic = rubber_band_logic(20);
frame(&mut root, &mut logic, &mut state, window, 64.0);
assert_eq!(list_widget(&root).max_offset(), 800.0);
root.event(&mut state, &ev(PointerPhase::Move, 80.0));
let w = list_widget(&root);
assert_eq!(
w.overscroll, 0.0,
"the grown content absorbed what used to be overscroll"
);
assert_eq!(
w.offset(),
350.0,
"the windowing offset advanced by exactly the finger delta, now in range"
);
}
#[test]
fn rubber_band_drag_mapping_matches_legacy_math() {
let mut logic = rubber_band_logic(1000);
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &ev(PointerPhase::Down, 200.0));
root.event(&mut state, &ev(PointerPhase::Move, 160.0)); root.event(&mut state, &ev(PointerPhase::Move, 100.0)); let w = list_widget(&root);
assert_eq!(w.offset(), 60.0, "an in-range drag maps one-for-one");
assert_eq!(w.overscroll, 0.0);
assert_eq!(w.edge_pull, 0.0);
root.event(&mut state, &ev(PointerPhase::Cancel, 100.0));
root.event(&mut state, &wheel(-1_000_000.0)); frame(&mut root, &mut logic, &mut state, window, 32.0);
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); root.event(&mut state, &ev(PointerPhase::Move, 110.0)); let w = list_widget(&root);
assert_eq!(w.offset(), 0.0, "the windowing offset never leaves range");
assert_eq!(w.overscroll, -10.0, "raw excess (-20) halved by resistance");
root.event(&mut state, &ev(PointerPhase::Cancel, 110.0));
root.event(&mut state, &wheel(1_000_000.0));
frame(&mut root, &mut logic, &mut state, window, 32.0);
let max_offset = list_widget(&root).max_offset();
root.event(&mut state, &ev(PointerPhase::Down, 200.0));
root.event(&mut state, &ev(PointerPhase::Move, 160.0)); root.event(&mut state, &ev(PointerPhase::Move, 100.0)); let w = list_widget(&root);
assert_eq!(w.offset(), max_offset);
assert_eq!(w.overscroll, 30.0, "raw excess (60) halved by resistance");
}
#[test]
fn edge_pull_equals_overscroll_under_rubber_band() {
let mut logic = rubber_band_logic(1000);
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); root.event(&mut state, &ev(PointerPhase::Move, 110.0)); let w = list_widget(&root);
assert_eq!(w.overscroll, -10.0);
assert_eq!(
w.edge_pull, -10.0,
"nothing rejected → the pull is the displacement, same sign"
);
root.event(&mut state, &ev(PointerPhase::Up, 110.0));
assert!(list_widget(&root).settling);
let mut ms = 32.0;
let mut eased = false;
for _ in 0..40 {
frame(&mut root, &mut logic, &mut state, window, ms);
ms += 16.0;
let w = list_widget(&root);
assert_eq!(
w.edge_pull, w.overscroll,
"the pull tracks the displacement through the whole settle"
);
if w.overscroll < 0.0 && w.overscroll > -10.0 {
eased = true;
}
if !w.settling {
break;
}
}
assert!(eased, "the settle eased through intermediate values");
let w = list_widget(&root);
assert!(!w.settling, "the settle terminates");
assert_eq!(w.overscroll, 0.0);
assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
}
fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
assert!(
(actual - expected).abs() < epsilon,
"{what}: {actual} is not within {epsilon} of {expected}"
);
}
#[test]
fn a_fresh_list_installs_the_platform_default() {
let mut logic = overscroll_logic(1000);
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
frame(&mut root, &mut logic, &mut (), Size::new(200.0, 200.0), 0.0);
let w = list_widget(&root);
assert_eq!(
format!("{:?}", w.physics),
format!("{:?}", crate::physics::default_physics())
);
assert_eq!(w.effect, crate::physics::default_overscroll_effect());
}
#[test]
fn default_drag_tension_tightens_with_depth() {
let mut logic = overscroll_logic(1000);
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); frame(&mut root, &mut logic, &mut state, window, 32.0);
root.event(&mut state, &ev(PointerPhase::Move, 110.0));
let first = list_widget(&root).overscroll;
assert_eq!(list_widget(&root).offset(), 0.0);
assert_close(
first,
-20.0 * DecelerationRate::NORMAL_FRICTION,
1e-12,
"the first past-edge move",
);
root.event(&mut state, &ev(PointerPhase::Move, 130.0));
let w = list_widget(&root);
assert_close(w.overscroll, -19.746_521_6, 1e-9, "the accumulated pull");
assert!(
(w.overscroll - first).abs() < first.abs(),
"the deeper pull displaces less per raw px"
);
assert_eq!(w.offset(), 0.0, "…and the windowing offset never moves");
assert_eq!(w.window()[0], 0, "no row materializes before index 0");
}
fn release_velocity(w: &ListViewWidget) -> f64 {
match w.ballistic.as_ref() {
Some(state) => state.sim.dx(0.0),
None => w.fling.unwrap_or(0.0),
}
}
#[test]
fn reverse_refling_keeps_the_fingers_velocity() {
let mut w = ListViewWidget::new(1000, 50.0);
w.viewport = Size::new(200.0, 200.0);
dispatch_list(&mut w, &wheel(1000.0), 0.0);
assert_eq!(w.offset(), 1000.0, "the fixture parked mid-content");
dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch_list(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); dispatch_list(&mut w, &ev(PointerPhase::Move, 50.0), 32.0);
dispatch_list(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
assert_close(release_velocity(&w), 1562.5, 1e-9, "the first release");
dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
dispatch_list(&mut w, &ev(PointerPhase::Move, 125.0), 64.0); dispatch_list(&mut w, &ev(PointerPhase::Move, 150.0), 80.0);
dispatch_list(&mut w, &ev(PointerPhase::Up, 150.0), 80.0);
assert_close(
release_velocity(&w),
-1562.5,
1e-9,
"the reverse re-fling runs at the finger's own velocity",
);
assert!(
w.ballistic.is_some(),
"…as a real ballistic curve, not a stalled remnant"
);
}
#[test]
fn momentum_retain_threshold_refuses_a_weak_refling() {
let mut w = ListViewWidget::new(1000, 50.0);
w.viewport = Size::new(200.0, 200.0);
dispatch_list(&mut w, &wheel(1000.0), 0.0);
assert_eq!(w.offset(), 1000.0, "the fixture parked mid-content");
dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch_list(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
dispatch_list(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
assert_close(release_velocity(&w), 1000.0, 1e-9, "the interrupted motion");
let mapped = Bouncing::new().carried_momentum(1000.0);
let threshold = MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped;
assert!(
(300.0..350.0).contains(&threshold),
"the fixture's release values must straddle the threshold: {threshold}"
);
dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
dispatch_list(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); dispatch_list(&mut w, &ev(PointerPhase::Move, 70.0), 148.0); dispatch_list(&mut w, &ev(PointerPhase::Up, 70.0), 148.0);
assert_close(
release_velocity(&w),
300.0,
1e-9,
"a release under the mapped threshold carries nothing forward",
);
}
#[test]
fn momentum_retain_threshold_carries_a_strong_refling() {
let mut w = ListViewWidget::new(1000, 50.0);
w.viewport = Size::new(200.0, 200.0);
dispatch_list(&mut w, &wheel(1000.0), 0.0);
assert_eq!(w.offset(), 1000.0, "the fixture parked mid-content");
dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch_list(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
dispatch_list(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
assert_close(release_velocity(&w), 1000.0, 1e-9, "the interrupted motion");
let mapped = Bouncing::new().carried_momentum(1000.0);
let threshold = MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped;
assert!(
(300.0..350.0).contains(&threshold),
"the fixture's release values must straddle the threshold: {threshold}"
);
dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
dispatch_list(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); dispatch_list(&mut w, &ev(PointerPhase::Move, 65.0), 148.0); dispatch_list(&mut w, &ev(PointerPhase::Up, 65.0), 148.0);
assert_close(
release_velocity(&w),
350.0 + mapped,
1e-9,
"a release over the mapped threshold carries `mapped` forward exactly",
);
}
#[test]
fn refresh_trigger_under_the_bouncing_default() {
let refreshes = Rc::new(Cell::new(0u32));
let refreshes_l = refreshes.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let refreshes = refreshes_l.clone();
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
.on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); frame(&mut root, &mut logic, &mut state, window, 32.0);
root.event(&mut state, &ev(PointerPhase::Move, 200.0));
let w = list_widget(&root);
assert_close(w.edge_pull, -57.2, 1e-9, "under the trigger");
assert_eq!(
w.edge_pull, w.overscroll,
"a bouncing surface rejects nothing, so the pull IS the displacement"
);
root.event(&mut state, &ev(PointerPhase::Up, 200.0));
assert_eq!(refreshes.get(), 0, "release under the trigger never fires");
root.event(&mut state, &ev(PointerPhase::Cancel, 200.0));
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); frame(&mut root, &mut logic, &mut state, window, 48.0);
root.event(&mut state, &ev(PointerPhase::Move, 240.0));
let w = list_widget(&root);
assert_close(w.edge_pull, -78.0, 1e-9, "past the trigger");
assert!(crossed_refresh_trigger(w.edge_pull));
assert_eq!(refreshes.get(), 0, "no fire before release");
root.event(&mut state, &ev(PointerPhase::Up, 240.0));
assert_eq!(refreshes.get(), 1, "release past the trigger fires once");
}
#[test]
fn refresh_trigger_under_a_clamping_physics() {
let refreshes = Rc::new(Cell::new(0u32));
let refreshes_l = refreshes.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let refreshes = refreshes_l.clone();
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
.physics(Clamping::new())
.on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
let window = Size::new(200.0, 200.0);
frame(&mut root, &mut logic, &mut state, window, 0.0);
frame(&mut root, &mut logic, &mut state, window, 16.0);
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); frame(&mut root, &mut logic, &mut state, window, 32.0);
root.event(&mut state, &ev(PointerPhase::Move, 140.0));
let w = list_widget(&root);
assert_eq!(w.offset(), 0.0, "a clamping surface never displaces");
assert_eq!(w.overscroll, 0.0);
assert_eq!(w.edge_pull, -50.0, "…but reports the whole rejected pull");
root.event(&mut state, &ev(PointerPhase::Up, 140.0));
assert_eq!(refreshes.get(), 0, "50px of raw pull is under the trigger");
root.event(&mut state, &ev(PointerPhase::Cancel, 140.0));
root.event(&mut state, &ev(PointerPhase::Down, 50.0));
root.event(&mut state, &ev(PointerPhase::Move, 90.0)); frame(&mut root, &mut logic, &mut state, window, 48.0);
root.event(&mut state, &ev(PointerPhase::Move, 190.0));
let w = list_widget(&root);
assert_eq!(w.offset(), 0.0);
assert_eq!(w.overscroll, 0.0);
assert_eq!(w.edge_pull, -100.0, "100px of raw pull, none of it shown");
assert_eq!(w.window()[0], 0, "no row materializes before index 0");
root.event(&mut state, &ev(PointerPhase::Up, 190.0));
assert_eq!(refreshes.get(), 1, "past 64px of raw pull, it fires once");
assert!(
list_widget(&root).settling,
"the rejected pull settles rather than springs — nothing displaced"
);
let mut ms = 64.0;
for _ in 0..40 {
frame(&mut root, &mut logic, &mut state, window, ms);
ms += 16.0;
if !list_widget(&root).settling {
break;
}
}
let w = list_widget(&root);
assert!(!w.settling, "the settle terminated");
assert_eq!(w.edge_pull, 0.0, "…leaving no pull for a stretch to paint");
}
#[test]
fn physics_builder_installs_custom_physics() {
let window = Size::new(200.0, 200.0);
let mut never_logic = move |_: &mut ()| -> ListView<()> {
list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i))).physics(NeverScrollable::new())
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
frame(&mut root, &mut never_logic, &mut state, window, 0.0);
frame(&mut root, &mut never_logic, &mut state, window, 16.0);
root.event(&mut state, &ev(PointerPhase::Down, 200.0));
root.event(&mut state, &ev(PointerPhase::Move, 160.0)); root.event(&mut state, &ev(PointerPhase::Move, 100.0));
let w = list_widget(&root);
assert_eq!(w.offset(), 0.0, "NeverScrollable must refuse the drag");
assert!(
!w.scrolling,
"NeverScrollable must never take the gesture over"
);
let mut default_logic = overscroll_logic(1000);
let mut default_root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut default_state = ();
frame(
&mut default_root,
&mut default_logic,
&mut default_state,
window,
0.0,
);
frame(
&mut default_root,
&mut default_logic,
&mut default_state,
window,
16.0,
);
default_root.event(&mut default_state, &ev(PointerPhase::Down, 200.0));
default_root.event(&mut default_state, &ev(PointerPhase::Move, 160.0));
default_root.event(&mut default_state, &ev(PointerPhase::Move, 100.0));
assert_eq!(
list_widget(&default_root).offset(),
60.0,
"the default twin scrolls normally"
);
}
#[test]
fn variable_extent_top_overscroll_never_materializes_before_index_zero() {
let fx = VarRows::new(tall_ids(60));
let mut logic = fx.logic_rubber_band();
let mut root = converged_var(&mut logic, &fx);
assert_eq!(list_widget(&root).window()[0], 0);
root.event(&mut (), &ev(PointerPhase::Down, 50.0));
root.event(&mut (), &ev(PointerPhase::Move, 90.0)); var_frame(&mut root, &mut logic, &fx, 100.0);
root.event(&mut (), &ev(PointerPhase::Move, 150.0));
let w = list_widget(&root);
assert_eq!(
w.offset(),
0.0,
"the windowing offset never leaves [0, max] in variable-extent mode either"
);
assert!(w.overscroll < 0.0, "a past-top drag overscrolls");
assert_eq!(w.window()[0], 0, "no row materializes before index 0");
root.event(&mut (), &ev(PointerPhase::Up, 150.0));
assert!(
list_widget(&root).settling,
"an overscrolled release settles in variable-extent mode too"
);
for n in 0..60 {
var_frame(&mut root, &mut logic, &fx, 116.0 + 16.0 * n as f64);
if !list_widget(&root).settling {
break;
}
}
let w = list_widget(&root);
assert!(!w.settling);
assert_eq!(w.overscroll, 0.0);
assert_eq!(w.offset(), 0.0);
}
fn nested_list(count: usize, extent: f64, viewport_h: f64) -> ListViewWidget {
let mut w = ListViewWidget::new(count, extent);
w.viewport = Size::new(200.0, viewport_h);
w
}
fn nested_scroll(viewport_h: f64) -> ScrollWidget {
let view: crate::scroll::ScrollView<()> =
crate::scroll::scroll_view(crate::test_support::leaf(200.0, 1000.0));
let mut counter = 0u64;
let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
let mut lctx = LayoutCtx::new();
w.layout(
&mut lctx,
&BoxConstraints::tight(Size::new(200.0, viewport_h)),
);
w
}
fn park_scroll(w: &mut ScrollWidget, viewport_h: f64, offset: f64) {
let mut unit = ();
let sa: &mut dyn Any = &mut unit;
let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, viewport_h));
w.event(
&mut ctx,
&InputEvent::Scroll {
position: Point::new(10.0, 10.0),
delta: ScrollDelta::Pixels(0.0, offset),
},
);
assert_eq!(w.offset(), offset, "the fixture parked where it meant to");
}
fn wire_row(outer: &mut ListViewWidget, index: usize, row: Box<dyn Widget>) {
let mut pod = ChildPod::new(row);
let mut lctx = LayoutCtx::new();
pod.layout_child(
&mut lctx,
&BoxConstraints::tight(Size::new(200.0, outer.item_extent)),
);
outer.children = vec![pod];
outer.keys = vec![index];
outer.sync_child_origins();
}
fn row_scroll(outer: &ListViewWidget) -> &ScrollWidget {
(outer.children[0].widget() as &dyn Any)
.downcast_ref::<ScrollWidget>()
.expect("the fixture wired a ScrollWidget row")
}
fn dispatch_list(w: &mut ListViewWidget, e: &InputEvent, t_ms: f64) {
let mut unit = ();
let sa: &mut dyn Any = &mut unit;
let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
w.event_at(&mut ctx, e, t_ms);
}
#[test]
fn list_view_outer_defers_when_the_nested_surface_can_consume() {
let mut outer = nested_list(10, 200.0, 200.0);
outer.offset = 400.0;
let mut row = nested_scroll(200.0);
park_scroll(&mut row, 200.0, 300.0);
wire_row(&mut outer, 2, Box::new(row));
dispatch_list(&mut outer, &ev(PointerPhase::Down, 100.0), 0.0);
assert!(
outer.inner_at_down.registered,
"the row's scroll surface reported itself on the routed Down"
);
assert!(outer.inner_at_down.can_consume_up_drag);
dispatch_list(&mut outer, &ev(PointerPhase::Move, 50.0), 16.0);
assert!(outer.deferring);
assert!(!outer.scrolling, "the list never took the gesture over");
assert_eq!(outer.offset, 400.0);
assert!(
outer.children[0].is_active(),
"no takeover Cancel went out — the row keeps its capture"
);
dispatch_list(&mut outer, &ev(PointerPhase::Move, 10.0), 32.0);
assert_eq!(outer.offset, 400.0, "the list still has not moved");
assert_eq!(outer.overscroll, 0.0);
assert_eq!(
row_scroll(&outer).offset(),
340.0,
"the nested surface consumed the 40px"
);
}
#[test]
fn list_view_outer_takes_over_when_the_nested_surface_is_pinned() {
let mut outer = nested_list(10, 200.0, 200.0);
outer.offset = 400.0;
let mut row = nested_scroll(200.0);
row.physics = Rc::new(crate::physics::parity::Clamping::new());
wire_row(&mut outer, 2, Box::new(row));
dispatch_list(&mut outer, &ev(PointerPhase::Down, 100.0), 0.0);
assert!(outer.inner_at_down.registered);
assert!(!outer.inner_at_down.can_consume_down_drag);
dispatch_list(&mut outer, &ev(PointerPhase::Move, 140.0), 16.0);
assert!(outer.scrolling);
assert!(!outer.deferring);
assert!(
!outer.children[0].is_active(),
"the takeover Cancel released the row's capture"
);
dispatch_list(&mut outer, &ev(PointerPhase::Move, 200.0), 32.0);
assert_eq!(outer.offset, 340.0, "the list consumed the 60px of drag");
assert_eq!(outer.overscroll, 0.0, "…in range, so no displacement");
assert_eq!(
row_scroll(&outer).offset(),
0.0,
"the nested surface never moved"
);
}
const ROW_EXTENT: f64 = 50.0;
const KEYED_WINDOW: Size = Size::new(200.0, 200.0);
#[derive(Default)]
struct RowLog {
painted: RefCell<HashMap<u64, u64>>,
torn: RefCell<Vec<u64>>,
}
impl RowLog {
fn painted(&self) -> HashMap<u64, u64> {
self.painted.borrow().clone()
}
fn torn(&self) -> Vec<u64> {
self.torn.borrow().clone()
}
}
struct RowView {
id: u64,
gens: Rc<Cell<u64>>,
log: Rc<RowLog>,
}
struct RowWidget {
id: u64,
generation: u64,
log: Rc<RowLog>,
}
impl View<()> for RowView {
type Element = RowWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> RowWidget {
let generation = self.gens.get();
self.gens.set(generation + 1);
RowWidget {
id: self.id,
generation,
log: self.log.clone(),
}
}
fn rebuild(
&self,
_prev: &Self,
element: &mut RowWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.id = self.id;
ChangeFlags::NONE
}
fn teardown(&self, element: &mut RowWidget, _ctx: &mut BuildCtx<'_>) {
element.log.torn.borrow_mut().push(element.id);
}
}
impl Widget for RowWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(bc.max())
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
self.log
.painted
.borrow_mut()
.insert(self.id, self.generation);
}
}
struct KeyedRows {
rows: Rc<RefCell<Vec<u64>>>,
gens: Rc<Cell<u64>>,
log: Rc<RowLog>,
}
impl KeyedRows {
fn new(ids: Vec<u64>) -> Self {
Self {
rows: Rc::new(RefCell::new(ids)),
gens: Rc::new(Cell::new(0)),
log: Rc::new(RowLog::default()),
}
}
fn keyed_logic(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
let rows = self.rows.clone();
let gens = self.gens.clone();
let log = self.log.clone();
move |_: &mut ()| {
let snapshot: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
let (keys, items) = (snapshot.clone(), snapshot.clone());
let (gens, log) = (gens.clone(), log.clone());
ListView::builder_keyed(
snapshot.len(),
ROW_EXTENT,
move |i| ChildKey::new(keys[i]),
move |i| {
any::<(), _>(RowView {
id: items[i],
gens: gens.clone(),
log: log.clone(),
})
},
)
}
}
fn positional_logic(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
let rows = self.rows.clone();
let gens = self.gens.clone();
let log = self.log.clone();
move |_: &mut ()| {
let items: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
let (gens, log) = (gens.clone(), log.clone());
ListView::builder(items.len(), ROW_EXTENT, move |i| {
any::<(), _>(RowView {
id: items[i],
gens: gens.clone(),
log: log.clone(),
})
})
}
}
}
fn keyed_frame(
root: &mut RenderRoot<(), ListView<()>>,
logic: &mut impl FnMut(&mut ()) -> ListView<()>,
log: &RowLog,
ms: f64,
) -> ChangeFlags {
log.painted.borrow_mut().clear();
log.torn.borrow_mut().clear();
frame(root, logic, &mut (), KEYED_WINDOW, ms)
}
fn converged(
logic: &mut impl FnMut(&mut ()) -> ListView<()>,
log: &RowLog,
) -> RenderRoot<(), ListView<()>> {
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
keyed_frame(&mut root, logic, log, 0.0);
keyed_frame(&mut root, logic, log, 16.0);
root
}
#[test]
fn keyed_mid_list_insert_keeps_each_row_with_its_key() {
let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
let before = fx.log.painted();
assert_eq!(before.len(), 5, "all five rows fit the viewport");
fx.rows.borrow_mut().insert(2, 25);
let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let after = fx.log.painted();
for id in [10, 20, 30, 40, 50] {
assert_eq!(
after[&id], before[&id],
"row {id} kept its own widget (and its state) across the insert"
);
}
let newest = before.values().copied().max().expect("rows painted");
assert!(
after[&25] > newest,
"the inserted row is built fresh, not handed a survivor's widget"
);
assert!(
fx.log.torn().is_empty(),
"no key left the window, so nothing is torn down"
);
assert!(
flags.contains(ChangeFlags::LAYOUT),
"a frame that materialized a new pod owes a layout pass"
);
}
#[test]
fn positional_mid_list_insert_still_misattaches_row_state() {
let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
let mut logic = fx.positional_logic();
let mut root = converged(&mut logic, &fx.log);
let before = fx.log.painted();
fx.rows.borrow_mut().insert(2, 25);
keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let after = fx.log.painted();
assert_ne!(
after[&30], before[&30],
"positional identity leaves row 30's state behind at index 2"
);
assert_eq!(
after[&25], before[&30],
"and hands it to whatever content now occupies that index"
);
}
#[test]
fn keyed_mid_list_remove_tears_down_only_the_removed_row() {
let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
let before = fx.log.painted();
fx.rows.borrow_mut().remove(2); let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let after = fx.log.painted();
assert_eq!(
fx.log.torn(),
vec![30],
"exactly the removed row's pod is torn down (its owner disposed)"
);
assert!(!after.contains_key(&30), "row 30 no longer paints");
for id in [10, 20, 40, 50] {
assert_eq!(
after[&id], before[&id],
"row {id} kept its own widget across the removal"
);
}
assert!(
flags.contains(ChangeFlags::LAYOUT),
"a frame that tore a pod down owes a layout pass"
);
}
#[test]
fn keyed_reorder_relocates_rows_and_reports_layout() {
let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
let before = fx.log.painted();
fx.rows.borrow_mut().reverse();
let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let after = fx.log.painted();
for id in [10, 20, 30, 40, 50] {
assert_eq!(
after[&id], before[&id],
"row {id} moved slot but kept its own widget"
);
}
assert!(
fx.log.torn().is_empty(),
"a reorder builds and tears down nothing"
);
assert!(
flags.contains(ChangeFlags::LAYOUT),
"relocated pods sit at new origins, so the frame owes a layout pass"
);
}
#[test]
fn an_unchanged_keyed_frame_reports_no_layout() {
let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
assert!(
flags.is_empty(),
"an identical keyed frame is a no-op reconciliation, got {flags:?}"
);
}
#[test]
fn keyed_window_shift_relocates_survivors_and_tears_down_the_leaver() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
let before = fx.log.painted();
assert_eq!(list_widget(&root).window(), &[0, 1, 2, 3, 4, 5]);
root.event(&mut (), &wheel(150.0));
let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let after = fx.log.painted();
assert_eq!(list_widget(&root).window(), &[1, 2, 3, 4, 5, 6, 7, 8]);
for id in [10, 20, 30, 40, 50] {
assert_eq!(
after[&id], before[&id],
"row {id} stayed in the window and kept its widget"
);
}
assert_eq!(
fx.log.torn(),
vec![0],
"the row that scrolled out is torn down"
);
assert!(
flags.contains(ChangeFlags::LAYOUT),
"a shifted window owes a layout pass"
);
}
#[test]
fn keyed_insert_under_a_scrolled_window_keeps_state_with_the_rows() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
root.event(&mut (), &wheel(500.0));
keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let before = fx.log.painted();
assert_eq!(list_widget(&root).window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
assert_eq!(before.len(), 8);
let offset_before = list_widget(&root).offset();
fx.rows.borrow_mut().insert(0, 5);
let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
let after = fx.log.painted();
assert_eq!(
list_widget(&root).offset(),
offset_before + ROW_EXTENT,
"anchored: the offset absorbs the shift instead of the window sliding"
);
for id in [80, 90, 100, 110, 120, 130, 140, 150] {
assert_eq!(
after[&id], before[&id],
"row {id} kept its widget — anchoring shows the SAME rows, not new ones"
);
}
assert!(
fx.log.torn().is_empty(),
"anchoring keeps the same window of rows visible: nothing leaves it"
);
assert!(flags.contains(ChangeFlags::LAYOUT));
}
fn painted_y(w: &ListViewWidget, item_index: usize) -> f64 {
let slot = w
.keys
.iter()
.position(|&k| k == item_index)
.expect("index is materialized");
w.children[slot].origin().y
}
#[test]
fn keyed_prepend_while_scrolled_anchors_the_visible_rows() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
root.event(&mut (), &wheel(500.0));
keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let before = fx.log.painted();
assert_eq!(list_widget(&root).window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
let offset_before = list_widget(&root).offset();
let y_before = painted_y(list_widget(&root), 8);
fx.rows
.borrow_mut()
.splice(0..0, [9990, 9980, 9970, 9960, 9950]);
let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
let after = fx.log.painted();
let w = list_widget(&root);
assert_eq!(
w.offset(),
offset_before + 5.0 * ROW_EXTENT,
"the offset shifts by exactly K * item_extent"
);
assert_eq!(
painted_y(w, 13), y_before,
"the anchor row paints at the exact same pixel position"
);
for id in [80, 90, 100, 110, 120, 130, 140, 150] {
assert_eq!(
after[&id], before[&id],
"row {id} kept its widget across the prepend — anchoring, not a rebuild"
);
}
assert!(
fx.log.torn().is_empty(),
"anchoring keeps the same rows visible — nothing leaves the window"
);
assert!(flags.contains(ChangeFlags::LAYOUT));
}
#[test]
fn keyed_removal_above_the_viewport_anchors_in_reverse() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
root.event(&mut (), &wheel(500.0));
keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let before = fx.log.painted();
assert_eq!(list_widget(&root).window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
let offset_before = list_widget(&root).offset();
let y_before = painted_y(list_widget(&root), 8);
fx.rows.borrow_mut().drain(0..3);
let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
let after = fx.log.painted();
let w = list_widget(&root);
assert_eq!(
w.offset(),
offset_before - 3.0 * ROW_EXTENT,
"removal above shifts the offset back by exactly the removed extent"
);
assert_eq!(
painted_y(w, 5), y_before,
"the anchor row paints at the exact same pixel position"
);
for id in [80, 90, 100, 110, 120, 130, 140, 150] {
assert_eq!(
after[&id], before[&id],
"row {id} kept its widget across the removal"
);
}
assert!(fx.log.torn().is_empty());
assert!(flags.contains(ChangeFlags::LAYOUT));
}
#[test]
fn keyed_removal_above_anchors_correctly_at_max_offset() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
root.event(&mut (), &wheel(1_000_000.0)); keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let before = fx.log.painted();
let offset_before = list_widget(&root).offset();
assert_eq!(offset_before, list_widget(&root).max_offset());
fx.rows.borrow_mut().drain(0..3);
let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
let after = fx.log.painted();
let w = list_widget(&root);
assert_eq!(
w.offset(),
offset_before - 3.0 * ROW_EXTENT,
"anchors correctly even scrolled to the very bottom"
);
assert_eq!(
w.offset(),
w.max_offset(),
"still pinned exactly at the new (smaller) bottom"
);
for (&id, &generation) in before.iter() {
assert_eq!(
after.get(&id),
Some(&generation),
"row {id} kept its widget"
);
}
assert!(flags.contains(ChangeFlags::LAYOUT));
}
#[test]
fn keyed_mutation_below_the_viewport_leaves_the_offset_alone() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
root.event(&mut (), &wheel(500.0));
keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let offset_before = list_widget(&root).offset();
fx.rows.borrow_mut().truncate(900);
keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
assert_eq!(
list_widget(&root).offset(),
offset_before,
"a below-viewport mutation leaves an already-scrolled offset untouched"
);
}
#[test]
fn keyed_prepend_at_the_very_top_still_anchors_off_zero() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
assert_eq!(list_widget(&root).offset(), 0.0);
fx.rows.borrow_mut().splice(0..0, [9990, 9980, 9970]);
let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
assert_eq!(
list_widget(&root).offset(),
3.0 * ROW_EXTENT,
"the offset shifts away from zero rather than staying pinned"
);
assert!(flags.contains(ChangeFlags::LAYOUT));
}
#[test]
fn keyed_prepend_near_top_does_not_immediately_refire_near_start() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let loads = Rc::new(Cell::new(0u32));
let loads_l = loads.clone();
let rows = fx.rows.clone();
let gens = fx.gens.clone();
let log = fx.log.clone();
let mut logic = move |_: &mut ()| -> ListView<()> {
let snapshot: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
let (keys, items) = (snapshot.clone(), snapshot.clone());
let (gens, log) = (gens.clone(), log.clone());
let loads = loads_l.clone();
ListView::builder_keyed(
snapshot.len(),
ROW_EXTENT,
move |i| ChildKey::new(keys[i]),
move |i| {
any::<(), _>(RowView {
id: items[i],
gens: gens.clone(),
log: log.clone(),
})
},
)
.on_near_start(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
};
let mut root = converged(&mut logic, &fx.log);
root.event(&mut (), &wheel(0.0));
assert_eq!(loads.get(), 1, "near-start fires once at the top");
fx.rows.borrow_mut().splice(0..0, [9999]);
keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
assert_eq!(
list_widget(&root).offset(),
ROW_EXTENT,
"anchored one row's worth off zero"
);
root.event(&mut (), &wheel(0.0));
assert_eq!(
loads.get(),
1,
"the corrected offset does not immediately re-fire the same edge"
);
root.event(&mut (), &wheel(500.0));
root.event(&mut (), &wheel(-450.0));
assert_eq!(
loads.get(),
2,
"the edge still rearms after genuinely scrolling away and back"
);
}
#[test]
fn positional_prepend_does_not_anchor_the_offset() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut logic = fx.positional_logic();
let mut root = converged(&mut logic, &fx.log);
root.event(&mut (), &wheel(500.0));
keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let offset_before = list_widget(&root).offset();
fx.rows.borrow_mut().insert(0, 12345);
keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
assert_eq!(
list_widget(&root).offset(),
offset_before,
"the positional path never corrects the offset"
);
}
const ESTIMATE: f64 = 60.0;
fn var_height(id: u64) -> f64 {
40.0 + (id % 5) as f64 * 20.0
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
enum Pass {
#[default]
Idle,
Rebuild,
Layout,
Paint,
}
#[derive(Default)]
struct BuildProbe {
pass: Cell<Pass>,
calls: Cell<usize>,
violations: RefCell<Vec<Pass>>,
}
impl BuildProbe {
fn note(&self) {
self.calls.set(self.calls.get() + 1);
let pass = self.pass.get();
if pass != Pass::Rebuild {
self.violations.borrow_mut().push(pass);
}
}
}
struct VarRowView {
id: u64,
gens: Rc<Cell<u64>>,
log: Rc<RowLog>,
}
struct VarRowWidget {
id: u64,
generation: u64,
log: Rc<RowLog>,
}
impl View<()> for VarRowView {
type Element = VarRowWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> VarRowWidget {
let generation = self.gens.get();
self.gens.set(generation + 1);
VarRowWidget {
id: self.id,
generation,
log: self.log.clone(),
}
}
fn rebuild(
&self,
_prev: &Self,
element: &mut VarRowWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.id = self.id;
ChangeFlags::NONE
}
fn teardown(&self, element: &mut VarRowWidget, _ctx: &mut BuildCtx<'_>) {
element.log.torn.borrow_mut().push(element.id);
}
}
impl Widget for VarRowWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(bc.max().width, var_height(self.id)))
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
self.log
.painted
.borrow_mut()
.insert(self.id, self.generation);
}
}
struct VarRows {
rows: Rc<RefCell<Vec<u64>>>,
gens: Rc<Cell<u64>>,
log: Rc<RowLog>,
probe: Rc<BuildProbe>,
}
impl VarRows {
fn new(ids: Vec<u64>) -> Self {
Self {
rows: Rc::new(RefCell::new(ids)),
gens: Rc::new(Cell::new(0)),
log: Rc::new(RowLog::default()),
probe: Rc::new(BuildProbe::default()),
}
}
fn logic(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
let rows = self.rows.clone();
let gens = self.gens.clone();
let log = self.log.clone();
let probe = self.probe.clone();
move |_: &mut ()| {
let snapshot: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
let (keys, items) = (snapshot.clone(), snapshot.clone());
let (gens, log, probe) = (gens.clone(), log.clone(), probe.clone());
ListView::builder_keyed(
snapshot.len(),
ESTIMATE,
move |i| ChildKey::new(keys[i]),
move |i| {
probe.note();
any::<(), _>(VarRowView {
id: items[i],
gens: gens.clone(),
log: log.clone(),
})
},
)
.estimated_item_extent(ESTIMATE)
}
}
fn logic_rubber_band(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
let mut inner = self.logic();
move |state: &mut ()| inner(state).physics(RubberBand::new())
}
fn true_extent(&self) -> f64 {
self.rows.borrow().iter().copied().map(var_height).sum()
}
}
fn var_frame(
root: &mut RenderRoot<(), ListView<()>>,
logic: &mut impl FnMut(&mut ()) -> ListView<()>,
fx: &VarRows,
ms: f64,
) -> (ChangeFlags, frust_core::PaintOutcome) {
fx.log.painted.borrow_mut().clear();
fx.log.torn.borrow_mut().clear();
fx.probe.pass.set(Pass::Rebuild);
let flags = root.rebuild(logic, &mut ());
fx.probe.pass.set(Pass::Layout);
root.layout(KEYED_WINDOW);
fx.probe.pass.set(Pass::Paint);
let mut sink = NullScene;
let outcome = root.paint(&mut sink, FrameTime::from_nanos((ms * 1_000_000.0) as u64));
fx.probe.pass.set(Pass::Idle);
(flags, outcome)
}
fn settle(
root: &mut RenderRoot<(), ListView<()>>,
logic: &mut impl FnMut(&mut ()) -> ListView<()>,
fx: &VarRows,
first_ms: f64,
limit: usize,
) -> usize {
for n in 0..limit {
let (_, outcome) = var_frame(root, logic, fx, first_ms + 16.0 * n as f64);
if !outcome.needs_frame {
return n + 1;
}
}
panic!("a variable-extent list did not settle within {limit} frames");
}
fn converged_var(
logic: &mut impl FnMut(&mut ()) -> ListView<()>,
fx: &VarRows,
) -> RenderRoot<(), ListView<()>> {
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
settle(&mut root, logic, fx, 0.0, 6);
root
}
fn assert_tiles_and_covers(w: &ListViewWidget) {
assert!(!w.children.is_empty(), "something must be materialized");
for slot in 0..w.children.len() {
let pod = &w.children[slot];
let id = w.keys[slot] as u64;
assert_eq!(
pod.size().height,
var_height(id),
"row {id} laid out at its own intrinsic height"
);
assert_eq!(
pod.origin().y,
w.slot_y[slot] - w.offset(),
"row {id} paints at its content position minus the offset"
);
if slot + 1 < w.children.len() {
assert_eq!(
w.children[slot + 1].origin().y,
pod.origin().y + pod.size().height,
"rows tile: no gap, no overlap between slots {slot} and {}",
slot + 1
);
}
}
let first = w.children[0].origin().y;
let last = w.children.last().expect("non-empty");
let bottom = last.origin().y + last.size().height;
assert!(
first <= 0.0,
"the first materialized row starts at or above the viewport top (got {first})"
);
assert!(
bottom >= w.viewport.height,
"the materialized window reaches the viewport bottom (got {bottom})"
);
}
#[test]
fn variable_rows_tile_at_their_own_measured_heights() {
let fx = VarRows::new((0..60).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
assert_tiles_and_covers(list_widget(&root));
root.event(&mut (), &wheel(700.0));
settle(&mut root, &mut logic, &fx, 100.0, 4);
assert_tiles_and_covers(list_widget(&root));
root.event(&mut (), &wheel(-300.0));
settle(&mut root, &mut logic, &fx, 200.0, 4);
assert_tiles_and_covers(list_widget(&root));
}
#[test]
fn variable_scroll_into_unmeasured_territory_converges_in_one_frame() {
let fx = VarRows::new((0..200).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(2_000.0));
let frames = settle(&mut root, &mut logic, &fx, 100.0, 2);
assert!(
frames <= 2,
"a scroll into unmeasured territory converges within one extra frame (took {frames})"
);
assert_tiles_and_covers(list_widget(&root));
assert!(
fx.probe.violations.borrow().is_empty(),
"the builder ran outside rebuild: {:?}",
fx.probe.violations.borrow()
);
}
#[test]
fn the_builder_never_runs_outside_rebuild_even_under_a_fling() {
let fx = VarRows::new((0..400).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &ev(PointerPhase::Down, 180.0));
var_frame(&mut root, &mut logic, &fx, 100.0);
root.event(&mut (), &ev(PointerPhase::Move, 120.0)); var_frame(&mut root, &mut logic, &fx, 116.0);
root.event(&mut (), &ev(PointerPhase::Move, 40.0)); root.event(&mut (), &ev(PointerPhase::Up, 40.0)); assert!(list_widget(&root).is_flinging());
for k in 0..12 {
var_frame(&mut root, &mut logic, &fx, 132.0 + 16.0 * k as f64);
}
assert!(fx.probe.calls.get() > 0, "the probe saw the builder at all");
assert!(
fx.probe.violations.borrow().is_empty(),
"the builder ran outside rebuild: {:?}",
fx.probe.violations.borrow()
);
assert!(list_widget(&root).offset() > 0.0);
}
#[test]
fn uniform_mode_takes_the_closed_form_path_and_measures_nothing() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut logic = fx.keyed_logic();
let mut root = converged(&mut logic, &fx.log);
root.event(&mut (), &wheel(500.0));
keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
let w = list_widget(&root);
assert!(w.estimated_extent.is_none(), "no estimate was declared");
assert!(!w.is_variable(), "the uniform path is taken");
assert!(w.measured.is_empty(), "the measured cache stays empty");
assert_eq!(w.measured_sum, 0.0);
assert!(w.slot_keys.is_empty() && w.slot_y.is_empty());
assert_eq!(
w.max_offset(),
1000.0 * ROW_EXTENT - w.viewport.height,
"the closed-form scroll extent is unchanged"
);
assert_eq!(
painted_y(w, 10),
10.0 * ROW_EXTENT - w.offset(),
"rows sit at the closed-form position"
);
}
#[test]
fn variable_total_extent_converges_to_the_true_sum() {
use frust_core::accesskit::Role;
let fx = VarRows::new((0..30).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
let estimated_total = list_widget(&root).content_extent();
assert!(
(estimated_total - fx.true_extent()).abs() > 1.0,
"the estimate and the truth differ to begin with"
);
for step in 0..40 {
root.event(&mut (), &wheel(100.0));
var_frame(&mut root, &mut logic, &fx, 100.0 + 16.0 * step as f64);
}
let w = list_widget(&root);
assert_eq!(w.measured.len(), 30, "every row was visited and measured");
assert!(
(w.content_extent() - fx.true_extent()).abs() < 1e-6,
"the total extent converged to Σ true extents: {} vs {}",
w.content_extent(),
fx.true_extent()
);
assert!(
(w.max_offset() - (fx.true_extent() - w.viewport.height)).abs() < 1e-6,
"max_offset tracks the converged extent"
);
let update = root.semantics();
let (_, list) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::List)
.expect("the ListView contributes a Role::List container node");
assert_eq!(
list.scroll_y_max(),
Some(fx.true_extent() - KEYED_WINDOW.height),
"the semantics scroll range tracks it too"
);
}
#[test]
fn variable_cache_evicts_a_removed_row_but_keeps_a_scrolled_away_one() {
let fx = VarRows::new((0..40).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
assert!(
list_widget(&root)
.measured
.contains_key(&ChildKey::new(0u64)),
"row 0 was measured while it was on screen"
);
root.event(&mut (), &wheel(400.0));
settle(&mut root, &mut logic, &fx, 100.0, 4);
let w = list_widget(&root);
assert!(!w.window().contains(&0), "row 0 is no longer materialized");
assert!(
w.measured.contains_key(&ChildKey::new(0u64)),
"a row that only left the window keeps its measurement"
);
let victim = list_widget(&root).window()[3] as u64;
let sum_before = list_widget(&root).measured_sum;
let count_before = list_widget(&root).measured.len();
fx.rows.borrow_mut().retain(|&id| id != victim);
root.rebuild(&mut logic, &mut ());
let w = list_widget(&root);
assert!(
!w.measured.contains_key(&ChildKey::new(victim)),
"the removed row's measurement is evicted"
);
assert_eq!(w.measured.len(), count_before - 1);
assert!(
(w.measured_sum - (sum_before - var_height(victim))).abs() < 1e-9,
"the running sum drops by exactly the evicted extent"
);
}
#[test]
fn variable_full_replace_clears_the_measured_cache() {
let fx = VarRows::new((0..40).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
assert!(!list_widget(&root).measured.is_empty());
*fx.rows.borrow_mut() = (0..40).map(|i| 10_000 + i).collect();
root.rebuild(&mut logic, &mut ());
let w = list_widget(&root);
assert!(w.measured.is_empty(), "a full replace clears the cache");
assert_eq!(w.measured_sum, 0.0);
}
#[test]
fn same_frame_prepend_and_append_leaves_the_measured_cache_intact() {
let fx = VarRows::new((0..60).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(400.0));
settle(&mut root, &mut logic, &fx, 100.0, 4);
let w = list_widget(&root);
let on_screen_ids: Vec<u64> = w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
assert!(!on_screen_ids.is_empty(), "the window materialized rows");
let measured_before: HashMap<u64, f64> = on_screen_ids
.iter()
.map(|&id| {
let m = w.measured[&ChildKey::new(id)];
(id, m.extent)
})
.collect();
assert_eq!(
measured_before.len(),
on_screen_ids.len(),
"every on-screen row was already measured, laid out once by `converged_var`"
);
{
let mut rows = fx.rows.borrow_mut();
rows.splice(0..0, [9_001u64, 9_002, 9_003]);
rows.push(9_050);
}
root.rebuild(&mut logic, &mut ());
let w = list_widget(&root);
let new_window_ids: std::collections::HashSet<u64> =
w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
assert!(
on_screen_ids.iter().any(|id| new_window_ids.contains(id)),
"at least one previously on-screen row is still reconciled into this \
frame's window by exact key — `any_survivor`, the precondition that \
keeps the wholesale clear from firing"
);
let mut survivors = 0;
let mut departed = 0;
for (id, extent_before) in &measured_before {
let entry = w.measured.get(&ChildKey::new(*id));
if new_window_ids.contains(id) {
survivors += 1;
assert!(
entry.is_some(),
"row {id} is still named by this frame's reconciled window \
(an exact key match), so its measurement must survive"
);
assert_eq!(
entry.unwrap().extent,
*extent_before,
"row {id}'s measured height is unchanged"
);
} else {
departed += 1;
assert!(
entry.is_none(),
"row {id} left this frame's reconciled window and neither \
departing-row hypothesis re-explains its new position — the \
conservative eviction this fix restores (re-measured on its \
next visit, never a permanent leak)"
);
}
}
assert!(survivors > 0, "the survivor assertion above is exercised");
assert!(
departed > 0,
"the window-departure assertion above is exercised — if this ever \
stops firing (e.g. a wider window swallows the whole shift), widen \
the mutation so the same-frame-both-sides mismatch still orphans a \
row and this test keeps covering both outcomes"
);
}
#[test]
fn probe_miss_with_survivor_evicts_a_row_that_left_the_data() {
let fx = VarRows::new((0..60).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(400.0));
settle(&mut root, &mut logic, &fx, 100.0, 4);
let w = list_widget(&root);
let on_screen_ids: Vec<u64> = w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
assert!(
on_screen_ids.len() > 1,
"the window materialized more than the victim alone"
);
let victim = on_screen_ids[0];
let offset_before = w.offset();
let count_before = w.measured.len();
let sum_before = w.measured_sum;
assert!(
w.measured.contains_key(&ChildKey::new(victim)),
"the victim was already measured, laid out once by `converged_var`"
);
{
let mut rows = fx.rows.borrow_mut();
rows.splice(0..0, [9_001u64, 9_002, 9_003]);
rows.retain(|&id| id != victim);
rows.push(9_050);
}
root.rebuild(&mut logic, &mut ());
let w = list_widget(&root);
assert_eq!(
w.offset(),
offset_before,
"the anchor probe missed, so no anchor-shift correction ran this \
frame — the offset a genuine prepend/removal would anchor is \
untouched here"
);
let new_window_ids: std::collections::HashSet<u64> =
w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
assert!(
on_screen_ids[1..]
.iter()
.any(|id| new_window_ids.contains(id)),
"at least one other previously on-screen row is still reconciled \
into this frame's window by exact key — `any_survivor`, the \
precondition that keeps the wholesale clear from firing"
);
for id in &on_screen_ids[1..] {
if new_window_ids.contains(id) {
assert!(
w.measured.contains_key(&ChildKey::new(*id)),
"row {id} is still named by this frame's reconciled \
window, so its measurement must survive"
);
}
}
assert!(
!w.measured.contains_key(&ChildKey::new(victim)),
"the removed row's measurement is evicted that same frame, even \
though the anchor probe missed — this fix's whole point"
);
assert!(
w.measured.len() <= count_before,
"the cache never grows across a rebuild-only pass over a removal"
);
assert!(
(w.measured_sum - w.measured.values().map(|m| m.extent).sum::<f64>()).abs() < 1e-9,
"the running sum still matches the surviving entries — \
`measured_sum` and `measured` stay consistent, no leak"
);
assert!(
w.measured_sum < sum_before,
"the sum strictly dropped (at least the victim's own extent left it)"
);
}
#[test]
fn variable_truncation_evicts_measurements_past_the_new_end() {
let fx = VarRows::new((0..40).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(2_000.0));
settle(&mut root, &mut logic, &fx, 100.0, 4);
assert!(list_widget(&root).measured.len() > 10);
fx.rows.borrow_mut().truncate(5);
root.rebuild(&mut logic, &mut ());
let w = list_widget(&root);
assert!(
w.measured.values().all(|m| m.index < 5),
"every entry the shrunk keying cannot produce is gone"
);
assert!(
(w.measured_sum - w.measured.values().map(|m| m.extent).sum::<f64>()).abs() < 1e-9,
"the running sum still matches the surviving entries"
);
}
#[test]
fn variable_prepend_anchors_by_the_estimate() {
let fx = VarRows::new((0..200).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(600.0));
settle(&mut root, &mut logic, &fx, 100.0, 4);
let anchor_id = list_widget(&root).window()[0] as u64;
let offset_before = list_widget(&root).offset();
let y_before = list_widget(&root).children[0].origin().y;
let painted_before = fx.log.painted();
fx.rows.borrow_mut().splice(0..0, [9001, 9002, 9003]);
let (flags, _) = var_frame(&mut root, &mut logic, &fx, 200.0);
let w = list_widget(&root);
assert_eq!(
w.offset(),
offset_before + 3.0 * ESTIMATE,
"the offset absorbs three unmeasured rows' worth of prepend"
);
assert_eq!(
w.children[0].origin().y,
y_before,
"the anchor row paints at the same pixel position"
);
assert_eq!(w.keys[0] as u64, anchor_id + 3, "shifted by three indices");
assert_eq!(
fx.log.painted()[&anchor_id],
painted_before[&anchor_id],
"and kept its own widget"
);
assert!(flags.contains(ChangeFlags::LAYOUT));
}
#[test]
fn an_unchanged_variable_frame_reports_no_layout_once_settled() {
let fx = VarRows::new((0..60).collect());
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(500.0));
settle(&mut root, &mut logic, &fx, 100.0, 4);
var_frame(&mut root, &mut logic, &fx, 200.0);
for k in 0..4 {
let (flags, outcome) = var_frame(&mut root, &mut logic, &fx, 216.0 + 16.0 * k as f64);
assert!(
flags.is_empty(),
"identical variable-extent frame {k} is a no-op reconciliation, got {flags:?}"
);
assert!(
!outcome.needs_frame,
"and it does not keep asking for another frame"
);
}
}
#[test]
fn variable_short_and_empty_lists_are_well_behaved() {
let fx = VarRows::new(vec![0, 1, 2]); let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
let w = list_widget(&root);
assert_eq!(w.window(), &[0, 1, 2]);
assert_eq!(
w.max_offset(),
0.0,
"content shorter than the viewport does not scroll"
);
assert_eq!(w.children[0].origin().y, 0.0);
assert_eq!(
w.children[2].origin().y,
100.0,
"rows 0 and 1 (40 + 60) stack above row 2"
);
fx.rows.borrow_mut().clear();
settle(&mut root, &mut logic, &fx, 100.0, 3);
assert!(list_widget(&root).window().is_empty());
assert_eq!(list_widget(&root).max_offset(), 0.0);
assert!(list_widget(&root).measured.is_empty());
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "requires ListView::builder_keyed")]
fn estimated_item_extent_needs_a_keyed_list() {
let _ = list_view(10, ROW_EXTENT, |i| any::<(), _>(gen_stub(i)))
.estimated_item_extent(ROW_EXTENT);
}
const TALL: f64 = 100.0;
const SHORT: f64 = 40.0;
fn tall_ids(n: usize) -> Vec<u64> {
(0..n).map(|i| i as u64 * 5 + 3).collect()
}
fn short_ids(n: usize) -> Vec<u64> {
(0..n).map(|i| i as u64 * 5).collect()
}
fn anchor_row(w: &ListViewWidget, fx: &VarRows) -> (u64, f64) {
let rows = fx.rows.borrow();
for (slot, pod) in w.children.iter().enumerate() {
if pod.origin().y + pod.size().height > 0.0 {
return (rows[w.keys[slot]], pod.origin().y);
}
}
panic!("no materialized row reaches the viewport top");
}
#[test]
fn measured_correction_holds_the_anchor_when_rows_measure_taller() {
let fx = VarRows::new(tall_ids(300));
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(1_500.0));
var_frame(&mut root, &mut logic, &fx, 100.0);
let w = list_widget(&root);
let pending = w.pending_correction;
let offset_before = w.offset();
let (anchor_id, anchor_y) = anchor_row(w, &fx);
assert!(
pending > 0.0,
"the rows above the viewport top measured taller than assumed"
);
let (flags, outcome) = var_frame(&mut root, &mut logic, &fx, 116.0);
let w = list_widget(&root);
assert_eq!(w.pending_correction, 0.0, "the correction was committed");
assert!(
(w.offset() - (offset_before + pending)).abs() < 1e-9,
"the offset absorbed exactly the measured delta"
);
let (id_after, y_after) = anchor_row(w, &fx);
assert_eq!(id_after, anchor_id, "the same row is still at the top");
assert!(
(y_after - anchor_y).abs() < 1e-9,
"the anchor row's painted y is unchanged across the correction \
({anchor_y} -> {y_after})"
);
assert!(flags.contains(ChangeFlags::LAYOUT));
assert!(!outcome.needs_frame, "one convergence frame, then settled");
}
#[test]
fn measured_correction_holds_the_anchor_when_rows_measure_shorter() {
let fx = VarRows::new(short_ids(300));
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(1_500.0));
var_frame(&mut root, &mut logic, &fx, 100.0);
let w = list_widget(&root);
let pending = w.pending_correction;
let offset_before = w.offset();
let (anchor_id, anchor_y) = anchor_row(w, &fx);
assert_eq!(
w.children[0].size().height,
SHORT,
"the fixture's rows really do measure shorter than the estimate"
);
assert!(
pending < 0.0,
"the rows above the viewport top measured shorter than assumed"
);
let (flags, _) = var_frame(&mut root, &mut logic, &fx, 116.0);
let w = list_widget(&root);
assert_eq!(w.pending_correction, 0.0);
assert!((w.offset() - (offset_before + pending)).abs() < 1e-9);
let (id_after, y_after) = anchor_row(w, &fx);
assert_eq!(id_after, anchor_id);
assert!(
(y_after - anchor_y).abs() < 1e-9,
"the anchor row's painted y is unchanged across the correction \
({anchor_y} -> {y_after})"
);
assert!(flags.contains(ChangeFlags::LAYOUT));
}
#[test]
fn full_replace_after_a_pending_correction_discards_it_instead_of_committing_it() {
let fx = VarRows::new(tall_ids(300));
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(1_500.0));
var_frame(&mut root, &mut logic, &fx, 100.0);
let w = list_widget(&root);
let pending = w.pending_correction;
let offset_before = w.offset();
assert!(
pending > 0.0,
"the rows above the viewport top measured taller than assumed"
);
*fx.rows.borrow_mut() = (0..300).map(|i| 10_000 + i).collect();
root.rebuild(&mut logic, &mut ());
let w = list_widget(&root);
assert_eq!(
w.pending_correction, 0.0,
"the stale correction is discarded, not left pending"
);
assert_eq!(
w.offset(),
offset_before,
"the offset did NOT absorb the stale correction — `item_count` is \
unchanged (300 before and after) and {offset_before} is already \
far inside `max_offset` either way, so the only way this could \
differ is the bug this pins: committing `pending` (giving \
{}) into geometry the full replace made unexplainable",
offset_before + pending
);
assert!(
w.measured.is_empty(),
"a full replace clears the measured cache too (the round-0 fix, \
still intact — `reconcile_keyed`'s wholesale-clear branch)"
);
assert_eq!(w.measured_sum, 0.0);
}
#[test]
fn drag_takeover_seeds_from_raw_offset_not_the_pending_corrected_painted_offset() {
let fx = VarRows::new(tall_ids(300));
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(1_500.0));
var_frame(&mut root, &mut logic, &fx, 100.0);
let w = list_widget(&root);
let pending = w.pending_correction;
assert!(
pending > 0.0,
"the rows above the viewport top measured taller than assumed"
);
let painted_before = w.painted_offset();
let down_y = 100.0;
root.event(&mut (), &ev(PointerPhase::Down, down_y));
let takeover_y = down_y + TOUCH_SLOP + 1.0;
root.event(&mut (), &ev(PointerPhase::Move, takeover_y)); assert!(
list_widget(&root).scrolling,
"the slop crossing took the gesture over"
);
assert_eq!(
list_widget(&root).pending_correction,
pending,
"takeover itself never touches pending_correction"
);
let dy = 30.0;
root.event(&mut (), &ev(PointerPhase::Move, takeover_y + dy));
let w = list_widget(&root);
assert!(
(w.painted_offset() - (painted_before - dy)).abs() < 1e-9,
"painted offset after the second Move equals the pre-takeover \
painted offset minus exactly the finger delta \
({painted_before} - {dy} = {}, got {})",
painted_before - dy,
w.painted_offset()
);
assert_eq!(
w.pending_correction, pending,
"the drag path never commits or otherwise touches pending_correction \
(only a rebuild does — see `ListViewWidget::apply_pending_correction`)"
);
}
#[test]
fn variable_prepend_refines_from_the_estimate_to_the_measurement() {
let fx = VarRows::new(tall_ids(60));
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(TALL / 2.0));
settle(&mut root, &mut logic, &fx, 100.0, 4);
let w = list_widget(&root);
let (anchor_id, anchor_y) = anchor_row(w, &fx);
let offset_before = w.offset();
fx.rows.borrow_mut().splice(0..0, [5_003u64, 5_008u64]);
var_frame(&mut root, &mut logic, &fx, 200.0);
let w = list_widget(&root);
assert!(
(w.offset() - (offset_before + 2.0 * ESTIMATE)).abs() < 1e-9,
"reconciliation shifts by the estimate first"
);
assert!(
(w.pending_correction - 2.0 * (TALL - ESTIMATE)).abs() < 1e-9,
"and layout records what the two prepended rows actually measured"
);
let (id_mid, y_mid) = anchor_row(w, &fx);
assert_eq!(id_mid, anchor_id);
assert!(
(y_mid - anchor_y).abs() < 1e-9,
"the anchor row did not move on the reconciling frame"
);
let (flags, _) = var_frame(&mut root, &mut logic, &fx, 216.0);
let w = list_widget(&root);
assert_eq!(w.pending_correction, 0.0);
assert!(
(w.offset() - (offset_before + 2.0 * TALL)).abs() < 1e-9,
"the offset ends up shifted by the prepended rows' *true* extent"
);
let (id_after, y_after) = anchor_row(w, &fx);
assert_eq!(id_after, anchor_id, "still the same row at the top");
assert!(
(y_after - anchor_y).abs() < 1e-9,
"and it never moved across either step"
);
assert!(flags.contains(ChangeFlags::LAYOUT));
}
fn fling_up_and_pump(
root: &mut RenderRoot<(), ListView<()>>,
logic: &mut impl FnMut(&mut ()) -> ListView<()>,
fx: &VarRows,
first_ms: f64,
) -> Vec<(f64, bool, f64)> {
root.event(&mut (), &ev(PointerPhase::Down, 20.0));
var_frame(root, logic, fx, first_ms);
root.event(&mut (), &ev(PointerPhase::Move, 90.0)); var_frame(root, logic, fx, first_ms + 16.0);
root.event(&mut (), &ev(PointerPhase::Move, 190.0)); root.event(&mut (), &ev(PointerPhase::Up, 190.0)); assert!(
list_widget(root).is_flinging(),
"the release started a fling"
);
let w = list_widget(root);
let mut trace = vec![(w.offset(), true, w.pending_correction)];
for k in 0..300 {
var_frame(root, logic, fx, first_ms + 32.0 + 16.0 * k as f64);
let w = list_widget(root);
trace.push((w.offset(), w.is_flinging(), w.pending_correction));
if !w.is_flinging() {
return trace;
}
}
panic!("the fling never came to rest");
}
fn assert_monotonic_upward(trace: &[(f64, bool, f64)]) {
for pair in trace.windows(2) {
let (before, _, _) = pair[0];
let (after, _, _) = pair[1];
assert!(
after <= before,
"the offset stepped backwards during an upward fling \
({before} -> {after})"
);
}
}
#[test]
fn an_upward_fling_accumulates_the_correction_and_applies_it_at_settle() {
let fx = VarRows::new(tall_ids(300));
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(8_000.0));
settle(&mut root, &mut logic, &fx, 100.0, 4);
let trace = fling_up_and_pump(&mut root, &mut logic, &fx, 200.0);
assert_monotonic_upward(&trace);
assert!(
trace.iter().any(|&(_, _, pending)| pending > 0.0),
"the fling crossed rows measuring taller than the estimate"
);
let (offset_at_rest, _, owed) = *trace.last().expect("the fling stopped");
assert!(
owed > 0.0,
"the whole accumulated sum is still owed when the fling stops"
);
let w = list_widget(&root);
let (anchor_id, anchor_y) = anchor_row(w, &fx);
let (flags, _) = var_frame(&mut root, &mut logic, &fx, 6_000.0);
let w = list_widget(&root);
assert!(
(w.offset() - (offset_at_rest + owed)).abs() < 1e-9,
"settling committed the accumulated correction in full"
);
let (id_after, y_after) = anchor_row(w, &fx);
assert_eq!(id_after, anchor_id, "the same row is still at the top");
assert!(
(y_after - anchor_y).abs() < 1e-9,
"committing an accumulated correction is invisible ({anchor_y} -> {y_after})"
);
assert!(flags.contains(ChangeFlags::LAYOUT));
let mut settled = None;
for k in 0..6 {
let before = list_widget(&root).offset();
var_frame(&mut root, &mut logic, &fx, 6_016.0 + 16.0 * k as f64);
let w = list_widget(&root);
if w.pending_correction == 0.0 && w.offset() == before {
settled = Some(before);
break;
}
}
let settled = settled.expect("the list settles after the fling");
for k in 0..3 {
var_frame(&mut root, &mut logic, &fx, 6_112.0 + 16.0 * k as f64);
assert_eq!(
list_widget(&root).offset(),
settled,
"the offset is at rest, not oscillating"
);
}
}
#[test]
fn an_upward_fling_over_shorter_rows_accumulates_the_other_way() {
let fx = VarRows::new(short_ids(400));
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(8_000.0));
settle(&mut root, &mut logic, &fx, 100.0, 4);
let trace = fling_up_and_pump(&mut root, &mut logic, &fx, 200.0);
assert_monotonic_upward(&trace);
let (offset_at_rest, _, owed) = *trace.last().expect("the fling stopped");
assert!(owed < 0.0, "shorter-than-estimated rows owe a negative sum");
let w = list_widget(&root);
let (anchor_id, anchor_y) = anchor_row(w, &fx);
var_frame(&mut root, &mut logic, &fx, 6_000.0);
let w = list_widget(&root);
assert!((w.offset() - (offset_at_rest + owed)).abs() < 1e-9);
let (id_after, y_after) = anchor_row(w, &fx);
assert_eq!(id_after, anchor_id);
assert!(
(y_after - anchor_y).abs() < 1e-9,
"committing an accumulated correction is invisible ({anchor_y} -> {y_after})"
);
}
#[test]
fn layout_and_paint_never_move_the_offset_while_a_correction_is_pending() {
let fx = VarRows::new(tall_ids(200));
let mut logic = fx.logic();
let mut root = converged_var(&mut logic, &fx);
root.event(&mut (), &wheel(1_500.0));
fx.probe.pass.set(Pass::Rebuild);
root.rebuild(&mut logic, &mut ());
let after_rebuild = list_widget(&root).offset();
fx.probe.pass.set(Pass::Layout);
root.layout(KEYED_WINDOW);
let w = list_widget(&root);
assert!(
w.pending_correction != 0.0,
"layout measured a correction to record"
);
assert_eq!(
w.offset(),
after_rebuild,
"...and did not apply it to the offset"
);
fx.probe.pass.set(Pass::Paint);
let mut sink = NullScene;
let outcome = root.paint(&mut sink, FrameTime::from_nanos(100_000_000));
fx.probe.pass.set(Pass::Idle);
let w = list_widget(&root);
assert!(
!w.is_flinging(),
"no fling is in flight, so the pump is inert"
);
assert_eq!(
w.offset(),
after_rebuild,
"paint moves the offset only via the fling pump"
);
assert!(w.pending_correction != 0.0, "the correction is still owed");
assert!(
outcome.needs_frame,
"and paint asked for the frame that commits it"
);
let (flags, _) = var_frame(&mut root, &mut logic, &fx, 116.0);
let w = list_widget(&root);
assert_eq!(w.pending_correction, 0.0);
assert!(
w.offset() > after_rebuild,
"rebuild is the pass that commits it"
);
assert!(flags.contains(ChangeFlags::LAYOUT));
}
#[test]
fn uniform_and_positional_modes_never_carry_a_correction() {
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut keyed = fx.keyed_logic();
let mut root = converged(&mut keyed, &fx.log);
root.event(&mut (), &wheel(500.0));
keyed_frame(&mut root, &mut keyed, &fx.log, 32.0);
fx.rows.borrow_mut().splice(0..0, [9990, 9980]);
keyed_frame(&mut root, &mut keyed, &fx.log, 48.0);
let w = list_widget(&root);
assert_eq!(
w.pending_correction, 0.0,
"a uniform keyed list measures nothing to correct from"
);
assert_eq!(w.placement_offset(), w.offset());
let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
let mut positional = fx.positional_logic();
let mut root = converged(&mut positional, &fx.log);
root.event(&mut (), &wheel(500.0));
keyed_frame(&mut root, &mut positional, &fx.log, 32.0);
fx.rows.borrow_mut().insert(0, 12_345);
keyed_frame(&mut root, &mut positional, &fx.log, 48.0);
let w = list_widget(&root);
assert_eq!(
w.pending_correction, 0.0,
"a positional list measures nothing to correct from"
);
assert_eq!(w.placement_offset(), w.offset());
}
fn pending_correction_widget(pending: f64) -> ListViewWidget {
let mut w = ListViewWidget::new(1000, ESTIMATE);
w.key_of = Some(Rc::new(|i: usize| ChildKey::new(i as u64)));
w.estimated_extent = Some(ESTIMATE);
w.viewport = Size::new(200.0, 200.0);
w.near_start_threshold = 100.0;
let cb: Rc<dyn Fn(&mut Loads)> = Rc::new(|s: &mut Loads| s.count += 1);
w.on_near_start = Some(crate::authoring::erase_callback(&cb));
w.near_start_armed = true;
w.offset = 50.0;
w.pending_correction = pending;
w
}
#[test]
fn an_uncommitted_correction_decides_no_edge() {
let mut w = pending_correction_widget(500.0);
let mut state = Loads::default();
run_loads(&mut w, &mut state, &wheel(0.0));
assert_eq!(
state.count, 0,
"at a placement of 550 the list is nowhere near the start"
);
assert!(w.apply_pending_correction(), "the commit applies");
assert_eq!(w.offset(), 550.0);
assert_eq!(w.pending_correction, 0.0);
run_loads(&mut w, &mut state, &wheel(0.0));
assert_eq!(
state.count, 0,
"committing a correction is bookkeeping, not scroll motion"
);
run_loads(&mut w, &mut state, &wheel(-500.0));
assert_eq!(w.offset(), 50.0);
assert_eq!(state.count, 1, "a real approach fires the load-older edge");
run_loads(&mut w, &mut state, &wheel(-20.0));
assert_eq!(state.count, 1, "and it stays edge-triggered");
}
#[test]
fn a_live_fling_withholds_the_correction_until_it_stops() {
let mut w = pending_correction_widget(500.0);
w.fling = Some(-40.0);
assert!(
!w.apply_pending_correction(),
"a live fling withholds the commit"
);
assert_eq!(w.pending_correction, 500.0, "the sum keeps accumulating");
for _ in 0..12 {
w.tick(16.0);
}
assert!(!w.is_flinging(), "the fling decayed under FLING_STOP");
assert!(w.apply_pending_correction(), "and the next rebuild commits");
assert_eq!(w.pending_correction, 0.0);
let mut w = pending_correction_widget(500.0);
let mut state = Loads::default();
w.fling = Some(-4_000.0);
assert!(!w.apply_pending_correction());
run_loads(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
assert!(!w.is_flinging(), "a pointer down takes the gesture over");
assert!(w.apply_pending_correction());
assert_eq!(w.pending_correction, 0.0);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "duplicate key")]
fn duplicate_keys_trip_the_debug_assert_on_build() {
let mut logic = |_: &mut ()| {
ListView::builder_keyed(
5,
ROW_EXTENT,
|_| ChildKey::new(7u32),
|i| any::<(), _>(gen_stub(i)),
)
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
frame(&mut root, &mut logic, &mut (), KEYED_WINDOW, 0.0);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "duplicate key")]
fn duplicate_keys_trip_the_debug_assert_on_rebuild() {
let collide = Rc::new(Cell::new(false));
let collide_l = collide.clone();
let mut logic = move |_: &mut ()| {
let collide = collide_l.clone();
ListView::builder_keyed(
5,
ROW_EXTENT,
move |i| ChildKey::new(if collide.get() { 7 } else { i }),
|i| any::<(), _>(gen_stub(i)),
)
};
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
frame(&mut root, &mut logic, &mut (), KEYED_WINDOW, 0.0);
collide.set(true);
frame(&mut root, &mut logic, &mut (), KEYED_WINDOW, 16.0);
}
fn stretch_logic(item_count: usize) -> impl FnMut(&mut ()) -> ListView<()> {
move |_: &mut ()| {
let mut view = list_view(item_count, 50.0, |i| any::<(), _>(gen_stub(i)))
.physics(RubberBand::new());
view.effect = OverscrollEffect::Stretch;
view
}
}
fn drag_20px_past_top(
root: &mut RenderRoot<(), ListView<()>>,
logic: &mut impl FnMut(&mut ()) -> ListView<()>,
state: &mut (),
window: Size,
) {
frame(root, logic, state, window, 0.0);
frame(root, logic, state, window, 16.0);
root.event(state, &ev(PointerPhase::Down, 50.0));
frame(root, logic, state, window, 32.0);
root.event(state, &ev(PointerPhase::Move, 90.0)); frame(root, logic, state, window, 48.0);
root.event(state, &ev(PointerPhase::Move, 110.0));
}
fn painted_transforms(root: &mut RenderRoot<(), ListView<()>>, ms: f64) -> Vec<kurbo::Affine> {
let mut scene = crate::test_support::RecordingScene::default();
root.paint(&mut scene, FrameTime::from_nanos((ms * 1_000_000.0) as u64));
assert_eq!(
scene.transforms.len(),
scene.transform_pops as usize,
"every pushed transform must be popped in the same paint"
);
scene.transforms
}
#[test]
fn stretch_keeps_row_origins_fixed() {
let window = Size::new(200.0, 200.0);
let mut translated: RenderRoot<(), ListView<()>> = RenderRoot::new();
drag_20px_past_top(
&mut translated,
&mut rubber_band_logic(1000),
&mut (),
window,
);
let mut stretched: RenderRoot<(), ListView<()>> = RenderRoot::new();
drag_20px_past_top(&mut stretched, &mut stretch_logic(1000), &mut (), window);
let translate = list_widget(&translated);
let stretch = list_widget(&stretched);
assert_eq!(stretch.offset(), 0.0, "the windowing offset never moves");
assert_eq!(
stretch.overscroll, -10.0,
"the resisted displacement is the physics' answer, effect or not"
);
assert_eq!(translate.overscroll, stretch.overscroll);
assert_eq!(translate.edge_pull, stretch.edge_pull);
assert_eq!(translate.children[0].origin().y, 10.0);
assert_eq!(stretch.children[0].origin().y, 0.0);
assert_eq!(
translate.children[0].origin().y - stretch.children[0].origin().y,
-stretch.overscroll,
"the two fixtures differ by exactly the overscroll displacement"
);
assert!(painted_transforms(&mut translated, 64.0).is_empty());
let transforms = painted_transforms(&mut stretched, 64.0);
assert_eq!(
transforms.len(),
1,
"one stretch transform for the viewport"
);
let [a, b, c, d, e, f] = transforms[0].as_coeffs();
assert_eq!([a, b, c, e], [1.0, 0.0, 0.0, 0.0], "scroll-axis-only scale");
assert!(
(d - (1.0 + crate::scroll::stretch_intensity(-10.0, 200.0))).abs() < 1e-12,
"scale is 1 + the shared curve's intensity: {d}"
);
assert!(
f.abs() < 1e-9,
"a top pull scales about the viewport's top edge: {f}"
);
}
#[test]
fn stretch_settles_back_to_identity() {
let window = Size::new(200.0, 200.0);
let mut logic = stretch_logic(1000);
let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
let mut state = ();
drag_20px_past_top(&mut root, &mut logic, &mut state, window);
assert_eq!(painted_transforms(&mut root, 64.0).len(), 1);
root.event(&mut state, &ev(PointerPhase::Up, 110.0));
assert!(list_widget(&root).settling);
let mut ms = 80.0;
for _ in 0..30 {
frame(&mut root, &mut logic, &mut state, window, ms);
ms += 16.0;
if !list_widget(&root).settling {
break;
}
}
let w = list_widget(&root);
assert!(!w.settling, "the settle terminated");
assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
assert_eq!(w.overscroll, 0.0);
assert_eq!(w.children[0].origin().y, 0.0, "the rows never moved");
assert!(
painted_transforms(&mut root, ms).is_empty(),
"…so paint pushes no transform at all — back to identity"
);
}
fn painted_stretch_at_rest(w: &mut ListViewWidget) -> Option<(f64, f64)> {
let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
let mut scene = crate::test_support::RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert_eq!(
scene.transforms.len(),
scene.transform_pops as usize,
"every pushed transform must be popped in the same paint"
);
let [a, b, c, d, e, f] = scene.transforms.first()?.as_coeffs();
assert_eq!(
[a, b, c, e],
[1.0, 0.0, 0.0, 0.0],
"a scroll-axis-only scale: no x scale, no skew, no x translation"
);
Some((f / (1.0 - d), d))
}
#[test]
fn clamping_fling_into_the_edge_settles_the_stretch() {
use crate::physics::Tolerance;
use crate::physics::simulation::ClampingScrollSimulation;
let mut w = ListViewWidget::new(1000, 50.0);
w.viewport = Size::new(200.0, 200.0);
w.physics = Rc::new(Clamping::new());
w.effect = OverscrollEffect::Stretch;
let bottom = w.max_offset();
dispatch_list(&mut w, &wheel(bottom - 100.0), 0.0);
assert_eq!(w.offset(), bottom - 100.0, "parked 100px short of the end");
dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 16.0); dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
dispatch_list(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
assert_close(release_velocity(&w), 1000.0, 1e-9, "the release velocity");
assert!(w.ballistic.is_some(), "…as a real ballistic curve");
let curve = ClampingScrollSimulation::new(
bottom - 100.0,
1000.0,
ClampingScrollSimulation::DEFAULT_FRICTION,
Tolerance::for_device_pixel_ratio(METRICS_FALLBACK_DPR),
);
assert!(
curve.final_x() > bottom + 100.0,
"the spline must overshoot the extent by >100px: {}",
curve.final_x()
);
let spline_frames = (curve.duration() * 1000.0 / 16.0).ceil();
let mut ms = 100.0;
let mut frames = 0.0;
let mut ballistic_frames = 0.0;
let mut peak = 0.0f64;
loop {
let mut ctx = PaintCtx::for_test(
Point::ZERO,
w.viewport,
FrameTime::from_nanos((ms * 1_000_000.0) as u64),
);
w.pump_fling(&mut ctx);
peak = peak.max(w.edge_pull.abs());
if w.ballistic.is_some() {
ballistic_frames += 1.0;
}
ms += 16.0;
frames += 1.0;
assert!(
frames < 2_000.0,
"the release never came to rest: edge_pull {}",
w.edge_pull
);
if !ctx.needs_frame() {
break;
}
}
assert!(
peak > 1.0,
"the fling must actually reach the edge for this to mean anything: {peak}"
);
assert_close(
w.offset(),
bottom,
1e-9,
"the offset ends pinned at the end",
);
assert_eq!(
w.overscroll, 0.0,
"the windowing offset carries no leftover"
);
assert_eq!(w.edge_pull, 0.0, "a finished fling leaves no pull standing");
assert_eq!(
painted_stretch_at_rest(&mut w),
None,
"…so paint pushes no transform at all — back to identity"
);
assert!(
ballistic_frames < spline_frames / 2.0,
"the pinned curve ran {ballistic_frames} frames of a {spline_frames}-frame spline"
);
}
#[test]
fn an_edit_command_reaches_the_focused_row() {
use crate::text_input;
use frust_core::EditCommand;
struct Field {
value: String,
}
fn logic(state: &mut Field) -> ListView<Field> {
let value = state.value.clone();
list_view(3, 50.0, move |_| {
let value = value.clone();
any::<Field, _>(text_input(value, |s: &mut Field, v: String| {
s.value = v;
}))
})
}
let mut state = Field {
value: "hello".to_string(),
};
let mut root: RenderRoot<Field, ListView<Field>> = RenderRoot::new();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(200.0, 200.0));
root.event(&mut state, &ev(PointerPhase::Down, 10.0));
root.event(&mut state, &ev(PointerPhase::Up, 10.0));
assert!(root.is_focus_active(), "the tap focused the row's field");
root.event(&mut state, &InputEvent::EditCommand(EditCommand::SelectAll));
root.event(&mut state, &InputEvent::EditCommand(EditCommand::Copy));
assert_eq!(
root.take_clipboard_write().as_deref(),
Some("hello"),
"the copy was answered by the focused row, through this router"
);
assert_eq!(state.value, "hello", "a copy edits nothing");
}
}
#[cfg(test)]
mod contact_release_tests {
use super::*;
use std::any::Any;
struct Grab {
opt_in: bool,
}
impl Widget for Grab {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.max()
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let InputEvent::Pointer(p) = event
&& p.phase == PointerPhase::Down
{
ctx.capture_pointer();
if self.opt_in {
ctx.capture_contacts();
}
}
EventResult::Handled
}
}
fn list(opt_in: bool) -> ListViewWidget {
let mut w = ListViewWidget::new(10, 200.0);
w.viewport = Size::new(200.0, 200.0);
let mut pod = ChildPod::new(Box::new(Grab { opt_in }));
pod.layout_child(
&mut LayoutCtx::new(),
&BoxConstraints::tight(Size::new(200.0, 200.0)),
);
w.children = vec![pod];
w.keys = vec![0];
w.sync_child_origins();
w
}
fn pointer(phase: PointerPhase, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(10.0, y),
button: PointerButton::Primary,
})
}
fn released(w: &mut ListViewWidget, event: &InputEvent, t_ms: f64) -> bool {
let mut unit = ();
let sa: &mut dyn Any = &mut unit;
let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
w.event_at(&mut ctx, event, t_ms);
ctx.is_capture_released()
}
#[test]
fn a_takeover_releases_the_row_and_signals_only_an_opt_in() {
for opt_in in [true, false] {
let mut w = list(opt_in);
assert!(!released(&mut w, &pointer(PointerPhase::Down, 100.0), 0.0));
assert!(w.children[0].is_active(), "the row captured");
let signalled = released(&mut w, &pointer(PointerPhase::Move, 60.0), 16.0);
assert!(w.scrolling, "the list took the drag over");
assert!(!w.children[0].is_active(), "and released the row");
assert_eq!(signalled, opt_in, "signalled only when the row opted in");
}
}
}
#[cfg(test)]
mod contact_tests {
use super::*;
use crate::{PanZoomTransform, pan_zoom, pinch_detector};
use frust_core::RenderRoot;
use frust_core::event::{PointerId, ScaleEvent, ScalePhase};
use std::any::Any;
use std::cell::RefCell;
#[derive(Default)]
#[allow(dead_code)]
struct App {
transforms: Vec<PanZoomTransform>,
scales: Vec<ScaleEvent>,
}
type Seen = Rc<RefCell<Vec<(PointerId, PointerPhase)>>>;
#[derive(Clone)]
#[allow(dead_code)]
struct RowContent {
seen: Seen,
grabs: bool,
opt_in: bool,
consumes: bool,
}
#[allow(dead_code)]
struct RowContentWidget(RowContent);
impl View<App> for RowContent {
type Element = RowContentWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> RowContentWidget {
RowContentWidget(self.clone())
}
fn rebuild(
&self,
_p: &Self,
_e: &mut RowContentWidget,
_c: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for RowContentWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(400.0, 200.0))
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
match event {
InputEvent::Pointer(p) => {
self.0.seen.borrow_mut().push((ctx.pointer_id(), p.phase));
if !self.0.grabs {
return EventResult::Ignored;
}
if p.phase == PointerPhase::Down {
ctx.capture_pointer();
if self.0.opt_in {
ctx.capture_contacts();
}
}
EventResult::Handled
}
InputEvent::Scale(_) | InputEvent::Housekeeping if self.0.consumes => {
EventResult::Handled
}
_ => EventResult::Ignored,
}
}
}
#[allow(dead_code)]
fn row_content(grabs: bool, opt_in: bool, consumes: bool) -> (RowContent, Seen) {
let seen = Seen::default();
let view = RowContent {
seen: seen.clone(),
grabs,
opt_in,
consumes,
};
(view, seen)
}
#[allow(dead_code)]
struct NullScene;
impl PaintScene for NullScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
}
#[allow(dead_code)]
fn touch(slot: u32, phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::PointerContact {
pointer_id: PointerId::touch(slot),
event: PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
},
}
}
#[allow(dead_code)]
fn root_over<V: View<App>>(logic: impl Fn() -> V + 'static) -> (RenderRoot<App, V>, App) {
let mut root: RenderRoot<App, V> = RenderRoot::new();
let mut state = App::default();
root.rebuild(&mut move |_: &mut App| logic(), &mut state);
root.layout(Size::new(400.0, 300.0));
(root, state)
}
#[allow(dead_code)]
fn viewport(root: &RenderRoot<App, ListView<App>>) -> &ListViewWidget {
let id = root.root_id().expect("root built");
(root.tree().pod(id).expect("root pod").widget() as &dyn Any)
.downcast_ref::<ListViewWidget>()
.expect("root is a ListViewWidget")
}
#[test]
fn a_pinch_survives_the_claimants_own_travel_past_slop() {
use PointerPhase::{Down, Move};
let (mut root, mut state) = root_over(|| {
let (content, _seen) = row_content(false, false, false);
ListView::builder(10, 200.0, move |_| {
AnyView::new(
pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)),
)
})
});
let mut sink = NullScene;
let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
root.paint(&mut sink, ms(0));
root.event(&mut state, &touch(0, Down, 100.0, 100.0));
root.event(&mut state, &touch(1, Down, 120.0, 100.0));
root.paint(&mut sink, ms(16));
root.event(&mut state, &touch(0, Move, 100.0, 40.0));
root.paint(&mut sink, ms(32));
root.event(&mut state, &touch(0, Move, 100.0, 10.0));
let phases: Vec<ScalePhase> = state.scales.iter().map(|e| e.phase).collect();
assert_eq!(
phases,
[ScalePhase::Begin, ScalePhase::Update],
"the pinch recognizer saw the whole spread"
);
let list = viewport(&root);
assert_eq!(list.offset(), 0.0, "the list never scrolled");
assert!(!list.scrolling, "and never took the gesture over");
assert_eq!(root.pointer_capture_claimant(), Some(PointerId::touch(0)));
assert!(
root.pointer_capture_contacts(),
"the list never cancelled/released the detector's opt-in"
);
}
#[test]
fn a_pinch_over_a_child_owned_press_survives_the_claimants_own_travel() {
use PointerPhase::{Down, Move};
let (mut root, mut state) = root_over(|| {
let (content, _seen) = row_content(true, false, false);
ListView::builder(10, 200.0, move |_| {
AnyView::new(
pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t)),
)
})
});
root.event(&mut state, &touch(0, Down, 100.0, 100.0));
root.event(&mut state, &touch(1, Down, 120.0, 100.0));
root.event(&mut state, &touch(0, Move, 100.0, 40.0));
root.event(&mut state, &touch(1, Move, 180.0, 40.0));
let list = viewport(&root);
assert_eq!(list.offset(), 0.0, "the list never scrolled");
assert!(!list.scrolling, "and never took the gesture over");
assert!(
!state.transforms.is_empty(),
"pan_zoom's own pinch zoomed the view instead"
);
}
#[test]
fn a_single_finger_drag_still_scrolls_through_a_pinch_detector_with_no_second_finger() {
use PointerPhase::{Down, Move};
let (mut root, mut state) = root_over(|| {
let (content, _seen) = row_content(false, false, false);
ListView::builder(10, 200.0, move |_| {
AnyView::new(
pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)),
)
})
});
root.event(&mut state, &touch(0, Down, 100.0, 100.0));
root.event(&mut state, &touch(0, Move, 100.0, 40.0));
assert!(
viewport(&root).scrolling,
"no second finger ever arrived to veto the takeover"
);
root.event(&mut state, &touch(0, Move, 100.0, 10.0));
assert_eq!(viewport(&root).offset(), 30.0);
assert!(state.scales.is_empty(), "never a pinch with one finger");
}
#[test]
fn releasing_the_second_finger_clears_the_veto_and_scrolling_resumes() {
use PointerPhase::{Down, Move, Up};
let (mut root, mut state) = root_over(|| {
let (content, _seen) = row_content(true, false, false);
ListView::builder(10, 200.0, move |_| {
AnyView::new(
pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)),
)
})
});
let mut sink = NullScene;
let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
root.paint(&mut sink, ms(0));
root.event(&mut state, &touch(0, Down, 100.0, 100.0));
root.event(&mut state, &touch(1, Down, 120.0, 100.0));
root.paint(&mut sink, ms(16));
root.event(&mut state, &touch(0, Move, 100.0, 40.0)); assert!(
!viewport(&root).scrolling,
"the pinch still owns the gesture"
);
root.event(&mut state, &touch(1, Up, 120.0, 40.0));
root.event(&mut state, &touch(0, Move, 100.0, 10.0));
assert!(
viewport(&root).scrolling,
"the list resumed scrolling once the pinch ended"
);
}
}