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-wasm-runner — lado host de las apps WASM Tier 3.
2//!
3//! [`WasmGuest`] carga un `.wasm` que sigue el ABI de `llimphi-wasm-app-sdk`,
4//! lo instancia con wasmi y corre su bucle Elm:
5//!
6//! 1. `wasm_init` construye el `Model` del guest (vive en la instancia).
7//! 2. `wasm_view` devuelve un [`WireNode`] serializado; lo deserializamos y lo
8//! cacheamos.
9//! 3. [`wire_to_view`] lo materializa en un `View<RunnerMsg>` Llimphi real.
10//! 4. Un click emite `RunnerMsg::Guest(bytes)`; [`WasmGuest::dispatch`] rebota
11//! esos bytes a `wasm_dispatch` y refresca la vista.
12//!
13//! El host mantiene un único `Store` vivo por app, así el `Model` persiste
14//! entre frames — exactamente el contrato Elm de Llimphi, sólo que la
15//! transición corre del otro lado de la frontera WASM.
16//!
17//! Es el gemelo, del lado Linux, del ejecutor de apps WASM de wawa
18//! (`sys_render_frame` + caps por frontera física): el mismo espíritu de
19//! "abrir apps servidas/distribuidas", pero pintando con Llimphi en vez de un
20//! framebuffer crudo.
21
22use llimphi_ui::llimphi_layout::taffy::{
23 prelude::{length, percent, Dimension, FlexDirection, Rect, Size, Style},
24 AlignItems, JustifyContent,
25};
26use llimphi_ui::llimphi_raster::peniko::Color;
27use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
28use llimphi_wire_view::{Align, Dim, Dir, Justify, TextAlign, WireInput, WireNode};
29use kikin_wasmi::{Concesion, Reja, Requiere};
30use wasmi::{Caller, CompilationMode, Config, Engine, Linker, Memory, Module, Store, TypedFunc};
31
32pub use format::Permisos;
33pub use llimphi_wire_view::{EventId, EventPayload};
34
35/// Mensaje del host. Un evento lleva el `EventId` del control y su `EventPayload`
36/// (Click/Text/Toggle); el host los rebota a `wasm_dispatch` y el guest
37/// reconstruye su `Msg`. `Focus` cambia el input enfocado (no cruza la frontera).
38#[derive(Clone, Debug)]
39pub enum RunnerMsg {
40 /// Evento de un control: id del handler + payload.
41 Event(EventId, EventPayload),
42 /// El foco de teclado pasó a este input (o a ninguno).
43 Focus(Option<EventId>),
44 /// Abrir/cerrar el dropdown con este `on_select` id (estado host-side).
45 ToggleSelect(EventId),
46}
47
48/// Una app WASM guest cargada y viva. Mantén una por app: el `Model` del guest
49/// reside en su instancia y persiste entre `dispatch`.
50pub struct WasmGuest {
51 store: Store<()>,
52 memory: Memory,
53 f_view: TypedFunc<(), u64>,
54 f_alloc: TypedFunc<u32, u32>,
55 f_dispatch: TypedFunc<(u32, u32, u32), ()>,
56 f_free: TypedFunc<(u32, u32), ()>,
57 /// Última vista decodificada — el host la materializa cada frame sin volver
58 /// a cruzar la frontera salvo tras un `dispatch`.
59 view: WireNode,
60 /// Input con el foco de teclado (su `on_input` EventId), si hay alguno.
61 focused: Option<EventId>,
62 /// Dropdown abierto (su `on_select` EventId), si hay alguno.
63 open_select: Option<EventId>,
64}
65
66impl WasmGuest {
67 /// Instancia el `.wasm` y corre `wasm_init` + el primer `wasm_view`.
68 ///
69 /// `permisos` es el bitfield efectivo (típicamente
70 /// `format::permisos_efectivos(declarados, concedidos)` que calcula
71 /// `llimphi-wasm-dist` tras verificar la concesión Ed25519). Gatea qué host
72 /// imports se enlazan: si el bit falta, la función no se registra y un guest
73 /// que la importe **trap-ea al instanciar** — frontera física, no tabla.
74 pub fn load(wasm_bytes: &[u8], permisos: Permisos) -> Result<Self, String> {
75 // Eager: los traps de compilación salen aquí, no en pleno frame. Igual
76 // criterio que llimphi-plugin-host y el kernel de wawa.
77 let mut config = Config::default();
78 config.compilation_mode(CompilationMode::Eager);
79 let engine = Engine::new(&config);
80 let module = Module::new(&engine, wasm_bytes).map_err(|e| format!("compilar wasm: {e}"))?;
81 let mut store = Store::new(&engine, ());
82 // Una app Tier 3 pura (sólo UI) no importa nada; las capacidades (red…)
83 // se enlazan aquí sólo si el permiso correspondiente está concedido.
84 let linker = build_linker(&engine, permisos)?;
85 let instance = linker
86 .instantiate_and_start(&mut store, &module)
87 .map_err(|e| format!("instanciar wasm: {e}"))?;
88
89 let memory = instance
90 .get_memory(&store, "memory")
91 .ok_or("el guest no exporta `memory`")?;
92 let f_init = instance
93 .get_typed_func::<(), ()>(&store, "wasm_init")
94 .map_err(|e| format!("export `wasm_init`: {e}"))?;
95 let f_view = instance
96 .get_typed_func::<(), u64>(&store, "wasm_view")
97 .map_err(|e| format!("export `wasm_view`: {e}"))?;
98 let f_alloc = instance
99 .get_typed_func::<u32, u32>(&store, "wasm_alloc")
100 .map_err(|e| format!("export `wasm_alloc`: {e}"))?;
101 let f_dispatch = instance
102 .get_typed_func::<(u32, u32, u32), ()>(&store, "wasm_dispatch")
103 .map_err(|e| format!("export `wasm_dispatch`: {e}"))?;
104 let f_free = instance
105 .get_typed_func::<(u32, u32), ()>(&store, "wasm_free")
106 .map_err(|e| format!("export `wasm_free`: {e}"))?;
107
108 f_init
109 .call(&mut store, ())
110 .map_err(|e| format!("wasm_init trap: {e}"))?;
111
112 let mut guest = WasmGuest {
113 store,
114 memory,
115 f_view,
116 f_alloc,
117 f_dispatch,
118 f_free,
119 view: WireNode::default(),
120 focused: None,
121 open_select: None,
122 };
123 guest.refresh()?;
124 Ok(guest)
125 }
126
127 /// La última vista decodificada del guest.
128 pub fn view(&self) -> &WireNode {
129 &self.view
130 }
131
132 /// Vuelve a pedir `wasm_view` y decodifica el `WireNode`.
133 fn refresh(&mut self) -> Result<(), String> {
134 let packed = self
135 .f_view
136 .call(&mut self.store, ())
137 .map_err(|e| format!("wasm_view trap: {e}"))?;
138 let ptr = (packed >> 32) as usize;
139 let len = (packed & 0xffff_ffff) as usize;
140 let data = self.memory.data(&self.store);
141 let end = ptr.checked_add(len).ok_or("wasm_view: rango desbordado")?;
142 if end > data.len() {
143 return Err("wasm_view: rango fuera de la memoria del guest".into());
144 }
145 let bytes = data[ptr..end].to_vec();
146 // Soltamos el buffer del guest antes de propagar errores de decodificación.
147 let _ = self.f_free.call(&mut self.store, (ptr as u32, len as u32));
148 self.view = postcard::from_bytes(&bytes).map_err(|e| format!("decodificar WireNode: {e}"))?;
149 Ok(())
150 }
151
152 /// Rebota un evento al guest: serializa el `payload`, lo escribe y corre
153 /// `wasm_dispatch(event_id, ptr, len)`, luego refresca la vista.
154 pub fn dispatch(&mut self, event_id: EventId, payload: EventPayload) -> Result<(), String> {
155 let bytes = postcard::to_allocvec(&payload).map_err(|e| format!("encode payload: {e}"))?;
156 let len = bytes.len() as u32;
157 let ptr = self
158 .f_alloc
159 .call(&mut self.store, len)
160 .map_err(|e| format!("wasm_alloc trap: {e}"))?;
161 self.memory
162 .write(&mut self.store, ptr as usize, &bytes)
163 .map_err(|e| format!("escribir payload: {e}"))?;
164 self.f_dispatch
165 .call(&mut self.store, (event_id, ptr, len))
166 .map_err(|e| format!("wasm_dispatch trap: {e}"))?;
167 self.refresh()
168 }
169
170 /// Aplica un `RunnerMsg`. Conveniencia para el `update` del host.
171 pub fn apply(&mut self, msg: &RunnerMsg) -> Result<(), String> {
172 match msg {
173 RunnerMsg::Event(id, payload) => {
174 // Cualquier evento cierra un dropdown abierto (elegir/clicar = cerrar).
175 self.open_select = None;
176 self.dispatch(*id, payload.clone())
177 }
178 RunnerMsg::Focus(f) => {
179 self.focused = *f;
180 Ok(())
181 }
182 RunnerMsg::ToggleSelect(id) => {
183 self.open_select = if self.open_select == Some(*id) {
184 None
185 } else {
186 Some(*id)
187 };
188 Ok(())
189 }
190 }
191 }
192
193 /// El input enfocado actualmente (su `on_input` EventId).
194 pub fn focused(&self) -> Option<EventId> {
195 self.focused
196 }
197
198 /// Traduce un evento de teclado a un `RunnerMsg` para el input enfocado:
199 /// computa "valor actual + tecla → texto nuevo" (Backspace borra el último
200 /// carácter; un carácter se anexa) y emite `Text`. Devuelve `None` si no hay
201 /// foco o la tecla no edita. Modelo value-driven: el guest es la fuente de
202 /// verdad del texto; el host no guarda buffer (cursor siempre al final, MVP).
203 pub fn key_to_msg(&self, event: &KeyEvent) -> Option<RunnerMsg> {
204 if event.state != KeyState::Pressed {
205 return None;
206 }
207 let id = self.focused?;
208 let inp = find_input(&self.view, id)?;
209 let nuevo = edit_value(&inp.value, &event.key, event.text.as_deref(), inp.multiline)?;
210 Some(RunnerMsg::Event(id, EventPayload::Text(nuevo)))
211 }
212
213 /// `RunnerMsg` para fijar el foco (lo usa el `on_focus` del host).
214 pub fn focus_msg(id: Option<u64>) -> RunnerMsg {
215 RunnerMsg::Focus(id.map(|x| x as EventId))
216 }
217
218 /// Materializa la vista cacheada en un `View<RunnerMsg>` Llimphi real.
219 pub fn render(&self) -> View<RunnerMsg> {
220 wire_to_view(&self.view, self.focused, self.open_select)
221 }
222}
223
224/// Computa el texto nuevo de un campo dado el actual y una tecla: Backspace borra
225/// el último carácter; un carácter se anexa; Enter inserta `\n` sólo si el campo
226/// es `multiline` (en uno de una línea no edita); otras teclas no editan
227/// (`None`). Pura — el corazón del modelo value-driven, testeable sin `KeyEvent`.
228pub fn edit_value(actual: &str, key: &Key, text: Option<&str>, multiline: bool) -> Option<String> {
229 match key {
230 Key::Named(NamedKey::Backspace) => {
231 let mut s = actual.to_string();
232 s.pop();
233 Some(s)
234 }
235 Key::Named(NamedKey::Enter) if multiline => Some(format!("{actual}\n")),
236 Key::Character(_) => text.map(|t| format!("{actual}{t}")),
237 _ => None,
238 }
239}
240
241/// Busca el `WireInput` cuyo `on_input` es `id` en el árbol.
242fn find_input(node: &WireNode, id: EventId) -> Option<&WireInput> {
243 if node.on_input == Some(id) {
244 if let Some(inp) = &node.input {
245 return Some(inp);
246 }
247 }
248 node.children.iter().find_map(|c| find_input(c, id))
249}
250
251// Colores por defecto de los controles (el estilo de la caja viene del nodo).
252const INPUT_TEXT: [u8; 4] = [230, 235, 245, 255];
253const INPUT_PLACEHOLDER: [u8; 4] = [120, 130, 145, 255];
254const INPUT_BORDER: [u8; 4] = [80, 90, 110, 255];
255const INPUT_BORDER_FOCUS: [u8; 4] = [90, 160, 230, 255];
256const SLIDER_TRACK: [u8; 4] = [40, 46, 58, 255];
257const SLIDER_FILL: [u8; 4] = [90, 160, 230, 255];
258const SELECT_BG: [u8; 4] = [28, 34, 44, 255];
259const SELECT_ITEM: [u8; 4] = [34, 40, 52, 255];
260const SELECT_SEL: [u8; 4] = [50, 70, 100, 255];
261
262/// Materializa un [`WireNode`] en un `View<RunnerMsg>` Llimphi real,
263/// recursivamente. `focused` = input con el foco; `open_select` = dropdown
264/// abierto (para pintar su lista).
265pub fn wire_to_view(
266 node: &WireNode,
267 focused: Option<EventId>,
268 open_select: Option<EventId>,
269) -> View<RunnerMsg> {
270 let mut style = Style {
271 flex_direction: match node.dir {
272 Dir::Row => FlexDirection::Row,
273 Dir::Column | Dir::Block => FlexDirection::Column,
274 },
275 size: Size {
276 width: dim(node.width),
277 height: dim(node.height),
278 },
279 flex_grow: node.grow,
280 ..Default::default()
281 };
282 if node.gap != 0.0 {
283 style.gap = Size {
284 width: length(node.gap),
285 height: length(node.gap),
286 };
287 }
288 let [pt, pr, pb, pl] = node.padding;
289 style.padding = Rect {
290 top: length(pt),
291 right: length(pr),
292 bottom: length(pb),
293 left: length(pl),
294 };
295 if let Some(a) = node.align {
296 style.align_items = Some(map_align(a));
297 }
298 if let Some(j) = node.justify {
299 style.justify_content = Some(map_justify(j));
300 }
301
302 let mut view = View::new(style);
303 if let Some(fill) = node.fill {
304 view = view.fill(color(fill));
305 }
306 if node.radius != 0.0 {
307 view = view.radius(node.radius as f64);
308 }
309
310 if let Some(inp) = &node.input {
311 // Campo editable: caja focusable que muestra value/placeholder + caret.
312 let id = node.on_input.unwrap_or(0);
313 let is_focused = focused == Some(id);
314 let (shown, col) = display_input(inp, is_focused);
315 let border = if is_focused { INPUT_BORDER_FOCUS } else { INPUT_BORDER };
316 view = view
317 .text(shown, 20.0, color(col))
318 .border(1.5, color(border))
319 .focusable(id as u64)
320 .on_click(RunnerMsg::Focus(Some(id)));
321 } else if let Some(checked) = node.toggle {
322 // Checkbox: glifo clickable que alterna su estado.
323 let id = node.on_toggle.unwrap_or(0);
324 let glyph = if checked { "\u{2611}" } else { "\u{2610}" }; // ☑ / ☐
325 view = view
326 .text(glyph, 24.0, color(INPUT_TEXT))
327 .on_click(RunnerMsg::Event(id, EventPayload::Toggle(!checked)));
328 } else if let Some(sl) = &node.slider {
329 // Slider: track con una barra de relleno proporcional; click-en-x fija
330 // el valor. id/min/max van Copy a la clausura.
331 let id = node.on_value.unwrap_or(0);
332 let (min, max) = (sl.min, sl.max);
333 let frac = if max > min {
334 ((sl.value - min) / (max - min)).clamp(0.0, 1.0)
335 } else {
336 0.0
337 };
338 view = view
339 .fill(color(SLIDER_TRACK))
340 .radius(6.0)
341 .on_click_at(move |lx, _ly, w, _h| {
342 let f = if w > 0.0 { (lx / w).clamp(0.0, 1.0) } else { 0.0 };
343 Some(RunnerMsg::Event(id, EventPayload::Value(min + f * (max - min))))
344 });
345 let bar = View::new(Style {
346 size: Size {
347 width: percent(frac),
348 height: percent(1.0_f32),
349 },
350 ..Default::default()
351 })
352 .fill(color(SLIDER_FILL))
353 .radius(6.0);
354 view = view.children(vec![bar]);
355 } else if let Some(sel) = &node.select {
356 // Dropdown: header con la opción actual + lista inline cuando está abierto.
357 let id = node.on_select.unwrap_or(0);
358 let is_open = open_select == Some(id);
359 let current = sel
360 .options
361 .get(sel.selected as usize)
362 .cloned()
363 .unwrap_or_else(|| "\u{2014}".into()); // —
364 let arrow = if is_open { "\u{25b4}" } else { "\u{25be}" }; // ▴ / ▾
365 let header = item_box(36.0)
366 .fill(color(SELECT_BG))
367 .border(1.0, color(INPUT_BORDER))
368 .text(format!("{current} {arrow}"), 18.0, color(INPUT_TEXT))
369 .on_click(RunnerMsg::ToggleSelect(id));
370 let mut kids = vec![header];
371 if is_open {
372 for (i, opt) in sel.options.iter().enumerate() {
373 let oid = i as u32;
374 let bg = if oid == sel.selected { SELECT_SEL } else { SELECT_ITEM };
375 kids.push(
376 item_box(32.0)
377 .fill(color(bg))
378 .text(opt.clone(), 18.0, color(INPUT_TEXT))
379 .on_click(RunnerMsg::Event(id, EventPayload::Select(oid))),
380 );
381 }
382 }
383 view = view.children(kids);
384 } else if let Some(rad) = &node.radio {
385 // Grupo de radio: todas las opciones visibles, la marcada con ◉. Cada
386 // fila emite Select(idx) (mismo payload que el dropdown).
387 let id = node.on_radio.unwrap_or(0);
388 let kids: Vec<View<RunnerMsg>> = rad
389 .options
390 .iter()
391 .enumerate()
392 .map(|(i, opt)| {
393 let oid = i as u32;
394 let marcado = oid == rad.selected;
395 let glyph = if marcado { "\u{25c9}" } else { "\u{25cb}" }; // ◉ / ○
396 let col = if marcado { SLIDER_FILL } else { INPUT_TEXT };
397 item_box(30.0)
398 .text(format!("{glyph} {opt}"), 18.0, color(col))
399 .on_click(RunnerMsg::Event(id, EventPayload::Select(oid)))
400 })
401 .collect();
402 view = view.children(kids);
403 } else {
404 if let Some(t) = &node.text {
405 view = view.text_aligned(
406 t.content.clone(),
407 t.size,
408 color(t.color),
409 map_text_align(t.align),
410 );
411 }
412 if let Some(id) = node.on_click {
413 view = view.on_click(RunnerMsg::Event(id, EventPayload::Click));
414 }
415 }
416
417 if !node.children.is_empty() {
418 view = view.children(
419 node.children
420 .iter()
421 .map(|c| wire_to_view(c, focused, open_select))
422 .collect(),
423 );
424 }
425 view
426}
427
428/// Una caja de fila a `height` px, ancho completo, contenido centrado a la
429/// izquierda con padding — el ladrillo del header/items del dropdown.
430fn item_box(height: f32) -> View<RunnerMsg> {
431 View::new(Style {
432 size: Size {
433 width: percent(1.0_f32),
434 height: length(height),
435 },
436 align_items: Some(AlignItems::CENTER),
437 padding: Rect {
438 left: length(10.0_f32),
439 right: length(10.0_f32),
440 top: length(0.0_f32),
441 bottom: length(0.0_f32),
442 },
443 ..Default::default()
444 })
445 .radius(6.0)
446}
447
448/// Texto a mostrar en un input y su color: value (o placeholder), enmascarado si
449/// es password, con un caret `│` cuando tiene el foco.
450fn display_input(inp: &WireInput, focused: bool) -> (String, [u8; 4]) {
451 let body = if inp.value.is_empty() {
452 if focused {
453 String::new()
454 } else {
455 return (inp.placeholder.clone(), INPUT_PLACEHOLDER);
456 }
457 } else if inp.password {
458 "\u{25cf}".repeat(inp.value.chars().count()) // ●
459 } else {
460 inp.value.clone()
461 };
462 let shown = if focused { format!("{body}\u{2502}") } else { body }; // │
463 (shown, INPUT_TEXT)
464}
465
466fn dim(d: Dim) -> Dimension {
467 match d {
468 Dim::Auto => Dimension::auto(),
469 Dim::Px(v) => length(v),
470 Dim::Pct(v) => percent(v),
471 }
472}
473
474fn color(c: [u8; 4]) -> Color {
475 Color::from_rgba8(c[0], c[1], c[2], c[3])
476}
477
478fn map_align(a: Align) -> AlignItems {
479 match a {
480 Align::Start => AlignItems::FLEX_START,
481 Align::Center => AlignItems::CENTER,
482 Align::End => AlignItems::FLEX_END,
483 Align::Stretch => AlignItems::STRETCH,
484 }
485}
486
487fn map_justify(j: Justify) -> JustifyContent {
488 match j {
489 Justify::Start => JustifyContent::FLEX_START,
490 Justify::Center => JustifyContent::CENTER,
491 Justify::End => JustifyContent::FLEX_END,
492 Justify::SpaceBetween => JustifyContent::SPACE_BETWEEN,
493 Justify::SpaceAround => JustifyContent::SPACE_AROUND,
494 }
495}
496
497fn map_text_align(a: TextAlign) -> llimphi_ui::llimphi_text::Alignment {
498 match a {
499 TextAlign::Start => llimphi_ui::llimphi_text::Alignment::Start,
500 TextAlign::Center => llimphi_ui::llimphi_text::Alignment::Center,
501 TextAlign::End => llimphi_ui::llimphi_text::Alignment::End,
502 }
503}
504
505// =====================================================================
506// Host imports — gateados por Permisos (frontera física, espejo host de
507// wawa::wasm::env). Namespace `"tawa"`. Si el bit no está, el import no se
508// enlaza y el guest que lo use trap-ea al instanciar.
509// =====================================================================
510
511/// Construye el linker para un guest con los `permisos` dados. Las funciones
512/// inocuas (log) van siempre; las que tocan recursos se enlazan sólo con su bit.
513///
514/// La DECISIÓN («¿este import se registra?») ya no vive aquí: la toma
515/// `kikin-core`, la misma política que gobierna al kernel de wawa y a los otros
516/// hosts de la suite. Aquí queda sólo lo que es nuestro: el ABI `tawa`.
517pub fn build_linker(engine: &Engine, permisos: Permisos) -> Result<Linker<()>, String> {
518 let mut reja = Reja::<(), Permisos>::nueva(engine, Concesion::nueva(permisos));
519
520 // `host_log` — traza, no toca recursos ajenos. Negarla no protegería nada.
521 reja.si("tawa::host_log", Requiere::Siempre, |l| {
522 l.func_wrap("tawa", "host_log", |caller: Caller<'_, ()>, ptr: i32, len: i32| {
523 if let Some(s) = read_utf8(&caller, ptr, len) {
524 eprintln!("[wasm] {s}");
525 }
526 })
527 .map(|_| ())
528 .map_err(|e| format!("enlazar host_log: {e}"))
529 })?;
530
531 // `host_net_request` — pide bytes a la red por hash (futuro: sobre
532 // BrahmanNet). Hoy es un stub que devuelve -1 (no implementado), pero la
533 // FRONTERA ya es real: sin `PERMISO_RED` el símbolo no existe y un guest
534 // que lo importe ni instancia.
535 reja.si("tawa::host_net_request", Requiere::Bit(format::PERMISO_RED), |l| {
536 l.func_wrap(
537 "tawa",
538 "host_net_request",
539 |_caller: Caller<'_, ()>, _ptr: i32, _len: i32| -> i32 { -1 },
540 )
541 .map(|_| ())
542 .map_err(|e| format!("enlazar host_net_request: {e}"))
543 })?;
544
545 Ok(reja.consumir())
546}
547
548fn read_utf8(caller: &Caller<'_, ()>, ptr: i32, len: i32) -> Option<String> {
549 let memory = caller.get_export("memory")?.into_memory()?;
550 let ptr = ptr.max(0) as usize;
551 let len = len.max(0) as usize;
552 let data = memory.data(caller);
553 let end = ptr.checked_add(len)?;
554 if end > data.len() {
555 return None;
556 }
557 String::from_utf8(data[ptr..end].to_vec()).ok()
558}