tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1//! llimphi-wasm-app-sdk — lado guest de las apps WASM Tier 3.
2//!
3//! Una app implementa [`WasmApp`]: un `Model` propio, una transición
4//! `update(&mut self, Msg)` y una vista `view(&self, &mut Ui) -> WireNode`. La
5//! UI se arma con los constructores libres de [`llimphi_wire_view`]
6//! (`col`/`row`/`text`) para lo no-interactivo y con el [`Ui`] para lo
7//! interactivo (`button`/`text_input`/`checkbox`).
8//!
9//! **Eventos con payload.** El `Ui` asigna un [`EventId`] a cada control y guarda
10//! su handler. El nodo lleva sólo el id; el host, al disparar, manda
11//! `dispatch(event_id, payload)`. El guest mira su tabla y reconstruye el `Msg`:
12//! un botón rinde su `Msg` fijo, un input rinde `f(texto_tecleado)`, un checkbox
13//! `f(nuevo_estado)`. Por eso **`Msg` ya no cruza la frontera** — sólo el id y el
14//! [`EventPayload`] —, así que `Msg` se libera de (de)serializar: basta `Clone`.
15//!
16//! [`export_wasm_app!`] genera el ABI: `wasm_init`, `wasm_view() -> u64`
17//! (ptr<<32|len del `WireNode` postcard), `wasm_dispatch(event_id, ptr, len)`,
18//! `wasm_alloc`, `wasm_free`. El `Model` y la tabla de handlers viven en un
19//! global y persisten entre frames porque el host mantiene viva la instancia.
20
21pub use llimphi_wire_view as wire;
22pub use llimphi_wire_view::{
23 col, leaf, row, spacer, text, Align, Dim, Dir, EventId, EventPayload, Justify, Rgba, TextAlign,
24 WireInput, WireNode, WireRadio, WireSelect, WireSlider, WireText,
25};
26
27/// Handler de un evento, del lado guest. El `Ui` los guarda; el dispatch los
28/// resuelve a un `Msg`.
29pub enum Handler<Msg> {
30 /// Click/tap → un `Msg` fijo.
31 Unit(Msg),
32 /// Texto tecleado → `Msg` construido con el texto nuevo.
33 Text(Box<dyn Fn(String) -> Msg>),
34 /// Toggle → `Msg` construido con el nuevo estado.
35 Toggle(Box<dyn Fn(bool) -> Msg>),
36 /// Slider → `Msg` construido con el valor nuevo.
37 Value(Box<dyn Fn(f32) -> Msg>),
38 /// Dropdown → `Msg` construido con el índice elegido.
39 Select(Box<dyn Fn(u32) -> Msg>),
40}
41
42/// Contexto de construcción de UI: asigna [`EventId`]s y acumula los handlers
43/// del frame. Se reconstruye en cada `view` y se entrega por `&mut`.
44pub struct Ui<Msg> {
45 handlers: Vec<Handler<Msg>>,
46}
47
48impl<Msg> Default for Ui<Msg> {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54impl<Msg> Ui<Msg> {
55 pub fn new() -> Self {
56 Self {
57 handlers: Vec::new(),
58 }
59 }
60
61 fn register(&mut self, h: Handler<Msg>) -> EventId {
62 let id = self.handlers.len() as EventId;
63 self.handlers.push(h);
64 id
65 }
66
67 /// Botón: nodo con `label` y el `Msg` a emitir al click. Encadena estilo a
68 /// gusto (`.fill(..).radius(..).size(..)`).
69 pub fn button(&mut self, label: impl Into<String>, size: f32, color: Rgba, msg: Msg) -> WireNode {
70 let id = self.register(Handler::Unit(msg));
71 text(label, size, color).on_click(id)
72 }
73
74 /// Hace clickable cualquier nodo ya construido, emitiendo `msg`.
75 pub fn clickable(&mut self, node: WireNode, msg: Msg) -> WireNode {
76 let id = self.register(Handler::Unit(msg));
77 node.on_click(id)
78 }
79
80 /// Campo de texto editable. `on_input(texto)` construye el `Msg` con el texto
81 /// nuevo en cada cambio. El `value` es la fuente de verdad (Elm): pintalo
82 /// desde tu `Model`.
83 pub fn text_input(
84 &mut self,
85 value: impl Into<String>,
86 placeholder: impl Into<String>,
87 on_input: impl Fn(String) -> Msg + 'static,
88 ) -> WireNode {
89 let id = self.register(Handler::Text(Box::new(on_input)));
90 WireNode::new()
91 .with_input(WireInput {
92 value: value.into(),
93 placeholder: placeholder.into(),
94 password: false,
95 multiline: false,
96 })
97 .on_input(id)
98 }
99
100 /// Igual que [`Self::text_input`] pero oculta el contenido (contraseñas).
101 pub fn password_input(
102 &mut self,
103 value: impl Into<String>,
104 placeholder: impl Into<String>,
105 on_input: impl Fn(String) -> Msg + 'static,
106 ) -> WireNode {
107 let id = self.register(Handler::Text(Box::new(on_input)));
108 WireNode::new()
109 .with_input(WireInput {
110 value: value.into(),
111 placeholder: placeholder.into(),
112 password: true,
113 multiline: false,
114 })
115 .on_input(id)
116 }
117
118 /// Campo multilínea: Enter inserta `\n` (en `text_input` se ignora). Para
119 /// notas/descripciones. El guest sigue siendo la fuente de verdad del texto.
120 pub fn multiline_input(
121 &mut self,
122 value: impl Into<String>,
123 placeholder: impl Into<String>,
124 on_input: impl Fn(String) -> Msg + 'static,
125 ) -> WireNode {
126 let id = self.register(Handler::Text(Box::new(on_input)));
127 WireNode::new()
128 .with_input(WireInput {
129 value: value.into(),
130 placeholder: placeholder.into(),
131 password: false,
132 multiline: true,
133 })
134 .on_input(id)
135 }
136
137 /// Checkbox. `on_toggle(nuevo_estado)` construye el `Msg`.
138 pub fn checkbox(&mut self, checked: bool, on_toggle: impl Fn(bool) -> Msg + 'static) -> WireNode {
139 let id = self.register(Handler::Toggle(Box::new(on_toggle)));
140 WireNode::new().with_toggle(checked).on_toggle(id)
141 }
142
143 /// Slider con `value` en `[min, max]`. `on_value(v)` construye el `Msg` con
144 /// el valor nuevo al clickear/arrastrar la barra.
145 pub fn slider(
146 &mut self,
147 value: f32,
148 min: f32,
149 max: f32,
150 on_value: impl Fn(f32) -> Msg + 'static,
151 ) -> WireNode {
152 let id = self.register(Handler::Value(Box::new(on_value)));
153 WireNode::new()
154 .with_slider(WireSlider { value, min, max })
155 .on_value(id)
156 }
157
158 /// Dropdown con `options` y la `selected` actual. `on_select(idx)` construye
159 /// el `Msg` con el índice elegido.
160 pub fn select(
161 &mut self,
162 options: Vec<String>,
163 selected: u32,
164 on_select: impl Fn(u32) -> Msg + 'static,
165 ) -> WireNode {
166 let id = self.register(Handler::Select(Box::new(on_select)));
167 WireNode::new()
168 .with_select(WireSelect { options, selected })
169 .on_select(id)
170 }
171
172 /// Grupo de radio: `options` siempre visibles con la `selected` marcada.
173 /// `on_choose(idx)` construye el `Msg` con la opción elegida (mismo handler
174 /// que el dropdown; sólo cambia el render). Devuelve el nodo dentro de una
175 /// columna para apilar las opciones.
176 pub fn radio(
177 &mut self,
178 options: Vec<String>,
179 selected: u32,
180 on_choose: impl Fn(u32) -> Msg + 'static,
181 ) -> WireNode {
182 let id = self.register(Handler::Select(Box::new(on_choose)));
183 WireNode::new()
184 .with_radio(WireRadio { options, selected })
185 .on_radio(id)
186 }
187
188 /// Resuelve un evento a un `Msg`. Lo usa el runtime generado por la macro.
189 pub fn resolve(&self, id: EventId, payload: EventPayload) -> Option<Msg>
190 where
191 Msg: Clone,
192 {
193 match self.handlers.get(id as usize)? {
194 Handler::Unit(m) => Some(m.clone()),
195 Handler::Text(f) => match payload {
196 EventPayload::Text(s) => Some(f(s)),
197 _ => None,
198 },
199 Handler::Toggle(f) => match payload {
200 EventPayload::Toggle(b) => Some(f(b)),
201 _ => None,
202 },
203 Handler::Value(f) => match payload {
204 EventPayload::Value(v) => Some(f(v)),
205 _ => None,
206 },
207 Handler::Select(f) => match payload {
208 EventPayload::Select(i) => Some(f(i)),
209 _ => None,
210 },
211 }
212 }
213}
214
215/// Contrato de una app WASM Tier 3. El runtime lo maneja vía
216/// [`export_wasm_app!`].
217pub trait WasmApp {
218 /// Mensajes de la app. Sólo `Clone` — no cruzan la frontera (lo hace el
219 /// `EventPayload`), así que no necesitan (de)serializar.
220 type Msg: Clone;
221
222 /// Estado inicial.
223 fn init() -> Self;
224
225 /// Transición. Muta el `Model` en sitio.
226 fn update(&mut self, msg: Self::Msg);
227
228 /// Vista. Construí lo interactivo con `ui` (asigna los EventIds).
229 fn view(&self, ui: &mut Ui<Self::Msg>) -> WireNode;
230}
231
232/// Genera el ABI del guest a partir de un tipo que implementa [`WasmApp`].
233#[macro_export]
234macro_rules! export_wasm_app {
235 ($app:ty) => {
236 // Model + la tabla de handlers del último `view`. Persisten entre frames.
237 static mut __WASM_RT: ::core::option::Option<(
238 $app,
239 $crate::Ui<<$app as $crate::WasmApp>::Msg>,
240 )> = ::core::option::Option::None;
241 static mut __WASM_OUT: ::std::vec::Vec<u8> = ::std::vec::Vec::new();
242
243 #[no_mangle]
244 pub extern "C" fn wasm_init() {
245 let app = <$app as $crate::WasmApp>::init();
246 unsafe {
247 __WASM_RT = ::core::option::Option::Some((app, $crate::Ui::new()));
248 }
249 }
250
251 #[no_mangle]
252 pub extern "C" fn wasm_view() -> u64 {
253 let rt = unsafe { __WASM_RT.as_mut().expect("wasm_view antes de wasm_init") };
254 let mut ui = $crate::Ui::new();
255 let node = $crate::WasmApp::view(&rt.0, &mut ui);
256 rt.1 = ui; // la tabla de este frame, para el próximo dispatch
257 let bytes = $crate::postcard_to_vec(&node);
258 let ptr = bytes.as_ptr() as u64;
259 let len = bytes.len() as u64;
260 unsafe {
261 __WASM_OUT = bytes;
262 }
263 (ptr << 32) | len
264 }
265
266 #[no_mangle]
267 pub extern "C" fn wasm_free(_ptr: u32, _len: u32) {
268 unsafe {
269 __WASM_OUT = ::std::vec::Vec::new();
270 }
271 }
272
273 #[no_mangle]
274 pub extern "C" fn wasm_alloc(len: u32) -> u32 {
275 let mut buf = ::std::vec![0u8; len as usize];
276 let ptr = buf.as_mut_ptr() as u32;
277 ::std::mem::forget(buf);
278 ptr
279 }
280
281 #[no_mangle]
282 pub extern "C" fn wasm_dispatch(event_id: u32, ptr: u32, len: u32) {
283 let bytes = unsafe {
284 ::std::vec::Vec::from_raw_parts(ptr as *mut u8, len as usize, len as usize)
285 };
286 let payload = match $crate::postcard_from_slice::<$crate::EventPayload>(&bytes) {
287 ::core::result::Result::Ok(p) => p,
288 ::core::result::Result::Err(_) => return,
289 };
290 unsafe {
291 if let ::core::option::Option::Some((app, ui)) = __WASM_RT.as_mut() {
292 if let ::core::option::Option::Some(msg) = ui.resolve(event_id, payload) {
293 $crate::WasmApp::update(app, msg);
294 }
295 }
296 }
297 }
298 };
299}
300
301/// Helper para que la macro no exija a cada guest declarar `postcard`.
302#[doc(hidden)]
303pub fn postcard_to_vec<T: serde::Serialize>(value: &T) -> std::vec::Vec<u8> {
304 postcard::to_allocvec(value).expect("postcard encode")
305}
306
307/// Helper de decodificación para la macro.
308#[doc(hidden)]
309pub fn postcard_from_slice<T: serde::de::DeserializeOwned>(
310 bytes: &[u8],
311) -> Result<T, postcard::Error> {
312 postcard::from_bytes(bytes)
313}