use std::time::Duration;
use frust::{AnimationController, FrameTime, SpringDesc};
pub(crate) const PRESS_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 380.0,
damping_ratio: 0.55,
};
const PRESS_ANIM_PERIOD: Duration = Duration::from_millis(300);
const FLING_VELOCITY: f64 = 4.0;
pub(crate) const RETARGET_TOLERANCE: f64 = 0.1;
const OVERSHOOT_LIMIT: f64 = 1.5;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ContentPadding {
pub(crate) left: f64,
pub(crate) right: f64,
pub(crate) top: f64,
pub(crate) bottom: f64,
}
impl ContentPadding {
pub(crate) const fn symmetric(horizontal: f64) -> Self {
Self {
left: horizontal,
right: horizontal,
top: 0.0,
bottom: 0.0,
}
}
fn lerp(self, to: Self, t: f64) -> Self {
Self {
left: lerp(self.left, to.left, t),
right: lerp(self.right, to.right, t),
top: lerp(self.top, to.top, t),
bottom: lerp(self.bottom, to.bottom, t),
}
}
fn matches(self, other: Self) -> bool {
(self.left - other.left).abs() <= RETARGET_TOLERANCE
&& (self.right - other.right).abs() <= RETARGET_TOLERANCE
&& (self.top - other.top).abs() <= RETARGET_TOLERANCE
&& (self.bottom - other.bottom).abs() <= RETARGET_TOLERANCE
}
}
fn lerp(from: f64, to: f64, t: f64) -> f64 {
from + (to - from) * t
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct RadiusPaddingMotion {
from_radius: f64,
to_radius: f64,
from_padding: ContentPadding,
to_padding: ContentPadding,
anim: AnimationController,
seeded: bool,
}
impl RadiusPaddingMotion {
pub(crate) fn new() -> Self {
let zero = ContentPadding {
left: 0.0,
right: 0.0,
top: 0.0,
bottom: 0.0,
};
Self {
from_radius: 0.0,
to_radius: 0.0,
from_padding: zero,
to_padding: zero,
anim: AnimationController::new(PRESS_ANIM_PERIOD),
seeded: false,
}
}
pub(crate) fn retarget(&mut self, radius: f64, padding: ContentPadding) -> bool {
if !self.seeded {
self.snap_to(radius, padding);
return false;
}
if (self.to_radius - radius).abs() <= RETARGET_TOLERANCE && self.to_padding.matches(padding)
{
return false;
}
self.from_radius = self.radius();
self.from_padding = self.padding();
self.to_radius = radius;
self.to_padding = padding;
self.anim = AnimationController::new(PRESS_ANIM_PERIOD);
self.anim.fling(FLING_VELOCITY, PRESS_SPRING);
true
}
pub(crate) fn snap_to(&mut self, radius: f64, padding: ContentPadding) {
self.from_radius = radius;
self.to_radius = radius;
self.from_padding = padding;
self.to_padding = padding;
self.anim = AnimationController::new(PRESS_ANIM_PERIOD);
self.seeded = true;
}
pub(crate) fn advance(&mut self, now: FrameTime) -> bool {
self.anim.advance(now)
}
pub(crate) fn is_seeded(&self) -> bool {
self.seeded
}
fn factor(&self) -> f64 {
let raw = self.anim.value();
if raw.is_finite() {
raw.clamp(0.0, OVERSHOOT_LIMIT)
} else {
0.0
}
}
pub(crate) fn radius(&self) -> f64 {
lerp(self.from_radius, self.to_radius, self.factor()).max(0.0)
}
pub(crate) fn padding(&self) -> ContentPadding {
self.from_padding.lerp(self.to_padding, self.factor())
}
#[cfg(test)]
pub(crate) fn target_radius(&self) -> f64 {
self.to_radius
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ft_secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
fn settle(motion: &mut RadiusPaddingMotion, max_steps: usize) -> usize {
let mut t = 0.0;
for step in 0..max_steps {
t += 1.0 / 60.0;
if !motion.advance(ft_secs(t)) {
return step;
}
}
max_steps
}
const PAD: ContentPadding = ContentPadding::symmetric(16.0);
#[test]
fn press_spring_matches_expressive_spatial_press() {
let token = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
assert_eq!(PRESS_SPRING.stiffness, token.stiffness);
assert_eq!(PRESS_SPRING.damping_ratio, token.damping_ratio);
assert_eq!(PRESS_SPRING.mass, 1.0);
assert_eq!(PRESS_SPRING.stiffness, 380.0);
assert_eq!(PRESS_SPRING.damping_ratio, 0.55);
}
#[test]
fn the_first_target_snaps_rather_than_springing() {
let mut motion = RadiusPaddingMotion::new();
assert!(
!motion.retarget(20.0, PAD),
"the mount frame seeds, it does not animate"
);
assert_eq!(motion.radius(), 20.0);
assert_eq!(motion.padding(), PAD);
assert!(!motion.advance(ft_secs(0.0)), "nothing to advance");
}
#[test]
fn a_target_inside_the_dead_band_is_ignored() {
let mut motion = RadiusPaddingMotion::new();
motion.retarget(20.0, PAD);
assert!(
!motion.retarget(20.0 + RETARGET_TOLERANCE / 2.0, PAD),
"a sub-tolerance change must not restart the spring"
);
assert_eq!(motion.target_radius(), 20.0);
assert!(
motion.retarget(20.0 + RETARGET_TOLERANCE * 2.0, PAD),
"a change past the tolerance starts a leg"
);
}
#[test]
fn a_press_springs_from_the_rest_radius_to_the_pressed_one() {
let mut motion = RadiusPaddingMotion::new();
motion.retarget(20.0, PAD); assert!(motion.retarget(8.0, PAD), "the press starts a leg");
assert_eq!(motion.radius(), 20.0, "the leg starts where it was");
settle(&mut motion, 600);
assert_eq!(motion.radius(), 8.0, "and settles exactly on target");
}
#[test]
fn retarget_mid_flight_is_continuous() {
let mut motion = RadiusPaddingMotion::new();
motion.retarget(20.0, PAD);
motion.retarget(8.0, PAD);
motion.advance(ft_secs(0.0));
motion.advance(ft_secs(0.05));
let mid = motion.radius();
assert!(
mid < 20.0 && mid > 8.0,
"sampled mid-flight (got {mid}), not at either endpoint"
);
assert!(motion.retarget(20.0, PAD));
assert_eq!(motion.radius(), mid);
settle(&mut motion, 600);
assert_eq!(motion.radius(), 20.0);
}
#[test]
fn padding_rides_the_same_progress_as_the_radius() {
let mut motion = RadiusPaddingMotion::new();
motion.retarget(20.0, ContentPadding::symmetric(16.0));
motion.retarget(8.0, ContentPadding::symmetric(24.0));
motion.advance(ft_secs(0.0));
motion.advance(ft_secs(0.05));
let factor = motion.factor();
assert_eq!(motion.radius(), lerp(20.0, 8.0, factor));
let pad = motion.padding();
assert_eq!(pad.left, lerp(16.0, 24.0, factor));
assert_eq!(pad.right, lerp(16.0, 24.0, factor));
assert_eq!(pad.top, 0.0, "a plain button has no vertical padding");
assert_eq!(pad.bottom, 0.0);
settle(&mut motion, 600);
assert_eq!(motion.padding(), ContentPadding::symmetric(24.0));
}
#[test]
fn overshoot_never_drives_a_corner_negative() {
let mut motion = RadiusPaddingMotion::new();
motion.retarget(28.0, PAD);
motion.retarget(0.0, PAD);
let mut t = 0.0;
for _ in 0..600 {
t += 1.0 / 60.0;
let animating = motion.advance(ft_secs(t));
assert!(motion.radius() >= 0.0, "radius went negative at t={t}");
if !animating {
break;
}
}
assert_eq!(motion.radius(), 0.0);
}
#[test]
fn snap_to_moves_without_animating() {
let mut motion = RadiusPaddingMotion::new();
motion.retarget(20.0, PAD);
motion.retarget(8.0, PAD);
motion.snap_to(28.0, ContentPadding::symmetric(48.0));
assert_eq!(motion.radius(), 28.0);
assert_eq!(motion.padding(), ContentPadding::symmetric(48.0));
assert!(
!motion.advance(ft_secs(1.0)),
"a snap leaves nothing running"
);
}
}