use super::widget::{Point, Rect};
use crate::clock::{EventInstant, FrameInstant};
use crate::reactive::{IntoSignal, Prop};
use crate::widgets::{Color, Container};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollbarAxis {
Vertical,
Horizontal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollAxis {
#[default]
None,
Vertical,
Horizontal,
Both,
}
impl ScrollAxis {
pub fn allows_vertical(&self) -> bool {
matches!(self, ScrollAxis::Vertical | ScrollAxis::Both)
}
pub fn allows_horizontal(&self) -> bool {
matches!(self, ScrollAxis::Horizontal | ScrollAxis::Both)
}
pub fn along(self, dx: f32, dy: f32) -> (f32, f32) {
match self {
ScrollAxis::Vertical => (0.0, dy),
ScrollAxis::Horizontal => (dx, 0.0),
ScrollAxis::Both => (dx, dy),
ScrollAxis::None => (0.0, 0.0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollbarVisibility {
#[default]
Always,
Hidden,
}
#[derive(Debug, Clone, Copy)]
pub struct ScrollbarMetrics {
pub width: f32,
pub hover_width: f32,
pub margin: f32,
pub min_handle_size: f32,
pub reserve_gutter: bool,
}
impl Default for ScrollbarMetrics {
fn default() -> Self {
Self {
width: 6.0,
hover_width: 10.0,
margin: 2.0,
min_handle_size: 20.0,
reserve_gutter: true,
}
}
}
fn pill() -> crate::widgets::Corners {
crate::widgets::Corners::rounded(100.0)
}
pub struct Scroll {
pub(crate) axis: ScrollAxis,
pub(crate) visibility: Prop<ScrollbarVisibility>,
pub(crate) width: Prop<f32>,
pub(crate) hover_width: Prop<f32>,
pub(crate) margin: Prop<f32>,
pub(crate) min_handle_size: Prop<f32>,
pub(crate) reserve_gutter: Prop<bool>,
pub(crate) fill_viewport: Prop<bool>,
pub(crate) track: Option<Box<dyn Fn(Container) -> Container>>,
pub(crate) handle: Option<Box<dyn Fn(Container) -> Container>>,
}
impl Scroll {
fn new(axis: ScrollAxis) -> Self {
Self {
axis,
visibility: Prop::Unset,
width: Prop::Unset,
hover_width: Prop::Unset,
margin: Prop::Unset,
min_handle_size: Prop::Unset,
reserve_gutter: Prop::Unset,
fill_viewport: Prop::Unset,
track: None,
handle: None,
}
}
pub fn vertical() -> Self {
Self::new(ScrollAxis::Vertical)
}
pub fn horizontal() -> Self {
Self::new(ScrollAxis::Horizontal)
}
pub fn both() -> Self {
Self::new(ScrollAxis::Both)
}
pub fn visibility<M>(mut self, visibility: impl IntoSignal<ScrollbarVisibility, M>) -> Self {
self.visibility = visibility.into_prop();
self
}
pub fn width<M>(mut self, width: impl IntoSignal<f32, M>) -> Self {
self.width = width.into_prop();
self
}
pub fn hover_width<M>(mut self, width: impl IntoSignal<f32, M>) -> Self {
self.hover_width = width.into_prop();
self
}
pub fn margin<M>(mut self, margin: impl IntoSignal<f32, M>) -> Self {
self.margin = margin.into_prop();
self
}
pub fn min_handle_size<M>(mut self, size: impl IntoSignal<f32, M>) -> Self {
self.min_handle_size = size.into_prop();
self
}
pub fn reserve_gutter<M>(mut self, reserve: impl IntoSignal<bool, M>) -> Self {
self.reserve_gutter = reserve.into_prop();
self
}
pub fn overlay(self) -> Self {
self.reserve_gutter(false)
}
pub fn fill_viewport<M>(mut self, fill: impl IntoSignal<bool, M>) -> Self {
self.fill_viewport = fill.into_prop();
self
}
pub fn default_track() -> Container {
Container::new()
.background(Color::rgba(1.0, 1.0, 1.0, 0.05))
.corners(pill())
}
pub fn default_handle() -> Container {
use crate::widgets::state_layer::{StateStyle, Stateful};
Container::new()
.background(Color::rgba(1.0, 1.0, 1.0, 0.3))
.corners(pill())
.when_hovered(|s: StateStyle| s.background(Color::rgba(1.0, 1.0, 1.0, 0.5)))
.when_pressed(|s: StateStyle| s.background(Color::rgba(1.0, 1.0, 1.0, 0.6)).ripple())
}
pub fn track(mut self, f: impl Fn(Container) -> Container + 'static) -> Self {
self.track = Some(Box::new(f));
self
}
pub fn handle(mut self, f: impl Fn(Container) -> Container + 'static) -> Self {
self.handle = Some(Box::new(f));
self
}
}
pub(crate) const TOUCH_SLOP: f32 = 18.0;
#[derive(Debug, Clone, Copy)]
pub(crate) struct ContentDrag {
pub origin: Point,
pub last: Point,
pub scrolling: bool,
}
impl ContentDrag {
pub fn landed(at: Point) -> Self {
Self {
origin: at,
last: at,
scrolling: false,
}
}
pub fn travel(&self, axis: ScrollAxis) -> f32 {
let (dx, dy) = axis.along(self.last.x - self.origin.x, self.last.y - self.origin.y);
dx.hypot(dy)
}
}
#[derive(Debug, Default)]
pub(crate) struct ScrollState {
pub offset_x: f32,
pub offset_y: f32,
pub content_width: f32,
pub content_height: f32,
pub viewport_width: f32,
pub viewport_height: f32,
pub scrollbar_hovered: bool,
pub scrollbar_track_hovered: bool, pub scrollbar_dragging: bool,
pub scrollbar_drag_start_y: f32,
pub scrollbar_drag_start_offset: f32,
pub h_scrollbar_hovered: bool,
pub h_scrollbar_track_hovered: bool, pub h_scrollbar_dragging: bool,
pub h_scrollbar_drag_start_x: f32,
pub h_scrollbar_drag_start_offset: f32,
pub content_drag: Option<ContentDrag>,
pub velocity_x: f32,
pub velocity_y: f32,
pub last_scroll_time: Option<EventInstant>,
pub gesture_ended: bool,
pub momentum_since: Option<FrameInstant>,
pub gesture_samples: u32,
}
const NOMINAL_FRAME_MS: f32 = 1000.0 / 60.0;
const DRAWN_PACING_FRAMES: f32 = 3.0;
impl ScrollState {
pub fn max_scroll_x(&self) -> f32 {
(self.content_width - self.viewport_width).max(0.0)
}
pub fn max_scroll_y(&self) -> f32 {
(self.content_height - self.viewport_height).max(0.0)
}
pub fn needs_vertical_scrollbar(&self) -> bool {
self.content_height > self.viewport_height
}
pub fn needs_horizontal_scrollbar(&self) -> bool {
self.content_width > self.viewport_width
}
pub fn clamp_offsets(&mut self) {
self.offset_x = self.offset_x.clamp(0.0, self.max_scroll_x());
self.offset_y = self.offset_y.clamp(0.0, self.max_scroll_y());
}
pub fn record_gesture_sample(&mut self, delta_x: f32, delta_y: f32, dt_ms: Option<f32>) {
const MIN_SAMPLE_MS: f32 = 1.0;
const SMOOTHING: f32 = 0.6;
const MAX_STEP: f32 = 120.0;
self.gesture_ended = false;
self.momentum_since = None;
let Some(dt_ms) = dt_ms else {
self.velocity_x = 0.0;
self.velocity_y = 0.0;
self.gesture_samples = 1;
return;
};
let dt_ms = dt_ms.max(MIN_SAMPLE_MS);
let step = |delta: f32| ((delta / dt_ms) * NOMINAL_FRAME_MS).clamp(-MAX_STEP, MAX_STEP);
let (step_x, step_y) = (step(delta_x), step(delta_y));
if self.gesture_samples <= 1 {
self.velocity_x = step_x;
self.velocity_y = step_y;
} else {
self.velocity_x = self.velocity_x * (1.0 - SMOOTHING) + step_x * SMOOTHING;
self.velocity_y = self.velocity_y * (1.0 - SMOOTHING) + step_y * SMOOTHING;
}
self.gesture_samples = self.gesture_samples.saturating_add(1);
}
pub fn stop_momentum(&mut self) {
self.velocity_x = 0.0;
self.velocity_y = 0.0;
self.momentum_since = None;
self.gesture_ended = false;
}
pub fn begin_gesture(&mut self) {
self.last_scroll_time = None;
self.gesture_samples = 0;
}
pub fn end_gesture(&mut self, at: EventInstant) {
let since_last_sample_ms = self
.last_scroll_time
.map(|t| at.duration_since(t).as_secs_f32() * 1000.0);
const GESTURE_STALE_MS: f32 = 100.0;
if since_last_sample_ms.is_none_or(|ms| ms > GESTURE_STALE_MS) {
self.velocity_x = 0.0;
self.velocity_y = 0.0;
}
self.gesture_ended = true;
self.gesture_samples = 0;
self.momentum_since = Some(at.as_frame_start());
}
pub fn should_apply_momentum(&self) -> bool {
const VELOCITY_THRESHOLD: f32 = 0.5;
self.gesture_ended
&& (self.velocity_x.abs() > VELOCITY_THRESHOLD
|| self.velocity_y.abs() > VELOCITY_THRESHOLD)
}
pub fn advance_momentum(&mut self, now: FrameInstant) -> bool {
const FRICTION: f32 = 0.92;
const VELOCITY_THRESHOLD: f32 = 0.5;
if !self.should_apply_momentum() {
return false;
}
let Some(began) = self.momentum_since else {
return false;
};
let frames = now.saturating_duration_since(began).as_secs_f32() * 1000.0 / NOMINAL_FRAME_MS;
let travelled = frames.min(DRAWN_PACING_FRAMES);
self.momentum_since = Some(now);
let mut animating = false;
let decay = FRICTION.powf(frames);
if self.velocity_x.abs() > VELOCITY_THRESHOLD {
self.velocity_x *= decay;
self.offset_x += self.velocity_x * travelled;
animating = true;
} else {
self.velocity_x = 0.0;
}
if self.velocity_y.abs() > VELOCITY_THRESHOLD {
self.velocity_y *= decay;
self.offset_y += self.velocity_y * travelled;
animating = true;
} else {
self.velocity_y = 0.0;
}
let max_x = self.max_scroll_x();
let max_y = self.max_scroll_y();
self.offset_x = self.offset_x.clamp(0.0, max_x);
self.offset_y = self.offset_y.clamp(0.0, max_y);
if self.offset_x == 0.0 || self.offset_x == max_x {
self.velocity_x = 0.0;
}
if self.offset_y == 0.0 || self.offset_y == max_y {
self.velocity_y = 0.0;
}
animating
}
pub fn scrollbar_track_rect(
&self,
axis: ScrollbarAxis,
bounds: Rect,
config: &ScrollbarMetrics,
needs_other_scrollbar: bool,
) -> Rect {
let margin = config.margin;
let width = config.width;
match axis {
ScrollbarAxis::Vertical => Rect::new(
bounds.x + bounds.width - width - margin,
bounds.y + margin,
width,
bounds.height - margin * 2.0,
),
ScrollbarAxis::Horizontal => {
let right_padding = if needs_other_scrollbar {
config.hover_width + margin
} else {
margin
};
Rect::new(
bounds.x + margin,
bounds.y + bounds.height - width - margin,
bounds.width - margin - right_padding,
width,
)
}
}
}
pub fn scrollbar_hit_area(
&self,
axis: ScrollbarAxis,
bounds: Rect,
config: &ScrollbarMetrics,
needs_other_scrollbar: bool,
) -> Rect {
let margin = config.margin;
match axis {
ScrollbarAxis::Vertical => Rect::new(
bounds.x + bounds.width - config.hover_width - margin,
bounds.y + margin,
config.hover_width,
bounds.height - margin * 2.0,
),
ScrollbarAxis::Horizontal => {
let right_padding = if needs_other_scrollbar {
config.hover_width + margin
} else {
margin
};
Rect::new(
bounds.x + margin,
bounds.y + bounds.height - config.hover_width - margin,
bounds.width - margin - right_padding,
config.hover_width,
)
}
}
}
pub fn scrollbar_handle_size(
&self,
axis: ScrollbarAxis,
track_size: f32,
config: &ScrollbarMetrics,
) -> f32 {
let (viewport, content) = match axis {
ScrollbarAxis::Vertical => (self.viewport_height, self.content_height),
ScrollbarAxis::Horizontal => (self.viewport_width, self.content_width),
};
if content <= viewport || content == 0.0 {
return 0.0;
}
let ratio = viewport / content;
(track_size * ratio).max(config.min_handle_size)
}
pub fn scrollbar_handle_offset(
&self,
axis: ScrollbarAxis,
track_size: f32,
handle_size: f32,
) -> f32 {
let (offset, max_scroll) = match axis {
ScrollbarAxis::Vertical => (self.offset_y, self.max_scroll_y()),
ScrollbarAxis::Horizontal => (self.offset_x, self.max_scroll_x()),
};
if max_scroll <= 0.0 {
return 0.0;
}
let available_travel = track_size - handle_size;
(offset / max_scroll) * available_travel
}
pub fn scrollbar_handle_rect(
&self,
axis: ScrollbarAxis,
bounds: Rect,
config: &ScrollbarMetrics,
needs_other_scrollbar: bool,
) -> Rect {
let track = self.scrollbar_track_rect(axis, bounds, config, needs_other_scrollbar);
let track_size = match axis {
ScrollbarAxis::Vertical => track.height,
ScrollbarAxis::Horizontal => track.width,
};
let handle_size = self.scrollbar_handle_size(axis, track_size, config);
let handle_offset = self.scrollbar_handle_offset(axis, track_size, handle_size);
match axis {
ScrollbarAxis::Vertical => {
Rect::new(track.x, track.y + handle_offset, track.width, handle_size)
}
ScrollbarAxis::Horizontal => Rect::new(
track.x.max(track.x + handle_offset),
track.y,
handle_size,
track.height,
),
}
}
pub fn is_track_hovered(&self, axis: ScrollbarAxis) -> bool {
match axis {
ScrollbarAxis::Vertical => self.scrollbar_track_hovered,
ScrollbarAxis::Horizontal => self.h_scrollbar_track_hovered,
}
}
pub fn is_handle_hovered(&self, axis: ScrollbarAxis) -> bool {
match axis {
ScrollbarAxis::Vertical => self.scrollbar_hovered,
ScrollbarAxis::Horizontal => self.h_scrollbar_hovered,
}
}
pub fn is_dragging(&self, axis: ScrollbarAxis) -> bool {
match axis {
ScrollbarAxis::Vertical => self.scrollbar_dragging,
ScrollbarAxis::Horizontal => self.h_scrollbar_dragging,
}
}
pub fn set_track_hovered(&mut self, axis: ScrollbarAxis, hovered: bool) {
match axis {
ScrollbarAxis::Vertical => self.scrollbar_track_hovered = hovered,
ScrollbarAxis::Horizontal => self.h_scrollbar_track_hovered = hovered,
}
}
pub fn set_handle_hovered(&mut self, axis: ScrollbarAxis, hovered: bool) {
match axis {
ScrollbarAxis::Vertical => self.scrollbar_hovered = hovered,
ScrollbarAxis::Horizontal => self.h_scrollbar_hovered = hovered,
}
}
pub fn set_dragging(&mut self, axis: ScrollbarAxis, dragging: bool) {
match axis {
ScrollbarAxis::Vertical => self.scrollbar_dragging = dragging,
ScrollbarAxis::Horizontal => self.h_scrollbar_dragging = dragging,
}
}
pub fn set_drag_start(&mut self, axis: ScrollbarAxis, pos: f32, offset: f32) {
match axis {
ScrollbarAxis::Vertical => {
self.scrollbar_drag_start_y = pos;
self.scrollbar_drag_start_offset = offset;
}
ScrollbarAxis::Horizontal => {
self.h_scrollbar_drag_start_x = pos;
self.h_scrollbar_drag_start_offset = offset;
}
}
}
pub fn drag_start(&self, axis: ScrollbarAxis) -> (f32, f32) {
match axis {
ScrollbarAxis::Vertical => (
self.scrollbar_drag_start_y,
self.scrollbar_drag_start_offset,
),
ScrollbarAxis::Horizontal => (
self.h_scrollbar_drag_start_x,
self.h_scrollbar_drag_start_offset,
),
}
}
pub fn set_offset(&mut self, axis: ScrollbarAxis, offset: f32) {
match axis {
ScrollbarAxis::Vertical => self.offset_y = offset,
ScrollbarAxis::Horizontal => self.offset_x = offset,
}
}
pub fn max_scroll(&self, axis: ScrollbarAxis) -> f32 {
match axis {
ScrollbarAxis::Vertical => self.max_scroll_y(),
ScrollbarAxis::Horizontal => self.max_scroll_x(),
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
fn end_gesture_after(state: &mut ScrollState, gap_ms: f32) {
let at = std::time::Instant::now();
state.last_scroll_time =
Some((at - std::time::Duration::from_micros((gap_ms * 1000.0) as u64)).into());
state.end_gesture(at.into());
}
use super::*;
fn scroller() -> ScrollState {
ScrollState {
content_height: 5000.0,
viewport_height: 400.0,
..Default::default()
}
}
fn wide_scroller() -> ScrollState {
ScrollState {
content_width: 5000.0,
viewport_width: 400.0,
..Default::default()
}
}
fn sideways_gesture(state: &mut ScrollState, samples: u32, delta: f32, dt_ms: f32) {
for i in 0..samples {
let dt = (i > 0).then_some(dt_ms);
state.record_gesture_sample(delta, 0.0, dt);
}
end_gesture_after(state, dt_ms);
}
#[test]
fn a_sideways_flick_glides_and_decays_like_a_downward_one() {
let mut sideways = wide_scroller();
sideways_gesture(&mut sideways, 5, 20.0, 8.0);
let sideways_speed = sideways.velocity_x;
let mut downward = scroller();
gesture(&mut downward, 5, 20.0, 8.0);
let downward_speed = downward.velocity_y;
assert!(
(sideways_speed - downward_speed).abs() < 0.01,
"the same gesture on either axis leaves the same speed"
);
let start = lifted();
let travelled = {
let before = sideways.offset_x;
let mut at = start;
for _ in 0..30 {
at = at + A_FRAME;
if !sideways.advance_momentum(at) {
break;
}
}
sideways.offset_x - before
};
let down = coast_at(&mut downward, start, A_FRAME, 30);
assert!(
travelled > 0.0,
"a sideways flick has to carry the content sideways"
);
assert!(
(travelled - down).abs() < down * 0.05,
"and as far as the same flick downward: {travelled}px against {down}px"
);
assert!(
(sideways.velocity_x - downward.velocity_y).abs() < 0.01,
"leaving the same speed behind it"
);
}
#[test]
fn an_axis_at_the_threshold_does_not_glide() {
for sideways in [true, false] {
let mut state = ScrollState {
content_width: 5000.0,
viewport_width: 400.0,
content_height: 5000.0,
viewport_height: 400.0,
gesture_ended: true,
momentum_since: Some(lifted()),
..Default::default()
};
let at_the_line = 0.5;
if sideways {
state.velocity_x = at_the_line;
state.velocity_y = 10.0;
} else {
state.velocity_x = 10.0;
state.velocity_y = at_the_line;
}
state.advance_momentum(lifted() + A_FRAME);
let (stalled, moving) = if sideways {
(state.offset_x, state.offset_y)
} else {
(state.offset_y, state.offset_x)
};
assert_eq!(
stalled, 0.0,
"a speed of exactly {at_the_line} is not a glide (sideways: \
{sideways})"
);
assert!(
moving > 0.0,
"and the other axis carried on (sideways: {sideways})"
);
}
}
#[test]
fn half_a_frame_moves_half_a_step_sideways() {
let mut state = wide_scroller();
sideways_gesture(&mut state, 5, 20.0, 8.0);
let speed = state.velocity_x;
let start = lifted();
state.advance_momentum(start + A_FRAME / 2);
let expected = speed * 0.92f32.powf(0.5) * 0.5;
assert!(
(state.offset_x - expected).abs() < expected * 0.05,
"half a frame of a {speed}px glide moved {}px, against the \
{expected}px half a step is worth",
state.offset_x
);
}
fn gesture(state: &mut ScrollState, samples: u32, delta: f32, dt_ms: f32) {
for i in 0..samples {
let dt = (i > 0).then_some(dt_ms);
state.record_gesture_sample(0.0, delta, dt);
}
end_gesture_after(state, dt_ms);
}
const A_FRAME: std::time::Duration = std::time::Duration::from_micros(16_667);
fn lifted() -> FrameInstant {
FrameInstant::from(std::time::Instant::now())
}
fn coast_at(state: &mut ScrollState, start: FrameInstant, each: Duration, steps: u32) -> f32 {
let before = state.offset_y;
let mut at = start;
for _ in 0..steps {
at = at + each;
if !state.advance_momentum(at) {
break;
}
}
state.offset_y - before
}
#[test]
fn a_flick_travels_the_same_distance_whatever_the_refresh_rate() {
let mut sixty = scroller();
gesture(&mut sixty, 5, 20.0, 8.0);
let at_sixty = coast_at(&mut sixty, lifted(), A_FRAME, 30);
let mut one_twenty = scroller();
gesture(&mut one_twenty, 5, 20.0, 8.0);
let at_one_twenty = coast_at(&mut one_twenty, lifted(), A_FRAME / 2, 60);
let difference = (at_sixty - at_one_twenty).abs();
assert!(
difference < at_sixty * 0.05,
"the same flick over the same half second travelled {at_sixty}px at \
60Hz and {at_one_twenty}px at 120Hz"
);
}
#[test]
fn a_pause_decays_the_flick_by_the_length_of_the_pause() {
let mut unbroken = scroller();
gesture(&mut unbroken, 5, 20.0, 8.0);
coast_at(&mut unbroken, lifted(), A_FRAME, 24);
let mut paused = scroller();
gesture(&mut paused, 5, 20.0, 8.0);
coast_at(&mut paused, lifted(), A_FRAME * 24, 1);
let difference = (unbroken.velocity_y - paused.velocity_y).abs();
assert!(
difference < 0.01,
"twenty-four frames of glide and one frame covering the same time \
have to leave the same speed: {} against {}",
unbroken.velocity_y,
paused.velocity_y
);
}
#[test]
fn a_pause_does_not_jump_the_content() {
let mut state = scroller();
gesture(&mut state, 5, 20.0, 8.0);
let first = state.velocity_y;
let moved = coast_at(&mut state, lifted(), A_FRAME * 24, 1);
assert!(
moved <= first * DRAWN_PACING_FRAMES * 1.01,
"one frame after a four hundred millisecond gap moved {moved}px, \
which is more than the {}px the drawn part of it was worth",
first * DRAWN_PACING_FRAMES
);
}
#[test]
fn a_slow_loop_carries_a_flick_as_far_as_a_fast_one() {
let mut sixty = scroller();
gesture(&mut sixty, 5, 20.0, 8.0);
let at_sixty = coast_at(&mut sixty, lifted(), A_FRAME, 120);
let mut thirty = scroller();
gesture(&mut thirty, 5, 20.0, 8.0);
let at_thirty = coast_at(&mut thirty, lifted(), A_FRAME * 2, 60);
let difference = (at_sixty - at_thirty).abs();
assert!(
difference < at_sixty * 0.05,
"the same flick carried {at_sixty}px at 60Hz and {at_thirty}px at \
30Hz"
);
}
#[test]
fn a_long_enough_pause_ends_the_flick_without_a_rule_about_it() {
let mut state = scroller();
gesture(&mut state, 5, 20.0, 8.0);
let at = lifted() + Duration::from_secs(2);
state.advance_momentum(at);
assert!(
!state.advance_momentum(at + A_FRAME),
"two seconds of decay is nothing left to animate"
);
}
#[test]
fn a_flick_survives_a_loop_that_was_late_delivering_the_lift() {
let mut state = scroller();
gesture(&mut state, 5, 20.0, 8.0);
assert!(
state.should_apply_momentum(),
"the gesture left a velocity to fling"
);
let late =
FrameInstant::from(std::time::Instant::now() + std::time::Duration::from_millis(250));
assert!(
state.advance_momentum(late),
"the flick has to run on the frame that finally arrived, not be \
expired by how long it took to arrive"
);
assert!(
state.offset_y != 0.0,
"and it has to have moved the content"
);
}
fn coast(state: &mut ScrollState) -> f32 {
coast_at(
state,
FrameInstant::from(std::time::Instant::now()),
A_FRAME,
600,
)
}
#[test]
fn a_gesture_that_covered_its_distance_faster_flings_further() {
let mut quick = scroller();
gesture(&mut quick, 6, 10.0, 8.0);
let quick_coast = coast(&mut quick);
let mut slow = scroller();
gesture(&mut slow, 6, 10.0, 60.0);
let slow_coast = coast(&mut slow);
assert!(
quick_coast > slow_coast * 2.0,
"same 50px of gesture, {}x apart in time, coasted {quick_coast} and {slow_coast}",
60.0 / 8.0
);
}
#[test]
fn a_finger_resting_before_it_lifts_does_not_fling() {
let mut state = scroller();
for i in 0..6 {
state.record_gesture_sample(0.0, 10.0, (i > 0).then_some(8.0));
}
assert!(state.velocity_y.abs() > 0.5, "the gesture built a speed");
end_gesture_after(&mut state, 400.0);
assert!(!state.should_apply_momentum());
assert_eq!(coast(&mut state), 0.0);
}
#[test]
fn a_gesture_in_progress_has_no_momentum_to_apply() {
let mut state = scroller();
for i in 0..6 {
state.record_gesture_sample(0.0, 10.0, (i > 0).then_some(8.0));
}
assert!(state.velocity_y.abs() > 0.5, "the gesture built a speed");
assert!(!state.should_apply_momentum(), "but it is not due");
assert!(
!state.advance_momentum(std::time::Instant::now().into()),
"and nothing is animating, so the loop is not kept awake for it"
);
assert_eq!(coast(&mut state), 0.0);
}
#[test]
fn a_momentum_abandoned_mid_flight_runs_down_to_nothing() {
let mut state = scroller();
gesture(&mut state, 6, 10.0, 8.0);
assert!(state.should_apply_momentum(), "the flick is due");
let start = lifted();
state.advance_momentum(start + A_FRAME);
assert!(state.should_apply_momentum(), "still due between frames");
let running = state.velocity_y;
let abandoned = start + A_FRAME + Duration::from_millis(400);
state.advance_momentum(abandoned);
assert!(
state.velocity_y < running * 0.2,
"four hundred milliseconds of friction leaves a fraction of the \
speed, not the speed it had: {} against {running}",
state.velocity_y
);
let a_second_later = abandoned + Duration::from_secs(1);
state.advance_momentum(a_second_later);
assert!(
!state.advance_momentum(a_second_later + A_FRAME),
"and a second more of it is nothing left to run"
);
}
#[test]
fn a_dropped_frame_does_not_cut_a_momentum_short() {
let mut state = scroller();
gesture(&mut state, 6, 10.0, 8.0);
let after_the_hitch = lifted() + A_FRAME * 3;
assert!(
state.advance_momentum(after_the_hitch),
"a hitch slows a flick, it does not end it"
);
assert!(
coast_at(&mut state, after_the_hitch, A_FRAME, 120) > 0.0,
"and there is still a glide to run"
);
}
#[test]
fn a_new_sample_takes_the_gesture_back() {
let mut state = scroller();
gesture(&mut state, 6, 10.0, 8.0);
assert!(state.should_apply_momentum());
state.record_gesture_sample(0.0, 4.0, Some(8.0));
assert!(!state.should_apply_momentum());
}
#[test]
fn the_first_sample_of_a_gesture_is_not_a_speed() {
let mut state = scroller();
state.record_gesture_sample(0.0, 30.0, None);
assert_eq!(state.velocity_y, 0.0);
end_gesture_after(&mut state, 8.0);
assert!(!state.should_apply_momentum());
}
#[test]
fn test_scroll_axis_allows_vertical() {
assert!(!ScrollAxis::None.allows_vertical());
assert!(ScrollAxis::Vertical.allows_vertical());
assert!(!ScrollAxis::Horizontal.allows_vertical());
assert!(ScrollAxis::Both.allows_vertical());
}
#[test]
fn test_scroll_axis_allows_horizontal() {
assert!(!ScrollAxis::None.allows_horizontal());
assert!(!ScrollAxis::Vertical.allows_horizontal());
assert!(ScrollAxis::Horizontal.allows_horizontal());
assert!(ScrollAxis::Both.allows_horizontal());
}
#[test]
fn test_scroll_state_max_scroll() {
let state = ScrollState {
content_width: 500.0,
content_height: 800.0,
viewport_width: 300.0,
viewport_height: 400.0,
..Default::default()
};
assert_eq!(state.max_scroll_x(), 200.0);
assert_eq!(state.max_scroll_y(), 400.0);
assert_eq!(state.max_scroll(ScrollbarAxis::Horizontal), 200.0);
assert_eq!(state.max_scroll(ScrollbarAxis::Vertical), 400.0);
}
#[test]
fn test_scroll_state_max_scroll_no_overflow() {
let state = ScrollState {
content_width: 200.0,
content_height: 300.0,
viewport_width: 300.0,
viewport_height: 400.0,
..Default::default()
};
assert_eq!(state.max_scroll_x(), 0.0);
assert_eq!(state.max_scroll_y(), 0.0);
}
#[test]
fn test_scroll_state_needs_scrollbar() {
let state = ScrollState {
content_width: 500.0,
content_height: 800.0,
viewport_width: 300.0,
viewport_height: 400.0,
..Default::default()
};
assert!(state.needs_horizontal_scrollbar());
assert!(state.needs_vertical_scrollbar());
}
#[test]
fn test_scroll_state_needs_scrollbar_no_overflow() {
let state = ScrollState {
content_width: 200.0,
content_height: 300.0,
viewport_width: 300.0,
viewport_height: 400.0,
..Default::default()
};
assert!(!state.needs_horizontal_scrollbar());
assert!(!state.needs_vertical_scrollbar());
}
#[test]
fn test_scroll_state_clamp_offsets() {
let mut state = ScrollState {
content_width: 500.0,
content_height: 800.0,
viewport_width: 300.0,
viewport_height: 400.0,
offset_x: 300.0, offset_y: -50.0, ..Default::default()
};
state.clamp_offsets();
assert_eq!(state.offset_x, 200.0); assert_eq!(state.offset_y, 0.0); }
#[test]
fn test_scroll_state_set_offset_by_axis() {
let mut state = ScrollState::default();
state.set_offset(ScrollbarAxis::Vertical, 100.0);
state.set_offset(ScrollbarAxis::Horizontal, 50.0);
assert_eq!(state.offset_y, 100.0);
assert_eq!(state.offset_x, 50.0);
}
#[test]
fn test_scroll_state_hover_states() {
let mut state = ScrollState::default();
assert!(!state.is_track_hovered(ScrollbarAxis::Vertical));
assert!(!state.is_handle_hovered(ScrollbarAxis::Vertical));
state.set_track_hovered(ScrollbarAxis::Vertical, true);
state.set_handle_hovered(ScrollbarAxis::Vertical, true);
assert!(state.is_track_hovered(ScrollbarAxis::Vertical));
assert!(state.is_handle_hovered(ScrollbarAxis::Vertical));
assert!(!state.is_track_hovered(ScrollbarAxis::Horizontal));
assert!(!state.is_handle_hovered(ScrollbarAxis::Horizontal));
}
#[test]
fn test_scroll_state_dragging() {
let mut state = ScrollState::default();
assert!(!state.is_dragging(ScrollbarAxis::Vertical));
assert!(!state.is_dragging(ScrollbarAxis::Horizontal));
state.set_dragging(ScrollbarAxis::Vertical, true);
assert!(state.is_dragging(ScrollbarAxis::Vertical));
assert!(!state.is_dragging(ScrollbarAxis::Horizontal));
state.set_dragging(ScrollbarAxis::Horizontal, true);
assert!(state.is_dragging(ScrollbarAxis::Horizontal));
}
#[test]
fn test_scroll_state_drag_start() {
let mut state = ScrollState::default();
state.set_drag_start(ScrollbarAxis::Vertical, 100.0, 50.0);
let (pos, offset) = state.drag_start(ScrollbarAxis::Vertical);
assert_eq!(pos, 100.0);
assert_eq!(offset, 50.0);
state.set_drag_start(ScrollbarAxis::Horizontal, 200.0, 75.0);
let (pos, offset) = state.drag_start(ScrollbarAxis::Horizontal);
assert_eq!(pos, 200.0);
assert_eq!(offset, 75.0);
}
#[test]
fn test_scrollbar_handle_size() {
let state = ScrollState {
viewport_height: 400.0,
content_height: 800.0,
viewport_width: 300.0,
content_width: 600.0,
..Default::default()
};
let config = ScrollbarMetrics::default();
let v_handle = state.scrollbar_handle_size(ScrollbarAxis::Vertical, 400.0, &config);
assert_eq!(v_handle, 200.0);
let h_handle = state.scrollbar_handle_size(ScrollbarAxis::Horizontal, 300.0, &config);
assert_eq!(h_handle, 150.0);
}
#[test]
fn test_scrollbar_handle_size_min() {
let state = ScrollState {
viewport_height: 100.0,
content_height: 10000.0, ..Default::default()
};
let config = ScrollbarMetrics::default();
let handle = state.scrollbar_handle_size(ScrollbarAxis::Vertical, 400.0, &config);
assert_eq!(handle, config.min_handle_size);
}
#[test]
fn test_scrollbar_handle_offset() {
let state = ScrollState {
viewport_height: 400.0,
content_height: 800.0,
offset_y: 200.0, ..Default::default()
};
let offset = state.scrollbar_handle_offset(ScrollbarAxis::Vertical, 400.0, 200.0);
assert_eq!(offset, 100.0);
}
#[test]
fn a_swipe_across_a_list_is_not_travel_along_it() {
let mut drag = ContentDrag::landed(Point::new(100.0, 100.0));
drag.last = Point::new(160.0, 108.0);
assert_eq!(
drag.travel(ScrollAxis::Vertical),
8.0,
"only the y moved it"
);
assert_eq!(
drag.travel(ScrollAxis::Horizontal),
60.0,
"only the x, for a list that runs sideways"
);
assert_eq!(
drag.travel(ScrollAxis::Both),
60.0_f32.hypot(8.0),
"both, when both scroll"
);
assert_eq!(
drag.travel(ScrollAxis::None),
0.0,
"and nothing where nothing scrolls"
);
}
}