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

⎇ main widgets/rive-button/src/lib.rs Rust · 298 líneas · 13.3 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · 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    /// Nombre accesible. El contenido del botón es una animación: no hay texto
55    /// del que derivarlo, así que sin esto el lector anuncia "botón" a secas.
56    label: Option<String>,
57}
58
59impl RiveButton {
60    /// Construye el botón a partir de tres clips Lottie propios.
61    ///
62    /// - `idle` / `hover` / `press`: los clips de cada estado.
63    /// - `press_secs`: duración del clip de `press` (segundos) — la condición
64    ///   `ClipDone` la usa para devolver el estado a `hover`/`idle`.
65    /// - `blend_secs`: crossfade entre estados (`0.0` = salto seco).
66    pub fn new(
67        idle: LottieAsset,
68        hover: LottieAsset,
69        press: LottieAsset,
70        press_secs: f64,
71        blend_secs: f64,
72    ) -> Self {
73        let mut sm = StateMachine::new();
74        let s_idle = sm.add_state("idle", IDLE, 1.0, true);
75        let s_hover = sm.add_state("hover", HOVER, 1.0, true);
76        let s_press = sm.add_state("press", PRESS, 1.0, false);
77        sm.set_clip_duration(PRESS, press_secs);
78        sm.set_entry(s_idle);
79
80        // Hover: idle ⇄ hover según el bool `hovered`.
81        sm.transition(s_idle, s_hover, vec![Condition::bool("hovered", true)], blend_secs);
82        sm.transition(s_hover, s_idle, vec![Condition::bool("hovered", false)], blend_secs);
83        // Press: desde cualquier estado, el trigger `pressed` entra a `press`
84        // (blend corto para que el pop se sienta inmediato).
85        sm.transition_any(s_press, vec![Condition::trigger("pressed")], blend_secs.min(0.06));
86        // Salida de press al terminar el clip: vuelve a donde corresponda según
87        // el puntero (sin loops: `press` es no-loop, así `ClipDone` aplica).
88        sm.transition(s_press, s_hover, vec![Condition::clip_done(), Condition::bool("hovered", true)], blend_secs);
89        sm.transition(s_press, s_idle, vec![Condition::clip_done(), Condition::bool("hovered", false)], blend_secs);
90
91        Self::from_state_machine(sm, vec![idle, hover, press])
92    }
93
94    /// Envuelve una `StateMachine` **ya armada** como botón reactivo — el seam por
95    /// el que entra una máquina **authored en `llimphi-anim-studio`**
96    /// (`Project::load(..).doc.compile()`), cerrando el lazo autor→consumidor.
97    ///
98    /// Le agrega los listeners de puntero estándar (`Enter`/`Exit` → input
99    /// `hovered`) para que el hover ande, ya que el `Doc` del studio es grafo
100    /// puro sin listeners. La máquina debe usar por convención el bool `hovered`
101    /// y el trigger `pressed`, y un estado de press no-loop; `clips` se indexa por
102    /// `ClipId` (= índice de estado en las máquinas del studio).
103    pub fn from_state_machine(mut sm: StateMachine, clips: Vec<LottieAsset>) -> Self {
104        sm.listener(Area::All, PointerTrigger::Enter, Action::set_bool("hovered", true));
105        sm.listener(Area::All, PointerTrigger::Exit, Action::set_bool("hovered", false));
106        Self { inst: sm.instance(), clips, label: None }
107    }
108
109    /// Botón listo para usar con clips Lottie embebidos (círculo que late en
110    /// reposo, gira en hover y da un cuarto de giro al apretarlo). Útil para
111    /// demos, tests y como placeholder hasta que la app traiga sus propios clips.
112    pub fn builtin() -> Self {
113        let idle = LottieAsset::from_str(IDLE_LOTTIE).expect("idle lottie embebido válido");
114        let hover = LottieAsset::from_str(HOVER_LOTTIE).expect("hover lottie embebido válido");
115        let press = LottieAsset::from_str(PRESS_LOTTIE).expect("press lottie embebido válido");
116        // press = 12 frames @ 30 fps = 0.4 s.
117        Self::new(idle, hover, press, 0.4, 0.18)
118    }
119
120    /// Avanza la animación `dt` segundos. Llamar desde un tick periódico del host
121    /// (`Handle::spawn_periodic`) mientras el botón esté visible.
122    pub fn advance(&mut self, dt: f64) {
123        self.inst.advance(dt);
124    }
125
126    /// Reenvía el puntero crudo en coords normalizadas `0..1` sobre el rect del
127    /// botón (o `None` si salió). Los listeners del motor derivan `hovered`.
128    pub fn pointer(&mut self, pos: Option<(f64, f64)>) {
129        self.inst.pointer_move(pos);
130    }
131
132    /// Dispara la animación de press (típico: desde el `on_click` del host). El
133    /// host hace además su propia acción de click; el widget sólo anima.
134    pub fn press(&mut self) {
135        self.inst.fire("pressed");
136    }
137
138    /// Nombre del estado actual (`"idle"` / `"hover"` / `"press"`; el de origen
139    /// mientras hay crossfade).
140    pub fn current_state(&self) -> &str {
141        self.inst.current_state()
142    }
143
144    /// ¿Hay un crossfade entre estados en curso?
145    pub fn is_transitioning(&self) -> bool {
146        self.inst.is_transitioning()
147    }
148
149    /// La vista del botón: pinta el frame de la máquina (con crossfade) y reenvía
150    /// puntero + click como mensajes del host.
151    ///
152    /// - `on_pointer`: mapea la posición normalizada `0..1` (o `None` al salir) a
153    ///   un `Msg`; el host lo rutea a [`pointer`](RiveButton::pointer).
154    /// - `on_click`: `Msg` emitido al click; el host debe llamar
155    ///   [`press`](RiveButton::press) al recibirlo (además de su propia acción).
156    /// Fija el nombre accesible del botón. Encadenalo tras el constructor:
157    /// `RiveButton::builtin().con_label("Ejecutar")`.
158    pub fn con_label(mut self, label: impl Into<String>) -> Self {
159        self.label = Some(label.into());
160        self
161    }
162
163    pub fn view<Msg, F>(&self, on_pointer: F, on_click: Msg) -> View<Msg>
164    where
165        Msg: Clone + 'static,
166        F: Fn(Option<(f64, f64)>) -> Msg + Clone + Send + Sync + 'static,
167    {
168        let leave = on_pointer.clone();
169        let mut v = View::new(fill())
170            .children(vec![state_machine_view::<Msg>(self.inst.render_frame(), self.clips.clone())])
171            .on_pointer_move_at(move |lx, ly, w, h| {
172                if w > 0.0 && h > 0.0 {
173                    Some(on_pointer(Some((lx as f64 / w as f64, ly as f64 / h as f64))))
174                } else {
175                    None
176                }
177            })
178            .on_pointer_leave(leave(None))
179            // Es un botón aunque su contenido sea una animación. Limitación:
180            // `view()` no recibe un rótulo, así que queda sin nombre accesible
181            // hasta que la API lo acepte (mismo hueco que `DockRailItem`).
182            .role(llimphi_ui::Role::Button)
183            .on_click(on_click);
184        if let Some(l) = &self.label {
185            v = v.aria_label(l.clone());
186        }
187        v
188    }
189}
190
191/// "Ocupa todo el rect del padre" — el caller envuelve esto en un contenedor con
192/// el tamaño que necesite.
193fn fill() -> Style {
194    Style {
195        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
196        ..Default::default()
197    }
198}
199
200/// idle: círculo azul cuya opacidad late (100→55→100) en 2 s.
201const IDLE_LOTTIE: &str = r#"{
202  "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
203  "layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1,
204    "ks":{"o":{"a":1,"k":[
205        {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[100]},
206        {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":30,"s":[55]},
207        {"t":60,"s":[100]}]},
208      "r":{"a":0,"k":0},"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
209    "shapes":[{"ty":"gr","it":[
210      {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[64,64]}},
211      {"ty":"fl","c":{"a":0,"k":[0.30,0.55,0.95]},"o":{"a":0,"k":100}},
212      {"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}}]}]}]}"#;
213
214/// hover: círculo verde que gira 360° en 2 s (loop).
215const HOVER_LOTTIE: &str = r#"{
216  "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
217  "layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1,
218    "ks":{"o":{"a":0,"k":100},
219      "r":{"a":1,"k":[
220        {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[0]},
221        {"t":60,"s":[360]}]},
222      "p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
223    "shapes":[{"ty":"gr","it":[
224      {"ty":"rc","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]},"r":{"a":0,"k":12}},
225      {"ty":"fl","c":{"a":0,"k":[0.30,0.80,0.45]},"o":{"a":0,"k":100}},
226      {"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}}]}]}]}"#;
227
228/// press: cuadrado naranja que da un cuarto de giro rápido (0→90° en 0,4 s), no-loop.
229const PRESS_LOTTIE: &str = r#"{
230  "v":"5.5.2","fr":30,"ip":0,"op":12,"w":100,"h":100,
231  "layers":[{"ty":4,"ip":0,"op":12,"st":0,"sr":1,
232    "ks":{"o":{"a":0,"k":100},
233      "r":{"a":1,"k":[
234        {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[0]},
235        {"t":12,"s":[90]}]},
236      "p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
237    "shapes":[{"ty":"gr","it":[
238      {"ty":"rc","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[66,66]},"r":{"a":0,"k":14}},
239      {"ty":"fl","c":{"a":0,"k":[0.95,0.55,0.15]},"o":{"a":0,"k":100}},
240      {"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}}]}]}]}"#;
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    /// El ciclo completo idle → hover → press → hover → idle dirigido sólo por
247    /// puntero y click, certificando la máquina sin GPU.
248    #[test]
249    fn ciclo_hover_press_certificado() {
250        let mut rb = RiveButton::builtin();
251        assert_eq!(rb.current_state(), "idle");
252
253        // Puntero al centro → Enter prende `hovered` → idle→hover (tras el blend).
254        rb.pointer(Some((0.5, 0.5)));
255        rb.advance(0.5);
256        assert_eq!(rb.current_state(), "hover", "el hover no entró con el puntero encima");
257
258        // Click → trigger `pressed` → estado press (clip no-loop).
259        rb.press();
260        rb.advance(0.2);
261        assert_eq!(rb.current_state(), "press", "el click no disparó el press");
262
263        // El clip de press (0,4 s) termina → ClipDone → vuelve a hover (sigue encima).
264        rb.advance(0.6);
265        assert_eq!(rb.current_state(), "hover", "press no volvió a hover al terminar el clip");
266
267        // Puntero fuera → Exit apaga `hovered` → hover→idle.
268        rb.pointer(None);
269        rb.advance(0.5);
270        assert_eq!(rb.current_state(), "idle", "el botón no volvió a idle al salir el puntero");
271    }
272
273    /// Un press estando fuera (sin hover) vuelve a `idle`, no a `hover`.
274    #[test]
275    fn press_sin_hover_vuelve_a_idle() {
276        let mut rb = RiveButton::builtin();
277        rb.press();
278        rb.advance(0.2);
279        assert_eq!(rb.current_state(), "press");
280        rb.advance(0.6);
281        assert_eq!(rb.current_state(), "idle", "sin puntero encima, press debe volver a idle");
282    }
283
284    /// La vista se construye sin panic con un Msg real (humo del cableado de hooks).
285    #[test]
286    fn view_se_construye() {
287        #[derive(Clone)]
288        enum Msg {
289            // El payload no se lee: el test comprueba que la vista se CONSTRUYE
290            // con los constructores de mensaje que le pasa el caller, no qué
291            // trae cada uno.
292            Pointer(#[allow(dead_code)] Option<(f64, f64)>),
293            Click,
294        }
295        let rb = RiveButton::builtin();
296        let _v = rb.view(Msg::Pointer, Msg::Click);
297    }
298}