use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct AnimationTimer {
start: Instant,
}
impl Default for AnimationTimer {
fn default() -> Self {
Self::new()
}
}
impl AnimationTimer {
pub fn new() -> Self {
Self {
start: Instant::now(),
}
}
pub fn with_start(start: Instant) -> Self {
Self { start }
}
pub fn elapsed(&self) -> Duration {
self.start.elapsed()
}
pub fn elapsed_ms(&self) -> u128 {
self.start.elapsed().as_millis()
}
pub fn reset(&mut self) {
self.start = Instant::now();
}
pub fn blink(&self, interval_ms: u64) -> bool {
(self.elapsed_ms() / interval_ms as u128).is_multiple_of(2)
}
pub fn blink_asymmetric(&self, on_ms: u64, off_ms: u64) -> bool {
let cycle = on_ms + off_ms;
let pos = (self.elapsed_ms() % cycle as u128) as u64;
pos < on_ms
}
pub fn cycle(&self, frame_count: usize, interval_ms: u64) -> usize {
if frame_count == 0 {
return 0;
}
((self.elapsed_ms() / interval_ms as u128) % frame_count as u128) as usize
}
pub fn progress(&self, duration_ms: u64, easing: Easing) -> f64 {
let elapsed = self.elapsed_ms() as f64;
let duration = duration_ms as f64;
let t = (elapsed / duration).min(1.0);
easing.apply(t)
}
pub fn progress_loop(&self, duration_ms: u64, easing: Easing) -> f64 {
let elapsed = self.elapsed_ms() as f64;
let duration = duration_ms as f64;
let t = (elapsed % duration) / duration;
easing.apply(t)
}
pub fn progress_pingpong(&self, duration_ms: u64, easing: Easing) -> f64 {
let elapsed = self.elapsed_ms() as f64;
let duration = duration_ms as f64;
let cycle = duration * 2.0;
let pos = elapsed % cycle;
let t = if pos < duration {
pos / duration
} else {
1.0 - ((pos - duration) / duration)
};
easing.apply(t)
}
pub fn is_elapsed(&self, duration_ms: u64) -> bool {
self.elapsed_ms() >= duration_ms as u128
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Easing {
#[default]
Linear,
EaseIn,
EaseOut,
EaseInOut,
EaseInQuad,
EaseOutQuad,
EaseInOutQuad,
EaseInCubic,
EaseOutCubic,
EaseInOutCubic,
EaseOutElastic,
EaseOutBounce,
}
impl Easing {
pub fn apply(&self, t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
match self {
Easing::Linear => t,
Easing::EaseIn => t * t * t,
Easing::EaseOut => 1.0 - (1.0 - t).powi(3),
Easing::EaseInOut => {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
Easing::EaseInQuad => t * t,
Easing::EaseOutQuad => 1.0 - (1.0 - t).powi(2),
Easing::EaseInOutQuad => {
if t < 0.5 {
2.0 * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
}
}
Easing::EaseInCubic => t * t * t,
Easing::EaseOutCubic => 1.0 - (1.0 - t).powi(3),
Easing::EaseInOutCubic => {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
Easing::EaseOutElastic => {
if t == 0.0 || t == 1.0 {
t
} else {
let c4 = (2.0 * std::f64::consts::PI) / 3.0;
2.0_f64.powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
}
}
Easing::EaseOutBounce => {
let n1 = 7.5625;
let d1 = 2.75;
if t < 1.0 / d1 {
n1 * t * t
} else if t < 2.0 / d1 {
let t = t - 1.5 / d1;
n1 * t * t + 0.75
} else if t < 2.5 / d1 {
let t = t - 2.25 / d1;
n1 * t * t + 0.9375
} else {
let t = t - 2.625 / d1;
n1 * t * t + 0.984375
}
}
}
}
pub fn interpolate(&self, from: f64, to: f64, t: f64) -> f64 {
let eased_t = self.apply(t);
from + (to - from) * eased_t
}
}
pub fn lerp_u8(from: u8, to: u8, t: f64) -> u8 {
let t = t.clamp(0.0, 1.0);
(from as f64 + (to as f64 - from as f64) * t).round() as u8
}
pub fn lerp_rgb(from: (u8, u8, u8), to: (u8, u8, u8), t: f64) -> (u8, u8, u8) {
(
lerp_u8(from.0, to.0, t),
lerp_u8(from.1, to.1, t),
lerp_u8(from.2, to.2, t),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlinkPattern {
Standard,
Fast,
Slow,
Pulse,
Heartbeat,
}
impl BlinkPattern {
pub fn is_visible(&self, timer: &AnimationTimer) -> bool {
match self {
BlinkPattern::Standard => timer.blink(500),
BlinkPattern::Fast => timer.blink(250),
BlinkPattern::Slow => timer.blink(1000),
BlinkPattern::Pulse => timer.blink_asymmetric(200, 800),
BlinkPattern::Heartbeat => {
let cycle_ms = 1000u64;
let pos = (timer.elapsed_ms() % cycle_ms as u128) as u64;
matches!(pos, 0..=99 | 200..=299)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndicatorStyle {
BlinkingDot,
PulsingDot,
SpinnerDots,
SpinnerLine,
BouncingBar,
GrowingDots,
}
impl IndicatorStyle {
pub fn render(&self, timer: &AnimationTimer) -> &'static str {
match self {
IndicatorStyle::BlinkingDot => {
if timer.blink(500) {
"●"
} else {
" "
}
}
IndicatorStyle::PulsingDot => {
if timer.blink(500) {
"●"
} else {
"○"
}
}
IndicatorStyle::SpinnerDots => {
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
FRAMES[timer.cycle(10, 80)]
}
IndicatorStyle::SpinnerLine => {
const FRAMES: [&str; 4] = ["|", "/", "-", "\\"];
FRAMES[timer.cycle(4, 100)]
}
IndicatorStyle::BouncingBar => {
const FRAMES: [&str; 8] = [
"[= ]", "[ = ]", "[ = ]", "[ =]", "[ = ]", "[ = ]", "[= ]", "[= ]",
];
FRAMES[timer.cycle(8, 150)]
}
IndicatorStyle::GrowingDots => {
const FRAMES: [&str; 4] = ["", ".", "..", "..."];
FRAMES[timer.cycle(4, 400)]
}
}
}
pub fn tick_interval_ms(&self) -> u64 {
match self {
IndicatorStyle::BlinkingDot => 100,
IndicatorStyle::PulsingDot => 100,
IndicatorStyle::SpinnerDots => 80,
IndicatorStyle::SpinnerLine => 100,
IndicatorStyle::BouncingBar => 150,
IndicatorStyle::GrowingDots => 400,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_animation_timer_new() {
let timer = AnimationTimer::new();
assert!(timer.elapsed_ms() < 100);
}
#[test]
fn test_animation_timer_elapsed() {
let timer = AnimationTimer::new();
thread::sleep(Duration::from_millis(50));
assert!(timer.elapsed_ms() >= 50);
}
#[test]
fn test_blink() {
let timer = AnimationTimer::new();
assert!(timer.blink(500));
}
#[test]
fn test_cycle() {
let timer = AnimationTimer::new();
let frame = timer.cycle(4, 100);
assert!(frame < 4);
}
#[test]
fn test_cycle_zero_frames() {
let timer = AnimationTimer::new();
assert_eq!(timer.cycle(0, 100), 0);
}
#[test]
fn test_progress() {
let timer = AnimationTimer::new();
let progress = timer.progress(1000, Easing::Linear);
assert!((0.0..=1.0).contains(&progress));
}
#[test]
fn test_easing_linear() {
assert_eq!(Easing::Linear.apply(0.0), 0.0);
assert_eq!(Easing::Linear.apply(0.5), 0.5);
assert_eq!(Easing::Linear.apply(1.0), 1.0);
}
#[test]
fn test_easing_ease_in() {
assert_eq!(Easing::EaseIn.apply(0.0), 0.0);
assert!(Easing::EaseIn.apply(0.5) < 0.5); assert_eq!(Easing::EaseIn.apply(1.0), 1.0);
}
#[test]
fn test_easing_ease_out() {
assert_eq!(Easing::EaseOut.apply(0.0), 0.0);
assert!(Easing::EaseOut.apply(0.5) > 0.5); assert_eq!(Easing::EaseOut.apply(1.0), 1.0);
}
#[test]
fn test_easing_clamp() {
assert_eq!(Easing::Linear.apply(-0.5), 0.0);
assert_eq!(Easing::Linear.apply(1.5), 1.0);
}
#[test]
fn test_interpolate() {
assert_eq!(Easing::Linear.interpolate(0.0, 100.0, 0.5), 50.0);
assert_eq!(Easing::Linear.interpolate(10.0, 20.0, 0.0), 10.0);
assert_eq!(Easing::Linear.interpolate(10.0, 20.0, 1.0), 20.0);
}
#[test]
fn test_lerp_u8() {
assert_eq!(lerp_u8(0, 255, 0.0), 0);
assert_eq!(lerp_u8(0, 255, 1.0), 255);
assert_eq!(lerp_u8(0, 255, 0.5), 128);
}
#[test]
fn test_lerp_rgb() {
assert_eq!(lerp_rgb((0, 0, 0), (255, 255, 255), 0.5), (128, 128, 128));
}
#[test]
fn test_blink_pattern_standard() {
let timer = AnimationTimer::new();
let _ = BlinkPattern::Standard.is_visible(&timer);
}
#[test]
fn test_indicator_style_render() {
let timer = AnimationTimer::new();
let _ = IndicatorStyle::BlinkingDot.render(&timer);
assert!(!IndicatorStyle::SpinnerDots.render(&timer).is_empty());
assert!(!IndicatorStyle::SpinnerLine.render(&timer).is_empty());
}
#[test]
fn test_indicator_tick_interval() {
assert!(IndicatorStyle::SpinnerDots.tick_interval_ms() > 0);
assert!(IndicatorStyle::BlinkingDot.tick_interval_ms() > 0);
}
#[test]
fn test_progress_pingpong() {
let timer = AnimationTimer::new();
let value = timer.progress_pingpong(500, Easing::Linear);
assert!((0.0..=1.0).contains(&value));
}
}