use std::time::Duration;
use teksilo_canvas::{Point, Vec2};
use teksilo_tokens::{GestureProfile, OverscrollStyle, ScrollPhysics, ScrollPhysicsTokens};
use crate::frame_tick_scheduler::{FrameTickScheduler, FrameTickSubscription};
use crate::overscroll::SCROLL_MOVE_EPSILON;
use crate::pointer::{EventTime, ScrollPhase};
use crate::widget_id::WidgetId;
use super::simulation::{
BouncingSimulation, ClampingSimulation, ScrollSimulation, rubber_band_inverse_with,
rubber_band_with,
};
use super::velocity::VelocityTracker;
pub const FLING_FRAME_INTERVAL: Duration = Duration::from_micros(16_667);
pub const DEFAULT_VIEWPORT_EXTENT: f32 = 400.0;
#[non_exhaustive]
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct ScrollStep {
pub offset: Point,
pub overscroll: Vec2,
pub absorbed: (bool, bool),
}
impl ScrollStep {
pub fn absorbed_any(&self) -> bool {
self.absorbed.0 || self.absorbed.1
}
}
#[derive(Debug)]
enum Animation {
Settle,
Fling {
start: EventTime,
x: Option<Box<dyn ScrollSimulation>>,
y: Option<Box<dyn ScrollSimulation>>,
},
}
#[derive(Debug)]
pub struct KineticScroller {
style: OverscrollStyle,
tokens: ScrollPhysicsTokens,
position: Point,
range_x: (f32, f32),
range_y: (f32, f32),
viewport: Vec2,
reduced_motion: bool,
os_momentum: bool,
tracker: VelocityTracker,
animation: Option<Animation>,
last_time: EventTime,
}
impl KineticScroller {
pub fn new(style: OverscrollStyle) -> Self {
Self::with_tokens(style, &ScrollPhysicsTokens::DEFAULT)
}
pub fn with_tokens(style: OverscrollStyle, tokens: &ScrollPhysicsTokens) -> Self {
Self {
style,
tokens: *tokens,
position: Point::ZERO,
range_x: (0.0, 0.0),
range_y: (0.0, 0.0),
viewport: Vec2::new(DEFAULT_VIEWPORT_EXTENT, DEFAULT_VIEWPORT_EXTENT),
reduced_motion: false,
os_momentum: false,
tracker: VelocityTracker::new(),
animation: None,
last_time: EventTime::ZERO,
}
}
pub fn set_range(&mut self, min: f32, max: f32) {
self.set_range_x(min, max);
self.set_range_y(min, max);
}
pub fn set_range_x(&mut self, min: f32, max: f32) {
self.range_x = normalize(min, max);
self.position.x = self.position.x.clamp(self.range_x.0, self.range_x.1);
}
pub fn set_range_y(&mut self, min: f32, max: f32) {
self.range_y = normalize(min, max);
self.position.y = self.position.y.clamp(self.range_y.0, self.range_y.1);
}
pub fn set_viewport(&mut self, size: Vec2) {
self.viewport = Vec2::new(
size.x.max(SCROLL_MOVE_EPSILON),
size.y.max(SCROLL_MOVE_EPSILON),
);
}
pub fn set_reduced_motion(&mut self, reduced: bool) {
self.reduced_motion = reduced;
if reduced && matches!(self.animation, Some(Animation::Fling { .. })) {
self.animation = Some(Animation::Settle);
}
}
pub fn reduced_motion(&self) -> bool {
self.reduced_motion
}
pub fn set_os_momentum(&mut self, os_momentum: bool) {
self.os_momentum = os_momentum;
}
pub fn os_momentum(&self) -> bool {
self.os_momentum
}
pub fn offset(&self) -> Point {
Point::new(
self.position.x.clamp(self.range_x.0, self.range_x.1),
self.position.y.clamp(self.range_y.0, self.range_y.1),
)
}
pub fn set_offset(&mut self, offset: Point) {
self.position = Point::new(
offset.x.clamp(self.range_x.0, self.range_x.1),
offset.y.clamp(self.range_y.0, self.range_y.1),
);
}
pub fn pointer_velocity(&self) -> Vec2 {
self.tracker.velocity()
}
pub fn pan(&mut self, time: EventTime, position: Point, delta: Vec2) -> ScrollStep {
self.tracker.add(time, position);
self.last_time = time;
self.animation = None;
let before = self.position;
self.position.x = self.drag_axis(self.position.x, delta.x, self.range_x, self.viewport.x);
self.position.y = self.drag_axis(self.position.y, delta.y, self.range_y, self.viewport.y);
self.step_from(before)
}
fn drag_axis(&self, current: f32, delta: f32, range: (f32, f32), extent: f32) -> f32 {
if !delta.is_finite() {
return current;
}
let (min, max) = range;
let clamped = current.clamp(min, max);
if self.hard_clamps() {
return (clamped + delta).clamp(min, max);
}
let factor = self.tokens.rubber_band_factor;
let raw_before = rubber_band_inverse_with(current - clamped, extent, factor);
let virtual_position = clamped + raw_before + delta;
if virtual_position < min {
min + rubber_band_with(virtual_position - min, extent, factor)
} else if virtual_position > max {
max + rubber_band_with(virtual_position - max, extent, factor)
} else {
virtual_position
}
}
fn hard_clamps(&self) -> bool {
self.reduced_motion || self.style == OverscrollStyle::Clamp
}
pub fn fling(&mut self, velocity: Vec2, profile: &GestureProfile) -> bool {
self.fling_at(self.last_time, velocity, profile)
}
pub fn fling_at(&mut self, time: EventTime, velocity: Vec2, profile: &GestureProfile) -> bool {
let magnitude = (velocity.x * velocity.x + velocity.y * velocity.y).sqrt();
if !magnitude.is_finite() || magnitude < profile.min_fling_velocity {
return false;
}
let velocity = if magnitude > profile.max_fling_velocity && magnitude > 0.0 {
let scale = profile.max_fling_velocity / magnitude;
Vec2::new(velocity.x * scale, velocity.y * scale)
} else {
velocity
};
self.last_time = time;
if self.reduced_motion {
self.animation = Some(Animation::Settle);
return true;
}
self.animation = Some(Animation::Fling {
start: time,
x: self.axis_simulation(self.position.x, velocity.x, self.range_x),
y: self.axis_simulation(self.position.y, velocity.y, self.range_y),
});
true
}
pub fn should_fling_for_phase(&self, phase: ScrollPhase) -> bool {
match phase {
ScrollPhase::Ended => !self.os_momentum,
ScrollPhase::Fling => true,
_ => false,
}
}
pub fn fling_for_phase(
&mut self,
phase: ScrollPhase,
velocity: Vec2,
profile: &GestureProfile,
) -> bool {
self.should_fling_for_phase(phase) && self.fling(velocity, profile)
}
fn axis_simulation(
&self,
position: f32,
velocity: f32,
range: (f32, f32),
) -> Option<Box<dyn ScrollSimulation>> {
let (min, max) = range;
let in_range = position >= min && position <= max;
if velocity == 0.0 && in_range {
return None;
}
match self.style {
OverscrollStyle::Clamp => Some(Box::new(ClampingSimulation::new(
position,
velocity,
min,
max,
&self.tokens,
))),
OverscrollStyle::RubberBand => Some(Box::new(BouncingSimulation::with_tokens(
position,
velocity,
min,
max,
min,
max,
&self.tokens,
))),
}
}
pub fn tick(&mut self, now: EventTime) -> Option<ScrollStep> {
let before = self.position;
let finished = match self.animation.as_ref() {
None => return None,
Some(Animation::Settle) => {
self.position = self.offset();
true
}
Some(Animation::Fling { start, x, y }) => {
let t = now.saturating_since(*start);
let mut done = true;
if let Some(sim) = x {
self.position.x = sim.position(t);
done &= sim.is_done(t);
}
if let Some(sim) = y {
self.position.y = sim.position(t);
done &= sim.is_done(t);
}
done
}
};
if finished {
self.position = self.offset();
self.animation = None;
}
self.last_time = now;
Some(self.step_from(before))
}
pub fn stop(&mut self) {
self.animation = None;
}
pub fn is_animating(&self) -> bool {
self.animation.is_some()
}
pub fn next_deadline(&self) -> Option<EventTime> {
self.animation
.as_ref()
.and_then(|_| self.last_time.checked_add(FLING_FRAME_INTERVAL))
}
fn step_from(&self, before: Point) -> ScrollStep {
let offset = self.offset();
ScrollStep {
offset,
overscroll: Vec2::new(self.position.x - offset.x, self.position.y - offset.y),
absorbed: (
axis_absorbed(before.x, self.position.x),
axis_absorbed(before.y, self.position.y),
),
}
}
}
fn axis_absorbed(before: f32, after: f32) -> bool {
(after - before).abs() > SCROLL_MOVE_EPSILON
}
fn normalize(min: f32, max: f32) -> (f32, f32) {
if !min.is_finite() || !max.is_finite() {
return (0.0, 0.0);
}
if min > max { (min, min) } else { (min, max) }
}
pub fn resolve_platform_physics(physics: ScrollPhysics) -> ScrollPhysics {
match physics {
ScrollPhysics::Platform => {
if cfg!(any(target_os = "macos", target_os = "ios")) {
ScrollPhysics::Bouncing
} else {
ScrollPhysics::Clamping
}
}
other => other,
}
}
#[derive(Debug)]
struct FlingEntry {
id: WidgetId,
start: EventTime,
last: EventTime,
last_position: Vec2,
x: Box<dyn ScrollSimulation>,
y: Box<dyn ScrollSimulation>,
_tick: Option<FrameTickSubscription>,
}
#[derive(Debug)]
pub struct FlingDriver {
entries: Vec<FlingEntry>,
tokens: ScrollPhysicsTokens,
scheduler: Option<FrameTickScheduler>,
reduced_motion: bool,
}
impl Default for FlingDriver {
fn default() -> Self {
Self::new()
}
}
impl FlingDriver {
pub fn new() -> Self {
Self {
entries: Vec::new(),
tokens: ScrollPhysicsTokens::DEFAULT,
scheduler: None,
reduced_motion: false,
}
}
pub fn with_scheduler(scheduler: FrameTickScheduler) -> Self {
Self {
scheduler: Some(scheduler),
..Self::new()
}
}
pub fn set_tokens(&mut self, tokens: &ScrollPhysicsTokens) {
self.tokens = *tokens;
}
pub fn set_reduced_motion(&mut self, reduced: bool) {
self.reduced_motion = reduced;
if reduced {
self.entries.clear();
}
}
pub fn start(
&mut self,
target: WidgetId,
velocity: Vec2,
physics: ScrollPhysics,
at: EventTime,
) {
self.stop(target);
if self.reduced_motion
|| !velocity.x.is_finite()
|| !velocity.y.is_finite()
|| (velocity.x == 0.0 && velocity.y == 0.0)
{
return;
}
let physics = resolve_platform_physics(physics);
let make = |v: f32| -> Box<dyn ScrollSimulation> {
match physics {
ScrollPhysics::Bouncing => Box::new(BouncingSimulation::with_tokens(
0.0,
v,
f32::MIN / 4.0,
f32::MAX / 4.0,
f32::MIN / 4.0,
f32::MAX / 4.0,
&self.tokens,
)),
ScrollPhysics::Clamping | ScrollPhysics::Platform => Box::new(
ClampingSimulation::new(0.0, v, f32::MIN / 4.0, f32::MAX / 4.0, &self.tokens),
),
}
};
self.entries.push(FlingEntry {
id: target,
start: at,
last: at,
last_position: Vec2::ZERO,
x: make(velocity.x),
y: make(velocity.y),
_tick: self.scheduler.as_ref().map(|s| s.subscribe(target)),
});
}
pub fn tick(&mut self, now: EventTime) -> Vec<(WidgetId, Vec2)> {
let mut out = Vec::new();
self.entries.retain_mut(|entry| {
let t = now.saturating_since(entry.start);
let position = Vec2::new(entry.x.position(t), entry.y.position(t));
let delta = Vec2::new(
position.x - entry.last_position.x,
position.y - entry.last_position.y,
);
entry.last_position = position;
entry.last = now;
if delta.x.abs() > SCROLL_MOVE_EPSILON || delta.y.abs() > SCROLL_MOVE_EPSILON {
out.push((entry.id, delta));
}
!(entry.x.is_done(t) && entry.y.is_done(t))
});
out
}
pub fn stop(&mut self, target: WidgetId) {
self.entries.retain(|e| e.id != target);
}
pub fn stop_all(&mut self) {
self.entries.clear();
}
pub fn is_flinging(&self, target: WidgetId) -> bool {
self.entries.iter().any(|e| e.id == target)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn next_deadline(&self) -> Option<EventTime> {
self.entries
.iter()
.filter_map(|e| e.last.checked_add(FLING_FRAME_INTERVAL))
.min()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_widgets::FillWidget;
use crate::widget_tree::WidgetTree;
fn touch() -> GestureProfile {
GestureProfile::TOUCH
}
fn scroller(style: OverscrollStyle) -> KineticScroller {
let mut s = KineticScroller::new(style);
s.set_range_y(0.0, 1000.0);
s.set_range_x(0.0, 0.0);
s.set_viewport(Vec2::new(400.0, 600.0));
s
}
fn two_ids() -> (WidgetId, WidgetId) {
let mut tree = WidgetTree::new();
(tree.add(FillWidget::new()), tree.add(FillWidget::new()))
}
#[test]
fn a_pan_inside_the_range_moves_one_for_one() {
let mut s = scroller(OverscrollStyle::Clamp);
let step = s.pan(EventTime::ZERO, Point::ZERO, Vec2::new(0.0, 120.0));
assert_eq!(step.offset.y, 120.0);
assert_eq!(step.overscroll, Vec2::ZERO);
assert_eq!(step.absorbed, (false, true), "only y was asked for");
}
#[test]
fn a_clamped_axis_at_the_boundary_absorbs_nothing() {
let mut s = scroller(OverscrollStyle::Clamp);
s.set_offset(Point::new(0.0, 1000.0));
let step = s.pan(EventTime::ZERO, Point::ZERO, Vec2::new(0.0, 50.0));
assert_eq!(step.offset.y, 1000.0);
assert!(!step.absorbed.1, "there was nowhere to go");
assert!(!step.absorbed_any());
}
#[test]
fn a_rubber_band_axis_absorbs_at_the_boundary_with_falling_gain() {
let mut s = scroller(OverscrollStyle::RubberBand);
s.set_offset(Point::new(0.0, 1000.0));
let first = s.pan(EventTime::from_millis(0), Point::ZERO, Vec2::new(0.0, 50.0));
assert_eq!(first.offset.y, 1000.0, "the offset stays at the bound");
assert!(first.overscroll.y > 0.0, "the overscroll takes the travel");
assert!(
first.overscroll.y < 50.0,
"and resists: {} of 50",
first.overscroll.y
);
assert!(first.absorbed.1);
let second = s.pan(
EventTime::from_millis(16),
Point::ZERO,
Vec2::new(0.0, 50.0),
);
let first_gain = first.overscroll.y;
let second_gain = second.overscroll.y - first.overscroll.y;
assert!(
second_gain < first_gain,
"gain must fall: {first_gain} then {second_gain}"
);
}
#[test]
fn a_reversed_drag_retraces_the_rubber_band() {
let mut s = scroller(OverscrollStyle::RubberBand);
s.set_offset(Point::new(0.0, 1000.0));
let out = s.pan(EventTime::from_millis(0), Point::ZERO, Vec2::new(0.0, 80.0));
let back = s.pan(
EventTime::from_millis(16),
Point::ZERO,
Vec2::new(0.0, -80.0),
);
assert!(
back.overscroll.y.abs() < 0.5,
"returned to {} rather than 0 after retracing {}",
back.overscroll.y,
out.overscroll.y
);
assert!(
(back.offset.y - 1000.0).abs() < 1e-3,
"the offset stayed at the bound, got {}",
back.offset.y
);
}
#[test]
fn reduced_motion_hard_clamps_the_rubber_band() {
let mut s = scroller(OverscrollStyle::RubberBand);
s.set_reduced_motion(true);
s.set_offset(Point::new(0.0, 1000.0));
let step = s.pan(EventTime::ZERO, Point::ZERO, Vec2::new(0.0, 200.0));
assert_eq!(
step.overscroll,
Vec2::ZERO,
"no rubber band under reduced motion"
);
assert_eq!(step.offset.y, 1000.0);
}
#[test]
fn a_pan_cancels_a_running_coast() {
let mut s = scroller(OverscrollStyle::Clamp);
s.pan(EventTime::from_millis(0), Point::ZERO, Vec2::new(0.0, 10.0));
assert!(s.fling(Vec2::new(0.0, 2000.0), &touch()));
assert!(s.is_animating());
s.pan(EventTime::from_millis(20), Point::ZERO, Vec2::new(0.0, 5.0));
assert!(!s.is_animating(), "grabbing the content stops the coast");
}
#[test]
fn below_the_minimum_velocity_a_fling_starts_nothing() {
let profile = touch();
let mut s = scroller(OverscrollStyle::Clamp);
s.set_offset(Point::new(0.0, 100.0));
let below = profile.min_fling_velocity - 1.0;
assert!(!s.fling(Vec2::new(0.0, below), &profile));
assert!(!s.is_animating(), "and starts no animation");
assert_eq!(s.next_deadline(), None, "and asks for no wake-up");
assert_eq!(s.offset().y, 100.0, "and does not move the content");
assert!(
s.fling(Vec2::new(0.0, profile.min_fling_velocity), &profile),
"exactly at the floor is a fling"
);
}
#[test]
fn a_fling_above_the_maximum_velocity_is_clamped_not_refused() {
let profile = touch();
let mut fast = scroller(OverscrollStyle::Clamp);
let mut capped = scroller(OverscrollStyle::Clamp);
assert!(fast.fling(Vec2::new(0.0, profile.max_fling_velocity * 10.0), &profile));
assert!(capped.fling(Vec2::new(0.0, profile.max_fling_velocity), &profile));
let at = EventTime::from_millis(500);
let a = fast.tick(at).unwrap().offset.y;
let b = capped.tick(at).unwrap().offset.y;
assert!(
(a - b).abs() < 1.0,
"a 10x over-speed release must behave exactly like the cap: {a} vs {b}"
);
}
#[test]
fn reduced_motion_settles_in_one_tick() {
let mut s = scroller(OverscrollStyle::Clamp);
s.set_reduced_motion(true);
s.set_offset(Point::new(0.0, 400.0));
assert!(
s.fling(Vec2::new(0.0, 6000.0), &touch()),
"the release is accepted"
);
assert!(s.is_animating(), "so there is exactly one step pending");
let step = s.tick(EventTime::from_millis(16)).expect("one step");
assert_eq!(step.offset.y, 400.0, "settled where the finger left it");
assert!(!s.is_animating(), "and it is over after that single tick");
assert_eq!(s.tick(EventTime::from_millis(32)), None);
}
#[test]
fn reduced_motion_collapses_a_coast_already_in_flight() {
let mut s = scroller(OverscrollStyle::Clamp);
assert!(s.fling(Vec2::new(0.0, 4000.0), &touch()));
s.tick(EventTime::from_millis(16));
s.set_reduced_motion(true);
s.tick(EventTime::from_millis(32));
assert!(!s.is_animating());
}
#[test]
fn a_clamping_coast_runs_then_stops_at_the_boundary() {
let mut s = scroller(OverscrollStyle::Clamp);
assert!(s.fling(Vec2::new(0.0, 4000.0), &touch()));
let mut last = 0.0;
for frame in 1..200 {
let Some(step) = s.tick(EventTime::from_millis(frame * 16)) else {
break;
};
assert!(step.offset.y >= last - 1e-3, "the coast reversed");
assert!(step.offset.y <= 1000.0, "the coast passed the boundary");
assert_eq!(step.overscroll, Vec2::ZERO, "clamping never overscrolls");
last = step.offset.y;
}
assert!(!s.is_animating(), "the coast must end");
assert_eq!(s.offset().y, 1000.0, "resting on the boundary");
}
#[test]
fn a_bouncing_coast_overshoots_then_rests_on_the_boundary() {
let mut s = scroller(OverscrollStyle::RubberBand);
s.set_offset(Point::new(0.0, 900.0));
assert!(s.fling(Vec2::new(0.0, 3000.0), &touch()));
let mut peak: f32 = 0.0;
for frame in 1..400 {
let Some(step) = s.tick(EventTime::from_millis(frame * 16)) else {
break;
};
peak = peak.max(step.overscroll.y);
}
assert!(
peak > 0.0,
"a bouncing coast must overshoot, peaked at {peak}"
);
assert!(!s.is_animating());
assert_eq!(s.offset().y, 1000.0);
}
#[test]
fn the_deadline_is_one_frame_out_and_only_while_animating() {
let mut s = scroller(OverscrollStyle::Clamp);
assert_eq!(s.next_deadline(), None);
s.pan(
EventTime::from_millis(100),
Point::ZERO,
Vec2::new(0.0, 5.0),
);
assert!(s.fling(Vec2::new(0.0, 3000.0), &touch()));
assert_eq!(
s.next_deadline(),
EventTime::from_millis(100).checked_add(FLING_FRAME_INTERVAL)
);
s.stop();
assert_eq!(s.next_deadline(), None);
}
#[test]
fn the_phase_guard_refuses_every_phase_the_host_is_already_animating() {
let mut s = scroller(OverscrollStyle::Clamp);
s.set_os_momentum(true);
for phase in [
ScrollPhase::Discrete,
ScrollPhase::Began,
ScrollPhase::Changed,
ScrollPhase::Ended,
ScrollPhase::Momentum,
ScrollPhase::MomentumEnded,
ScrollPhase::Cancelled,
] {
assert!(
!s.should_fling_for_phase(phase),
"{phase:?} must not fling on a host with its own momentum"
);
}
assert!(s.should_fling_for_phase(ScrollPhase::Fling));
s.set_os_momentum(false);
assert!(s.should_fling_for_phase(ScrollPhase::Ended));
assert!(s.should_fling_for_phase(ScrollPhase::Fling));
for phase in [
ScrollPhase::Discrete,
ScrollPhase::Began,
ScrollPhase::Changed,
ScrollPhase::Momentum,
ScrollPhase::MomentumEnded,
ScrollPhase::Cancelled,
] {
assert!(
!s.should_fling_for_phase(phase),
"{phase:?} is not a release"
);
}
}
#[test]
fn fling_for_phase_starts_nothing_on_a_momentum_delta() {
let mut s = scroller(OverscrollStyle::Clamp);
s.set_os_momentum(true);
assert!(!s.fling_for_phase(ScrollPhase::Momentum, Vec2::new(0.0, 4000.0), &touch()));
assert!(!s.is_animating(), "the classic double-momentum bug");
assert!(!s.fling_for_phase(ScrollPhase::Ended, Vec2::new(0.0, 4000.0), &touch()));
assert!(
!s.is_animating(),
"the OS is about to send its own momentum"
);
assert!(s.fling_for_phase(ScrollPhase::Fling, Vec2::new(0.0, 4000.0), &touch()));
assert!(s.is_animating(), "an explicit fling hands the coast to us");
}
#[test]
fn panning_feeds_the_velocity_tracker() {
let mut s = scroller(OverscrollStyle::Clamp);
for i in 0..8u64 {
let y = i as f32 * -6.0;
s.pan(
EventTime::from_millis(i * 10),
Point::new(0.0, y),
Vec2::new(0.0, -y),
);
}
let v = s.pointer_velocity();
assert!(
(v.y + 600.0).abs() < 10.0,
"the pointer was moving at -600 dp/s, tracker says {}",
v.y
);
}
#[test]
fn the_driver_hands_out_deltas_until_the_coast_is_spent() {
let (a, _) = two_ids();
let mut d = FlingDriver::new();
d.start(
a,
Vec2::new(0.0, 3000.0),
ScrollPhysics::Clamping,
EventTime::ZERO,
);
assert!(d.is_flinging(a));
let mut total = 0.0f32;
let mut frames = 0;
for frame in 1..400 {
let out = d.tick(EventTime::from_millis(frame * 16));
for (id, delta) in out {
assert_eq!(id, a);
assert!(delta.y >= -1e-3, "a coast must not reverse");
total += delta.y;
frames += 1;
}
if d.is_empty() {
break;
}
}
assert!(
frames > 10,
"the coast should last more than {frames} frames"
);
assert!(d.is_empty(), "and must end");
let expected =
super::super::simulation::fling_distance(3000.0, &ScrollPhysicsTokens::DEFAULT);
assert!(
(total - expected).abs() < 2.0,
"integrated {total}, simulation says {expected}"
);
}
#[test]
fn stopping_a_target_ends_only_its_coast() {
let (a, b) = two_ids();
let mut d = FlingDriver::new();
d.start(
a,
Vec2::new(0.0, 3000.0),
ScrollPhysics::Clamping,
EventTime::ZERO,
);
d.start(
b,
Vec2::new(0.0, 3000.0),
ScrollPhysics::Clamping,
EventTime::ZERO,
);
assert_eq!(d.len(), 2);
d.stop(a);
assert!(!d.is_flinging(a));
assert!(d.is_flinging(b));
d.stop(a);
assert_eq!(d.len(), 1, "stopping twice is idempotent");
d.stop_all();
assert!(d.is_empty());
}
#[test]
fn restarting_a_target_replaces_its_coast() {
let (a, _) = two_ids();
let mut d = FlingDriver::new();
d.start(
a,
Vec2::new(0.0, 3000.0),
ScrollPhysics::Clamping,
EventTime::ZERO,
);
d.start(
a,
Vec2::new(0.0, 1000.0),
ScrollPhysics::Clamping,
EventTime::ZERO,
);
assert_eq!(d.len(), 1, "one coast per target");
}
#[test]
fn a_zero_velocity_start_is_a_no_op() {
let (a, _) = two_ids();
let mut d = FlingDriver::new();
d.start(a, Vec2::ZERO, ScrollPhysics::Clamping, EventTime::ZERO);
assert!(d.is_empty());
d.start(
a,
Vec2::new(f32::NAN, 0.0),
ScrollPhysics::Clamping,
EventTime::ZERO,
);
assert!(d.is_empty());
}
#[test]
fn reduced_motion_stops_the_driver_entirely() {
let (a, _) = two_ids();
let mut d = FlingDriver::new();
d.start(
a,
Vec2::new(0.0, 3000.0),
ScrollPhysics::Clamping,
EventTime::ZERO,
);
d.set_reduced_motion(true);
assert!(d.is_empty(), "a running coast is dropped");
d.start(
a,
Vec2::new(0.0, 3000.0),
ScrollPhysics::Clamping,
EventTime::ZERO,
);
assert!(d.is_empty(), "and no new one starts");
assert_eq!(d.next_deadline(), None);
}
#[test]
fn the_driver_deadline_is_the_earliest_across_its_coasts() {
let (a, b) = two_ids();
let mut d = FlingDriver::new();
assert_eq!(d.next_deadline(), None);
d.start(
a,
Vec2::new(0.0, 3000.0),
ScrollPhysics::Clamping,
EventTime::from_millis(10),
);
d.start(
b,
Vec2::new(0.0, 3000.0),
ScrollPhysics::Clamping,
EventTime::from_millis(40),
);
assert_eq!(
d.next_deadline(),
EventTime::from_millis(10).checked_add(FLING_FRAME_INTERVAL)
);
}
#[test]
fn a_coast_holds_a_frame_tick_subscription_for_its_target() {
let (a, _) = two_ids();
let scheduler = FrameTickScheduler::new();
let mut d = FlingDriver::with_scheduler(scheduler.clone());
assert_eq!(scheduler.subscriber_count(), 0);
d.start(
a,
Vec2::new(0.0, 3000.0),
ScrollPhysics::Clamping,
EventTime::ZERO,
);
assert_eq!(scheduler.subscriber_count(), 1);
d.stop(a);
assert_eq!(
scheduler.subscriber_count(),
0,
"stopping releases the wake"
);
}
#[test]
fn platform_physics_resolves_to_a_concrete_family() {
assert_ne!(
resolve_platform_physics(ScrollPhysics::Platform),
ScrollPhysics::Platform
);
assert_eq!(
resolve_platform_physics(ScrollPhysics::Clamping),
ScrollPhysics::Clamping
);
assert_eq!(
resolve_platform_physics(ScrollPhysics::Bouncing),
ScrollPhysics::Bouncing
);
let expected = if cfg!(any(target_os = "macos", target_os = "ios")) {
ScrollPhysics::Bouncing
} else {
ScrollPhysics::Clamping
};
assert_eq!(resolve_platform_physics(ScrollPhysics::Platform), expected);
}
}