tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb llimphi-motion/src/lib.rs Rust · 259 líneas · 8.2 KB Historia Quién tocó qué crudo

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · Sergio ·

1//! `llimphi-motion` — animaciones simples sobre el bucle Elm de Llimphi.
2//!
3//! Llimphi es Elm puro: `update(msg) -> model`. Para animar un valor en
4//! el tiempo (un alpha que sube de 0 a 1, una posición que se desliza)
5//! la app guarda un [`Tween`] en su modelo y pide al `Handle` que le
6//! dispatchee un `Msg::Tick` periódicamente (cada ~16 ms) hasta que la
7//! animación termine. Cada `update` lee `tween.value()` y la `view` la
8//! pinta.
9//!
10//! Esta crate es deliberadamente chiquita:
11//! - [`Lerp`] — interpolación lineal genérica (impls para `f32`,
12//!   `(f32, f32)` y `Color`).
13//! - [`Tween`] — interpolación temporizada con easing entre dos valores.
14//! - [`animate`] — helper que arranca un loop de ticks autosuficiente
15//!   sobre un `Handle`.
16//!
17//! Las duraciones y easings canónicos viven en [`llimphi_theme::motion`].
18//!
19//! ## Patrón típico
20//!
21//! ```ignore
22//! use llimphi_motion::{Tween, animate};
23//! use llimphi_theme::motion;
24//!
25//! enum Msg { ToastShow, Tick, ToastHidden }
26//! struct Model { toast_alpha: Tween<f32> }
27//!
28//! // update:
29//! Msg::ToastShow => {
30//!     model.toast_alpha = Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic);
31//!     animate(handle, motion::NORMAL, || Msg::Tick);
32//!     model
33//! }
34//! Msg::Tick => {
35//!     // El loop interno terminará solo cuando el tween esté done;
36//!     // la `view` ya lee el alpha actual sin más.
37//!     model
38//! }
39//!
40//! // view:
41//! toast_view().alpha(model.toast_alpha.value())
42//! ```
43
44#![forbid(unsafe_code)]
45
46use std::time::{Duration, Instant};
47
48pub use llimphi_theme::motion;
49pub use llimphi_theme::Color;
50use llimphi_ui::Handle;
51
52/// Interpolación lineal genérica entre `self` y `other` con factor `t`
53/// en `[0.0, 1.0]`. Cada impl decide cómo combinar componentes; los
54/// callers pasan `t` ya con el easing aplicado.
55pub trait Lerp: Copy {
56    fn lerp(self, other: Self, t: f32) -> Self;
57}
58
59impl Lerp for f32 {
60    #[inline]
61    fn lerp(self, other: Self, t: f32) -> Self {
62        self + (other - self) * t
63    }
64}
65
66impl Lerp for f64 {
67    #[inline]
68    fn lerp(self, other: Self, t: f32) -> Self {
69        self + (other - self) * t as f64
70    }
71}
72
73impl Lerp for (f32, f32) {
74    #[inline]
75    fn lerp(self, other: Self, t: f32) -> Self {
76        (self.0.lerp(other.0, t), self.1.lerp(other.1, t))
77    }
78}
79
80impl Lerp for (f64, f64) {
81    #[inline]
82    fn lerp(self, other: Self, t: f32) -> Self {
83        (self.0.lerp(other.0, t), self.1.lerp(other.1, t))
84    }
85}
86
87impl Lerp for Color {
88    /// Interpolación componente a componente sobre los 4 canales RGBA
89    /// en espacio sRGB lineal-asumido. No es colorimetricamente correcto
90    /// (debería ser oklab), pero para fades de alpha/tinte de UI es
91    /// indistinguible y mucho más barato.
92    #[inline]
93    fn lerp(self, other: Self, t: f32) -> Self {
94        let a = self.components;
95        let b = other.components;
96        Color {
97            components: [
98                a[0].lerp(b[0], t),
99                a[1].lerp(b[1], t),
100                a[2].lerp(b[2], t),
101                a[3].lerp(b[3], t),
102            ],
103            ..self
104        }
105    }
106}
107
108/// Animación temporizada de un valor `T: Lerp` entre `from` y `to`.
109///
110/// El tween es **observable**: la app llama [`Tween::value`] desde su
111/// `view` y obtiene el valor interpolado para el frame actual. No tiene
112/// estado mutable: el tiempo se mide contra un `Instant` de inicio, así
113/// que el mismo `Tween` puede ser leído desde múltiples lugares sin
114/// que se desincronice.
115#[derive(Debug, Clone, Copy)]
116pub struct Tween<T: Lerp> {
117    pub from: T,
118    pub to: T,
119    started: Instant,
120    pub duration: Duration,
121    /// Función de easing aplicada a `t ∈ [0, 1]` antes de interpolar.
122    /// Las canónicas viven en [`llimphi_theme::motion`].
123    pub easing: fn(f32) -> f32,
124}
125
126impl<T: Lerp> Tween<T> {
127    /// Arranca el tween *ahora*. La primera lectura siguiente devuelve
128    /// `from`; cuando hayan pasado `duration` segundos, devuelve `to`.
129    pub fn new(from: T, to: T, duration: Duration, easing: fn(f32) -> f32) -> Self {
130        Self {
131            from,
132            to,
133            started: Instant::now(),
134            duration,
135            easing,
136        }
137    }
138
139    /// Tween que ya está terminado y siempre devuelve el mismo valor.
140    /// Útil para inicializar un campo de modelo antes de cualquier animación.
141    pub fn idle(value: T) -> Self {
142        Self {
143            from: value,
144            to: value,
145            started: Instant::now() - Duration::from_secs(1),
146            duration: Duration::from_millis(1),
147            easing: motion::linear,
148        }
149    }
150
151    /// Progreso normalizado en `[0.0, 1.0]`, ya con easing aplicado.
152    pub fn progress(&self) -> f32 {
153        if self.duration.is_zero() {
154            return 1.0;
155        }
156        let elapsed = self.started.elapsed().as_secs_f32();
157        let t = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
158        (self.easing)(t)
159    }
160
161    /// Valor actual interpolado.
162    pub fn value(&self) -> T {
163        self.from.lerp(self.to, self.progress())
164    }
165
166    /// `true` si la animación ya completó su `duration`.
167    pub fn done(&self) -> bool {
168        self.started.elapsed() >= self.duration
169    }
170}
171
172/// Lanza un loop de ticks de animación que dispara `make_msg()` a ~60 Hz
173/// durante `duration`, y se autodetiene cuando termina. El callback no
174/// hace falta que verifique el tiempo: la app lee `tween.value()` y el
175/// hilo interno se encarga de los frames.
176///
177/// Cada tick dispatcha un `Msg` al `update` — la app no tiene que hacer
178/// nada en ese update salvo, eventualmente, leer el `Tween` cuya
179/// `progress()` cambió desde la última lectura. La `view` luego se
180/// repinta con el valor interpolado del frame.
181///
182/// **Detención**: el hilo de ticks vive `duration + 32ms` (un frame
183/// extra de gracia para que el último tick caiga *después* del tope
184/// del tween y la `view` final pinte el valor `to`). No hace falta
185/// cancelar manualmente. Para tweens encadenados (A → B → C) la app
186/// llama `animate()` de nuevo desde el `update` cuando el tween anterior
187/// termina.
188///
189/// Internamente usa un hilo dedicado (no `spawn_periodic`, que es
190/// infinito) y dispatcha vía `Handle::dispatch` clonado.
191pub fn animate<F, Msg>(handle: &Handle<Msg>, duration: Duration, make_msg: F)
192where
193    F: Fn() -> Msg + Send + Sync + 'static,
194    Msg: Clone + Send + 'static,
195{
196    let frame = Duration::from_millis(16);
197    let total = duration + Duration::from_millis(32);
198    let handle = handle.clone();
199    std::thread::spawn(move || {
200        let start = Instant::now();
201        while start.elapsed() <= total {
202            handle.dispatch(make_msg());
203            std::thread::sleep(frame);
204        }
205    });
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn lerp_f32_endpoints() {
214        assert!((0.0_f32.lerp(10.0, 0.0) - 0.0).abs() < 1e-6);
215        assert!((0.0_f32.lerp(10.0, 1.0) - 10.0).abs() < 1e-6);
216        assert!((0.0_f32.lerp(10.0, 0.5) - 5.0).abs() < 1e-6);
217    }
218
219    #[test]
220    fn lerp_tuple_componentwise() {
221        let p = (0.0_f32, 100.0).lerp((10.0, 0.0), 0.5);
222        assert!((p.0 - 5.0).abs() < 1e-6);
223        assert!((p.1 - 50.0).abs() < 1e-6);
224    }
225
226    #[test]
227    fn lerp_color_endpoints() {
228        let a = Color::from_rgba8(0, 0, 0, 0);
229        let b = Color::from_rgba8(255, 255, 255, 255);
230        let mid = <Color as Lerp>::lerp(a, b, 0.5);
231        let [r, g, bl, al] = mid.components;
232        assert!((r - 0.5).abs() < 1e-3);
233        assert!((g - 0.5).abs() < 1e-3);
234        assert!((bl - 0.5).abs() < 1e-3);
235        assert!((al - 0.5).abs() < 1e-3);
236    }
237
238    #[test]
239    fn tween_idle_returns_constant_value() {
240        let t = Tween::idle(42.0_f32);
241        assert!((t.value() - 42.0).abs() < 1e-6);
242        assert!(t.done());
243    }
244
245    #[test]
246    fn tween_zero_duration_immediately_done() {
247        let t = Tween::new(0.0_f32, 1.0, Duration::ZERO, motion::linear);
248        assert!((t.progress() - 1.0).abs() < 1e-6);
249        assert!((t.value() - 1.0).abs() < 1e-6);
250    }
251
252    #[test]
253    fn tween_progress_clamps_after_duration() {
254        let t = Tween::new(0.0_f32, 10.0, Duration::from_millis(1), motion::linear);
255        std::thread::sleep(Duration::from_millis(10));
256        assert!((t.progress() - 1.0).abs() < 1e-6);
257        assert!((t.value() - 10.0).abs() < 1e-6);
258    }
259}