use std::cell::{Cell, RefCell};
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, any, fling_decay, fling_displacement,
};
use kurbo::{Affine, Point, Rect, Size};
use crate::authoring::{ErasedArgCallback, ErasedCallback, presses};
use crate::physics::effect::OverscrollEffect;
use crate::physics::{
MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR, ScrollMetrics, ScrollPhysics, Simulation,
default_overscroll_effect, default_physics,
};
pub(crate) const OVERSCROLL_RESISTANCE: f64 = 0.5;
pub(crate) const REFRESH_TRIGGER_PX: f64 = 64.0;
pub(crate) const SETTLE_DECAY: f64 = 0.988;
pub(crate) const SETTLE_STOP_PX: f64 = 0.5;
pub(crate) const METRICS_FALLBACK_DPR: f64 = 1.0;
pub(crate) fn crossed_refresh_trigger(overscroll: f64) -> bool {
overscroll < -REFRESH_TRIGGER_PX
}
pub(crate) const STRETCH_INTENSITY: f64 = 0.016;
pub(crate) const STRETCH_EXP_SCALAR: f64 = std::f64::consts::E / 0.33;
pub(crate) fn stretch_intensity(edge_pull: f64, viewport_dimension: f64) -> f64 {
if viewport_dimension <= 0.0 {
return 0.0;
}
let x = (edge_pull.abs() / viewport_dimension).clamp(0.0, 1.0);
STRETCH_INTENSITY * x + STRETCH_INTENSITY * (1.0 - (-x * STRETCH_EXP_SCALAR).exp())
}
pub(crate) fn stretch_about_edge(origin: Point, size: Size, edge_pull: f64) -> Option<Affine> {
let intensity = stretch_intensity(edge_pull, size.height);
if intensity == 0.0 {
return None;
}
let anchor = if edge_pull < 0.0 {
origin.y
} else {
origin.y + size.height
};
Some(
Affine::translate((0.0, anchor))
* Affine::scale_non_uniform(1.0, 1.0 + intensity)
* Affine::translate((0.0, -anchor)),
)
}
const CLAIM_PROBE_PX: f64 = 1.0;
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct InnerScrollState {
pub(crate) registered: bool,
pub(crate) can_consume_down_drag: bool,
pub(crate) can_consume_up_drag: bool,
}
impl InnerScrollState {
pub(crate) fn defers(self, dy: f64) -> bool {
if !self.registered {
return false;
}
if dy > 0.0 {
self.can_consume_down_drag
} else {
self.can_consume_up_drag
}
}
}
thread_local! {
static SCROLL_CLAIM: RefCell<Vec<Rc<Cell<InnerScrollState>>>> =
const { RefCell::new(Vec::new()) };
}
struct ScrollClaimGuard;
impl Drop for ScrollClaimGuard {
fn drop(&mut self) {
SCROLL_CLAIM.with(|stack| {
stack.borrow_mut().pop();
});
}
}
pub(crate) fn with_scroll_claim<R>(claim: &Rc<Cell<InnerScrollState>>, f: impl FnOnce() -> R) -> R {
SCROLL_CLAIM.with(|stack| stack.borrow_mut().push(Rc::clone(claim)));
let _guard = ScrollClaimGuard;
f()
}
pub(crate) fn ambient_scroll_claim() -> Option<Rc<Cell<InnerScrollState>>> {
SCROLL_CLAIM.with(|stack| stack.borrow().last().cloned())
}
thread_local! {
static MULTI_CONTACT_VETO: RefCell<Vec<Rc<Cell<bool>>>> = const { RefCell::new(Vec::new()) };
}
struct MultiContactVetoGuard;
impl Drop for MultiContactVetoGuard {
fn drop(&mut self) {
MULTI_CONTACT_VETO.with(|stack| {
stack.borrow_mut().pop();
});
}
}
pub(crate) fn with_scroll_veto<R>(veto: &Rc<Cell<bool>>, f: impl FnOnce() -> R) -> R {
MULTI_CONTACT_VETO.with(|stack| stack.borrow_mut().push(Rc::clone(veto)));
let _guard = MultiContactVetoGuard;
f()
}
pub(crate) fn ambient_scroll_veto() -> Option<Rc<Cell<bool>>> {
MULTI_CONTACT_VETO.with(|stack| stack.borrow().last().cloned())
}
pub(crate) fn inner_claim_state(
physics: &dyn ScrollPhysics,
metrics: &ScrollMetrics,
) -> InnerScrollState {
let leading_free = physics
.apply_boundary_conditions(metrics, metrics.min_scroll_extent - CLAIM_PROBE_PX)
== 0.0;
let trailing_free = physics
.apply_boundary_conditions(metrics, metrics.max_scroll_extent + CLAIM_PROBE_PX)
== 0.0;
let has_capacity = metrics.max_scroll_extent > metrics.min_scroll_extent;
InnerScrollState {
registered: has_capacity && physics.should_accept_user_offset(metrics),
can_consume_down_drag: metrics.pixels > metrics.min_scroll_extent || leading_free,
can_consume_up_drag: metrics.pixels < metrics.max_scroll_extent || trailing_free,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScrollInfo {
pub offset: f64,
pub max_offset: f64,
pub overscroll: f64,
}
type OnScroll<State> = Rc<dyn Fn(&mut State, ScrollInfo)>;
type OnRefresh<State> = Rc<dyn Fn(&mut State)>;
pub struct ScrollView<State: 'static> {
child: AnyView<State>,
on_scroll: Option<OnScroll<State>>,
on_refresh_release: Option<OnRefresh<State>>,
physics: Option<Rc<dyn ScrollPhysics>>,
pub(crate) effect: OverscrollEffect,
}
impl<State: 'static> ScrollView<State> {
pub fn new<V: View<State>>(child: V) -> Self {
Self {
child: any(child),
on_scroll: None,
on_refresh_release: None,
physics: None,
effect: default_overscroll_effect(),
}
}
pub fn on_scroll<F: Fn(&mut State, ScrollInfo) + 'static>(mut self, callback: F) -> Self {
self.on_scroll = Some(Rc::new(callback));
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
}
}
pub fn scroll_view<State: 'static, V: View<State>>(child: V) -> ScrollView<State> {
ScrollView::new(child)
}
pub(crate) struct BallisticState {
pub(crate) sim: Box<dyn Simulation>,
pub(crate) start: Option<FrameTime>,
}
impl BallisticState {
pub(crate) fn elapsed_secs(&self, now: FrameTime) -> f64 {
match self.start {
Some(start) => now.saturating_sub(start).as_secs_f64(),
None => 0.0,
}
}
}
pub struct ScrollWidget {
child: ChildPod,
offset: f64,
viewport: Size,
content: Size,
scrolling: bool,
drag_position: f64,
settling: bool,
pub(crate) physics: Rc<dyn ScrollPhysics>,
pub(crate) effect: OverscrollEffect,
pub(crate) edge_pull: f64,
ballistic: Option<BallisticState>,
carried_velocity: f64,
pending_scroll_notify: bool,
on_scroll: Option<ErasedArgCallback<ScrollInfo>>,
on_refresh_release: Option<ErasedCallback>,
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>,
}
impl ScrollWidget {
fn new(child: ChildPod) -> Self {
Self {
child,
offset: 0.0,
viewport: Size::ZERO,
content: Size::ZERO,
scrolling: false,
drag_position: 0.0,
settling: false,
physics: default_physics(),
effect: default_overscroll_effect(),
edge_pull: 0.0,
ballistic: None,
carried_velocity: 0.0,
pending_scroll_notify: false,
on_scroll: None,
on_refresh_release: None,
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,
}
}
pub fn offset(&self) -> f64 {
self.offset
}
pub fn max_offset(&self) -> f64 {
(self.content.height - self.viewport.height).max(0.0)
}
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)
}
fn displacement(&self) -> f64 {
self.offset - self.offset.clamp(0.0, self.max_offset())
}
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 painted_offset(&self) -> f64 {
match self.effect {
OverscrollEffect::Translate => self.offset,
OverscrollEffect::Stretch | OverscrollEffect::None => self.offset - self.displacement(),
}
}
fn sync_child_origin(&mut self) {
self.child
.set_origin(Point::new(0.0, -self.painted_offset()));
}
fn scroll_info(&self) -> ScrollInfo {
let max = self.max_offset();
ScrollInfo {
offset: self.offset.clamp(0.0, max),
max_offset: max,
overscroll: self.displacement(),
}
}
fn notify_scroll(&mut self, ctx: &mut EventCtx) {
let info = self.scroll_info();
if let Some(cb) = self.on_scroll.as_mut() {
cb(ctx, info);
}
}
fn deliver_pending_scroll(&mut self, ctx: &mut EventCtx) {
if self.pending_scroll_notify {
self.pending_scroll_notify = false;
self.notify_scroll(ctx);
}
}
fn apply_drag_offset(&mut self, delta: f64) {
let metrics = self.metrics_at(self.offset);
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);
self.offset = self.drag_position - rejected;
self.edge_pull = self.displacement() + rejected;
}
pub fn settle_tick(&mut self, dt_ms: f64) -> bool {
if !self.settling {
return false;
}
let max = self.max_offset();
let target = self.offset.clamp(0.0, max);
let remaining = target - self.offset;
let rejected = self.edge_pull - self.displacement();
if remaining.abs() <= SETTLE_STOP_PX && rejected.abs() <= SETTLE_STOP_PX {
self.offset = target;
self.settling = false;
self.edge_pull = 0.0;
self.sync_child_origin();
return false;
}
let retained = SETTLE_DECAY.powf(dt_ms);
self.offset = target - remaining * retained;
self.edge_pull = self.displacement() + rejected * retained;
self.sync_child_origin();
true
}
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_origin();
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 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 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);
self.offset = proposed - rejected;
self.edge_pull = self.displacement() + rejected;
self.sync_child_origin();
if done || self.ballistic_is_pinned_outward(proposed, rejected, velocity) {
self.ballistic = None;
self.settle_ballistic_residual();
}
}
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);
}
self.pending_scroll_notify = true;
}
if self.fling.is_some() || self.settling || self.ballistic.is_some() {
ctx.request_frame();
}
}
fn send_child_cancel(&mut self, ctx: &mut EventCtx, pos: Point) {
let cancel = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Cancel,
position: pos,
button: PointerButton::Primary,
});
self.child.event_child(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_scroll(ctx);
}
match event {
InputEvent::Housekeeping | InputEvent::Overlay(_) => {
self.child.event_child(ctx, event);
EventResult::Ignored
}
InputEvent::Key(_) | InputEvent::Ime(_) | InputEvent::EditCommand(_) => {
if self.child.is_focused() {
self.child.event_child(ctx, event)
} else {
EventResult::Ignored
}
}
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.edge_pull = 0.0;
self.set_offset(self.offset + dy);
self.sync_child_origin();
self.notify_scroll(ctx);
ctx.request_redraw();
EventResult::Handled
}
InputEvent::Pointer(p) => match p.phase {
PointerPhase::Down => {
if !presses(p) {
return self.child.event_child(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.displacement();
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 child = &mut self.child;
with_scroll_claim(&claim, || {
with_scroll_veto(&veto, || child.event_child(ctx, event))
});
self.inner_at_down = claim.get();
EventResult::Handled
}
PointerPhase::Move => {
if !self.down_active {
return self.child.event_child(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_origin();
self.notify_scroll(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;
self.child.event_child(ctx, event);
} else {
self.scrolling = true;
self.settling = false;
self.last_drag = p.position;
self.drag_position = self.offset;
self.send_child_cancel(ctx, p.position);
ctx.release_captured_child(&mut self.child);
ctx.request_redraw();
}
} else {
self.child.event_child(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;
}
}
self.notify_scroll(ctx);
} else {
self.child.event_child(ctx, event);
}
self.child.set_active(false);
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 => {
self.child.event_child(ctx, event);
self.child.set_active(false);
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.settling = false;
self.ballistic = None;
self.edge_pull = 0.0;
self.pending_scroll_notify = false;
self.set_offset(self.offset);
self.sync_child_origin();
ctx.request_redraw();
EventResult::Handled
}
},
_ => {
let result = self.child.event_child(ctx, event);
if event.is_broadcast() {
EventResult::Ignored
} else {
result
}
}
}
}
}
impl<State: 'static> View<State> for ScrollView<State> {
type Element = ScrollWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ScrollWidget {
let mut widget = ScrollWidget::new(crate::authoring::build_child(&self.child, ctx));
widget.on_scroll = self
.on_scroll
.as_ref()
.map(crate::authoring::erase_callback_arg);
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
}
fn rebuild(
&self,
prev: &Self,
element: &mut ScrollWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_scroll = self
.on_scroll
.as_ref()
.map(crate::authoring::erase_callback_arg);
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;
crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
}
fn teardown(&self, element: &mut ScrollWidget, ctx: &mut BuildCtx<'_>) {
crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
}
}
impl Widget for ScrollWidget {
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 child_bc = BoxConstraints::new(Size::new(vw, 0.0), Size::new(vw, f64::INFINITY));
self.content = self.child.layout_child(ctx, &child_bc);
let vh = if bc.max().height.is_finite() {
bc.max().height
} else {
self.content.height
};
self.viewport = Size::new(vw, vh);
if !self.scrolling && !self.settling && self.ballistic.is_none() {
self.set_offset(self.offset); }
self.sync_child_origin();
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_origin();
ctx.constrain_visible_rect(Rect::from_origin_size(ctx.origin(), ctx.size()));
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);
}
self.child.paint_child(ctx, scene);
if stretch.is_some() {
scene.pop_transform();
}
scene.pop_clip();
}
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.content.height - self.viewport.height).max(0.0);
ctx.push_container(
Role::ScrollView,
|node| {
node.set_scroll_y(self.offset);
node.set_scroll_y_min(0.0);
node.set_scroll_y_max(max_offset);
},
|ctx| self.child.semantics_child(ctx),
);
}
crate::authoring::visit_children!(child);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::physics::parity::{Bouncing, Clamping, DecelerationRate, NeverScrollable};
use crate::physics::rubber_band::RubberBand;
use crate::test_support::leaf;
use std::any::Any;
fn laid_out(vw: f64, vh: f64, content_h: f64) -> ScrollWidget {
let view: ScrollView<()> = scroll_view(leaf(vw, content_h));
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::loose(Size::new(vw, vh)));
w
}
fn laid_out_rubber_band(vw: f64, vh: f64, content_h: f64) -> ScrollWidget {
let mut w = laid_out(vw, vh, content_h);
w.physics = Rc::new(RubberBand::new());
w
}
fn scroll(y: f64, lines: bool, amount: f64) -> InputEvent {
let delta = if lines {
ScrollDelta::Lines(0.0, amount)
} else {
ScrollDelta::Pixels(0.0, amount)
};
InputEvent::Scroll {
position: Point::new(10.0, y),
delta,
}
}
fn ev(phase: PointerPhase, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(10.0, y),
button: PointerButton::Primary,
})
}
fn dispatch(w: &mut ScrollWidget, event: &InputEvent, t_ms: f64) {
let mut unit = ();
let state_any: &mut dyn Any = &mut unit;
let mut ctx = EventCtx::new(state_any, Point::ZERO, w.viewport);
w.event_at(&mut ctx, event, t_ms);
}
#[test]
fn max_offset_is_content_minus_viewport() {
let w = laid_out(200.0, 100.0, 1000.0);
assert_eq!(w.max_offset(), 900.0);
assert_eq!(w.offset(), 0.0);
}
#[test]
fn wheel_scrolls_and_clamps_with_no_overscroll() {
let mut w = laid_out(200.0, 100.0, 1000.0);
dispatch(&mut w, &scroll(50.0, true, 3.0), 0.0);
assert_eq!(w.offset(), 120.0);
dispatch(&mut w, &scroll(50.0, true, 100.0), 0.0);
assert_eq!(w.offset(), 900.0);
dispatch(&mut w, &scroll(50.0, false, -5000.0), 0.0);
assert_eq!(w.offset(), 0.0);
}
#[test]
fn drag_past_slop_scrolls_the_offset() {
let mut w = laid_out(200.0, 100.0, 1000.0);
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
assert_eq!(w.offset(), 0.0);
assert!(w.scrolling);
dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
assert_eq!(w.offset(), 30.0);
}
#[test]
fn takeover_sends_the_child_a_cancel() {
#[derive(Default)]
struct Rec {
downs: u32,
cancels: u32,
}
struct Probe;
struct ProbeW;
impl View<Rec> for Probe {
type Element = ProbeW;
fn build(&self, _c: &mut BuildCtx<'_>) -> ProbeW {
ProbeW
}
fn rebuild(&self, _p: &Self, _e: &mut ProbeW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for ProbeW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(200.0, 1000.0))
}
fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
if let InputEvent::Pointer(p) = e {
let rec = ctx.state_mut::<Rec>();
match p.phase {
PointerPhase::Down => rec.downs += 1,
PointerPhase::Cancel => rec.cancels += 1,
_ => {}
}
}
EventResult::Handled
}
}
let view: ScrollView<Rec> = scroll_view(Probe);
let mut counter = 0u64;
let mut w = View::<Rec>::build(&view, &mut BuildCtx::new(&mut counter));
w.viewport = Size::new(200.0, 100.0);
let mut state = Rec::default();
let run = |w: &mut ScrollWidget, state: &mut Rec, e: &InputEvent, t: f64| {
let sa: &mut dyn Any = state;
let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, 100.0));
w.event_at(&mut ctx, e, t);
};
run(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
run(&mut w, &mut state, &ev(PointerPhase::Move, 70.0), 16.0);
assert_eq!(state.downs, 1);
assert_eq!(state.cancels, 1);
run(&mut w, &mut state, &ev(PointerPhase::Move, 50.0), 32.0);
assert_eq!(state.cancels, 1);
}
#[derive(Default)]
struct Rec {
downs: u32,
cancels: u32,
moves: u32,
}
struct Probe;
struct ProbeW;
impl View<Rec> for Probe {
type Element = ProbeW;
fn build(&self, _c: &mut BuildCtx<'_>) -> ProbeW {
ProbeW
}
fn rebuild(&self, _p: &Self, _e: &mut ProbeW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for ProbeW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(200.0, 1000.0))
}
fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
if let InputEvent::Pointer(p) = e {
let rec = ctx.state_mut::<Rec>();
match p.phase {
PointerPhase::Down => rec.downs += 1,
PointerPhase::Cancel => rec.cancels += 1,
PointerPhase::Move => rec.moves += 1,
_ => {}
}
}
EventResult::Ignored
}
}
fn probe_scroll() -> ScrollWidget {
let view: ScrollView<Rec> = scroll_view(Probe);
let mut counter = 0u64;
let mut w = View::<Rec>::build(&view, &mut BuildCtx::new(&mut counter));
w.viewport = Size::new(200.0, 100.0);
w
}
fn run_rec(w: &mut ScrollWidget, state: &mut Rec, e: &InputEvent, t: f64) -> bool {
let sa: &mut dyn Any = state;
let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, 100.0));
w.event_at(&mut ctx, e, t);
ctx.needs_redraw()
}
#[test]
fn an_overlay_broadcast_reaches_the_child_and_is_never_consumed() {
use frust_core::{OverlayEvent, OverlayEventKind, OverlayKey};
#[derive(Default)]
struct Seen {
overlays: u32,
pointers: u32,
}
struct Owner;
struct OwnerW;
impl View<Seen> for Owner {
type Element = OwnerW;
fn build(&self, _c: &mut BuildCtx<'_>) -> OwnerW {
OwnerW
}
fn rebuild(&self, _p: &Self, _e: &mut OwnerW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for OwnerW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(200.0, 1000.0))
}
fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
match e {
InputEvent::Overlay(_) => {
ctx.state_mut::<Seen>().overlays += 1;
EventResult::Handled
}
InputEvent::Pointer(_) => {
ctx.state_mut::<Seen>().pointers += 1;
EventResult::Handled
}
_ => EventResult::Ignored,
}
}
}
let view: ScrollView<Seen> = scroll_view(Owner);
let mut counter = 0u64;
let mut w = View::<Seen>::build(&view, &mut BuildCtx::new(&mut counter));
w.viewport = Size::new(200.0, 100.0);
let mut state = Seen::default();
let broadcast = InputEvent::Overlay(OverlayEvent {
key: OverlayKey::next(),
kind: OverlayEventKind::OutsideDown,
});
let result = {
let sa: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, 100.0));
w.event_at(&mut ctx, &broadcast, 0.0)
};
assert_eq!(
state.overlays, 1,
"a floated surface's own input reaches its owner through the viewport"
);
assert_eq!(
result,
EventResult::Ignored,
"and is never consumed, whatever the child returned"
);
assert_eq!(state.pointers, 0, "it is not a pointer event");
assert!(!w.down_active && !w.scrolling);
}
#[test]
fn hover_move_without_down_never_scrolls_or_cancels_child() {
let mut w = probe_scroll();
let mut state = Rec::default();
let redraw = run_rec(&mut w, &mut state, &ev(PointerPhase::Move, 40.0), 16.0);
assert!(!w.scrolling, "hover must not enter scrolling");
assert!(!w.down_active);
assert_eq!(w.offset(), 0.0, "hover must not move the offset");
assert_eq!(state.cancels, 0, "hover must not cancel the child");
assert!(!redraw, "hover must not request a redraw");
assert_eq!(state.moves, 1);
}
#[test]
fn cancel_clears_down_active() {
let mut w = probe_scroll();
let mut state = Rec::default();
run_rec(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
assert!(w.down_active);
run_rec(&mut w, &mut state, &ev(PointerPhase::Cancel, 100.0), 16.0);
assert!(!w.down_active, "Cancel disarms the gesture");
assert!(!w.scrolling);
run_rec(&mut w, &mut state, &ev(PointerPhase::Move, 20.0), 32.0);
assert!(!w.scrolling, "hover after Cancel must not take over");
assert_eq!(w.offset(), 0.0);
}
#[test]
fn fling_after_release_decays_and_clamps() {
let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0); dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
assert!(w.is_flinging(), "release with velocity starts a fling");
let mut steps = 0;
while w.tick(16.0) {
steps += 1;
assert!(steps < 100_000, "fling failed to terminate");
}
assert!(!w.is_flinging());
assert!(w.offset() >= 0.0 && w.offset() <= w.max_offset());
}
#[test]
fn pump_fling_signals_needs_frame_until_at_rest() {
let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0); dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
assert!(w.is_flinging(), "release with velocity starts a fling");
let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
w.pump_fling(&mut ctx);
assert!(
ctx.needs_frame(),
"an in-flight fling requests continuation"
);
assert!(w.is_flinging());
while w.tick(16.0) {}
assert!(!w.is_flinging());
let mut ctx_rest = PaintCtx::new(Point::ZERO, w.viewport);
w.pump_fling(&mut ctx_rest);
assert!(
!ctx_rest.needs_frame(),
"a fling at rest stops requesting frames"
);
}
#[test]
fn tick_without_a_fling_is_a_noop() {
let mut w = laid_out(200.0, 100.0, 1000.0);
assert!(!w.tick(16.0));
assert_eq!(w.offset(), 0.0);
}
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 scroll_widget(root: &frust_core::RenderRoot<(), ScrollView<()>>) -> &ScrollWidget {
let id = root.root_id().expect("root built");
(root.tree().pod(id).expect("root pod").widget() as &dyn Any)
.downcast_ref::<ScrollWidget>()
.expect("root is a ScrollWidget")
}
#[test]
fn fling_advances_from_injected_paint_frame_time() {
use frust_core::{FrameTime, RenderRoot};
fn logic(_: &mut ()) -> ScrollView<()> {
scroll_view(leaf(200.0, 1000.0))
}
let mut root: RenderRoot<(), ScrollView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(200.0, 100.0));
let ft = |ms: f64| FrameTime::from_nanos((ms * 1_000_000.0) as u64);
let mut sink = NullScene;
root.paint(&mut sink, ft(0.0));
root.event(&mut state, &ev(PointerPhase::Down, 100.0));
root.paint(&mut sink, ft(16.0));
root.event(&mut state, &ev(PointerPhase::Move, 75.0)); root.paint(&mut sink, ft(32.0));
root.event(&mut state, &ev(PointerPhase::Move, 50.0)); root.event(&mut state, &ev(PointerPhase::Up, 50.0));
assert!(
scroll_widget(&root).is_flinging(),
"release with paint-clock velocity starts a fling"
);
let before = scroll_widget(&root).offset();
root.paint(&mut sink, ft(48.0));
root.paint(&mut sink, ft(64.0));
assert!(
scroll_widget(&root).offset() > before,
"the fling advanced from the injected paint frame time"
);
}
struct Ticker;
struct TickerWidget;
impl View<()> for Ticker {
type Element = TickerWidget;
fn build(&self, _c: &mut BuildCtx<'_>) -> TickerWidget {
TickerWidget
}
fn rebuild(&self, _p: &Self, _e: &mut TickerWidget, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for TickerWidget {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(100.0, 100.0))
}
fn paint(&mut self, ctx: &mut PaintCtx, _s: &mut dyn PaintScene) {
ctx.request_frame();
}
}
#[test]
fn offscreen_flex_animator_culled_until_scrolled_into_view() {
use frust_core::RenderRoot;
fn logic(_: &mut ()) -> ScrollView<()> {
scroll_view(crate::Column(vec![any(leaf(100.0, 1000.0)), any(Ticker)]))
}
let mut root: RenderRoot<(), ScrollView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(100.0, 100.0));
let mut sink = NullScene;
let outcome = root.paint(&mut sink, FrameTime::ZERO);
assert!(
!outcome.needs_frame,
"an offscreen animator's frame request is culled"
);
root.event(&mut state, &scroll(50.0, false, 5000.0));
let outcome = root.paint(&mut sink, FrameTime::ZERO);
assert!(
outcome.needs_frame,
"scrolling the animator into view resumes its frame requests"
);
}
#[test]
fn offset_reclamps_when_content_shrinks() {
let mut w = laid_out(200.0, 100.0, 1000.0);
dispatch(&mut w, &scroll(50.0, false, 800.0), 0.0);
assert_eq!(w.offset(), 800.0);
w.content = Size::new(200.0, 150.0);
w.set_offset(w.offset);
assert_eq!(w.max_offset(), 50.0);
assert_eq!(w.offset(), 50.0);
}
#[test]
fn drag_past_top_overscrolls_with_resistance_then_settles_back() {
let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); assert!(w.scrolling);
assert_eq!(w.offset(), 0.0, "the takeover move does not itself scroll");
dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
assert!(w.offset() < 0.0, "a drag past the top overscrolls negative");
assert_eq!(
w.offset(),
-10.0,
"overscroll is the raw excess (-20) * OVERSCROLL_RESISTANCE (0.5)"
);
dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
assert!(
!w.is_flinging(),
"an overscrolled release settles, never flings"
);
let mut steps = 0;
while w.settle_tick(16.0) {
steps += 1;
assert!(steps < 10_000, "settle failed to terminate");
}
assert_eq!(
w.offset(),
0.0,
"the surface settles back to the clamped edge"
);
}
#[test]
fn wheel_never_overscrolls_past_top() {
let mut w = laid_out(200.0, 100.0, 1000.0);
dispatch(&mut w, &scroll(50.0, false, -5000.0), 0.0);
assert_eq!(w.offset(), 0.0);
assert!(!w.settling, "wheel input starts no settle animation");
}
#[derive(Default)]
struct ScrollLog {
infos: Vec<ScrollInfo>,
refreshes: u32,
}
struct Content(Size);
struct ContentW(Size);
impl<S: 'static> View<S> for Content {
type Element = ContentW;
fn build(&self, _c: &mut BuildCtx<'_>) -> ContentW {
ContentW(self.0)
}
fn rebuild(&self, _p: &Self, _e: &mut ContentW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for ContentW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.0)
}
fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
}
fn observed(with_refresh: bool) -> ScrollWidget {
let mut view: ScrollView<ScrollLog> = scroll_view(Content(Size::new(200.0, 1000.0)))
.on_scroll(|s: &mut ScrollLog, info| s.infos.push(info));
if with_refresh {
view = view.on_refresh_release(|s: &mut ScrollLog| s.refreshes += 1);
}
let mut counter = 0u64;
let mut w = View::<ScrollLog>::build(&view, &mut BuildCtx::new(&mut counter));
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
w
}
fn observed_rubber_band(with_refresh: bool) -> ScrollWidget {
let mut w = observed(with_refresh);
w.physics = Rc::new(RubberBand::new());
w
}
fn run_log(w: &mut ScrollWidget, state: &mut ScrollLog, e: &InputEvent, t: f64) {
let sa: &mut dyn Any = state;
let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
w.event_at(&mut ctx, e, t);
}
#[test]
fn on_scroll_observes_drag_deltas() {
let mut w = observed(false);
let mut state = ScrollLog::default();
run_log(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 70.0), 16.0); run_log(&mut w, &mut state, &ev(PointerPhase::Move, 40.0), 32.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 10.0), 48.0);
assert!(!state.infos.is_empty(), "on_scroll fired on the drag");
let last = state.infos.last().unwrap();
assert!(
last.offset > 0.0,
"the observed offset grew as content scrolled"
);
assert_eq!(last.overscroll, 0.0, "an in-range drag has no overscroll");
assert_eq!(last.max_offset, 900.0);
}
#[test]
fn on_refresh_release_fires_only_past_trigger_and_only_on_release() {
let mut w = observed_rubber_band(true);
let mut state = ScrollLog::default();
run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); run_log(&mut w, &mut state, &ev(PointerPhase::Move, 180.0), 32.0); assert_eq!(w.offset(), -45.0, "under the trigger (|-45| < 64)");
run_log(&mut w, &mut state, &ev(PointerPhase::Up, 180.0), 48.0);
assert_eq!(
state.refreshes, 0,
"release under the trigger does not refresh"
);
let mut w = observed_rubber_band(true);
let mut state = ScrollLog::default();
run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); run_log(&mut w, &mut state, &ev(PointerPhase::Move, 290.0), 32.0); assert_eq!(w.offset(), -100.0, "past the trigger (|-100| > 64)");
assert_eq!(state.refreshes, 0, "no fire before release");
run_log(&mut w, &mut state, &ev(PointerPhase::Up, 290.0), 48.0);
assert_eq!(state.refreshes, 1, "release past the trigger fires once");
}
#[test]
fn cancel_during_overscroll_never_fires_refresh_and_snaps_back() {
let mut w = observed_rubber_band(true);
let mut state = ScrollLog::default();
run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); run_log(&mut w, &mut state, &ev(PointerPhase::Move, 290.0), 32.0); assert_eq!(w.offset(), -100.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Cancel, 290.0), 48.0);
assert_eq!(
state.refreshes, 0,
"Cancel never fires the refresh callback"
);
assert_eq!(w.offset(), 0.0, "Cancel snaps the surface back into range");
assert!(!w.settling);
}
#[test]
fn layout_mid_drag_preserves_top_overscroll() {
let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); assert!(w.scrolling);
dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
assert_eq!(
w.offset(),
-10.0,
"resisted overscroll before the layout pass"
);
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
assert_eq!(
w.offset(),
-10.0,
"a layout pass mid-drag must not snap the overscroll back into range"
);
assert!(w.scrolling, "still an active drag after the layout pass");
}
#[test]
fn layout_mid_settle_preserves_decaying_overscroll() {
let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 290.0), 32.0); assert_eq!(w.offset(), -100.0, "past the refresh trigger (|-100| > 64)");
dispatch(&mut w, &ev(PointerPhase::Up, 290.0), 48.0);
assert!(
w.settling,
"an overscrolled release enters the settle animation"
);
assert!(!w.is_flinging());
let still_settling = w.settle_tick(16.0);
assert!(still_settling);
let after_tick = w.offset();
assert!(
after_tick < 0.0,
"one settle tick eases toward the edge but is still out of range"
);
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
assert_eq!(
w.offset(),
after_tick,
"a layout pass mid-settle must not snap the decaying overscroll back into range"
);
assert!(w.settling, "still settling after the layout pass");
}
#[test]
fn edge_pull_equals_overscroll_under_rubber_band() {
let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
assert_eq!(w.offset(), -10.0);
assert_eq!(w.edge_pull, -10.0, "negative past the top, like overscroll");
assert_eq!(
w.edge_pull,
w.scroll_info().overscroll,
"nothing rejected → the two are the same number"
);
dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
assert!(w.settling);
assert!(w.settle_tick(16.0));
assert!(
w.edge_pull < 0.0 && w.edge_pull > -10.0,
"one settle tick eases the pull toward the edge: {}",
w.edge_pull
);
assert_eq!(w.edge_pull, w.scroll_info().overscroll);
while w.settle_tick(16.0) {}
assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
assert_eq!(w.scroll_info().overscroll, 0.0);
let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
dispatch(&mut w, &scroll(50.0, false, 5000.0), 0.0); dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 60.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 0.0), 32.0); assert_eq!(w.edge_pull, 30.0, "positive past the bottom");
assert_eq!(w.edge_pull, w.scroll_info().overscroll);
}
struct Ramp {
from: f64,
}
impl Simulation for Ramp {
fn x(&self, time: f64) -> f64 {
self.from + 100.0 * time
}
fn dx(&self, _time: f64) -> f64 {
100.0
}
fn is_done(&self, time: f64) -> bool {
time >= 0.1
}
}
#[derive(Debug)]
struct RampPhysics;
impl ScrollPhysics for RampPhysics {
fn create_ballistic_simulation(
&self,
metrics: &ScrollMetrics,
_velocity: f64,
) -> Option<Box<dyn Simulation>> {
Some(Box::new(Ramp {
from: metrics.pixels,
}))
}
}
fn frame_time(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
#[test]
fn ballistic_driver_runs_generic_simulation() {
let mut w = observed(false);
w.physics = Rc::new(RampPhysics);
let mut state = ScrollLog::default();
run_log(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 75.0), 16.0); run_log(&mut w, &mut state, &ev(PointerPhase::Move, 50.0), 32.0); run_log(&mut w, &mut state, &ev(PointerPhase::Up, 50.0), 32.0);
assert!(
w.ballistic.is_some(),
"a physics handing back a simulation owns the release"
);
assert!(w.fling.is_none(), "…and the legacy fling never starts");
assert!(!w.settling);
let start = w.offset();
let observed_before = state.infos.len();
let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(100.0));
w.pump_fling(&mut ctx);
assert!(ctx.needs_frame(), "a live simulation asks for continuation");
assert_eq!(w.offset(), start, "the seeding frame moves nothing");
assert!(!w.pending_scroll_notify);
let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(116.0));
w.pump_fling(&mut ctx);
assert!(
(w.offset() - (start + 1.6)).abs() < 1e-9,
"the offset follows sim.x(t): {}",
w.offset()
);
assert!(w.pending_scroll_notify, "recorded at paint, not fired");
assert_eq!(
state.infos.len(),
observed_before,
"the notification stays one event late"
);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 50.0), 132.0);
assert!(!w.pending_scroll_notify);
assert_eq!(state.infos.len(), observed_before + 1);
let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(300.0));
w.pump_fling(&mut ctx);
assert!((w.offset() - (start + 20.0)).abs() < 1e-9);
assert!(w.ballistic.is_none(), "a done simulation is dropped");
assert!(!w.is_flinging());
let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(316.0));
w.pump_fling(&mut ctx);
assert!(!ctx.needs_frame(), "at rest the shell can idle again");
}
#[derive(Debug)]
struct CarryPhysics;
impl ScrollPhysics for CarryPhysics {
fn carried_momentum(&self, existing_velocity: f64) -> f64 {
if existing_velocity == 0.0 { 0.0 } else { 100.0 }
}
}
fn fling_then_refling(w: &mut ScrollWidget) -> (f64, f64) {
dispatch(w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(w, &ev(PointerPhase::Move, 75.0), 16.0); dispatch(w, &ev(PointerPhase::Move, 50.0), 32.0); dispatch(w, &ev(PointerPhase::Up, 50.0), 32.0);
let first = w.fling.expect("release with velocity flings");
dispatch(w, &ev(PointerPhase::Down, 100.0), 48.0);
dispatch(w, &ev(PointerPhase::Move, 75.0), 64.0);
dispatch(w, &ev(PointerPhase::Move, 50.0), 80.0);
dispatch(w, &ev(PointerPhase::Up, 50.0), 80.0);
(first, w.fling.expect("the second release flings too"))
}
#[test]
fn carried_momentum_hook_feeds_new_fling() {
let mut w = laid_out(200.0, 100.0, 5000.0);
w.physics = Rc::new(CarryPhysics);
let (first, second) = fling_then_refling(&mut w);
assert_eq!(
second,
first + 100.0,
"a fling started during live motion carries the physics' momentum"
);
let mut w = laid_out_rubber_band(200.0, 100.0, 5000.0);
let (first, second) = fling_then_refling(&mut w);
assert_eq!(second, first, "RubberBand starts every fling cold");
}
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_surface_installs_the_platform_default() {
let w = laid_out(200.0, 100.0, 1000.0);
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 w = laid_out(200.0, 100.0, 1000.0);
dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0);
dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
let first = w.offset();
assert_close(
first,
-20.0 * DecelerationRate::NORMAL_FRICTION,
1e-12,
"the first past-edge move, at zero depth",
);
dispatch(&mut w, &ev(PointerPhase::Move, 130.0), 48.0);
let second = w.offset() - first;
assert_close(second, -8.349_286_4, 1e-9, "the second, deeper move");
assert_close(w.offset(), -18.749_286_4, 1e-9, "the accumulated pull");
assert!(
second.abs() < first.abs(),
"the deeper pull must displace LESS per raw px: {second} vs {first}"
);
assert_eq!(w.edge_pull, w.scroll_info().overscroll);
}
#[test]
fn default_release_past_the_edge_springs_back_to_the_boundary() {
let mut w = laid_out(200.0, 100.0, 1000.0);
dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
assert!(w.offset() < 0.0, "the drag left the surface past its top");
dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
assert!(
w.ballistic.is_some(),
"the default physics owns the release with a spring"
);
assert!(w.fling.is_none() && !w.settling, "…so no legacy path runs");
assert!(w.is_flinging(), "post-release motion is live");
let mut ms = 100.0;
let mut steps = 0;
let mut moved = false;
while w.is_flinging() {
let before = w.offset();
let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(ms));
w.pump_fling(&mut ctx);
moved |= w.offset() != before;
ms += 16.0;
steps += 1;
assert!(steps < 2_000, "the bounce-back never settled");
}
assert!(moved, "the spring must actually animate, not snap");
assert!(
w.offset().abs() < 1e-6,
"the spring converges onto the boundary: {}",
w.offset()
);
assert!(
w.edge_pull.abs() < 1e-6,
"…and leaves no pull behind: {}",
w.edge_pull
);
}
#[test]
fn default_carried_momentum_compounds_a_refling() {
let mut w = laid_out(200.0, 100.0, 5000.0);
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0); dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
let first = w
.ballistic
.as_ref()
.expect("a release above the fling minimum is ballistic")
.sim
.dx(0.0);
assert_close(first, 1562.5, 1e-9, "the first release velocity");
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 64.0);
dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 80.0);
dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 80.0);
let second = w
.ballistic
.as_ref()
.expect("the second release is ballistic too")
.sim
.dx(0.0);
assert_close(
second,
first + Bouncing::new().carried_momentum(first),
1e-9,
"the re-fling carries Bouncing::carried_momentum(first)",
);
assert!(second > first, "…which is a genuine speed-up");
}
fn release_velocity(w: &ScrollWidget) -> 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 = laid_out(200.0, 100.0, 5000.0);
dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0);
dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
assert_close(release_velocity(&w), 1562.5, 1e-9, "the first release");
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
dispatch(&mut w, &ev(PointerPhase::Move, 125.0), 64.0); dispatch(&mut w, &ev(PointerPhase::Move, 150.0), 80.0);
dispatch(&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 small_reverse_flick_is_not_inverted() {
let mut w = laid_out(200.0, 100.0, 5000.0);
dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
dispatch(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
assert_close(release_velocity(&w), 1000.0, 1e-9, "the live motion");
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
dispatch(&mut w, &ev(PointerPhase::Move, 108.0), 68.0);
dispatch(&mut w, &ev(PointerPhase::Move, 116.0), 88.0);
dispatch(&mut w, &ev(PointerPhase::Move, 124.0), 108.0); dispatch(&mut w, &ev(PointerPhase::Up, 124.0), 108.0);
let released = release_velocity(&w);
assert!(
released <= 0.0,
"a reverse flick must never relaunch the surface the way it was \
already going: {released}"
);
assert_close(
released,
-400.0,
1e-9,
"…it runs at the finger's own velocity instead",
);
}
#[test]
fn momentum_retain_threshold_refuses_a_weak_refling() {
let mut w = laid_out(200.0, 100.0, 5000.0);
dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
dispatch(&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(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
dispatch(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 148.0); dispatch(&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 = laid_out(200.0, 100.0, 5000.0);
dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
dispatch(&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(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
dispatch(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); dispatch(&mut w, &ev(PointerPhase::Move, 65.0), 148.0); dispatch(&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 default_min_fling_gate_is_one_hundred() {
let mut slow = laid_out(200.0, 100.0, 5000.0);
dispatch(&mut slow, &scroll(50.0, false, 1000.0), 0.0);
dispatch(&mut slow, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut slow, &ev(PointerPhase::Move, 80.0), 16.0); dispatch(&mut slow, &ev(PointerPhase::Move, 94.0), 100.0);
dispatch(&mut slow, &ev(PointerPhase::Up, 94.0), 100.0);
assert!(
slow.ballistic.is_none(),
"60 px/s is under the bouncing minimum (100), so no ballistic"
);
assert_close(
slow.fling.expect("the legacy fling catches it instead"),
60.0,
1e-9,
"the legacy fallback runs at the raw release velocity",
);
let mut fast = laid_out(200.0, 100.0, 5000.0);
dispatch(&mut fast, &scroll(50.0, false, 1000.0), 0.0);
dispatch(&mut fast, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut fast, &ev(PointerPhase::Move, 80.0), 16.0); dispatch(&mut fast, &ev(PointerPhase::Move, 85.0), 100.0);
dispatch(&mut fast, &ev(PointerPhase::Up, 85.0), 100.0);
let sim = fast
.ballistic
.as_ref()
.expect("150 px/s clears the bouncing minimum");
assert_close(sim.sim.dx(0.0), 150.0, 1e-9, "…at the release velocity");
assert!(fast.fling.is_none(), "and the legacy fling stays out of it");
assert_eq!(Bouncing::new().min_fling_velocity(), 100.0);
}
#[test]
fn refresh_trigger_under_the_bouncing_default() {
let mut w = observed(true);
let mut state = ScrollLog::default();
run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); run_log(&mut w, &mut state, &ev(PointerPhase::Move, 200.0), 32.0);
assert_close(w.edge_pull, -57.2, 1e-9, "under the trigger");
assert_eq!(
w.edge_pull,
w.scroll_info().overscroll,
"a bouncing surface rejects nothing, so the pull IS the displacement"
);
run_log(&mut w, &mut state, &ev(PointerPhase::Up, 200.0), 48.0);
assert_eq!(state.refreshes, 0, "release under the trigger never fires");
let mut w = observed(true);
let mut state = ScrollLog::default();
run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); run_log(&mut w, &mut state, &ev(PointerPhase::Move, 240.0), 32.0);
assert_close(w.edge_pull, -78.0, 1e-9, "past the trigger");
assert!(crossed_refresh_trigger(w.edge_pull));
assert_eq!(state.refreshes, 0, "no fire before release");
run_log(&mut w, &mut state, &ev(PointerPhase::Up, 240.0), 48.0);
assert_eq!(state.refreshes, 1, "release past the trigger fires once");
}
#[test]
fn refresh_trigger_under_a_clamping_physics() {
let mut w = observed(true);
w.physics = Rc::new(Clamping::new());
let mut state = ScrollLog::default();
run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); run_log(&mut w, &mut state, &ev(PointerPhase::Move, 140.0), 32.0);
assert_eq!(w.offset(), 0.0, "a clamping surface never displaces");
assert_eq!(w.scroll_info().overscroll, 0.0);
assert_eq!(w.edge_pull, -50.0, "…but reports the whole rejected pull");
run_log(&mut w, &mut state, &ev(PointerPhase::Up, 140.0), 48.0);
assert_eq!(state.refreshes, 0, "50px of raw pull is under the trigger");
let mut w = observed(true);
w.physics = Rc::new(Clamping::new());
let mut state = ScrollLog::default();
run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); run_log(&mut w, &mut state, &ev(PointerPhase::Move, 190.0), 32.0);
assert_eq!(w.offset(), 0.0);
assert_eq!(w.edge_pull, -100.0, "100px of raw pull, none of it shown");
run_log(&mut w, &mut state, &ev(PointerPhase::Up, 190.0), 48.0);
assert_eq!(state.refreshes, 1, "past 64px of raw pull, it fires once");
assert!(w.settling, "the rejected pull settles rather than springs");
while w.settle_tick(16.0) {}
assert_eq!(w.edge_pull, 0.0);
}
fn drag_20px_past_top(w: &mut ScrollWidget) {
dispatch(w, &ev(PointerPhase::Down, 50.0), 0.0);
dispatch(w, &ev(PointerPhase::Move, 90.0), 16.0); dispatch(w, &ev(PointerPhase::Move, 110.0), 32.0);
}
fn drag_60px_past_bottom(w: &mut ScrollWidget) {
dispatch(w, &scroll(50.0, false, 5000.0), 0.0); dispatch(w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(w, &ev(PointerPhase::Move, 60.0), 16.0); dispatch(w, &ev(PointerPhase::Move, 0.0), 32.0);
}
fn painted_stretch(w: &mut ScrollWidget) -> 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"
);
assert!(
scene.transforms.len() <= 1,
"the stretch pushes at most one transform"
);
assert_eq!(scene.rects.len(), 1, "the child paints either way");
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 stretch_intensity_curve_pins() {
assert_eq!(stretch_intensity(0.0, 100.0), 0.0);
assert!(
(stretch_intensity(-10.0, 100.0) - 0.010_579_271_721_449_07).abs() < 1e-9,
"the curve drifted from its pinned constants: {}",
stretch_intensity(-10.0, 100.0)
);
assert_eq!(
stretch_intensity(10.0, 100.0),
stretch_intensity(-10.0, 100.0),
"magnitude-only: the sign picks the anchor, never the amount"
);
let mut previous = 0.0;
for step in 1..=100 {
let intensity = stretch_intensity(step as f64, 100.0);
assert!(
intensity > previous,
"the curve must increase monotonically (step {step}): {intensity} <= {previous}"
);
previous = intensity;
}
assert!(stretch_intensity(100.0, 100.0) <= 2.0 * STRETCH_INTENSITY);
assert_eq!(
stretch_intensity(500.0, 100.0),
stretch_intensity(100.0, 100.0),
"the normalized pull clamps at 1.0"
);
assert_eq!(
stretch_intensity(-10.0, 0.0),
0.0,
"a degenerate viewport stretches nothing"
);
}
#[test]
fn stretch_keeps_child_origin_fixed() {
let mut translate = laid_out_rubber_band(200.0, 100.0, 1000.0);
drag_20px_past_top(&mut translate);
let mut stretch = laid_out_rubber_band(200.0, 100.0, 1000.0);
stretch.effect = OverscrollEffect::Stretch;
drag_20px_past_top(&mut stretch);
let mut none = laid_out_rubber_band(200.0, 100.0, 1000.0);
none.effect = OverscrollEffect::None;
drag_20px_past_top(&mut none);
assert_eq!(stretch.offset(), -10.0, "the resisted overscroll, as ever");
assert_eq!(translate.offset(), stretch.offset());
assert_eq!(none.offset(), stretch.offset());
assert_eq!(translate.edge_pull, stretch.edge_pull);
assert_eq!(translate.child.origin().y, 10.0);
assert_eq!(stretch.child.origin().y, 0.0);
assert_eq!(none.child.origin().y, 0.0);
assert_eq!(
translate.child.origin().y - stretch.child.origin().y,
-stretch.displacement(),
"the two fixtures differ by exactly the overscroll displacement"
);
assert_eq!(painted_stretch(&mut translate), None);
assert_eq!(painted_stretch(&mut none), None);
assert!(painted_stretch(&mut stretch).is_some());
}
#[test]
fn stretch_anchor_follows_pulled_edge() {
let mut top = laid_out_rubber_band(200.0, 100.0, 1000.0);
top.effect = OverscrollEffect::Stretch;
drag_20px_past_top(&mut top);
assert_eq!(top.edge_pull, -10.0, "pulled past the top");
let (anchor, scale) = painted_stretch(&mut top).expect("a held pull stretches");
assert!(
anchor.abs() < 1e-9,
"a top pull scales about the viewport's top edge: {anchor}"
);
assert!(
(scale - (1.0 + stretch_intensity(-10.0, 100.0))).abs() < 1e-12,
"scale is 1 + the curve's intensity: {scale}"
);
assert!(scale > 1.0, "the content grows, never shrinks");
let mut bottom = laid_out_rubber_band(200.0, 100.0, 1000.0);
bottom.effect = OverscrollEffect::Stretch;
drag_60px_past_bottom(&mut bottom);
assert_eq!(bottom.edge_pull, 30.0, "pulled past the bottom");
let (anchor, scale) = painted_stretch(&mut bottom).expect("a held pull stretches");
assert!(
(anchor - 100.0).abs() < 1e-9,
"a bottom pull scales about the viewport's bottom edge: {anchor}"
);
assert!((scale - (1.0 + stretch_intensity(30.0, 100.0))).abs() < 1e-12);
}
#[test]
fn stretch_settles_back_to_identity() {
let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
w.effect = OverscrollEffect::Stretch;
drag_20px_past_top(&mut w);
let (_, held) = painted_stretch(&mut w).expect("the held pull stretches");
dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
assert!(w.settling, "an overscrolled release settles, effect or not");
let (_, releasing) = painted_stretch(&mut w).expect("the settle still stretches");
assert!(
releasing <= held,
"the stretch decays with the pull, never grows: {releasing} > {held}"
);
let mut steps = 0;
while w.settle_tick(16.0) {
steps += 1;
assert!(steps < 10_000, "settle failed to terminate");
}
assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
assert_eq!(
painted_stretch(&mut w),
None,
"…so paint pushes no transform at all — back to identity"
);
let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
let mut scene = crate::test_support::RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert!(
!ctx.needs_frame(),
"a settled stretch stops requesting frames"
);
}
#[derive(Debug)]
struct RejectPastEdge;
impl ScrollPhysics for RejectPastEdge {
fn apply_boundary_conditions(&self, metrics: &ScrollMetrics, value: f64) -> f64 {
value - value.clamp(metrics.min_scroll_extent, metrics.max_scroll_extent)
}
}
#[test]
fn stretch_under_boundary_rejection_uses_edge_pull() {
let mut w = laid_out(200.0, 100.0, 1000.0);
w.effect = OverscrollEffect::Stretch;
w.physics = Rc::new(RejectPastEdge);
drag_20px_past_top(&mut w);
assert_eq!(
w.offset(),
0.0,
"a clamping physics never lets the position leave range"
);
assert_eq!(
w.scroll_info().overscroll,
0.0,
"…so there is no displacement for Translate to have shown"
);
assert_eq!(w.edge_pull, -20.0, "the pull is still reported in full");
let (anchor, scale) = painted_stretch(&mut w).expect("a rejected pull still stretches");
assert!(
anchor.abs() < 1e-9,
"anchored at the pulled (top) edge: {anchor}"
);
assert!((scale - (1.0 + stretch_intensity(-20.0, 100.0))).abs() < 1e-12);
assert!(scale > 1.0, "clamping + stretch is a visible effect");
assert_eq!(
w.child.origin().y,
0.0,
"and the content itself never moves"
);
}
#[test]
fn clamping_fling_into_the_edge_settles_the_stretch() {
use crate::physics::Tolerance;
use crate::physics::simulation::ClampingScrollSimulation;
let mut w = laid_out(200.0, 100.0, 1000.0);
w.physics = Rc::new(Clamping::new());
w.effect = OverscrollEffect::Stretch;
dispatch(&mut w, &scroll(50.0, false, 800.0), 0.0);
assert_eq!(w.offset(), 800.0, "parked 100px short of the 900px bottom");
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 16.0); dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
dispatch(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
let released = w.ballistic.as_ref().expect("a clamping release flings");
assert_close(released.sim.dx(0.0), 1000.0, 1e-9, "the release velocity");
let curve = ClampingScrollSimulation::new(
800.0,
1000.0,
ClampingScrollSimulation::DEFAULT_FRICTION,
Tolerance::for_device_pixel_ratio(METRICS_FALLBACK_DPR),
);
assert!(
curve.final_x() > 1000.0,
"the spline must overshoot the 900px 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, frame_time(ms));
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(),
900.0,
1e-9,
"the offset ends pinned at the edge",
);
assert_eq!(w.edge_pull, 0.0, "a finished fling leaves no pull standing");
assert_eq!(
painted_stretch(&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"
);
}
const OUTER: usize = 0;
const MIDDLE: usize = 1;
const INNER: usize = 2;
#[derive(Default)]
struct Nest {
scrolls: [Vec<ScrollInfo>; 3],
refreshes: [u32; 3],
}
#[derive(Clone, Copy, Default)]
struct ContentSeen {
downs: u32,
cancels: u32,
}
struct NestContent(Rc<Cell<ContentSeen>>);
struct NestContentW(Rc<Cell<ContentSeen>>);
fn nest_content(seen: &Rc<Cell<ContentSeen>>) -> NestContent {
NestContent(Rc::clone(seen))
}
fn spacer_content() -> NestContent {
NestContent(Rc::new(Cell::new(ContentSeen::default())))
}
impl View<Nest> for NestContent {
type Element = NestContentW;
fn build(&self, _c: &mut BuildCtx<'_>) -> NestContentW {
NestContentW(Rc::clone(&self.0))
}
fn rebuild(&self, _p: &Self, _e: &mut NestContentW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for NestContentW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(200.0, 1000.0))
}
fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
fn event(&mut self, _ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
if let InputEvent::Pointer(p) = e {
let mut seen = self.0.get();
match p.phase {
PointerPhase::Down => seen.downs += 1,
PointerPhase::Cancel => seen.cancels += 1,
_ => {}
}
self.0.set(seen);
}
EventResult::Ignored
}
}
fn nest_surface(child: impl View<Nest>, viewport_h: f64, layer: usize) -> ScrollWidget {
let view: ScrollView<Nest> = scroll_view(child)
.on_scroll(move |s: &mut Nest, info| s.scrolls[layer].push(info))
.on_refresh_release(move |s: &mut Nest| s.refreshes[layer] += 1);
let mut counter = 0u64;
let mut w = View::<Nest>::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 nest_surface_rubber_band(
child: impl View<Nest>,
viewport_h: f64,
layer: usize,
) -> ScrollWidget {
let mut w = nest_surface(child, viewport_h, layer);
w.physics = Rc::new(RubberBand::new());
w
}
fn nest(outer: &mut ScrollWidget, inner: ScrollWidget) {
outer.child = ChildPod::new(Box::new(inner));
}
fn nested_of(outer: &ScrollWidget) -> &ScrollWidget {
(outer.child.widget() as &dyn Any)
.downcast_ref::<ScrollWidget>()
.expect("the fixture wired a ScrollWidget child")
}
fn nested_list_of(outer: &ScrollWidget) -> &crate::list_view::ListViewWidget {
(outer.child.widget() as &dyn Any)
.downcast_ref::<crate::list_view::ListViewWidget>()
.expect("the fixture wired a ListViewWidget child")
}
fn nested_list(viewport_h: f64) -> crate::list_view::ListViewWidget {
let view: crate::list_view::ListView<Nest> =
crate::list_view::list_view(10, 100.0, |_| any(spacer_content()));
let mut counter = 0u64;
let mut w = View::<Nest>::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 run_nest(w: &mut ScrollWidget, state: &mut Nest, e: &InputEvent, t: f64) {
let sa: &mut dyn Any = state;
let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
w.event_at(&mut ctx, e, t);
}
fn park(w: &mut dyn Widget, viewport_h: f64, offset: f64) {
let mut throwaway = Nest::default();
let sa: &mut dyn Any = &mut throwaway;
let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, viewport_h));
w.event(&mut ctx, &scroll(50.0, false, offset));
}
#[test]
fn outer_defers_when_inner_can_consume() {
let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
let seen = Rc::new(Cell::new(ContentSeen::default()));
let mut inner = nest_surface(nest_content(&seen), 120.0, INNER);
park(&mut inner, 120.0, 400.0);
assert_eq!(inner.offset(), 400.0);
nest(&mut outer, inner);
let mut state = Nest::default();
run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
assert!(
outer.inner_at_down.registered,
"the nested surface reported itself on the forwarded Down"
);
assert!(outer.inner_at_down.can_consume_up_drag);
assert_eq!(seen.get().downs, 1, "the Down still reached the content");
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 50.0), 16.0);
assert!(outer.deferring, "the outer deferred to the nested surface");
assert!(!outer.scrolling);
assert_eq!(outer.offset(), 0.0, "…and never moved");
assert!(state.scrolls[OUTER].is_empty(), "…nor reported a scroll");
assert!(nested_of(&outer).scrolling, "the inner took the gesture");
assert_eq!(seen.get().cancels, 1);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 10.0), 32.0);
assert_eq!(
nested_of(&outer).offset(),
440.0,
"the inner consumed the 40px"
);
assert_eq!(outer.offset(), 0.0);
assert!(state.scrolls[OUTER].is_empty());
assert!(!state.scrolls[INNER].is_empty(), "the inner reported it");
assert_eq!(seen.get().cancels, 1, "no second Cancel from anywhere");
}
#[test]
fn outer_takes_over_when_inner_pinned() {
let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
let seen = Rc::new(Cell::new(ContentSeen::default()));
let mut inner = nest_surface(nest_content(&seen), 120.0, INNER);
inner.physics = Rc::new(Clamping::new());
nest(&mut outer, inner);
let mut state = Nest::default();
run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
assert!(
outer.inner_at_down.registered,
"the inner still reports itself…"
);
assert!(
!outer.inner_at_down.can_consume_down_drag,
"…pinned against a downward drag"
);
assert!(
outer.inner_at_down.can_consume_up_drag,
"…though not against an upward one"
);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
assert!(outer.scrolling);
assert!(!outer.deferring);
assert_eq!(
seen.get().cancels,
1,
"the outer's takeover Cancel reached the content through the inner"
);
assert_eq!(
outer.offset(),
0.0,
"the takeover move does not itself scroll"
);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
assert_eq!(
outer.offset(),
-10.0,
"the resisted 20px past-top overscroll, as ever"
);
assert_eq!(nested_of(&outer).offset(), 0.0, "the inner never moved");
assert!(state.scrolls[INNER].is_empty());
}
#[test]
fn bouncing_inner_wins_even_at_edge() {
let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
let seen = Rc::new(Cell::new(ContentSeen::default()));
let inner = nest_surface_rubber_band(nest_content(&seen), 120.0, INNER);
nest(&mut outer, inner);
let mut state = Nest::default();
run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
assert!(
outer.inner_at_down.can_consume_down_drag,
"a displacement-allowing physics claims even pinned at the top"
);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
assert!(
outer.deferring,
"the outer defers even though the inner sits at offset 0"
);
assert!(nested_of(&outer).scrolling);
assert_eq!(seen.get().cancels, 1, "the inner's own takeover Cancel");
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
assert_eq!(
nested_of(&outer).offset(),
-10.0,
"the inner rubber-bands past its own top"
);
assert_eq!(outer.offset(), 0.0, "and the outer stays exactly put");
assert!(state.scrolls[OUTER].is_empty());
}
#[test]
fn content_fits_inner_does_not_steal_the_drag() {
let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
let seen = Rc::new(Cell::new(ContentSeen::default()));
let inner = nest_surface(nest_content(&seen), 1000.0, INNER);
assert_eq!(
inner.max_offset(),
0.0,
"the fixture's content exactly fits"
);
nest(&mut outer, inner);
let mut state = Nest::default();
run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
assert!(
!outer.inner_at_down.registered,
"no real capacity to scroll, so the claim never registers"
);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
assert!(outer.scrolling, "the outer takes the drag over");
assert!(!outer.deferring);
assert_eq!(
seen.get().cancels,
1,
"the outer's takeover Cancel reached the content through the inner"
);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
assert_eq!(
outer.offset(),
-10.0,
"the resisted 20px past-top overscroll, same as a pinned-inner takeover"
);
assert_eq!(nested_of(&outer).offset(), 0.0, "the inner never moved");
assert_eq!(nested_of(&outer).edge_pull, 0.0, "…nor accrued any pull");
assert!(state.scrolls[INNER].is_empty(), "the inner saw nothing");
}
#[test]
fn no_inner_behavior_identical() {
let seen = Rc::new(Cell::new(ContentSeen::default()));
let mut w = nest_surface(nest_content(&seen), 100.0, OUTER);
assert_eq!(w.max_offset(), 900.0);
let mut state = Nest::default();
run_nest(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
assert!(!w.inner_at_down.registered, "a plain child claims nothing");
assert_eq!(seen.get().downs, 1);
run_nest(&mut w, &mut state, &ev(PointerPhase::Move, 70.0), 16.0);
assert!(w.scrolling, "the slop still takes the gesture over");
assert!(!w.deferring);
assert_eq!(seen.get().cancels, 1, "exactly one child Cancel, as before");
assert_eq!(w.offset(), 0.0, "the takeover move does not itself scroll");
run_nest(&mut w, &mut state, &ev(PointerPhase::Move, 40.0), 32.0);
assert_eq!(
w.offset(),
30.0,
"the 30px `drag_past_slop_scrolls_the_offset` pins"
);
assert_eq!(seen.get().cancels, 1, "no further move reaches the child");
}
#[test]
fn three_deep_nesting_pairs_nearest() {
let seen = Rc::new(Cell::new(ContentSeen::default()));
let innermost = nest_surface(nest_content(&seen), 80.0, INNER);
let mut middle = nest_surface(spacer_content(), 140.0, MIDDLE);
nest(&mut middle, innermost);
let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
nest(&mut outer, middle);
let mut state = Nest::default();
run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 75.0), 0.0);
assert!(outer.inner_at_down.registered, "outer sees the middle");
assert!(
nested_of(&outer).inner_at_down.registered,
"middle sees the innermost"
);
assert!(
!nested_of(nested_of(&outer)).inner_at_down.registered,
"the innermost sees no scrollable below it"
);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 35.0), 16.0);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 5.0), 32.0);
assert!(outer.deferring && nested_of(&outer).deferring);
assert!(!nested_of(nested_of(&outer)).deferring);
assert!(nested_of(nested_of(&outer)).scrolling);
assert_eq!(outer.offset(), 0.0, "the outermost never moved");
assert_eq!(nested_of(&outer).offset(), 0.0, "nor the middle");
assert_eq!(
nested_of(nested_of(&outer)).offset(),
30.0,
"only the innermost took the drag"
);
assert!(state.scrolls[OUTER].is_empty() && state.scrolls[MIDDLE].is_empty());
assert!(!state.scrolls[INNER].is_empty());
}
#[test]
fn up_and_cancel_still_reach_child_when_deferring() {
let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
let seen = Rc::new(Cell::new(ContentSeen::default()));
let inner = nest_surface_rubber_band(nest_content(&seen), 120.0, INNER);
nest(&mut outer, inner);
let mut state = Nest::default();
run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
assert!(
outer.deferring,
"the inner can rubber-band, so the outer defers"
);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 210.0), 32.0);
assert_eq!(nested_of(&outer).offset(), -75.0);
assert!(crossed_refresh_trigger(nested_of(&outer).edge_pull));
run_nest(&mut outer, &mut state, &ev(PointerPhase::Up, 210.0), 48.0);
assert_eq!(
state.refreshes[INNER], 1,
"the forwarded Up fired the INNER's refresh exactly once"
);
assert_eq!(state.refreshes[OUTER], 0, "and never the outer's");
assert_eq!(outer.offset(), 0.0, "the outer never scrolled at all");
assert!(state.scrolls[OUTER].is_empty());
assert!(!outer.deferring);
assert!(!outer.inner_at_down.registered);
assert!(!outer.scrolling && !outer.down_active);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 64.0);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 80.0);
assert!(outer.deferring, "still the inner's gesture");
run_nest(
&mut outer,
&mut state,
&ev(PointerPhase::Cancel, 60.0),
96.0,
);
assert_eq!(seen.get().cancels, 3);
assert_eq!(state.refreshes[INNER], 1, "a Cancel never fires a refresh");
assert!(
!outer.deferring,
"the Cancel clears the arbitration state too"
);
assert!(!outer.inner_at_down.registered);
}
#[test]
fn a_nested_list_view_wins_the_drag_from_a_scroll_view() {
let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
let mut list = nested_list(150.0);
park(&mut list, 150.0, 300.0);
assert_eq!(list.offset(), 300.0);
outer.child = ChildPod::new(Box::new(list));
let mut state = Nest::default();
run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
assert!(
outer.inner_at_down.registered,
"a nested ListView reports itself on the same seam"
);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 50.0), 16.0);
assert!(outer.deferring);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 10.0), 32.0);
assert_eq!(outer.offset(), 0.0, "the outer never moved");
assert!(state.scrolls[OUTER].is_empty());
assert_eq!(
nested_list_of(&outer).offset(),
340.0,
"the nested list took the 40px"
);
}
#[test]
fn a_pinned_nested_list_view_hands_the_drag_back_to_the_scroll_view() {
let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
let mut list = nested_list(150.0);
list.physics = Rc::new(Clamping::new());
outer.child = ChildPod::new(Box::new(list));
let mut state = Nest::default();
run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
assert!(
!outer.inner_at_down.can_consume_down_drag,
"the nested list is pinned at its own top"
);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
assert!(outer.scrolling, "so the outer takes over as it always has");
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
assert_eq!(
outer.offset(),
-10.0,
"the resisted 20px past-top overscroll"
);
assert_eq!(
nested_list_of(&outer).offset(),
0.0,
"the nested list never moved"
);
}
#[test]
fn a_content_fits_nested_list_view_does_not_steal_the_drag() {
let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
let list = nested_list(1000.0);
assert_eq!(list.max_offset(), 0.0, "the fixture's content exactly fits");
outer.child = ChildPod::new(Box::new(list));
let mut state = Nest::default();
run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
assert!(
!outer.inner_at_down.registered,
"no real capacity to scroll, so the claim never registers"
);
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
assert!(outer.scrolling, "the outer takes the drag over");
run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
assert_eq!(
outer.offset(),
-10.0,
"the resisted 20px past-top overscroll, same as a pinned-inner takeover"
);
assert_eq!(
nested_list_of(&outer).offset(),
0.0,
"the nested list never moved"
);
}
#[test]
fn physics_builder_installs_custom_physics() {
let view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0)).physics(NeverScrollable::new());
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::loose(Size::new(200.0, 100.0)));
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
assert_eq!(w.offset(), 0.0, "NeverScrollable must refuse the drag");
assert!(
!w.scrolling,
"NeverScrollable must never take the gesture over"
);
let mut default_w = laid_out(200.0, 100.0, 1000.0);
dispatch(&mut default_w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut default_w, &ev(PointerPhase::Move, 70.0), 16.0);
dispatch(&mut default_w, &ev(PointerPhase::Move, 40.0), 32.0);
assert_eq!(
default_w.offset(),
30.0,
"the default twin scrolls normally"
);
}
#[test]
fn effect_builder_reaches_widget() {
let view: ScrollView<()> =
scroll_view(leaf(200.0, 1000.0)).overscroll_effect(OverscrollEffect::Stretch);
let mut counter = 0u64;
let w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
assert_eq!(w.effect, OverscrollEffect::Stretch);
let default_view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0));
let default_w = View::<()>::build(&default_view, &mut BuildCtx::new(&mut counter));
assert_eq!(
default_w.effect,
crate::physics::default_overscroll_effect()
);
#[cfg(not(target_os = "android"))]
assert_eq!(default_w.effect, OverscrollEffect::Translate);
}
#[test]
fn rebuild_preserves_builder_physics() {
let physics_view: ScrollView<()> =
scroll_view(leaf(200.0, 1000.0)).physics(NeverScrollable::new());
let mut counter = 0u64;
let mut w = View::<()>::build(&physics_view, &mut BuildCtx::new(&mut counter));
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
View::<()>::rebuild(
&physics_view,
&physics_view,
&mut w,
&mut BuildCtx::new(&mut counter),
);
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
assert_eq!(
w.offset(),
0.0,
"still installed after a re-asserting rebuild"
);
assert!(!w.scrolling);
dispatch(&mut w, &ev(PointerPhase::Cancel, 40.0), 48.0);
let plain_view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0));
View::<()>::rebuild(
&plain_view,
&physics_view,
&mut w,
&mut BuildCtx::new(&mut counter),
);
dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
assert_eq!(
w.offset(),
0.0,
"a rebuild whose view carries no .physics(...) leaves the widget's physics untouched"
);
assert!(!w.scrolling);
}
#[test]
fn an_edit_command_reaches_the_focused_child() {
use crate::text_input;
use frust_core::{EditCommand, RenderRoot};
struct Field {
value: String,
}
fn logic(state: &mut Field) -> ScrollView<Field> {
scroll_view(text_input(
state.value.clone(),
|s: &mut Field, v: String| {
s.value = v;
},
))
}
let mut state = Field {
value: "hello".to_string(),
};
let mut root: RenderRoot<Field, ScrollView<Field>> = RenderRoot::new();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(200.0, 100.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 child 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 child, through this router"
);
assert_eq!(state.value, "hello", "a copy edits nothing");
}
}
#[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;
#[derive(Default)]
struct App {
transforms: Vec<PanZoomTransform>,
scales: Vec<ScaleEvent>,
}
type Seen = Rc<RefCell<Vec<(PointerId, PointerPhase)>>>;
#[derive(Clone)]
struct Tall {
seen: Seen,
grabs: bool,
opt_in: bool,
consumes: bool,
}
struct TallWidget(Tall);
impl View<App> for Tall {
type Element = TallWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> TallWidget {
TallWidget(self.clone())
}
fn rebuild(&self, _p: &Self, _e: &mut TallWidget, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for TallWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(400.0, 1000.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,
}
}
}
fn tall(grabs: bool, opt_in: bool, consumes: bool) -> (Tall, Seen) {
let seen = Seen::default();
let view = Tall {
seen: seen.clone(),
grabs,
opt_in,
consumes,
};
(view, seen)
}
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 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,
},
}
}
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)
}
fn viewport(root: &RenderRoot<App, ScrollView<App>>) -> &ScrollWidget {
let id = root.root_id().expect("root built");
(root.tree().pod(id).expect("root pod").widget() as &dyn Any)
.downcast_ref::<ScrollWidget>()
.expect("root is a ScrollWidget")
}
#[test]
fn a_second_finger_never_rearms_the_viewport_around_a_pan_zoom() {
use PointerPhase::{Down, Move};
let (content, seen) = tall(false, false, false);
let (mut root, mut state) = root_over(move || {
scroll_view(
pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t)),
)
});
let t0 = PointerId::touch(0);
root.event(&mut state, &touch(0, Down, 100.0, 100.0));
assert_eq!(root.pointer_capture_claimant(), Some(t0));
assert!(root.pointer_capture_contacts(), "pan_zoom opted in");
root.event(&mut state, &touch(1, Down, 100.0, 250.0));
root.event(&mut state, &touch(0, Move, 110.0, 100.0));
root.event(&mut state, &touch(0, Move, 120.0, 100.0));
let scroll = viewport(&root);
assert_eq!(scroll.offset(), 0.0, "the viewport never scrolled");
assert!(!scroll.scrolling, "and never took the gesture over");
assert_eq!(
scroll.down_start,
Point::new(100.0, 100.0),
"its drag is still anchored on the claimant"
);
assert_eq!(
state.transforms.last().map(|t| t.offset),
Some(kurbo::Vec2::new(20.0, 0.0)),
"pan_zoom kept receiving the claimant's moves — it was never cancelled"
);
assert!(
!seen
.borrow()
.iter()
.any(|(_, phase)| *phase == PointerPhase::Cancel)
);
assert_eq!(root.pointer_capture_claimant(), Some(t0));
assert!(
root.pointer_capture_contacts(),
"still routing the second finger to pan_zoom"
);
}
#[test]
fn a_pinch_inside_a_viewport_reports_its_scale_and_leaves_the_viewport_alone() {
use PointerPhase::{Down, Move, Up};
let (content, _) = tall(true, false, false);
let (mut root, mut state) = root_over(move || {
scroll_view(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(1, Move, 120.0, 160.0));
root.paint(&mut sink, ms(32));
root.event(&mut state, &touch(1, Move, 120.0, 250.0));
root.event(&mut state, &touch(1, Up, 120.0, 250.0));
let phases: Vec<ScalePhase> = state.scales.iter().map(|e| e.phase).collect();
assert_eq!(
phases,
[ScalePhase::Begin, ScalePhase::Update, ScalePhase::End],
"the pinch recognizer saw the whole spread"
);
let scroll = viewport(&root);
assert_eq!(scroll.offset(), 0.0);
assert!(!scroll.scrolling);
assert_eq!(root.pointer_capture_claimant(), Some(PointerId::touch(0)));
root.event(&mut state, &touch(0, Up, 100.0, 100.0));
assert!(!root.is_pointer_captured());
}
#[test]
fn a_vertical_drag_over_a_non_panning_child_still_scrolls_and_ends_its_opt_in() {
use PointerPhase::{Cancel, Down, Move, Up};
let (content, seen) = tall(true, true, false);
let (mut root, mut state) = root_over(move || scroll_view(content.clone()));
let t0 = PointerId::touch(0);
root.event(&mut state, &touch(0, Down, 100.0, 100.0));
assert!(root.pointer_capture_contacts(), "the child opted in");
root.event(&mut state, &touch(0, Move, 100.0, 60.0));
assert!(viewport(&root).scrolling);
assert_eq!(*seen.borrow(), [(t0, Down), (t0, Cancel)]);
assert_eq!(
root.pointer_capture_claimant(),
Some(t0),
"the drag is still the first finger's"
);
assert!(
!root.pointer_capture_contacts(),
"but the cancelled child no longer gets other fingers"
);
let outcome = root.event(&mut state, &touch(1, Down, 100.0, 200.0));
assert!(!outcome.handled);
assert_eq!(seen.borrow().len(), 2);
root.event(&mut state, &touch(0, Move, 100.0, 30.0));
assert_eq!(viewport(&root).offset(), 30.0);
root.event(&mut state, &touch(0, Up, 100.0, 30.0));
assert!(!root.is_pointer_captured());
assert_eq!(
seen.borrow().len(),
2,
"the child heard nothing after its Cancel"
);
}
fn bare(child: Tall) -> ScrollWidget {
let view: ScrollView<App> = scroll_view(child);
let mut counter = 0u64;
let mut w = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
w.layout(
&mut LayoutCtx::new(),
&BoxConstraints::tight(Size::new(400.0, 300.0)),
);
w
}
fn run(w: &mut ScrollWidget, event: &InputEvent, t_ms: f64) -> (EventResult, bool) {
let mut state = App::default();
let sa: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(400.0, 300.0));
let result = w.event_at(&mut ctx, event, t_ms);
(result, ctx.is_capture_released())
}
fn mouse(phase: PointerPhase, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(100.0, y),
button: PointerButton::Primary,
})
}
#[test]
fn a_takeover_raises_the_release_only_for_an_opted_in_child() {
for opt_in in [true, false] {
let (content, _) = tall(true, opt_in, false);
let mut w = bare(content);
assert!(!run(&mut w, &mouse(PointerPhase::Down, 100.0), 0.0).1);
let (_, released) = run(&mut w, &mouse(PointerPhase::Move, 60.0), 16.0);
assert!(w.scrolling, "took over");
assert!(!w.child.is_active(), "and released the child");
assert_eq!(released, opt_in, "signalled only when the child opted in");
}
}
#[test]
fn a_scale_keeps_the_childs_result_and_a_broadcast_is_never_consumed() {
let scale = InputEvent::Scale(ScaleEvent {
phase: ScalePhase::Update,
scale_delta: 1.1,
focal: Point::new(100.0, 100.0),
velocity: 0.0,
});
let (consumer, _) = tall(false, false, true);
let mut w = bare(consumer);
assert_eq!(run(&mut w, &scale, 0.0).0, EventResult::Handled);
assert_eq!(
run(&mut w, &InputEvent::Housekeeping, 0.0).0,
EventResult::Ignored,
"a broadcast is never consumed, whatever the child returned"
);
let (bystander, _) = tall(false, false, false);
let mut w = bare(bystander);
assert_eq!(run(&mut w, &scale, 0.0).0, EventResult::Ignored);
}
#[test]
fn a_wheel_zoom_handled_inside_the_viewport_is_not_applied_twice() {
let (content, _) = tall(false, false, false);
let (mut root, mut state) = root_over(move || {
pinch_detector(scroll_view(
pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t)),
))
.on_scale(|s: &mut App, e| s.scales.push(e))
});
let outcome = root.event(
&mut state,
&InputEvent::Scale(ScaleEvent {
phase: ScalePhase::Update,
scale_delta: 1.5,
focal: Point::new(100.0, 100.0),
velocity: 0.0,
}),
);
assert!(outcome.handled);
assert_eq!(state.transforms.len(), 1, "pan_zoom zoomed once");
assert!(
state.scales.is_empty(),
"and the enclosing recognizer did not zoom again"
);
}
#[test]
fn a_pinch_survives_the_claimants_own_travel_past_slop() {
use PointerPhase::{Down, Move};
let (content, _seen) = tall(false, false, false);
let (mut root, mut state) = root_over(move || {
scroll_view(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 scroll = viewport(&root);
assert_eq!(scroll.offset(), 0.0, "the viewport never scrolled");
assert!(!scroll.scrolling, "and never took the gesture over");
assert_eq!(root.pointer_capture_claimant(), Some(PointerId::touch(0)));
assert!(
root.pointer_capture_contacts(),
"the viewport 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 (content, _seen) = tall(true, false, false);
let (mut root, mut state) = root_over(move || {
scroll_view(
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 scroll = viewport(&root);
assert_eq!(scroll.offset(), 0.0, "the viewport never scrolled");
assert!(!scroll.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 (content, _seen) = tall(false, false, false);
let (mut root, mut state) = root_over(move || {
scroll_view(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 (content, _seen) = tall(true, false, false);
let (mut root, mut state) = root_over(move || {
scroll_view(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 viewport resumed scrolling once the pinch ended"
);
}
}