tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
llimphi-wire-view/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
1//! llimphi-wire-view — IR serializable de un subset de `View<Msg>`.
2//!
3//! Un `WireNode` espeja la forma de `llimphi_compositor::View`: un árbol de
4//! cajas con dirección flex, dimensiones, padding, relleno, radio, un texto
5//! opcional y handlers. La diferencia clave: los callbacks **no cruzan la
6//! frontera WASM**. En lugar de un `Fn() -> Msg`, cada nodo interactivo carga
7//! `on_click: Option<Vec<u8>>` — los bytes postcard del `Msg` del guest. El
8//! host rebota esos bytes a `dispatch` del guest, que los decodifica y corre
9//! su `update`. Como en Llimphi `on_click` ya es `Option<Msg>` por valor (no
10//! una clausura), el mapeo es directo.
11//!
12//! `no_std + alloc`: el mismo IR sirve para un guest no_std (incluso una app
13//! wawa) y para el host con std.
14
15#![no_std]
16
17extern crate alloc;
18
19use alloc::string::String;
20use alloc::vec::Vec;
21use serde::{Deserialize, Serialize};
22
23/// Color RGBA de 8 bits por canal — el mismo formato que `Color::from_rgba8`.
24pub type Rgba = [u8; 4];
25
26/// Dimensión de un eje. Espeja `taffy::Dimension` en su subset útil.
27#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
28pub enum Dim {
29 /// Tamaño automático (contenido).
30 Auto,
31 /// Píxeles lógicos.
32 Px(f32),
33 /// Fracción del padre, `0.0..=1.0`.
34 Pct(f32),
35}
36
37impl Default for Dim {
38 fn default() -> Self {
39 Dim::Auto
40 }
41}
42
43/// Dirección del eje principal flex.
44#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
45pub enum Dir {
46 /// Caja simple (hoja o stack vertical). Se materializa como columna.
47 Block,
48 /// Fila — hijos en horizontal.
49 Row,
50 /// Columna — hijos en vertical.
51 Column,
52}
53
54impl Default for Dir {
55 fn default() -> Self {
56 Dir::Block
57 }
58}
59
60/// Alineación cruzada (`align-items`).
61#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
62pub enum Align {
63 Start,
64 Center,
65 End,
66 Stretch,
67}
68
69/// Distribución en el eje principal (`justify-content`).
70#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
71pub enum Justify {
72 Start,
73 Center,
74 End,
75 SpaceBetween,
76 SpaceAround,
77}
78
79/// Alineación horizontal del texto dentro de su caja.
80#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
81pub enum TextAlign {
82 #[default]
83 Start,
84 Center,
85 End,
86}
87
88/// Texto a rasterizar dentro de un nodo.
89#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
90pub struct WireText {
91 pub content: String,
92 pub size: f32,
93 pub color: Rgba,
94 pub align: TextAlign,
95 pub italic: bool,
96}
97
98/// Identificador de un handler de evento. El guest lo asigna por frame (en su
99/// `view`); el host lo rebota en `dispatch(event_id, payload)` y el guest mira
100/// su tabla para reconstruir el `Msg`. Reemplaza el viejo modelo de "bytes del
101/// Msg en el nodo": ahora el nodo lleva sólo el id, así un evento puede acarrear
102/// un payload (texto tecleado, estado de un toggle) que el host inyecta.
103pub type EventId = u32;
104
105/// Lo que un evento acarrea de vuelta al guest. El host lo serializa (postcard)
106/// y lo pasa a `dispatch`; el guest lo entrega al handler del `event_id`.
107#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
108pub enum EventPayload {
109 /// Click/tap — sin datos. Handler `Unit(Msg)`.
110 Click,
111 /// Texto nuevo de un campo editable. Handler `Text(Fn(String)->Msg)`.
112 Text(String),
113 /// Nuevo estado de un checkbox/switch. Handler `Toggle(Fn(bool)->Msg)`.
114 Toggle(bool),
115 /// Nuevo valor de un slider. Handler `Value(Fn(f32)->Msg)`.
116 Value(f32),
117 /// Índice de la opción elegida en un dropdown. Handler `Select(Fn(u32)->Msg)`.
118 Select(u32),
119}
120
121/// Campo de texto editable. El `value` es la fuente de verdad del guest; el host
122/// lo pinta y, al teclear, emite `on_input` con el texto nuevo (modelo Elm: el
123/// host no guarda estado de edición, sólo notifica).
124#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
125pub struct WireInput {
126 pub value: String,
127 pub placeholder: String,
128 /// Oculta el contenido (●●●) — para contraseñas.
129 pub password: bool,
130 /// Acepta varias líneas: Enter inserta `\n` en vez de ignorarse. El guest
131 /// sigue siendo la fuente de verdad del texto (modelo value-driven).
132 pub multiline: bool,
133}
134
135/// Slider: barra con un valor en `[min, max]`. Al clickear/arrastrar, el host
136/// emite `on_value` con el valor nuevo según la posición.
137#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
138pub struct WireSlider {
139 pub value: f32,
140 pub min: f32,
141 pub max: f32,
142}
143
144/// Dropdown: lista de opciones con una seleccionada. El host pinta el header con
145/// la opción actual; al elegir otra, emite `on_select` con su índice.
146#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
147pub struct WireSelect {
148 pub options: Vec<String>,
149 pub selected: u32,
150}
151
152/// Grupo de radio: lista siempre visible con una opción marcada. Al clickear
153/// otra, emite `on_radio` con su índice (mismo payload que el dropdown,
154/// `Select(idx)`). Difiere del dropdown sólo en el render: todas las opciones se
155/// ven a la vez con un indicador ◉/○, sin estado de apertura.
156#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
157pub struct WireRadio {
158 pub options: Vec<String>,
159 pub selected: u32,
160}
161
162/// Nodo del árbol. Recursivo, igual que `View<Msg>`. Construir con el builder
163/// encadenable o con los constructores libres (`col`, `row`, `text`, `leaf`).
164/// Los nodos interactivos los arma el `Ui` del SDK, que asigna los `EventId`.
165#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
166pub struct WireNode {
167 pub dir: Dir,
168 pub width: Dim,
169 pub height: Dim,
170 pub grow: f32,
171 /// Gap entre hijos (ambos ejes), en px lógicos.
172 pub gap: f32,
173 /// Padding `[top, right, bottom, left]`, en px lógicos.
174 pub padding: [f32; 4],
175 pub align: Option<Align>,
176 pub justify: Option<Justify>,
177 pub fill: Option<Rgba>,
178 pub radius: f32,
179 pub text: Option<WireText>,
180 /// Campo editable, si este nodo es un input.
181 pub input: Option<WireInput>,
182 /// Estado de un checkbox, si este nodo es uno.
183 pub toggle: Option<bool>,
184 /// Slider, si este nodo es uno.
185 pub slider: Option<WireSlider>,
186 /// Dropdown, si este nodo es uno.
187 pub select: Option<WireSelect>,
188 /// Grupo de radio, si este nodo es uno.
189 pub radio: Option<WireRadio>,
190 /// Handler de click (evento sin payload).
191 pub on_click: Option<EventId>,
192 /// Handler del texto tecleado en `input`.
193 pub on_input: Option<EventId>,
194 /// Handler del cambio de `toggle`.
195 pub on_toggle: Option<EventId>,
196 /// Handler del valor de `slider`.
197 pub on_value: Option<EventId>,
198 /// Handler de la opción elegida en `select`.
199 pub on_select: Option<EventId>,
200 /// Handler de la opción elegida en `radio`.
201 pub on_radio: Option<EventId>,
202 pub children: Vec<WireNode>,
203}
204
205impl WireNode {
206 pub fn new() -> Self {
207 Self::default()
208 }
209
210 pub fn dir(mut self, d: Dir) -> Self {
211 self.dir = d;
212 self
213 }
214
215 pub fn width(mut self, d: Dim) -> Self {
216 self.width = d;
217 self
218 }
219
220 pub fn height(mut self, d: Dim) -> Self {
221 self.height = d;
222 self
223 }
224
225 pub fn size(mut self, w: Dim, h: Dim) -> Self {
226 self.width = w;
227 self.height = h;
228 self
229 }
230
231 pub fn grow(mut self, g: f32) -> Self {
232 self.grow = g;
233 self
234 }
235
236 pub fn gap(mut self, g: f32) -> Self {
237 self.gap = g;
238 self
239 }
240
241 /// Padding uniforme en los cuatro lados.
242 pub fn pad(mut self, p: f32) -> Self {
243 self.padding = [p; 4];
244 self
245 }
246
247 /// Padding por lado `[top, right, bottom, left]`.
248 pub fn padding(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
249 self.padding = [top, right, bottom, left];
250 self
251 }
252
253 pub fn align(mut self, a: Align) -> Self {
254 self.align = Some(a);
255 self
256 }
257
258 pub fn justify(mut self, j: Justify) -> Self {
259 self.justify = Some(j);
260 self
261 }
262
263 pub fn fill(mut self, color: Rgba) -> Self {
264 self.fill = Some(color);
265 self
266 }
267
268 pub fn radius(mut self, r: f32) -> Self {
269 self.radius = r;
270 self
271 }
272
273 /// Fija el texto del nodo (alineación `Start`, sin itálica).
274 pub fn label(mut self, content: impl Into<String>, size: f32, color: Rgba) -> Self {
275 self.text = Some(WireText {
276 content: content.into(),
277 size,
278 color,
279 align: TextAlign::Start,
280 italic: false,
281 });
282 self
283 }
284
285 /// Alinea el texto ya fijado (no-op si no hay texto).
286 pub fn text_align(mut self, align: TextAlign) -> Self {
287 if let Some(t) = self.text.as_mut() {
288 t.align = align;
289 }
290 self
291 }
292
293 /// Asocia el `EventId` del handler de click (lo asigna el `Ui` del SDK).
294 pub fn on_click(mut self, id: EventId) -> Self {
295 self.on_click = Some(id);
296 self
297 }
298
299 /// Marca el nodo como campo editable.
300 pub fn with_input(mut self, input: WireInput) -> Self {
301 self.input = Some(input);
302 self
303 }
304
305 /// `EventId` del handler que recibe el texto tecleado.
306 pub fn on_input(mut self, id: EventId) -> Self {
307 self.on_input = Some(id);
308 self
309 }
310
311 /// Marca el nodo como checkbox con el estado dado.
312 pub fn with_toggle(mut self, checked: bool) -> Self {
313 self.toggle = Some(checked);
314 self
315 }
316
317 /// `EventId` del handler que recibe el nuevo estado del checkbox.
318 pub fn on_toggle(mut self, id: EventId) -> Self {
319 self.on_toggle = Some(id);
320 self
321 }
322
323 /// Marca el nodo como slider.
324 pub fn with_slider(mut self, slider: WireSlider) -> Self {
325 self.slider = Some(slider);
326 self
327 }
328
329 /// `EventId` del handler que recibe el valor nuevo del slider.
330 pub fn on_value(mut self, id: EventId) -> Self {
331 self.on_value = Some(id);
332 self
333 }
334
335 /// Marca el nodo como dropdown.
336 pub fn with_select(mut self, select: WireSelect) -> Self {
337 self.select = Some(select);
338 self
339 }
340
341 /// `EventId` del handler que recibe el índice elegido en el dropdown.
342 pub fn on_select(mut self, id: EventId) -> Self {
343 self.on_select = Some(id);
344 self
345 }
346
347 /// Marca el nodo como grupo de radio.
348 pub fn with_radio(mut self, radio: WireRadio) -> Self {
349 self.radio = Some(radio);
350 self
351 }
352
353 /// `EventId` del handler que recibe el índice elegido en el grupo de radio.
354 pub fn on_radio(mut self, id: EventId) -> Self {
355 self.on_radio = Some(id);
356 self
357 }
358
359 pub fn children(mut self, children: Vec<WireNode>) -> Self {
360 self.children = children;
361 self
362 }
363
364 pub fn child(mut self, child: WireNode) -> Self {
365 self.children.push(child);
366 self
367 }
368}
369
370/// Columna con hijos.
371pub fn col(children: Vec<WireNode>) -> WireNode {
372 WireNode::new().dir(Dir::Column).children(children)
373}
374
375/// Fila con hijos.
376pub fn row(children: Vec<WireNode>) -> WireNode {
377 WireNode::new().dir(Dir::Row).children(children)
378}
379
380/// Nodo de texto.
381pub fn text(content: impl Into<String>, size: f32, color: Rgba) -> WireNode {
382 WireNode::new().label(content, size, color)
383}
384
385/// Caja vacía (contenedor o separador). Usar `.grow(1.0)` para spacer flexible.
386pub fn leaf() -> WireNode {
387 WireNode::new()
388}
389
390/// Separador flexible (`flex-grow: 1`).
391pub fn spacer() -> WireNode {
392 WireNode::new().grow(1.0)
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use alloc::vec;
399
400 #[test]
401 fn round_trip_postcard() {
402 let tree = col(vec![
403 text("0", 160.0, [230, 240, 250, 255]).grow(1.0),
404 WireNode::new()
405 .with_input(WireInput {
406 value: "hola".into(),
407 placeholder: "nombre…".into(),
408 password: false,
409 multiline: false,
410 })
411 .on_input(7),
412 row(vec![
413 text("+1", 28.0, [10, 30, 20, 255])
414 .fill([60, 200, 130, 255])
415 .radius(12.0)
416 .on_click(1),
417 text("reset", 22.0, [30, 10, 10, 255])
418 .fill([220, 80, 80, 255])
419 .radius(12.0)
420 .on_click(2),
421 ])
422 .gap(16.0)
423 .justify(Justify::Center),
424 ])
425 .pad(32.0)
426 .fill([20, 24, 32, 255]);
427
428 let bytes = postcard::to_allocvec(&tree).unwrap();
429 let back: WireNode = postcard::from_bytes(&bytes).unwrap();
430 assert_eq!(tree, back);
431 assert_eq!(back.children[1].on_input, Some(7));
432 assert_eq!(back.children[2].children[0].on_click, Some(1));
433
434 // EventPayload también round-trippea.
435 let p = EventPayload::Text("hola".into());
436 assert_eq!(
437 postcard::from_bytes::<EventPayload>(&postcard::to_allocvec(&p).unwrap()).unwrap(),
438 p
439 );
440 }
441}