tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definitions

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 widgets/rive-button/src/lib.rs Rust · 277 lines · 12.2 KB History Who touched what raw

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! `llimphi-widget-rive-button` — botón **reactivo** dirigido por una máquina de
2//! estados estilo Rive ([`llimphi_anim::StateMachine`]).
3//!
4//! Es el primer consumidor *real* de la máquina de estados fuera del
5//! `llimphi-anim-studio` (que la autora) y de los examples de `llimphi-lottie`.
6//! Encapsula la composición canónica del demo `pointer_listeners_demo` como un
7//! widget reusable: tres estados —`idle` / `hover` / `press`— cada uno con su
8//! clip Lottie, hilados por inputs que el motor deriva del puntero crudo:
9//!
10//! - **hover** (`bool`): los listeners `Enter`/`Exit` sobre toda la superficie
11//!   prenden/apagan el input `hovered`; `idle ⇄ hover` con crossfade.
12//! - **press** (`trigger`): el host llama [`RiveButton::press`] desde su
13//!   `on_click`; dispara el trigger `pressed`, la máquina entra al estado `press`
14//!   (clip **no-loop**) y, al terminar el clip (`ClipDone`), vuelve a `hover` o
15//!   `idle` según siga el puntero encima.
16//!
17//! El widget es **stateful** (como `text-editor`/`nodegraph`): el host lo guarda
18//! en su `Model`, lo avanza con [`advance`] desde un tick periódico, le reenvía
19//! el puntero con [`pointer`] y el click con [`press`], y lo pinta con [`view`].
20//! El hit-testing vive en el motor, no en la app.
21//!
22//! ```ignore
23//! // En el Model del host:
24//! boton: RiveButton::builtin(),
25//! // update:
26//! Msg::Tick      => m.boton.advance(DT),
27//! Msg::Pointer(p)=> m.boton.pointer(p),
28//! Msg::Click     => { m.boton.press(); /* acción del host */ }
29//! // view:
30//! m.boton.view(Msg::Pointer, Msg::Click)
31//! ```
32//!
33//! [`advance`]: RiveButton::advance
34//! [`pointer`]: RiveButton::pointer
35//! [`press`]: RiveButton::press
36//! [`view`]: RiveButton::view
37
38#![forbid(unsafe_code)]
39
40use llimphi_anim::{Action, Area, Condition, Instance, PointerTrigger, StateMachine};
41use llimphi_lottie::{state_machine_view, LottieAsset};
42use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
43use llimphi_ui::View;
44
45/// id de clip = índice en el `Vec<LottieAsset>` que se pasa a `state_machine_view`.
46const IDLE: u32 = 0;
47const HOVER: u32 = 1;
48const PRESS: u32 = 2;
49
50/// Botón reactivo: la máquina de estados viva + sus tres clips.
51pub struct RiveButton {
52    inst: Instance,
53    clips: Vec<LottieAsset>,
54}
55
56impl RiveButton {
57    /// Construye el botón a partir de tres clips Lottie propios.
58    ///
59    /// - `idle` / `hover` / `press`: los clips de cada estado.
60    /// - `press_secs`: duración del clip de `press` (segundos) — la condición
61    ///   `ClipDone` la usa para devolver el estado a `hover`/`idle`.
62    /// - `blend_secs`: crossfade entre estados (`0.0` = salto seco).
63    pub fn new(
64        idle: LottieAsset,
65        hover: LottieAsset,
66        press: LottieAsset,
67        press_secs: f64,
68        blend_secs: f64,
69    ) -> Self {
70        let mut sm = StateMachine::new();
71        let s_idle = sm.add_state("idle", IDLE, 1.0, true);
72        let s_hover = sm.add_state("hover", HOVER, 1.0, true);
73        let s_press = sm.add_state("press", PRESS, 1.0, false);
74        sm.set_clip_duration(PRESS, press_secs);
75        sm.set_entry(s_idle);
76
77        // Hover: idle ⇄ hover según el bool `hovered`.
78        sm.transition(s_idle, s_hover, vec![Condition::bool("hovered", true)], blend_secs);
79        sm.transition(s_hover, s_idle, vec![Condition::bool("hovered", false)], blend_secs);
80        // Press: desde cualquier estado, el trigger `pressed` entra a `press`
81        // (blend corto para que el pop se sienta inmediato).
82        sm.transition_any(s_press, vec![Condition::trigger("pressed")], blend_secs.min(0.06));
83        // Salida de press al terminar el clip: vuelve a donde corresponda según
84        // el puntero (sin loops: `press` es no-loop, así `ClipDone` aplica).
85        sm.transition(s_press, s_hover, vec![Condition::clip_done(), Condition::bool("hovered", true)], blend_secs);
86        sm.transition(s_press, s_idle, vec![Condition::clip_done(), Condition::bool("hovered", false)], blend_secs);
87
88        Self::from_state_machine(sm, vec![idle, hover, press])
89    }
90
91    /// Envuelve una `StateMachine` **ya armada** como botón reactivo — el seam por
92    /// el que entra una máquina **authored en `llimphi-anim-studio`**
93    /// (`Project::load(..).doc.compile()`), cerrando el lazo autor→consumidor.
94    ///
95    /// Le agrega los listeners de puntero estándar (`Enter`/`Exit` → input
96    /// `hovered`) para que el hover ande, ya que el `Doc` del studio es grafo
97    /// puro sin listeners. La máquina debe usar por convención el bool `hovered`
98    /// y el trigger `pressed`, y un estado de press no-loop; `clips` se indexa por
99    /// `ClipId` (= índice de estado en las máquinas del studio).
100    pub fn from_state_machine(mut sm: StateMachine, clips: Vec<LottieAsset>) -> Self {
101        sm.listener(Area::All, PointerTrigger::Enter, Action::set_bool("hovered", true));
102        sm.listener(Area::All, PointerTrigger::Exit, Action::set_bool("hovered", false));
103        Self { inst: sm.instance(), clips }
104    }
105
106    /// Botón listo para usar con clips Lottie embebidos (círculo que late en
107    /// reposo, gira en hover y da un cuarto de giro al apretarlo). Útil para
108    /// demos, tests y como placeholder hasta que la app traiga sus propios clips.
109    pub fn builtin() -> Self {
110        let idle = LottieAsset::from_str(IDLE_LOTTIE).expect("idle lottie embebido válido");
111        let hover = LottieAsset::from_str(HOVER_LOTTIE).expect("hover lottie embebido válido");
112        let press = LottieAsset::from_str(PRESS_LOTTIE).expect("press lottie embebido válido");
113        // press = 12 frames @ 30 fps = 0.4 s.
114        Self::new(idle, hover, press, 0.4, 0.18)
115    }
116
117    /// Avanza la animación `dt` segundos. Llamar desde un tick periódico del host
118    /// (`Handle::spawn_periodic`) mientras el botón esté visible.
119    pub fn advance(&mut self, dt: f64) {
120        self.inst.advance(dt);
121    }
122
123    /// Reenvía el puntero crudo en coords normalizadas `0..1` sobre el rect del
124    /// botón (o `None` si salió). Los listeners del motor derivan `hovered`.
125    pub fn pointer(&mut self, pos: Option<(f64, f64)>) {
126        self.inst.pointer_move(pos);
127    }
128
129    /// Dispara la animación de press (típico: desde el `on_click` del host). El
130    /// host hace además su propia acción de click; el widget sólo anima.
131    pub fn press(&mut self) {
132        self.inst.fire("pressed");
133    }
134
135    /// Nombre del estado actual (`"idle"` / `"hover"` / `"press"`; el de origen
136    /// mientras hay crossfade).
137    pub fn current_state(&self) -> &str {
138        self.inst.current_state()
139    }
140
141    /// ¿Hay un crossfade entre estados en curso?
142    pub fn is_transitioning(&self) -> bool {
143        self.inst.is_transitioning()
144    }
145
146    /// La vista del botón: pinta el frame de la máquina (con crossfade) y reenvía
147    /// puntero + click como mensajes del host.
148    ///
149    /// - `on_pointer`: mapea la posición normalizada `0..1` (o `None` al salir) a
150    ///   un `Msg`; el host lo rutea a [`pointer`](RiveButton::pointer).
151    /// - `on_click`: `Msg` emitido al click; el host debe llamar
152    ///   [`press`](RiveButton::press) al recibirlo (además de su propia acción).
153    pub fn view<Msg, F>(&self, on_pointer: F, on_click: Msg) -> View<Msg>
154    where
155        Msg: Clone + 'static,
156        F: Fn(Option<(f64, f64)>) -> Msg + Clone + Send + Sync + 'static,
157    {
158        let leave = on_pointer.clone();
159        View::new(fill())
160            .children(vec![state_machine_view::<Msg>(self.inst.render_frame(), self.clips.clone())])
161            .on_pointer_move_at(move |lx, ly, w, h| {
162                if w > 0.0 && h > 0.0 {
163                    Some(on_pointer(Some((lx as f64 / w as f64, ly as f64 / h as f64))))
164                } else {
165                    None
166                }
167            })
168            .on_pointer_leave(leave(None))
169            .on_click(on_click)
170    }
171}
172
173/// "Ocupa todo el rect del padre" — el caller envuelve esto en un contenedor con
174/// el tamaño que necesite.
175fn fill() -> Style {
176    Style {
177        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
178        ..Default::default()
179    }
180}
181
182/// idle: círculo azul cuya opacidad late (100→55→100) en 2 s.
183const IDLE_LOTTIE: &str = r#"{
184  "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
185  "layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1,
186    "ks":{"o":{"a":1,"k":[
187        {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[100]},
188        {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":30,"s":[55]},
189        {"t":60,"s":[100]}]},
190      "r":{"a":0,"k":0},"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
191    "shapes":[{"ty":"gr","it":[
192      {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[64,64]}},
193      {"ty":"fl","c":{"a":0,"k":[0.30,0.55,0.95]},"o":{"a":0,"k":100}},
194      {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#;
195
196/// hover: círculo verde que gira 360° en 2 s (loop).
197const HOVER_LOTTIE: &str = r#"{
198  "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
199  "layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1,
200    "ks":{"o":{"a":0,"k":100},
201      "r":{"a":1,"k":[
202        {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[0]},
203        {"t":60,"s":[360]}]},
204      "p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
205    "shapes":[{"ty":"gr","it":[
206      {"ty":"rc","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]},"r":{"a":0,"k":12}},
207      {"ty":"fl","c":{"a":0,"k":[0.30,0.80,0.45]},"o":{"a":0,"k":100}},
208      {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#;
209
210/// press: cuadrado naranja que da un cuarto de giro rápido (0→90° en 0,4 s), no-loop.
211const PRESS_LOTTIE: &str = r#"{
212  "v":"5.5.2","fr":30,"ip":0,"op":12,"w":100,"h":100,
213  "layers":[{"ty":4,"ip":0,"op":12,"st":0,"sr":1,
214    "ks":{"o":{"a":0,"k":100},
215      "r":{"a":1,"k":[
216        {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[0]},
217        {"t":12,"s":[90]}]},
218      "p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
219    "shapes":[{"ty":"gr","it":[
220      {"ty":"rc","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[66,66]},"r":{"a":0,"k":14}},
221      {"ty":"fl","c":{"a":0,"k":[0.95,0.55,0.15]},"o":{"a":0,"k":100}},
222      {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#;
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    /// El ciclo completo idle → hover → press → hover → idle dirigido sólo por
229    /// puntero y click, certificando la máquina sin GPU.
230    #[test]
231    fn ciclo_hover_press_certificado() {
232        let mut rb = RiveButton::builtin();
233        assert_eq!(rb.current_state(), "idle");
234
235        // Puntero al centro → Enter prende `hovered` → idle→hover (tras el blend).
236        rb.pointer(Some((0.5, 0.5)));
237        rb.advance(0.5);
238        assert_eq!(rb.current_state(), "hover", "el hover no entró con el puntero encima");
239
240        // Click → trigger `pressed` → estado press (clip no-loop).
241        rb.press();
242        rb.advance(0.2);
243        assert_eq!(rb.current_state(), "press", "el click no disparó el press");
244
245        // El clip de press (0,4 s) termina → ClipDone → vuelve a hover (sigue encima).
246        rb.advance(0.6);
247        assert_eq!(rb.current_state(), "hover", "press no volvió a hover al terminar el clip");
248
249        // Puntero fuera → Exit apaga `hovered` → hover→idle.
250        rb.pointer(None);
251        rb.advance(0.5);
252        assert_eq!(rb.current_state(), "idle", "el botón no volvió a idle al salir el puntero");
253    }
254
255    /// Un press estando fuera (sin hover) vuelve a `idle`, no a `hover`.
256    #[test]
257    fn press_sin_hover_vuelve_a_idle() {
258        let mut rb = RiveButton::builtin();
259        rb.press();
260        rb.advance(0.2);
261        assert_eq!(rb.current_state(), "press");
262        rb.advance(0.6);
263        assert_eq!(rb.current_state(), "idle", "sin puntero encima, press debe volver a idle");
264    }
265
266    /// La vista se construye sin panic con un Msg real (humo del cableado de hooks).
267    #[test]
268    fn view_se_construye() {
269        #[derive(Clone)]
270        enum Msg {
271            Pointer(Option<(f64, f64)>),
272            Click,
273        }
274        let rb = RiveButton::builtin();
275        let _v = rb.view(Msg::Pointer, Msg::Click);
276    }
277}