tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
widgets/rive-button/src/lib.rs
History
Who touched what
raw
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}