use std::time::Duration;
use crate::clock::FrameInstant;
use crate::widgets::state_layer::RippleConfig;
const FADE_IN: f32 = 0.075;
const HELD_GROWTH: f32 = 1.0;
const CONFIRM_GROWTH: f32 = 0.225;
const FADE_OUT: f32 = 0.375;
const CANCEL_FADE: f32 = 0.075;
const START_RADIUS: f32 = 0.3;
pub const MAX_LIVE_RIPPLES: usize = 4;
fn sane_speed(speed: f32) -> f32 {
if speed.is_finite() {
speed.clamp(0.05, 20.0)
} else {
1.0
}
}
fn phase(elapsed: f32, duration: f32) -> f32 {
if duration <= 0.0 {
return 1.0;
}
(elapsed / duration).clamp(0.0, 1.0)
}
fn ease_out(t: f32) -> f32 {
1.0 - (1.0 - t).powi(3)
}
fn since(start: FrameInstant, now: FrameInstant) -> f32 {
now.saturating_duration_since(start).as_secs_f32()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExitKind {
Confirmed,
Cancelled,
}
#[derive(Debug, Clone, Copy)]
struct Exit {
kind: ExitKind,
at: FrameInstant,
growth: f32,
}
#[derive(Debug, Clone)]
pub struct Ripple {
origin: (f32, f32),
born: FrameInstant,
exit: Option<Exit>,
growth: f32,
opacity: f32,
}
impl Ripple {
fn new(origin: (f32, f32), now: FrameInstant) -> Self {
Self {
origin,
born: now,
exit: None,
growth: 0.0,
opacity: 0.0,
}
}
pub fn opacity(&self) -> f32 {
self.opacity
}
fn full_radius(width: f32, height: f32) -> f32 {
0.5 * (width * width + height * height).sqrt()
}
pub fn radius(&self, width: f32, height: f32) -> f32 {
Self::full_radius(width, height) * (START_RADIUS + (1.0 - START_RADIUS) * self.growth)
}
pub fn center(&self, width: f32, height: f32) -> (f32, f32) {
let (x, y) = self.origin;
(
x + (width / 2.0 - x) * self.growth,
y + (height / 2.0 - y) * self.growth,
)
}
fn is_leaving(&self) -> bool {
self.exit.is_some()
}
fn begin_exit(&mut self, kind: ExitKind, config: &RippleConfig, now: FrameInstant) {
if self.exit.is_some() {
return;
}
self.advance(config, now);
self.exit = Some(Exit {
kind,
at: now,
growth: self.growth,
});
}
fn opacity_at(&self, config: &RippleConfig, now: FrameInstant) -> f32 {
let fade_speed = sane_speed(config.fade_speed);
let rise_over = FADE_IN / fade_speed;
let risen = phase(since(self.born, now), rise_over);
let Some(exit) = self.exit else {
return risen;
};
let falls_over = match exit.kind {
ExitKind::Confirmed => FADE_OUT,
ExitKind::Cancelled => CANCEL_FADE,
} / fade_speed;
let falls_from = exit.at.max(self.born + Duration::from_secs_f32(rise_over));
risen * (1.0 - ease_out(phase(since(falls_from, now), falls_over)))
}
fn gone(&self, config: &RippleConfig, now: FrameInstant) -> bool {
let Some(exit) = self.exit else {
return false;
};
let fade_speed = sane_speed(config.fade_speed);
let falls_over = match exit.kind {
ExitKind::Confirmed => FADE_OUT,
ExitKind::Cancelled => CANCEL_FADE,
} / fade_speed;
let falls_from = exit
.at
.max(self.born + Duration::from_secs_f32(FADE_IN / fade_speed));
phase(since(falls_from, now), falls_over) >= 1.0
}
fn held_growth(&self, config: &RippleConfig, now: FrameInstant) -> f32 {
let duration = HELD_GROWTH / sane_speed(config.expand_speed);
ease_out(phase(since(self.born, now), duration))
}
fn advance(&mut self, config: &RippleConfig, now: FrameInstant) -> bool {
self.opacity = self.opacity_at(config, now);
let Some(exit) = self.exit else {
self.growth = self.held_growth(config, now);
return self.growth < 1.0 || self.opacity < 1.0;
};
match exit.kind {
ExitKind::Confirmed => {
let fade = FADE_OUT / sane_speed(config.fade_speed);
let over = (CONFIRM_GROWTH / sane_speed(config.expand_speed)).min(fade);
let t = phase(since(exit.at, now), over);
self.growth = exit.growth + (1.0 - exit.growth) * ease_out(t);
}
ExitKind::Cancelled => {
self.growth = self.held_growth(config, now);
}
}
!self.gone(config, now)
}
}
#[derive(Debug, Clone, Default)]
pub struct RippleState {
live: Vec<Ripple>,
}
impl RippleState {
pub fn new() -> Self {
Self::default()
}
pub fn start(&mut self, local_x: f32, local_y: f32, now: FrameInstant) {
while self.live.len() >= MAX_LIVE_RIPPLES {
self.live.remove(0);
}
self.live.push(Ripple::new((local_x, local_y), now));
}
pub fn release(&mut self, config: &RippleConfig, now: FrameInstant) {
if let Some(held) = self.live.iter_mut().rev().find(|r| !r.is_leaving()) {
held.begin_exit(ExitKind::Confirmed, config, now);
}
}
pub fn cancel(&mut self, config: &RippleConfig, now: FrameInstant) {
for ripple in self.live.iter_mut().filter(|r| !r.is_leaving()) {
ripple.begin_exit(ExitKind::Cancelled, config, now);
}
}
pub fn is_active(&self) -> bool {
!self.live.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Ripple> {
self.live.iter()
}
pub fn advance(&mut self, config: &RippleConfig, now: FrameInstant) -> bool {
let mut animating = false;
self.live.retain_mut(|ripple| {
let alive = ripple.advance(config, now);
animating |= alive;
alive || ripple.opacity > 0.0
});
animating
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
fn cfg() -> RippleConfig {
RippleConfig::default()
}
fn at(base: FrameInstant, ms: u64) -> FrameInstant {
base + Duration::from_millis(ms)
}
#[test]
fn a_click_never_moves_the_radius_backwards() {
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
state.start(10.0, 10.0, t0);
state.advance(&cfg(), at(t0, 80));
let at_release = state.iter().next().unwrap().growth;
state.release(&cfg(), at(t0, 80));
let mut previous = at_release;
for ms in [100, 140, 200, 260, 320, 400, 460] {
state.advance(&cfg(), at(t0, ms));
let Some(ripple) = state.iter().next() else {
break;
};
assert!(
ripple.growth >= previous - f32::EPSILON,
"growth went backwards at {ms}ms: {previous} -> {}",
ripple.growth
);
previous = ripple.growth;
}
}
#[test]
fn a_click_completes_the_expansion_it_started() {
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
state.start(10.0, 10.0, t0);
state.advance(&cfg(), at(t0, 80));
state.release(&cfg(), at(t0, 80));
state.advance(&cfg(), at(t0, 80 + 225));
let ripple = state.iter().next().expect("still visible");
assert!(
ripple.growth > 0.99,
"the expansion has to finish, got {}",
ripple.growth
);
assert!(ripple.opacity > 0.0, "and still be visible while it does");
}
#[test]
fn a_tap_released_before_its_first_frame_is_still_seen() {
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
state.start(10.0, 10.0, t0);
state.release(&cfg(), t0);
let mut peak: f32 = 0.0;
for ms in [8, 16, 32, 60, 75, 100, 160] {
state.advance(&cfg(), at(t0, ms));
if let Some(ripple) = state.iter().next() {
peak = peak.max(ripple.opacity);
}
}
assert!(
peak > 0.99,
"a tap has to be fully visible at some point, peaked at {peak}"
);
}
#[test]
fn a_click_shorter_than_the_rise_still_reaches_full_strength() {
for click_ms in [20, 40, 60, 74] {
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
state.start(10.0, 10.0, t0);
state.advance(&cfg(), at(t0, click_ms));
state.release(&cfg(), at(t0, click_ms));
state.advance(&cfg(), at(t0, 75));
let ripple = state.iter().next().expect("still visible");
assert!(
ripple.opacity > 0.99,
"a {click_ms}ms click has to reach full strength, got {}",
ripple.opacity
);
}
}
#[test]
fn the_size_does_not_depend_on_where_the_press_landed() {
let t0 = FrameInstant::from(std::time::Instant::now());
let (w, h) = (200.0, 40.0);
let mut corner = RippleState::new();
corner.start(0.0, 0.0, t0);
let mut middle = RippleState::new();
middle.start(w / 2.0, h / 2.0, t0);
for state in [&mut corner, &mut middle] {
state.release(&cfg(), t0);
state.advance(&cfg(), at(t0, 300));
}
let radius = |s: &RippleState| s.iter().next().unwrap().radius(w, h);
assert_eq!(radius(&corner), radius(&middle));
}
#[test]
fn a_finished_ripple_covers_the_container_and_not_before() {
let t0 = FrameInstant::from(std::time::Instant::now());
let (w, h) = (200.0, 40.0);
let mut state = RippleState::new();
state.start(0.0, 0.0, t0);
state.release(&cfg(), t0);
state.advance(&cfg(), at(t0, 400));
let ripple = state.iter().next().expect("still visible");
assert!(ripple.growth > 0.99, "the growth has to have finished");
let (cx, cy) = ripple.center(w, h);
let radius = ripple.radius(w, h);
for (x, y) in [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)] {
let reach = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt();
assert!(
reach <= radius + 0.01,
"corner ({x},{y}) is {reach} away, disc reaches {radius}"
);
}
}
#[test]
fn an_impossible_speed_does_not_stall_or_invert_the_effect() {
for (expand, fade) in [(0.0, 0.0), (-2.0, -2.0), (f32::NAN, f32::INFINITY)] {
let config = RippleConfig {
expand_speed: expand,
fade_speed: fade,
..Default::default()
};
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
state.start(10.0, 10.0, t0);
state.release(&config, t0);
for ms in [50, 200, 1000, 5000, 10_000] {
state.advance(&config, at(t0, ms));
for ripple in state.iter() {
assert!(
(0.0..=1.0).contains(&ripple.growth),
"growth left its range with speeds ({expand}, {fade}): {}",
ripple.growth
);
assert!((0.0..=1.0).contains(&ripple.opacity));
}
}
assert!(
!state.is_active(),
"the ripple has to leave even with speeds ({expand}, {fade})"
);
}
}
#[test]
fn a_confirmed_ripple_leaves_when_it_has_faded() {
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
state.start(10.0, 10.0, t0);
state.release(&cfg(), at(t0, 80));
assert!(state.advance(&cfg(), at(t0, 200)));
assert!(state.is_active());
state.advance(&cfg(), at(t0, 80 + 400));
assert!(!state.is_active(), "a faded ripple is not kept around");
}
#[test]
fn leaving_without_releasing_does_not_complete_the_expansion() {
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
state.start(10.0, 10.0, t0);
state.advance(&cfg(), at(t0, 80));
let at_exit = state.iter().next().unwrap().growth;
state.cancel(&cfg(), at(t0, 80));
state.advance(&cfg(), at(t0, 80 + 40));
let ripple = state.iter().next().expect("still fading");
assert!(
ripple.growth < at_exit + 0.1,
"a cancelled ripple is not rushed to completion"
);
state.advance(&cfg(), at(t0, 80 + 80));
assert!(!state.is_active(), "and it leaves quickly");
}
#[test]
fn a_second_press_does_not_erase_the_first() {
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
state.start(10.0, 10.0, t0);
state.release(&cfg(), at(t0, 80));
state.advance(&cfg(), at(t0, 120));
state.start(90.0, 40.0, at(t0, 120));
state.advance(&cfg(), at(t0, 160));
let origins: Vec<_> = state.iter().map(|r| r.origin).collect();
assert_eq!(origins, vec![(10.0, 10.0), (90.0, 40.0)]);
}
#[test]
fn a_release_confirms_only_the_ripple_still_held() {
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
state.start(10.0, 10.0, t0);
state.release(&cfg(), at(t0, 50));
state.start(90.0, 40.0, at(t0, 60));
state.release(&cfg(), at(t0, 100));
let leaving: Vec<_> = state.iter().map(|r| r.is_leaving()).collect();
assert_eq!(leaving, vec![true, true]);
let first = state.iter().next().unwrap();
assert_eq!(first.exit.map(|e| e.at), Some(at(t0, 50)));
}
#[test]
fn the_oldest_goes_when_the_bound_is_reached() {
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
for i in 0..(MAX_LIVE_RIPPLES + 2) {
state.start(i as f32, 0.0, at(t0, i as u64 * 10));
}
assert_eq!(state.iter().count(), MAX_LIVE_RIPPLES);
let origins: Vec<_> = state.iter().map(|r| r.origin.0).collect();
assert_eq!(origins, vec![2.0, 3.0, 4.0, 5.0]);
}
#[test]
fn a_held_ripple_stops_asking_for_frames_once_it_is_fully_grown() {
let t0 = FrameInstant::from(std::time::Instant::now());
let mut state = RippleState::new();
state.start(10.0, 10.0, t0);
assert!(state.advance(&cfg(), at(t0, 100)));
assert!(
!state.advance(&cfg(), at(t0, 2000)),
"a held, fully grown ripple must let the loop go quiet"
);
assert!(state.is_active(), "but it is still on screen");
}
}