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//! Demo interactiva del Tier 1+2: máquina de estados de animación (`llimphi-anim`)
2//! manejando dos clips Lottie con **crossfade**, manejada por un input vivo.
3//!
4//! Dos estados: `idle` (círculo azul que late) ⇄ `walk` (cuadrado naranja que
5//! gira), con un blend de 0.35 s entre ellos. El input booleano `moving` dispara
6//! las transiciones en ambos sentidos. **Espacio** o el botón lo togglean —
7//! ves el crossfade disolver una animación en la otra.
8//!
9//! Esto es el cableado canónico inputs↔app (Tier 2): el `Model` tiene una
10//! `Instance`, un `spawn_periodic` la avanza por frame (`advance(dt)`), los
11//! eventos setean inputs (`set_bool`), y `view` pinta `render_frame()` vía
12//! `state_machine_view`. El núcleo de estados no sabe de Lottie ni de vello.
13//!
14//! Corre con:
15//! `cargo run -p llimphi-lottie --example state_machine_demo --release`
16
17use std::time::Duration;
18
19use llimphi_anim::{Condition, StateMachine};
20use llimphi_lottie::{state_machine_view, LottieAsset};
21use llimphi_ui::llimphi_layout::taffy::{
22 prelude::{length, percent, FlexDirection, Size, Style},
23 AlignItems, JustifyContent, Rect,
24};
25use llimphi_ui::llimphi_raster::peniko::Color;
26use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
27
28// ClipId 0 = idle, 1 = walk.
29const IDLE: u32 = 0;
30const WALK: u32 = 1;
31
32/// idle: círculo azul cuya opacidad late (100→35→100) en 2 s.
33const IDLE_LOTTIE: &str = r#"{
34 "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
35 "layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1,
36 "ks":{"o":{"a":1,"k":[
37 {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[100]},
38 {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":30,"s":[35]},
39 {"t":60,"s":[100]}]},
40 "r":{"a":0,"k":0},"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
41 "shapes":[{"ty":"gr","it":[
42 {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[72,72]}},
43 {"ty":"fl","c":{"a":0,"k":[0.30,0.55,0.95]},"o":{"a":0,"k":100}},
44 {"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}}]}]}]}"#;
45
46/// walk: cuadrado naranja redondeado que gira 360° en 2 s.
47const WALK_LOTTIE: &str = r#"{
48 "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
49 "layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1,
50 "ks":{"o":{"a":0,"k":100},
51 "r":{"a":1,"k":[
52 {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[0]},
53 {"t":60,"s":[360]}]},
54 "p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
55 "shapes":[{"ty":"gr","it":[
56 {"ty":"rc","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]},"r":{"a":0,"k":10}},
57 {"ty":"fl","c":{"a":0,"k":[0.95,0.55,0.15]},"o":{"a":0,"k":100}},
58 {"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}}]}]}]}"#;
59
60#[derive(Clone)]
61enum Msg {
62 /// Tick de animación: avanza la máquina por `dt`.
63 Tick,
64 /// Togglea el input `moving` (Espacio o el botón).
65 ToggleMoving,
66}
67
68struct Model {
69 inst: llimphi_anim::Instance,
70 clips: Vec<LottieAsset>,
71 moving: bool,
72}
73
74struct Demo;
75
76/// Período del tick → también el `dt` que le pasamos a `advance` (fijo, alcanza
77/// para una demo; una app real mediría el dt real).
78const TICK: Duration = Duration::from_millis(16);
79
80impl App for Demo {
81 type Model = Model;
82 type Msg = Msg;
83
84 fn title() -> &'static str {
85 "llimphi · state machine + crossfade"
86 }
87
88 fn initial_size() -> (u32, u32) {
89 (420, 520)
90 }
91
92 fn init(handle: &Handle<Self::Msg>) -> Self::Model {
93 let idle = LottieAsset::from_str(IDLE_LOTTIE).expect("idle lottie");
94 let walk = LottieAsset::from_str(WALK_LOTTIE).expect("walk lottie");
95
96 let mut sm = StateMachine::new();
97 let s_idle = sm.add_state("idle", IDLE, 1.0, true);
98 let s_walk = sm.add_state("walk", WALK, 1.0, true);
99 sm.set_entry(s_idle);
100 // El mismo input `moving` rige ambos sentidos, con blend de 0.35 s.
101 sm.transition(s_idle, s_walk, vec![Condition::bool("moving", true)], 0.35);
102 sm.transition(s_walk, s_idle, vec![Condition::bool("moving", false)], 0.35);
103
104 // Corre el reloj de animación (~60 fps).
105 handle.spawn_periodic(TICK, || Msg::Tick);
106
107 Model {
108 inst: sm.instance(),
109 clips: vec![idle, walk],
110 moving: false,
111 }
112 }
113
114 fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
115 match msg {
116 Msg::Tick => model.inst.advance(TICK.as_secs_f64()),
117 Msg::ToggleMoving => {
118 model.moving = !model.moving;
119 model.inst.set_bool("moving", model.moving);
120 }
121 }
122 model
123 }
124
125 fn on_key(_: &Self::Model, e: &KeyEvent) -> Option<Self::Msg> {
126 if e.state == KeyState::Pressed && e.key == Key::Named(NamedKey::Space) {
127 Some(Msg::ToggleMoving)
128 } else {
129 None
130 }
131 }
132
133 fn view(model: &Self::Model) -> View<Self::Msg> {
134 // Caja cuadrada que aloja la animación (state_machine_view es absolute-fill).
135 let stage = View::new(Style {
136 size: Size {
137 width: length(280.0_f32),
138 height: length(280.0_f32),
139 },
140 flex_shrink: 0.0,
141 align_items: Some(AlignItems::CENTER),
142 justify_content: Some(JustifyContent::CENTER),
143 ..Default::default()
144 })
145 .children(vec![state_machine_view::<Msg>(
146 model.inst.render_frame(),
147 model.clips.clone(),
148 )]);
149
150 let estado = if model.inst.is_transitioning() {
151 "· · · crossfade · · ·".to_string()
152 } else {
153 format!("estado: {}", model.inst.current_state())
154 };
155 let status = View::new(Style {
156 size: Size {
157 width: percent(1.0_f32),
158 height: length(28.0_f32),
159 },
160 align_items: Some(AlignItems::CENTER),
161 justify_content: Some(JustifyContent::CENTER),
162 ..Default::default()
163 })
164 .text(estado, 18.0, Color::from_rgba8(180, 200, 230, 255));
165
166 let label = if model.moving {
167 "moving = true (Espacio para parar)"
168 } else {
169 "moving = false (Espacio para mover)"
170 };
171 let boton = View::new(Style {
172 size: Size {
173 width: length(280.0_f32),
174 height: length(52.0_f32),
175 },
176 align_items: Some(AlignItems::CENTER),
177 justify_content: Some(JustifyContent::CENTER),
178 ..Default::default()
179 })
180 .fill(if model.moving {
181 Color::from_rgba8(200, 120, 50, 255)
182 } else {
183 Color::from_rgba8(50, 110, 190, 255)
184 })
185 .radius(12.0)
186 .text(label.to_string(), 16.0, Color::from_rgba8(245, 245, 250, 255))
187 .on_click(Msg::ToggleMoving);
188
189 View::new(Style {
190 flex_direction: FlexDirection::Column,
191 size: Size {
192 width: percent(1.0_f32),
193 height: percent(1.0_f32),
194 },
195 gap: Size {
196 width: length(0.0_f32),
197 height: length(20.0_f32),
198 },
199 align_items: Some(AlignItems::CENTER),
200 justify_content: Some(JustifyContent::CENTER),
201 padding: Rect {
202 left: length(24.0_f32),
203 right: length(24.0_f32),
204 top: length(24.0_f32),
205 bottom: length(24.0_f32),
206 },
207 ..Default::default()
208 })
209 .fill(Color::from_rgba8(18, 22, 30, 255))
210 .children(vec![stage, status, boton])
211 }
212}
213
214fn main() {
215 llimphi_ui::run::<Demo>();
216}