tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
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
46pub mod morfo;
47
48use std::time::{Duration, Instant};
49
50pub use llimphi_theme::motion;
51pub use llimphi_theme::Color;
52use llimphi_despacho::Despachador;
53
54/// Interpolación lineal genérica entre `self` y `other` con factor `t`
55/// en `[0.0, 1.0]`. Cada impl decide cómo combinar componentes; los
56/// callers pasan `t` ya con el easing aplicado.
57pub trait Lerp: Copy {
58 fn lerp(self, other: Self, t: f32) -> Self;
59}
60
61impl Lerp for f32 {
62 #[inline]
63 fn lerp(self, other: Self, t: f32) -> Self {
64 self + (other - self) * t
65 }
66}
67
68impl Lerp for f64 {
69 #[inline]
70 fn lerp(self, other: Self, t: f32) -> Self {
71 self + (other - self) * t as f64
72 }
73}
74
75impl Lerp for (f32, f32) {
76 #[inline]
77 fn lerp(self, other: Self, t: f32) -> Self {
78 (self.0.lerp(other.0, t), self.1.lerp(other.1, t))
79 }
80}
81
82impl Lerp for (f64, f64) {
83 #[inline]
84 fn lerp(self, other: Self, t: f32) -> Self {
85 (self.0.lerp(other.0, t), self.1.lerp(other.1, t))
86 }
87}
88
89impl Lerp for Color {
90 /// Interpolación componente a componente sobre los 4 canales RGBA
91 /// en espacio sRGB lineal-asumido. No es colorimetricamente correcto
92 /// (debería ser oklab), pero para fades de alpha/tinte de UI es
93 /// indistinguible y mucho más barato.
94 #[inline]
95 fn lerp(self, other: Self, t: f32) -> Self {
96 let a = self.components;
97 let b = other.components;
98 Color {
99 components: [
100 a[0].lerp(b[0], t),
101 a[1].lerp(b[1], t),
102 a[2].lerp(b[2], t),
103 a[3].lerp(b[3], t),
104 ],
105 ..self
106 }
107 }
108}
109
110/// Animación temporizada de un valor `T: Lerp` entre `from` y `to`.
111///
112/// El tween es **observable**: la app llama [`Tween::value`] desde su
113/// `view` y obtiene el valor interpolado para el frame actual. No tiene
114/// estado mutable: el tiempo se mide contra un `Instant` de inicio, así
115/// que el mismo `Tween` puede ser leído desde múltiples lugares sin
116/// que se desincronice.
117#[derive(Debug, Clone, Copy)]
118pub struct Tween<T: Lerp> {
119 pub from: T,
120 pub to: T,
121 started: Instant,
122 pub duration: Duration,
123 /// Función de easing aplicada a `t ∈ [0, 1]` antes de interpolar.
124 /// Las canónicas viven en [`llimphi_theme::motion`].
125 pub easing: fn(f32) -> f32,
126}
127
128impl<T: Lerp> Tween<T> {
129 /// Arranca el tween *ahora*. La primera lectura siguiente devuelve
130 /// `from`; cuando hayan pasado `duration` segundos, devuelve `to`.
131 pub fn new(from: T, to: T, duration: Duration, easing: fn(f32) -> f32) -> Self {
132 Self {
133 from,
134 to,
135 started: Instant::now(),
136 duration,
137 easing,
138 }
139 }
140
141 /// Tween que ya está terminado y siempre devuelve el mismo valor.
142 /// Útil para inicializar un campo de modelo antes de cualquier animación.
143 pub fn idle(value: T) -> Self {
144 Self {
145 from: value,
146 to: value,
147 started: Instant::now() - Duration::from_secs(1),
148 duration: Duration::from_millis(1),
149 easing: motion::linear,
150 }
151 }
152
153 /// Progreso normalizado en `[0.0, 1.0]`, ya con easing aplicado.
154 pub fn progress(&self) -> f32 {
155 if self.duration.is_zero() {
156 return 1.0;
157 }
158 let elapsed = self.started.elapsed().as_secs_f32();
159 let t = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
160 (self.easing)(t)
161 }
162
163 /// Valor actual interpolado.
164 pub fn value(&self) -> T {
165 self.from.lerp(self.to, self.progress())
166 }
167
168 /// `true` si la animación ya completó su `duration`.
169 pub fn done(&self) -> bool {
170 self.started.elapsed() >= self.duration
171 }
172}
173
174/// Lanza un loop de ticks de animación que dispara `make_msg()` a ~60 Hz
175/// durante `duration`, y se autodetiene cuando termina. El callback no
176/// hace falta que verifique el tiempo: la app lee `tween.value()` y el
177/// hilo interno se encarga de los frames.
178///
179/// Cada tick dispatcha un `Msg` al `update` — la app no tiene que hacer
180/// nada en ese update salvo, eventualmente, leer el `Tween` cuya
181/// `progress()` cambió desde la última lectura. La `view` luego se
182/// repinta con el valor interpolado del frame.
183///
184/// **Detención**: el hilo de ticks vive `duration + 32ms` (un frame
185/// extra de gracia para que el último tick caiga *después* del tope
186/// del tween y la `view` final pinte el valor `to`). No hace falta
187/// cancelar manualmente. Para tweens encadenados (A → B → C) la app
188/// llama `animate()` de nuevo desde el `update` cuando el tween anterior
189/// termina.
190///
191/// Internamente usa un hilo dedicado (no `spawn_periodic`, que es
192/// infinito) y dispatcha vía `Handle::dispatch` clonado.
193pub fn animate<D, F, Msg>(despachador: &D, duration: Duration, make_msg: F)
194where
195 // Genérico sobre QUIÉN despacha, no sobre `Handle`. Los llamadores no
196 // cambian —`Handle` implementa el trait— pero ahora también sirve un
197 // despachador de una app enjaulada, o uno de mentira en un test.
198 D: Despachador<Msg> + Clone + Send + 'static,
199 F: Fn() -> Msg + Send + Sync + 'static,
200 Msg: Clone + Send + 'static,
201{
202 let frame = Duration::from_millis(16);
203 let total = duration + Duration::from_millis(32);
204 let despachador = despachador.clone();
205 std::thread::spawn(move || {
206 let start = Instant::now();
207 while start.elapsed() <= total {
208 despachador.despachar(make_msg());
209 std::thread::sleep(frame);
210 }
211 });
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn lerp_f32_endpoints() {
220 assert!((0.0_f32.lerp(10.0, 0.0) - 0.0).abs() < 1e-6);
221 assert!((0.0_f32.lerp(10.0, 1.0) - 10.0).abs() < 1e-6);
222 assert!((0.0_f32.lerp(10.0, 0.5) - 5.0).abs() < 1e-6);
223 }
224
225 #[test]
226 fn lerp_tuple_componentwise() {
227 let p = (0.0_f32, 100.0).lerp((10.0, 0.0), 0.5);
228 assert!((p.0 - 5.0).abs() < 1e-6);
229 assert!((p.1 - 50.0).abs() < 1e-6);
230 }
231
232 #[test]
233 fn lerp_color_endpoints() {
234 let a = Color::from_rgba8(0, 0, 0, 0);
235 let b = Color::from_rgba8(255, 255, 255, 255);
236 let mid = <Color as Lerp>::lerp(a, b, 0.5);
237 let [r, g, bl, al] = mid.components;
238 assert!((r - 0.5).abs() < 1e-3);
239 assert!((g - 0.5).abs() < 1e-3);
240 assert!((bl - 0.5).abs() < 1e-3);
241 assert!((al - 0.5).abs() < 1e-3);
242 }
243
244 #[test]
245 fn tween_idle_returns_constant_value() {
246 let t = Tween::idle(42.0_f32);
247 assert!((t.value() - 42.0).abs() < 1e-6);
248 assert!(t.done());
249 }
250
251 #[test]
252 fn tween_zero_duration_immediately_done() {
253 let t = Tween::new(0.0_f32, 1.0, Duration::ZERO, motion::linear);
254 assert!((t.progress() - 1.0).abs() < 1e-6);
255 assert!((t.value() - 1.0).abs() < 1e-6);
256 }
257
258 #[test]
259 fn tween_progress_clamps_after_duration() {
260 let t = Tween::new(0.0_f32, 10.0, Duration::from_millis(1), motion::linear);
261 std::thread::sleep(Duration::from_millis(10));
262 assert!((t.progress() - 1.0).abs() < 1e-6);
263 assert!((t.value() - 10.0).abs() < 1e-6);
264 }
265}