🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
//! `motion` — animacja i interpolacja (własny, lekki mechanizm libLuny).
//!
//! To nie jest klon `GtkStack`/`AdwAnimation`: libLuna daje tylko **matematykę ruchu**
//! (krzywe [`Easing`] i interpolację [`lerp`]), a stan animacji trzyma aplikacja — zgodnie
//! z modelem immediate. Dzięki temu przejścia w `Stack`, odsłanianie `Revealer` czy
//! pojawianie się `Toast` płynnie sterują się prostą postępu `t` (0..1).
/// Krzywa wygładzania (easing) przebiegu animacji.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Easing {
/// Liniowo (bez wygładzania).
Linear,
/// Wolny start (przyspiesza).
#[default]
EaseIn,
/// Szybki start (zwalnia pod koniec).
EaseOut,
/// Wolny start i koniec (najbardziej „naturalny”).
EaseInOut,
}
impl Easing {
/// Mapuje postęp liniowy `t` (0..1) na wygładzoną wartość (0..1).
pub fn apply(self, t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
match self {
Easing::Linear => t,
Easing::EaseIn => t * t,
Easing::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
Easing::EaseInOut => {
if t < 0.5 {
2.0 * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
}
}
}
}
/// Interpoluje `from` → `to` dla postępu `t` (z wygładzeniem tej krzywej).
pub fn lerp(self, from: f64, to: f64, t: f64) -> f64 {
from + (to - from) * self.apply(t)
}
}
/// Interpolacja liniowa `from` → `to` (bez wygładzania).
pub fn lerp(from: f64, to: f64, t: f64) -> f64 {
from + (to - from) * t.clamp(0.0, 1.0)
}
/// Animowany skalar: płynnie przechodzi do celu `to` w zadanym czasie.
///
/// Sterowany z aplikacji: woła się [`Animation::advance`] o `dt` (sekundy) i bieżący czas
/// trwania, a wartość odczytuje przez [`Animation::value`]. Zero zależności od pętli zdarzeń.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Animation {
from: f64,
to: f64,
progress: f64,
easing: Easing,
}
impl Default for Animation {
fn default() -> Self {
Self {
from: 0.0,
to: 0.0,
progress: 1.0,
easing: Easing::EaseInOut,
}
}
}
impl Animation {
/// Animacja z podaną krzywą (stan początkowy: 0, bez ruchu).
pub fn new(easing: Easing) -> Self {
Self {
easing,
..Self::default()
}
}
/// Bieżąca (wyświetlana) wartość.
pub fn value(&self) -> f64 {
self.easing.lerp(self.from, self.to, self.progress)
}
/// Wartość docelowa.
pub fn target(&self) -> f64 {
self.to
}
pub fn progress(&self) -> f64 {
self.progress
}
pub fn easing(&self) -> Easing {
self.easing
}
pub fn set_easing(&mut self, easing: Easing) {
self.easing = easing;
}
/// Rozpoczyna płynne przejście do `to` (od bieżącej wartości).
pub fn animate_to(&mut self, to: f64) {
self.from = self.value();
self.to = to;
self.progress = 0.0;
}
/// Ustawia wartość natychmiast (bez animacji).
pub fn jump_to(&mut self, value: f64) {
self.from = value;
self.to = value;
self.progress = 1.0;
}
pub fn is_animating(&self) -> bool {
self.progress < 1.0
}
/// Postęp animacji z krokiem `dt` przy czasie `duration`; zwraca `true`, gdy trwa dalej.
pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
if !self.is_animating() {
return false;
}
if duration <= 0.0 {
self.progress = 1.0;
} else {
self.progress = (self.progress + dt / duration).min(1.0);
}
self.is_animating()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoints_are_preserved() {
for e in [
Easing::Linear,
Easing::EaseIn,
Easing::EaseOut,
Easing::EaseInOut,
] {
assert!((e.apply(0.0)).abs() < 1e-9);
assert!((e.apply(1.0) - 1.0).abs() < 1e-9);
}
}
#[test]
fn ease_in_lags_and_ease_out_leads_at_midpoint() {
assert!(Easing::EaseIn.apply(0.5) < 0.5);
assert!(Easing::EaseOut.apply(0.5) > 0.5);
assert!((Easing::EaseInOut.apply(0.5) - 0.5).abs() < 1e-9);
}
#[test]
fn progress_is_clamped() {
assert_eq!(Easing::Linear.apply(-1.0), 0.0);
assert_eq!(Easing::Linear.apply(2.0), 1.0);
assert_eq!(lerp(10.0, 20.0, 5.0), 20.0);
}
#[test]
fn lerp_moves_between_values() {
assert_eq!(lerp(0.0, 100.0, 0.25), 25.0);
assert_eq!(Easing::Linear.lerp(10.0, 20.0, 0.5), 15.0);
}
#[test]
fn animation_reaches_target_and_stops() {
let mut a = Animation::new(Easing::Linear);
assert!(!a.is_animating());
assert_eq!(a.value(), 0.0);
a.animate_to(1.0);
assert!(a.is_animating());
assert!(a.advance(0.5, 1.0)); // połowa czasu
assert!((a.value() - 0.5).abs() < 1e-9);
assert!(!a.advance(0.5, 1.0)); // koniec
assert_eq!(a.value(), 1.0);
assert!(!a.is_animating());
}
#[test]
fn animation_jump_is_instant() {
let mut a = Animation::new(Easing::EaseInOut);
a.jump_to(0.75);
assert_eq!(a.value(), 0.75);
assert!(!a.is_animating());
a.animate_to(0.0);
assert_eq!(a.value(), 0.75); // start z bieżącej wartości
}
#[test]
fn animation_zero_duration_completes() {
let mut a = Animation::new(Easing::Linear);
a.animate_to(5.0);
// Zerowy czas trwania: pierwszy krok domyka animację.
assert!(!a.advance(0.0, 0.0));
assert_eq!(a.value(), 5.0);
}
}