tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}