1# Manual de Llimphi
2
3> Motor gráfico soberano de gioser. `wgpu` + `vello` + `taffy` + `parley`,
4> bucle Elm `input → update → view → layout → raster → present`.
5> Reemplazo total de GPUI (extinto 2026-05-26): toda app gráfica de la suite
6> corre sobre Llimphi.
7
8Este documento es la **referencia de uso** orientada a humanos y a IA.
9Está organizado para salto directo: cada capa, widget y módulo trae su API
10real (firmas copiadas del código). Para el **porqué** arquitectónico ver
11[`SDD.md`](SDD.md); para la regla de concurrencia ver
12[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md).
13
14---
15
16## Índice
17
181. [Modelo mental en 60 segundos](#1-modelo-mental-en-60-segundos)
192. [Arquitectura — las capas](#2-arquitectura--las-capas)
203. [Quickstart — la app mínima](#3-quickstart--la-app-mínima)
214. [El trait `App` (bucle Elm)](#4-el-trait-app-bucle-elm)
225. [`Handle` — efectos y concurrencia](#5-handle--efectos-y-concurrencia)
236. [`View<Msg>` — el DSL declarativo](#6-viewmsg--el-dsl-declarativo)
247. [Layout (`taffy` / `Style`)](#7-layout-taffy--style)
258. [Eventos e interacción](#8-eventos-e-interacción)
269. [Texto](#9-texto)
2710. [Canvas custom y GPU directo](#10-canvas-custom-y-gpu-directo)
2811. [Theme y paletas](#11-theme-y-paletas)
2912. [Capas base (hal · raster · text · motion · icons · surface)](#12-capas-base)
3013. [Catálogo de widgets](#13-catálogo-de-widgets)
3114. [Catálogo de módulos](#14-catálogo-de-módulos)
3215. [`llimphi-workspace` — chasis tipo tmux](#15-llimphi-workspace--chasis-tipo-tmux)
3316. [Reglas duras y gotchas](#16-reglas-duras-y-gotchas)
3417. [Comandos y demos](#17-comandos-y-demos)
3518. [Cheat-sheet](#18-cheat-sheet)
3619. [Índice de crates](#19-índice-de-crates)
37
38---
39
40## 1. Modelo mental en 60 segundos
41
42Llimphi es **Elm sobre la GPU**. Una app es un tipo que implementa el trait
43`App` con cuatro piezas:
44
45- `Model` — estado **inmutable** de la app.
46- `Msg` — todo lo que puede pasar (`Clone + Send`).
47- `update(model, msg, handle) -> model` — transición **pura** que devuelve un
48 modelo nuevo.
49- `view(&model) -> View<Msg>` — función **pura** que describe la pantalla como
50 un árbol de `View`.
51
52El runtime hace el bucle: un evento (click/tecla/rueda) produce un `Msg`,
53`update` deriva el nuevo `Model`, `view` reconstruye el árbol, `taffy` calcula
54las cajas, `vello` rasteriza, y se hace swap del frame. **No hay mutabilidad
55compartida, no hay vDOM ajeno, no hay callbacks imperativos**: declarás qué se
56ve y qué `Msg` emite cada nodo.
57
58```
59 evento ─▶ Msg ─▶ update(model,msg) ─▶ model' ─▶ view(model') ─▶ View<Msg>
60 │
61 present ◀─ raster(vello) ◀─ layout(taffy) ◀──────────────────────┘
62```
63
64Tres reglas de oro:
651. **`view` es pura** — no muta nada, sólo lee el modelo y arma el árbol.
662. **Cómputo pesado va a un worker** vía `Handle::spawn`, nunca síncrono en
67 `update`/`init`/handlers (congela la ventana → "Not Responding").
683. **Widgets son visuales y stateless**; el estado vive en tu `Model`.
69 **Módulos** sí encapsulan estado + comportamiento.
70
71---
72
73## 2. Arquitectura — las capas
74
75```
764. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent)
77 └ llimphi-compositor . árbol View<Msg>, mount sobre taffy, paint, hit-test (winit-free)
783. llimphi-layout ....... motor de layout (taffy: flexbox + grid)
792. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo
801. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji
810. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer)
82```
83
84El **split compositor/runtime** (2026-05-31) es importante: `llimphi-compositor`
85es *winit-free* (sólo `View`, `mount`, `paint`, hit-test). `llimphi-ui` lo corre
86sobre winit y **re-exporta todo el compositor**, así escribís `llimphi_ui::View`
87sin enterarte del split. Esto habilita un futuro runtime sobre el framebuffer
88del kernel `wawa` reusando el mismo compositor.
89
90Auxiliares: `llimphi-theme` (paletas), `llimphi-motion` (tweens),
91`llimphi-icons` (iconos vectoriales), `llimphi-surface` (texturas externas),
92`llimphi-workspace` (chasis tmux), `llimphi-gallery` (showcase).
93
94Catálogo: **~45 widgets** (visuales) + **10 módulos** (features con estado).
95
96---
97
98## 3. Quickstart — la app mínima
99
100```rust
101use llimphi_ui::llimphi_layout::taffy::prelude::*;
102use llimphi_ui::llimphi_raster::peniko::Color;
103use llimphi_ui::{App, Handle, View};
104
105#[derive(Clone)]
106enum Msg { Increment, Reset }
107
108struct Counter;
109
110impl App for Counter {
111 type Model = u32;
112 type Msg = Msg;
113
114 fn title() -> &'static str { "llimphi · counter" }
115
116 fn init(_: &Handle<Self::Msg>) -> Self::Model { 0 }
117
118 fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
119 match msg {
120 Msg::Increment => model.saturating_add(1),
121 Msg::Reset => 0,
122 }
123 }
124
125 fn view(model: &Self::Model) -> View<Self::Msg> {
126 let boton = View::new(Style {
127 size: Size { width: length(160.0), height: length(56.0) },
128 align_items: Some(AlignItems::Center),
129 justify_content: Some(JustifyContent::Center),
130 ..Default::default()
131 })
132 .fill(Color::from_rgba8(60, 200, 130, 255))
133 .radius(12.0)
134 .text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255))
135 .on_click(Msg::Increment);
136
137 View::new(Style {
138 flex_direction: FlexDirection::Column,
139 size: Size { width: percent(1.0), height: percent(1.0) },
140 align_items: Some(AlignItems::Center),
141 justify_content: Some(JustifyContent::Center),
142 gap: Size { width: length(0.0), height: length(24.0) },
143 ..Default::default()
144 })
145 .fill(Color::from_rgba8(20, 24, 32, 255))
146 .children(vec![
147 View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE),
148 boton,
149 ])
150 }
151}
152
153fn main() { llimphi_ui::run::<Counter>(); }
154```
155
156`Cargo.toml`:
157```toml
158[dependencies]
159llimphi-ui = { workspace = true }
160llimphi-theme = { workspace = true }
161# + los widgets/modules que uses:
162# llimphi-widget-button = { workspace = true }
163```
164
165Corre con `cargo run -p <tu-crate> --release`. El ejemplo vivo está en
166`llimphi-ui/examples/counter.rs`.
167
168---
169
170## 4. El trait `App` (bucle Elm)
171
172Definido en `llimphi-ui/src/lib.rs`. El estado es inmutable; cada evento
173produce un `Model` nuevo.
174
175```rust
176pub trait App: 'static {
177 type Model: 'static;
178 type Msg: Clone + Send + 'static;
179
180 fn init(handle: &Handle<Self::Msg>) -> Self::Model;
181 fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
182 fn view(model: &Self::Model) -> View<Self::Msg>;
183
184 // --- Todo lo de abajo tiene default; sobreescribí lo que necesites ---
185
186 fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> { None }
187
188 fn on_wheel(_model: &Self::Model, _delta: WheelDelta,
189 _cursor: (f32, f32), _modifiers: Modifiers) -> Option<Self::Msg> { None }
190
191 /// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta
192 /// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo
193 /// vidrio"). La transición la maneja tu Model.
194 fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> { None }
195
196 /// Drag&drop de archivos desde el file manager. Un evento por archivo.
197 fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> { None }
198
199 /// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El
200 /// runtime administra el foco; guardás `id` en tu Model para pintar el ring
201 /// y rutear el teclado. Ver §8 (Foco y teclado).
202 fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> { None }
203
204 /// IME (composición de texto: CJK, acentos muertos, emoji). Opt-in vía
205 /// `ime_allowed()` para no robarle el texto a las apps que sólo leen
206 /// `on_key`. Flujo: Enabled → Preedit* → Commit/Disabled. Ver §8 (IME).
207 fn ime_allowed() -> bool { false }
208 fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> { None }
209 /// Área del caret en px físicos para ubicar la ventana de candidatos.
210 fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { None }
211
212 fn title() -> &'static str { "llimphi" }
213 fn app_id() -> Option<&'static str> { None } // app_id del xdg-toplevel en Wayland
214 fn initial_size() -> (u32, u32) { (960, 540) }
215}
216```
217
218Punto de entrada: `pub fn run<A: App>()` — corre hasta que el usuario cierre la
219ventana o la app llame `Handle::quit`.
220
221**Eventos de teclado** (`KeyEvent`):
222```rust
223pub struct KeyEvent {
224 pub key: Key, // re-export de winit; usar NamedKey para teclas especiales
225 pub state: KeyState, // Pressed | Released
226 pub text: Option<String>, // texto resultante con IME/modifiers; None para flechas etc.
227 pub modifiers: Modifiers, // { shift, ctrl, alt, meta }
228 pub repeat: bool,
229}
230```
231`Key` y `NamedKey` se re-exportan desde `llimphi_ui`.
232
233**Rueda** (`WheelDelta { x, y }`): normalizado a "líneas". Convención CSS:
234`y` positivo = scroll hacia abajo.
235
236---
237
238## 5. `Handle` — efectos y concurrencia
239
240`Handle<Msg>` es `Send + Clone`. Llega a `init` y `update`. Es el único modo
241legítimo de producir efectos sin romper la pureza de la transición.
242
243```rust
244impl<Msg: Send + 'static> Handle<Msg> {
245 pub fn quit(&self); // cierra la ventana / termina el bucle
246 pub fn dispatch(&self, msg: Msg); // encola un Msg para el próximo turno
247 pub fn spawn<F: FnOnce() -> Msg + Send + 'static>(&self, f: F); // worker; su Msg reentra al update
248 pub fn spawn_periodic<F: Fn() -> Msg + Send + 'static>(&self, period: Duration, f: F); // tick periódico
249 pub fn for_test() -> Self; // handle "muerto" para tests sin event loop
250}
251```
252
253- **`spawn`** — trabajo bloqueante (IO, PAM, parse, efemérides). El `Msg` que
254 devuelve la closure se entrega al `update` en el hilo de UI. **Este es el
255 patrón obligatorio para todo cómputo pesado** (§16).
256- **`spawn_periodic`** — feeds a intervalos: ticks de simulación (~11 Hz en
257 dominium), polling, animaciones por reloj. El thread muere cuando se cierra
258 el event loop.
259
260---
261
262## 6. `View<Msg>` — el DSL declarativo
263
264Un `View` = `Style` de taffy + relleno + texto/imagen/painter + handlers +
265hijos. Todo se arma con builders encadenables (`self -> Self`). Definido en
266`llimphi-compositor/src/view.rs`.
267
268```rust
269View::new(style: Style) -> View<Msg>
270```
271
272### Apariencia
273| Método | Efecto |
274|---|---|
275| `.fill(Color)` | color de fondo |
276| `.hover_fill(Color)` | color al pasar el cursor (habilita hit-test de hover) |
277| `.radius(f64)` | esquinas redondeadas |
278| `.alpha(f32)` | opacidad de todo el subtree `[0,1]` (capa intermedia — no gratis) |
279| `.transform(Affine)` | afín 2D alrededor del centro del rect (estilo CSS `transform-origin:50% 50%`) |
280| `.clip(bool)` | recorta hijos al rect (paint + hit-test) |
281| `.image(Image)` | pinta `peniko::Image` centrada, preservando aspect ratio |
282| `.children(Vec<View<Msg>>)` | hijos |
283
284### Texto (ver §9)
285```rust
286.text(content, size_px, color) // centrado
287.text_aligned(content, size_px, color, Alignment)
288.text_aligned_italic(content, size_px, color, Alignment, italic)
289.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option<String>)
290.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada
291.line_height(mult) // override interlínea (default 1.2)
292```
293
294### Interacción (ver §8)
295```rust
296.on_click(Msg)
297.on_click_at(|lx, ly, w, h| -> Option<Msg>) // posición local + tamaño del rect
298.on_right_click(Msg) / .on_right_click_at(...)
299.on_middle_click(Msg)
300.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
301.draggable(|phase: DragPhase, dx, dy| -> Option<Msg>)
302.draggable_at(|phase, dx, dy, lx0, ly0| -> Option<Msg>) // + posición inicial del press
303.drag_payload(u64) // payload que viaja con el drag
304.on_drop(|payload: u64| -> Option<Msg>) // este nodo es drop target
305.drop_hover_fill(Color) // resaltado mientras un drag lo sobrevuela
306.on_scroll(|dx, dy| -> Option<Msg>) // rueda local (antes del on_wheel global)
307.focusable(u64) // nodo enfocable por Tab/click (id opaco)
308```
309
310### Pintura custom (ver §10)
311```rust
312.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... })
313.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... })
314```
315
316Notas clave:
317- **Un nodo es draggable *o* clickable**, no ambos: `draggable` sobreescribe
318 `on_click`.
319- Las variantes `*_at` ganan sobre las simples si ambas están.
320- `PaintRect { x, y, w, h }` es el rect **absoluto** del nodo en píxeles físicos.
321- `DragPhase` = `Move` (un evento por `CursorMoved`, `dx/dy` = delta **desde el
322 evento anterior**, no acumulado) | `End` (al soltar).
323
324---
325
326## 7. Layout (`taffy` / `Style`)
327
328`Style` es el `taffy::Style` directo, re-exportado vía
329`llimphi_ui::llimphi_layout::taffy::prelude::*`. Es Flexbox + CSS Grid puro.
330
331Campos más usados:
332```rust
333Style {
334 flex_direction: FlexDirection::Row | Column,
335 size: Size { width, height }, // length(px) | percent(0..1) | Dimension::auto()
336 min_size, max_size,
337 flex_grow: f32, flex_shrink: f32,
338 align_items: Some(AlignItems::{Start,Center,End,Stretch}),
339 justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}),
340 gap: Size { width, height },
341 padding: Rect { left, right, top, bottom }, // con length(px)
342 margin: Rect { ... },
343 ..Default::default()
344}
345```
346
347Helpers de `prelude`: `length(px)`, `percent(frac)`, `auto()`, `Dimension`,
348`Size`, `Rect`, `FlexDirection`, `AlignItems`, `JustifyContent`.
349
350`llimphi-layout` además expone:
351- `LayoutTree::new()` / `.clear()` (reuso entre frames), `.leaf(style)`,
352 `.node(style, &children)`, `.compute(...)`, `.compute_with_measure(F)`.
353- `Rect { x, y, w, h }` y `ComputedLayout { rects: HashMap<NodeId, Rect> }`.
354
355En el 99% de los casos no tocás `LayoutTree` a mano: lo maneja el runtime al
356montar tu `View`. Sólo armás `Style`s.
357
358---
359
360## 8. Eventos e interacción
361
362| Quiero… | Cómo |
363|---|---|
364| Botón / fila clickable | `.on_click(Msg)` (+ `.hover_fill` para feedback) |
365| Saber dónde se clickeó (canvas) | `.on_click_at(\|lx,ly,w,h\| ...)` → convertir a coords de mundo |
366| Menú contextual | `.on_right_click(Msg::OpenMenu{..})`, guardar pos en Model, abrir en `view_overlay` |
367| Abrir en pestaña nueva | `.on_middle_click(Msg)` |
368| Preview al pasar el mouse | `.on_pointer_enter(Msg)` / `.on_pointer_leave(Msg)` |
369| Resize de panel | `.draggable(\|phase,dx,dy\| ...)` acumulando delta en el Model |
370| Arrastrar entidad de un canvas | `.draggable_at(\|phase,dx,dy,lx0,ly0\| ...)` |
371| Drag&drop entre zonas | origen: `.drag_payload(id)`; destino: `.on_drop(\|id\| ...)` + `.drop_hover_fill` |
372| Scroll global | `App::on_wheel(model, delta, cursor, mods)` |
373| Área de scroll | widget `scroll_y(...)` (autocontenido) o `.on_scroll(\|dx,dy\| ...)` por nodo |
374| Teclado | `App::on_key(model, &KeyEvent) -> Option<Msg>` |
375| Foco / Tab | `.focusable(id)` en los nodos + `App::on_focus(model, id)` (ver abajo) |
376| IME (CJK, acentos) | `App::ime_allowed() -> true` + `App::on_ime(model, &ImeEvent)` (ver abajo) |
377| Drop de archivos del SO | `App::on_file_drop(model, path)` |
378
379**Patrón overlay** (menús/modales): el modelo guarda "menú abierto sí/no".
380Mientras esté abierto, `view_overlay` devuelve `Some(view)`; clicks fuera se
381cierran envolviendo los items en un scrim a pantalla completa con
382`on_click = DismissOverlay`. Cuando el modelo dice cerrado, `view_overlay`
383devuelve `None`.
384
385**Scroll** (widget `llimphi-widget-scroll`). `scroll_y(offset, content_len,
386viewport_len, content, on_scroll, &palette)` arma un viewport clipeado +
387contenido desplazado `-offset` + barra arrastrable. Es **stateless**: el offset
388vive en tu Model. `on_scroll(delta_px)` (rueda y arrastre) emite un delta a
389sumar; clampealo con `scroll::clamp_offset` en tu `update`. Helpers:
390`ensure_visible(offset, vp, item_top, item_h)` para llevar la selección a la
391vista (teclado); `approach(cur, target, factor)` para scroll suave/inercia
392(driveado por `Handle::spawn_periodic`).
393
394**Foco y teclado.** Marcá los nodos navegables con `.focusable(id)` (id `u64`
395que vos elegís). El runtime es la **única fuente de verdad** del foco: lo mueve
396con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo
397enfocable, y te avisa con `App::on_focus(model, Option<u64>)`. Guardás el id en
398tu Model para (a) pintar el ring (`if model.focus == Some(id) { .fill(accent) }`
399en `view`) y (b) rutear el teclado al campo activo desde `on_key`. No setees el
400foco por tu cuenta vía Msg: quedaría desincronizado del runtime.
401
402**IME** (composición de texto). Opt-in: `ime_allowed() -> true`. Con IME activo
403el texto compuesto **no** llega por `KeyEvent.text` sino por `on_ime`:
404`ImeEvent::Enabled` → uno o más `Preedit{text, cursor}` (texto en composición, a
405pintar subrayado en el caret) → `Commit(text)` (insertá como tecleado) o
406`Disabled`. Reportá el área del caret con `ime_cursor_area(model)` para ubicar
407la ventana de candidatos (CJK) junto al cursor.
408
409---
410
411## 9. Texto
412
413`TextSpec` (en compositor) describe el texto de un nodo:
414```rust
415pub struct TextSpec {
416 pub content: String,
417 pub size_px: f32,
418 pub color: Color,
419 pub alignment: Alignment, // Start | Center | End | Justify
420 pub italic: bool,
421 pub font_family: Option<String>, // string CSS con fallbacks
422 pub line_height: f32, // múltiplo; default 1.2
423 pub runs: Option<Vec<(usize, usize, Color)>>, // color por rango de BYTES
424}
425```
426
427- `Center` es el default (apto para labels). Para editores/párrafos usar
428 `.text_aligned(..., Alignment::Start)`.
429- **Multicolor en una sola pasada de shaping**: `.text_runs(...)` colorea
430 rangos de bytes — es la base del syntax highlighting (un nodo por línea, no
431 por token). Anclado arriba-izquierda; el caller dimensiona el rect.
432- El runtime mide el texto con parley durante el layout (`compute_with_measure`)
433 para que taffy reserve el alto real del texto envuelto a varias líneas
434 (evita "textos aplastados").
435- Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique.
436
437---
438
439## 10. Canvas custom y GPU directo
440
441Dos hooks para pintar primitivas no expresables como composición de `View`s.
442Conviven en el mismo árbol; el runtime pinta **toda la pasada vello primero**,
443luego los `gpu_painter` en orden DFS.
444
445### `paint_with` — vía vello (el default)
446```rust
447.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| {
448 // dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`.
449 // NO dejar push_layer sin pop_layer; NO resetear la scene.
450})
451```
452Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal.
453Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada).
454
455### `gpu_paint_with` — sube vertex buffers directo a wgpu, salta vello
456```rust
457.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| {
458 // abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello.
459 // (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC.
460})
461```
462Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de
463nakui, pineal denso. Rango 100 K – 10 M+ primitivos. **No** soporta texto ni AA
464fino ni múltiples grosores de stroke por flush. Para texto encima de un render
465GPU, usar `view_overlay` (segunda Scene vello).
466
467### ¿Cuándo cada uno?
468| Pregunta | vello (`paint_with`) | GPU directo (`gpu_paint_with`) |
469|---|---|---|
470| Primitivos/frame | < ~500 K rebuild / < ~2 M Scene reusada | 100 K – 10 M+ |
471| ¿Cambian cada frame? | sí, rebuild barato | mejor estático (buffer persistente) |
472| Curvas Bezier | nativas | hay que teselar |
473| Texto | sí | no |
474| AA fino | sí (analítico) | no (sin MSAA) |
475
476**Default: `paint_with`** salvo que ya midas que el volumen lo justifica
477(factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU
478expone `GpuPipelines`/`GpuBatch` en `llimphi-raster` (§12).
479
480---
481
482## 11. Theme y paletas
483
484`llimphi-theme::Theme` es un struct de slots semánticos de color. Cuatro presets
485`const`: `Theme::dark()` (default), `light()`, `aurora()`, `sunset()`.
486
487```rust
488pub struct Theme {
489 pub name: &'static str,
490 // fondos
491 pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus,
492 pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color,
493 // texto
494 pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color,
495 // bordes y acento
496 pub border, border_focus, accent: Color,
497}
498
499Theme::all() -> Vec<Theme> // orden de rotación canónico
500Theme::by_name(name) -> Option<Theme>
501Theme::next_after(current_name) -> Theme // para el theme-switcher
502```
503
504Tokens auxiliares en el mismo crate:
505- `motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms}` + `ease_out_cubic`,
506 `ease_in_out_cubic`, `linear`.
507- `alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT}` (constantes `u8`).
508- `radius::{XS=2, SM=4, MD=8, LG=12, XL=20}` (`f64`).
509
510**Patrón de widgets**: cada widget define su `XxxPalette` con
511`Palette::from_theme(&theme)`. Tu app guarda un `Theme` en el Model, deriva las
512paletas que necesita en `view`, y se las pasa a los widgets. Para cambiar de
513tema, el `theme-switcher` emite `Msg(next_theme)` y reconstruís todo.
514
515---
516
517## 12. Capas base
518
519### `llimphi-hal` — superficie
520```rust
521Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result<Hal, HalError> // async
522trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result<Frame,_>; fn present(frame, hal); }
523WinitSurface::new(hal, window: Arc<Window>) -> Result<Self, HalError>
524Frame::view() -> &wgpu::TextureView; Frame::size() -> (u32,u32)
525```
526`Hal::new` pide adapter `Backends::PRIMARY` (Vulkan) y cae a `all()` sólo si no
527hay — **no volver a `InstanceDescriptor::default()`**: el backend GL de Mesa
528sobre Wayland segfaultea en el teardown. El runtime de `llimphi-ui` ya maneja
529todo esto; sólo tocás HAL si escribís un runtime nuevo.
530
531### `llimphi-raster` — rasterización
532```rust
533Renderer::new(hal) -> Result<Renderer,_>
534Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
535// GPU directo:
536GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout
537GpuBatch::new(&pipelines)
538 .line_width(w) .add_line(p0,p1,color) .add_polyline(&pts,color)
539 .add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
540 .primitive_count() -> u32
541 .flush(device, queue, encoder, view, viewport, load_op)
542```
543Re-exporta `vello` y `peniko` (`Color`, `Image`, `Fill`, etc.).
544
545### `llimphi-text` — shaping
546```rust
547Typesetter::new() // una por proceso (FontContext es caro)
548 .layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()>
549 .layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout<RunBrush>
550TextBlock::simple(text, size_px, color, origin)
551layout_block(ts, &block) / measure(ts, &block) -> Measurement
552draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin)
553Alignment::{Start, Center, End, Justify}
554```
555
556### `llimphi-motion` — tweens
557```rust
558trait Lerp { fn lerp(self, other, t: f32) -> Self; } // impl para f32,f64,(f32,f32),(f64,f64),Color
559Tween::new(from, to, duration, easing: fn(f32)->f32) // o Tween::idle(value)
560tween.value() / .progress() / .done()
561animate(handle, duration, make_msg) // arranca los ticks del tween
562```
563Patrón: guardás `Tween<T>` en el Model, `animate(...)` en el update, la `view`
564lee `tween.value()` cada repaint. El tween se auto-termina.
565
566### `llimphi-icons` — iconos vectoriales (~23, grid 24×24)
567```rust
568Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right,
569 Home, Search, Info, Warning, Error, Bell, Settings, More, ...}
570icon_view(Icon, color, stroke_width) -> View<Msg>
571paint_icon(scene, rect, icon, color, stroke_width) // dentro de un paint_with
572```
573`stroke_width` en unidades del grid 24×24 (1.6 es armónico).
574
575### `llimphi-surface` — texturas externas
576```rust
577ExternalSurface::new(device, queue) // barato de clonar (Arc<Mutex> interno)
578 .upload(&rgba, w, h) // desde otro hilo/decoder/cámara
579 .view(style) -> View<Msg> // blittea a su rect en el árbol Elm
580 .blit(queue, encoder, dst_view, rect, viewport) // o manual desde gpu_paint_with
581```
582
583---
584
585## 13. Catálogo de widgets
586
587Los widgets son **funciones puras** que devuelven `View<Msg>` (o specs que se
588convierten a `View`). Son **stateless**: el estado vive en tu Model. Convención:
589cada uno trae `XxxPalette::from_theme(&Theme)`. Crates en
590`widgets/<nombre>/`, dep `llimphi-widget-<nombre>`.
591
592### Controles
593
594**button** — `button_view(label, &ButtonPalette, on_click: Msg) -> View`;
595`button_styled(label, style, alignment, &palette, on_click)`.
596
597**field** — wrapper de formulario (label + helper/error + requerido).
598`field_view(FieldSpec { label, control: View<Msg>, required, helper, error, palette })`.
599
600**text-input** — input single-line **con estado** `TextInputState`
601(`new()`/`masked()`, `text()`, `set_text()`, `apply_key(&KeyEvent) -> bool`,
602soporta undo/redo + selección con Shift). Render:
603`text_input_view(&state, placeholder, focused, &palette, on_focus: Msg)`.
604
605**text-area** — multilínea con estado `TextAreaState` (Enter = newline, sin
606auto-submit). `text_area_view(&state, placeholder, focused, body_height, &palette, on_focus)`.
607
608**slider** — sin estado. `slider_view(label, value, min, max, &palette,
609on_change: Fn(DragPhase, delta_value) -> Option<Msg>)`. El delta viene en
610unidades, no píxeles.
611
612**switch** — `switch_view(progress: f32 [0..1], on_toggle: Msg, &palette)`. La
613app guarda el `bool` y opcionalmente anima `progress` con un `Tween`.
614
615**segmented** — N opciones exclusivas. `segmented_view(&[&str], selected: usize,
616make_msg: Fn(usize)->Msg, &palette)`.
617
618**progress** — `linear_progress_view(progress, track, fill, height)` y
619`radial_progress_view(progress, track, fill, stroke_ratio)`. Sin eventos.
620
621**spinner** — `spinner_view(color, stroke_ratio, speed_rev_per_sec)`. Animado por
622reloj absoluto; requiere redraws periódicos (`spawn_periodic`).
623
624**badge** — `count_badge_view(count, BadgeKind)` ("99+" si ≥100) y
625`dot_badge_view(BadgeKind)`. `BadgeKind::{Info,Success,Warning,Error,Neutral}`.
626
627**avatar** — `avatar_view(name, size_px)`: círculo determinista (color por hash
628del nombre + inicial).
629
630**tooltip** — render puro. `tooltip_view(TooltipSpec { anchor, viewport, side:
631Side, text, palette })`. Se monta en `view_overlay`; la app controla visibilidad
632con `on_pointer_enter/leave`.
633
634**empty** — empty-state. `empty_view(Icon, title, description: Option<&str>, &palette)`.
635
636**skeleton** — placeholder con shimmer. `skeleton_view`, `skeleton_box_view(w,h,..)`,
637`skeleton_line_view(w,..)`. Requiere redraws periódicos.
638
639**banner** — tira de status. `banner_view(BannerKind::{Info,Success,Warning,Error}, message)`.
640
641### Contenedores y layout
642
643**panel** — chrome (gradiente + hairline accent). `panel_view(children, PanelStyle)`;
644`PanelStyle::{from_theme, from_theme_large, neutral}`. `panel_signature_painter(style)`
645para reusar el look en un `paint_with`.
646
647**card** — `card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette)`.
648
649**stat-card** — métrica de dashboard. `stat_card_view(label, value, description,
650accent, &recent_items, &palette)`.
651
652**tabs** — `tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg,
653content: View<Msg>, tab_height, palette, tab_width })`. Selección la maneja la app.
654
655**splitter** — divisor draggable de 2 panes. `splitter_two(Direction::{Row,Column},
656a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette)`.
657`PaneSize::{Fixed(px), Flex}`. La app acumula el delta en su Model.
658
659**scroll** — área de scroll vertical con barra arrastrable. `scroll_y(offset,
660content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette)`.
661Stateless (offset en el Model); rueda autocontenida. Helpers: `clamp_offset`,
662`ensure_visible` (selección a la vista), `approach` (scroll suave). Ver §8.
663
664**tiled** — grilla auto cols×rows de tiles con title bar. `tiled_view(tiles, &palette)`,
665`tiled_view_cols(tiles, cols, &palette)`, y variantes `*_reorderable*` con
666`on_reorder: Fn(from, to)->Option<Msg>` (drag-to-swap por la title bar). `TileSpec { label, content }`.
667
668**panes** — árbol binario BSP tipo tmux. La app guarda un `Layout`:
669```rust
670Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second }
671layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves()
672panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec<Side>,DragPhase,delta)->Option<Msg>,
673 on_focus: Fn(PaneId)->Msg, &palette)
674```
675
676**grid** — grilla 2D virtualizada. `ventana_visible(total, vp_w, vp_h, scroll_fila,
677&metrics) -> VisibleWindow` para virtualizar, luego `grid_view(GridSpec { cells:
678Vec<GridCell { content, label, selected, on_click }>, cols, metrics, caption, ... })`.
679
680**list** — lista vertical virtualizada. `list_view(ListSpec { rows: Vec<ListRow {
681label, selected, on_click }>, total, caption, truncated_hint, row_height, palette })`.
682La app prefiltra las filas visibles.
683
684**tree** — árbol expand/collapse. `tree_view(TreeSpec { rows: Vec<TreeRow { label,
685depth, has_children, expanded, selected, on_toggle, on_select }>, row_height,
686indent_px, palette })`. La app aplana el árbol según nodos expandidos.
687
688**navigator** — navegador data-agnóstico de nodos en dos modos conmutables
689(**árbol** ↔ **grafo**, reusa tree + nodegraph). Render-only: la app guarda
690`expanded`/`selected`/`mode`. Pasa un bosque de `NavNode { id: u64, label,
691kind: NavKind (Monad|Group|Dir|File|Other), children }` y callbacks por id.
692```rust
693navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides },
694 is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg,
695 on_select: Fn(u64)->Msg, on_context: Option<Fn(u64)->Msg>)
696// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
697// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
698// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.
699```
700
701**app-header** — `app_header(label, actions: Vec<View<Msg>>, &palette)`.
702
703**status-bar** — `status_bar_view(left, center, right, &palette)` con
704`StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized()`.
705
706**breadcrumb** — `breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)`
707(el último segmento no es clickable).
708
709**modal** — diálogo centrado con scrim. `modal_view(ModalSpec { title, body:
710View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette })`.
711`ModalButton::{primary, cancel, destructive}(label, msg)`. Se monta en `view_overlay`.
712
713**toast** — notificaciones efímeras bottom-right. La app guarda `Vec<Toast>`
714(`Toast::{info,success,warning,error}(id, text, duration)`), filtra
715`is_alive(now)`, y `toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg)`.
716
717**splash** — splash de arranque (cuatro cuadrantes andinos). `splash_view(started_at:
718Instant, bg, fg_text)`; basado en tiempo, requiere redraws.
719
720### Ricos / interactivos
721
722**nodegraph** — lienzo de nodos + cables Bezier. Sin estado (la app guarda
723posiciones y `Wire`s).
724```rust
725NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
726Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
727nodegraph_view(&nodes, &wires, &palette, &metrics,
728 on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
729 on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
730// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)
731```
732
733**timeline** — scrub clickeable. `timeline_view(progress: f32, &palette,
734on_seek: Fn(f32 [0..1])->Option<Msg>)`.
735
736**text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda
737`EditorState`:
738```rust
739EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo();
740.add_cursor_at(line,col); .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult;
741.ensure_caret_visible(visible_lines)
742// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option<Msg>
743text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer)
744text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer)
745text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer)
746syntax_palette_dark(&theme) -> SyntaxPalette // en lib.rs del widget
747```
748
749**text-editor-core** — núcleo **agnóstico** (sin GPU, sin Llimphi; sólo
750`peniko::Color`). Reutilizable en TUI/web/headless. Tipos clave:
751- `Buffer` (sobre `ropey`): `from_str`, `text`, `insert(offset,s)`, `delete(s,e)`,
752 `offset_to_pos`, `pos_to_offset`, `slice`, `line(n)`.
753- `Pos { line, col }`, `Selection { anchor, caret }`, `Cursor { caret, anchor:
754 Option, desired_col }` con `move_left/right/up/down/word_left/...`,
755 `selection_range(&buf)`, `collapse`.
756- Ops: `replace_selection`, `delete_backward/forward`, `indent_or_insert_tab`,
757 `insert_newline_auto_indent` → devuelven `EditDelta { start, removed, inserted,
758 cursor_before, cursor_after }` con `.apply()/.undo()`.
759- `UndoStack`: `push(delta)`, `undo/redo(&mut buf, &mut cursor) -> bool`, `can_undo/redo`.
760- `FindState { query, case_sensitive }`: `all_matches`, `find_next`, `find_prev`.
761- Matching de brackets: `find_bracket_pair(&buf, &cursor) -> Option<(Pos, Pos)>`, `Direction`.
762- `Clipboard` (trait `get/set`), `MemClipboard`, `NullClipboard`.
763- `Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity,
764 message: String, source: Option<String> }` (+ ctors `error(..)`, `warning(..)`);
765 `Severity::{Error, Warning, Information, Hint}`.
766- Highlight tree-sitter: `Language::{Plain, Rust, Python, Wat}`
767 (+ `Language::from_cell_language(s)`); `Highlighter::new(lang)` con
768 `.highlight(&mut self, source: &str) -> Vec<Vec<Span>>` (un `Vec<Span>` **por
769 línea**), `.set_language(lang)`, `.language()`; helpers de módulo
770 `invalidate_tree_cache(lang)` y `apply_pending_edits(lang, &edits)` para el
771 caché incremental. `TokenKind`, `Span`, `SyntaxPalette::color(kind)`.
772
773**text-editor-lsp** — cliente LSP por stdin/stdout. `trait LspClient` (fire-and-forget
774`request_*` + lecturas de caché `latest_*`/`clear_*`): completions, hover,
775definition, references, rename, formatting, signature help, document symbols.
776`RustAnalyzerClient::start(workspace_root)`; `NoopLspClient` para tests.
777
778**clipboard** — portapapeles del sistema vía `arboard`. `SystemClipboard::new()`,
779`is_available()`, impl `Clipboard`. No-op silencioso si no hay display (CI headless).
780
781**menubar** — barra de menú mac-style. `menubar_view(&MenuBarSpec { menu: &AppMenu,
782open: Option<usize>, theme, viewport, height, on_open: Fn(Option<usize>)->Msg,
783on_command: Fn(&str)->Msg })`; dropdown en `view_overlay` con `menubar_overlay(spec)`
784o `menubar_overlay_animated(spec, active, appear)`. Navegación por teclado:
785`menubar_nav`, `menubar_command_at`.
786
787**edit-menu** — menú estándar de edición sobre un editor.
788`EditFlags::from_editor(&state, masked)`, `edit_context_menu(anchor, viewport,
789&theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss)` →
790`ContextMenuSpec`. `apply(&mut state, EditAction, &mut clipboard) -> ApplyResult`.
791`EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}`.
792
793**context-menu** — menú contextual genérico (look "webpage"). `ContextMenuItem::
794action(label).with_shortcut(..).icon(..).disabled().destructive().submenu(children)`
795o `::separator()`. `context_menu_view(ContextMenuSpec { anchor, viewport, header,
796items, active, on_pick: Fn(usize)->Msg, on_dismiss, palette })`; `context_menu_view_ex`
797con submenús/animación. Se monta en `view_overlay` con scrim.
798
799**theme-switcher** — `theme_switcher_view(¤t: &Theme, on_change: Fn(Theme)->Msg)`
800(+ `_styled`/`_flex`). Cicla `Theme::next_after`.
801
802**shortcuts-help** — overlay "?" con atajos agrupados. `shortcuts_help_view(
803ShortcutsHelpSpec { title, groups: Vec<ShortcutGroup { title, entries:
804Vec<ShortcutEntry { keys, description }> }>, viewport, on_dismiss, palette })`.
805
806**wawa-mark** — sello vectorial del SO wawa. `wawa_mark_view(&WawaMarkPalette)`;
807`paint_mark(scene, rect, &palette)` para canvas custom. Usar en contenedor cuadrado.
808
809---
810
811## 14. Catálogo de módulos
812
813Los módulos encapsulan **estado + comportamiento** (a diferencia de los widgets).
814Todos siguen el mismo contrato:
815
816```
817State + Msg + Action + apply(state, msg, ...) -> Action
818 + on_key(state, &KeyEvent) -> Option<Msg>
819 + open_shortcut(&KeyEvent) -> bool
820 + view(state, ..., to_host: F) -> View<HostMsg>
821 + Palette
822```
823
824La app guarda `Option<ModuleState>` (o el state directo, p. ej. bookmarks),
825rutea el atajo de apertura con `open_shortcut`, rutea teclas con `on_key`, aplica
826`Msg`s con `apply`, y monta el `view` pasando un mapeo `to_host: Fn(ModuleMsg) ->
827HostMsg`. Cuando `apply` devuelve una `Action` (p. ej. `Invoke(id)`, `OpenAt{..}`,
828`GoTo{..}`), la app ejecuta el efecto. Crates en `modules/<nombre>/`.
829
830| Módulo | Atajo | Acción que devuelve | Propósito |
831|---|---|---|---|
832| **command-palette** | `Ctrl+Shift+P` | `Invoke(String)` | paleta de comandos fuzzy. El host declara `&[Command]` |
833| **file-picker** | `Ctrl+P` | `Open(PathBuf)` | fuzzy file picker; host pasa `&[PathBuf]` + `root` |
834| **fif** (find-in-files) | `Ctrl+Shift+F` | `OpenAt{path,line,col}`, `Searched{..}`, `Replaced{..}` | buscar/reemplazar; dual-view (dialog + barra). `search()` / `replace_all()` hacen el I/O |
835| **diff-viewer** | `Ctrl+Shift+D` | — | diff side-by-side. `DiffState::new(before_label, after_label, before, after)` computa con `similar` |
836| **mini-map** | `Ctrl+Shift+M` | `JumpTo(line)` | minimapa del buffer; agnóstico del editor (recibe `Snapshot`) |
837| **bookmarks** | `Ctrl+Alt+B` toggle, `Ctrl+Shift+B` lista, `Ctrl+Alt+N/P` nav | `JumpTo{path,line}` | marcadores per-file persistentes (state directo, no Option) |
838| **symbol-outline** | `Ctrl+Shift+O` | `GoTo{line,col}` | outline de símbolos; host arma `Vec<SymbolItem>` (LSP/tree-sitter/custom) |
839| **selector** | — | — | abstracción portátil abrir/guardar: `trait Selector` (`HostSelector` con PathBuf, `WawaSelector` placeholder content-addressed) |
840| **plugin-host** | — | `OpenAt{..}`, `SetStatus(..)` | runtime WASM (wasmi) con permisos por bitfield; `PluginHost::load_from_dir`/`invoke(id, cap, args)` |
841| **shuma-term** | `` Ctrl+` `` | `SetStatus(..)` | terminal integrada. `spawn(cwd)` lanza PTY (`shuma_exec`), `vt100::Parser` renderiza; `Tick` drena el PTY |
842
843Patrón típico de integración (command-palette):
844```rust
845struct Model { palette: Option<PaletteState>, commands: Vec<Command>, /* … */ }
846enum Msg { Palette(PaletteMsg), /* … */ }
847
848// on_key:
849if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); }
850if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); }
851
852// update:
853Msg::Palette(m) => {
854 if let Some(state) = model.palette.as_mut() {
855 match command_palette::apply(state, m, &model.commands) {
856 PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; }
857 PaletteAction::Close => model.palette = None,
858 PaletteAction::None => {}
859 }
860 }
861}
862
863// view_overlay:
864model.palette.as_ref().map(|s|
865 command_palette::view(s, &model.commands, &palette, Msg::Palette))
866```
867
868---
869
870## 15. `llimphi-workspace` — chasis tipo tmux
871
872Monta cualquier componente en un layout intercambiable con splits resizables
873(máquina de estados de foco/split/cierre + chrome estándar). Construido sobre
874`llimphi-widget-panes`.
875
876```rust
877Workspace::new()
878 .focused() -> PaneId .count() .leaves() -> Vec<PaneId> .layout() -> &Layout
879 .focus(id) .split(Axis) -> PaneId .close() -> Option<PaneId> .resize(&path, delta)
880 .apply(WsMsg) -> WsEffect
881
882enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec<Side>, f32) }
883enum WsEffect { None, Created(PaneId), Closed(PaneId) }
884
885workspace_view(&ws, &WorkspacePalette,
886 leaf: FnMut(PaneId)->View<Host>, // materializa el contenido de cada panel
887 lift: Fn(WsMsg)->Host) // sube los Msg del chasis a tu Msg
888```
889
890Patrón: `enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }`. En `update`,
891`ws.apply(msg)` te avisa con `WsEffect::{Created,Closed}(id)` para que crees o
892destruyas el estado del panel correspondiente.
893
894---
895
896## 16. Reglas duras y gotchas
897
898### 🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE)
899Ningún `update`/`init`/handler puede ejecutar trabajo **síncrono** pesado
900(efemérides, simulación, IO, parse, embeddings, layout de árboles grandes).
901Bloquea el hilo → "Not Responding". **`init` corre dentro de `resumed`, después
902de crear la ventana**, así que un cómputo pesado ahí ya congela una ventana
903visible.
904
905Patrón (referencia: `cosmos-app-llimphi`):
906```rust
907// Model: Option<Resultado> (None = "calculando…") + flag dirty + contador de generación.
908struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64 }
909enum Msg { XComputed(u64, Arc<Resultado>) }
910
911// al FINAL de update() (que tiene el Handle):
912if m.x_dirty {
913 m.x_dirty = false;
914 m.x_gen = m.x_gen.wrapping_add(1);
915 let (gen, input) = (m.x_gen, m.input.clone()); // sólo lo que el worker necesita (Send)
916 handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input))));
917}
918// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un
919// resultado tardío pise a uno más nuevo en drags/toggles rápidos).
920Msg::XComputed(gen, x) => if gen == m.x_gen {
921 m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
922}
923```
924La **generación** es imprescindible si el recálculo se dispara seguido. Ver
925[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) y su checklist por app.
926
927### Otras
928- **Solvers iterativos** (Newton/bisección): cota dura `for _ in 0..N`, nunca
929 `loop {}` con corte pegado al epsilon de f64 — en debug no converge → loop
930 infinito.
931- **Backend GPU**: preferir Vulkan (`Backends::PRIMARY`); el GL de Mesa sobre
932 Wayland segfaultea en el teardown. Ya está hecho en `Hal::new`, no revertir.
933- **Un nodo es draggable o clickable**, no ambos.
934- **`alpha` y `clip`** crean capas intermedias: tienen costo, usar sólo cuando
935 hace falta.
936- **`paint_with`** no debe dejar `push_layer` sin `pop_layer` ni resetear la
937 Scene.
938- **Hit-test respeta `.transform()`**: un nodo rotado/escalado/trasladado recibe
939 los clicks donde se ve pintado (el runtime invierte el afín acumulado). Lo que
940 **no** se ajusta todavía: la posición local que reciben los handlers `*_at` se
941 reporta en coords de pantalla, no en el espacio local del nodo transformado.
942- **GPUI está extinto**: no agregar dependencias ni código GPUI (regla §3 de
943 `CLAUDE.md`).
944- **Texto en regla pesada**: crear un `Typesetter` por frame es caro
945 (`FontContext::new` enumera fuentes del sistema). El runtime ya cachea uno y lo
946 pasa a `paint_with`.
947
948---
949
950## 17. Comandos y demos
951
952```bash
953cargo check --workspace # smoke test mínimo (debe pasar siempre)
954cargo run -p <crate> --release # correr una app
955cargo run -p <crate> --example <demo> --release # correr un demo
956
957# demos del propio framework:
958cargo run -p llimphi-ui --example counter --release # bucle Elm completo
959cargo run -p llimphi-ui --example editor --release # text field + teclado
960cargo run -p llimphi-ui --example gpu_paint_demo --release
961cargo run -p llimphi-gallery --release # showcase de TODO el kit
962cargo run -p nada --release # editor real para ejercitar widgets
963
964# benchmark GPU directo vs vello:
965cargo run -p llimphi-gpu-bench --release
966```
967
968`llimphi-gallery` (`src/main.rs`, ~967 líneas) es la **referencia viva** del
969patrón completo: `Model`/`Msg`/`init`/`update`/`view`/`view_overlay` con overlays
970mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown).
971Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; `?` abre
972atajos; `Esc` cierra el overlay activo.
973
974---
975
976## 18. Cheat-sheet
977
978```rust
979// ── App mínima ──────────────────────────────────────────────
980impl App for X { type Model; type Msg; init; update; view; }
981llimphi_ui::run::<X>();
982
983// ── Nodo ────────────────────────────────────────────────────
984View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
985 .fill(c).hover_fill(c).radius(r).clip(b).alpha(a).transform(xf)
986 .text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al)
987 .image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{})
988 .on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m)
989 .on_pointer_enter(m) | .on_pointer_leave(m)
990 .draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|)
991 .drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c)
992 .children(vec![..])
993
994// ── Efectos ─────────────────────────────────────────────────
995handle.spawn(|| Msg::Done(compute())); // worker → reentra al update
996handle.spawn_periodic(dur, || Msg::Tick); // feed periódico
997handle.dispatch(Msg::X); handle.quit();
998
999// ── Estilo de layout (taffy prelude) ────────────────────────
1000length(px) percent(0..1) Dimension::auto()
1001FlexDirection::{Row,Column} AlignItems::{Start,Center,End,Stretch}
1002JustifyContent::{Start,Center,End,SpaceBetween}
1003
1004// ── Theme ───────────────────────────────────────────────────
1005Theme::dark()/light()/aurora()/sunset(); Theme::next_after(name); XxxPalette::from_theme(&t)
1006
1007// ── Overlay (menús/modales) ─────────────────────────────────
1008fn view_overlay(m) -> Option<View<Msg>> { if m.open { Some(menu) } else { None } }
1009```
1010
1011---
1012
1013## 19. Índice de crates
1014
1015**Framework** (`02_ruway/llimphi/`):
1016`llimphi-hal` · `llimphi-raster` · `llimphi-text` · `llimphi-layout` ·
1017`llimphi-compositor` · `llimphi-ui` · `llimphi-theme` · `llimphi-motion` ·
1018`llimphi-icons` · `llimphi-surface` · `llimphi-workspace` · `llimphi-gallery` ·
1019`llimphi-gpu-bench`.
1020
1021**Widgets** (`widgets/`, dep `llimphi-widget-<n>`): app-header · avatar · badge ·
1022banner · breadcrumb · button · card · clipboard · context-menu · edit-menu ·
1023empty · field · gallery · grid · list · menubar · modal · navigator · nodegraph ·
1024panel · panes · progress · segmented · shortcuts-help · skeleton · slider · splash ·
1025splitter · stat-card · status-bar · switch · tabs · text-area · text-editor ·
1026text-editor-core · text-editor-lsp · text-input · theme-switcher · tiled ·
1027timeline · toast · tooltip · tree · wawa-mark.
1028
1029**Módulos** (`modules/`): bookmarks · command-palette · diff-viewer · fif ·
1030file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline.
1031
1032**Android** (`android/`): clear-screen-android · vello-hello-android ·
1033vello-text-android.
1034
1035---
1036
1037> Documentos hermanos: [`SDD.md`](SDD.md) (diseño y roadmap),
1038> [`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) (regla de
1039> concurrencia), [`README.md`](README.md) / [`LEEME.md`](LEEME.md) (overview).
1040> Las firmas de este manual reflejan el código al 2026-06-01; ante divergencia,
1041> la fuente autoritativa es el `lib.rs` de cada crate.
MANUAL.md
Manual de Llimphi
Motor gráfico soberano de gioser. wgpu + vello + taffy + parley,
bucle Elm input → update → view → layout → raster → present.
Reemplazo total de GPUI (extinto 2026-05-26): toda app gráfica de la suite
corre sobre Llimphi.
Este documento es la referencia de uso orientada a humanos y a IA.
Está organizado para salto directo: cada capa, widget y módulo trae su API
real (firmas copiadas del código). Para el porqué arquitectónico ver
SDD.md; para la regla de concurrencia ver
COMPUTO-FUERA-DEL-HILO-UI.md.
Índice
- Modelo mental en 60 segundos
- Arquitectura — las capas
- Quickstart — la app mínima
- El trait
App (bucle Elm)
Handle — efectos y concurrencia
View<Msg> — el DSL declarativo
- Layout (
taffy / Style)
- Eventos e interacción
- Texto
- Canvas custom y GPU directo
- Theme y paletas
- Capas base (hal · raster · text · motion · icons · surface)
- Catálogo de widgets
- Catálogo de módulos
llimphi-workspace — chasis tipo tmux
- Reglas duras y gotchas
- Comandos y demos
- Cheat-sheet
- Índice de crates
1. Modelo mental en 60 segundos
Llimphi es Elm sobre la GPU. Una app es un tipo que implementa el trait
App con cuatro piezas:
Model — estado inmutable de la app.
Msg — todo lo que puede pasar (Clone + Send).
update(model, msg, handle) -> model — transición pura que devuelve un
modelo nuevo.
view(&model) -> View<Msg> — función pura que describe la pantalla como
un árbol de View.
El runtime hace el bucle: un evento (click/tecla/rueda) produce un Msg,
update deriva el nuevo Model, view reconstruye el árbol, taffy calcula
las cajas, vello rasteriza, y se hace swap del frame. No hay mutabilidad
compartida, no hay vDOM ajeno, no hay callbacks imperativos: declarás qué se
ve y qué Msg emite cada nodo.
evento ─▶ Msg ─▶ update(model,msg) ─▶ model' ─▶ view(model') ─▶ View<Msg>
│
present ◀─ raster(vello) ◀─ layout(taffy) ◀──────────────────────┘
Tres reglas de oro:
view es pura — no muta nada, sólo lee el modelo y arma el árbol.
- Cómputo pesado va a un worker vía
Handle::spawn, nunca síncrono en
update/init/handlers (congela la ventana → "Not Responding").
- Widgets son visuales y stateless; el estado vive en tu
Model.
Módulos sí encapsulan estado + comportamiento.
2. Arquitectura — las capas
4. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent)
└ llimphi-compositor . árbol View<Msg>, mount sobre taffy, paint, hit-test (winit-free)
3. llimphi-layout ....... motor de layout (taffy: flexbox + grid)
2. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo
1. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji
0. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer)
El split compositor/runtime (2026-05-31) es importante: llimphi-compositor
es winit-free (sólo View, mount, paint, hit-test). llimphi-ui lo corre
sobre winit y re-exporta todo el compositor, así escribís llimphi_ui::View
sin enterarte del split. Esto habilita un futuro runtime sobre el framebuffer
del kernel wawa reusando el mismo compositor.
Auxiliares: llimphi-theme (paletas), llimphi-motion (tweens),
llimphi-icons (iconos vectoriales), llimphi-surface (texturas externas),
llimphi-workspace (chasis tmux), llimphi-gallery (showcase).
Catálogo: ~45 widgets (visuales) + 10 módulos (features con estado).
3. Quickstart — la app mínima
use llimphi_ui::llimphi_layout::taffy::prelude::*;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{App, Handle, View};
#[derive(Clone)]
enum Msg { Increment, Reset }
struct Counter;
impl App for Counter {
type Model = u32;
type Msg = Msg;
fn title() -> &'static str { "llimphi · counter" }
fn init(_: &Handle<Self::Msg>) -> Self::Model { 0 }
fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Increment => model.saturating_add(1),
Msg::Reset => 0,
}
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let boton = View::new(Style {
size: Size { width: length(160.0), height: length(56.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(Color::from_rgba8(60, 200, 130, 255))
.radius(12.0)
.text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255))
.on_click(Msg::Increment);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0), height: percent(1.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(0.0), height: length(24.0) },
..Default::default()
})
.fill(Color::from_rgba8(20, 24, 32, 255))
.children(vec![
View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE),
boton,
])
}
}
fn main() { llimphi_ui::run::<Counter>(); }
Cargo.toml:
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
# + los widgets/modules que uses:
# llimphi-widget-button = { workspace = true }
Corre con cargo run -p <tu-crate> --release. El ejemplo vivo está en
llimphi-ui/examples/counter.rs.
4. El trait App (bucle Elm)
Definido en llimphi-ui/src/lib.rs. El estado es inmutable; cada evento
produce un Model nuevo.
pub trait App: 'static {
type Model: 'static;
type Msg: Clone + Send + 'static;
fn init(handle: &Handle<Self::Msg>) -> Self::Model;
fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
fn view(model: &Self::Model) -> View<Self::Msg>;
// --- Todo lo de abajo tiene default; sobreescribí lo que necesites ---
fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> { None }
fn on_wheel(_model: &Self::Model, _delta: WheelDelta,
_cursor: (f32, f32), _modifiers: Modifiers) -> Option<Self::Msg> { None }
/// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta
/// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo
/// vidrio"). La transición la maneja tu Model.
fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> { None }
/// Drag&drop de archivos desde el file manager. Un evento por archivo.
fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> { None }
/// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El
/// runtime administra el foco; guardás `id` en tu Model para pintar el ring
/// y rutear el teclado. Ver §8 (Foco y teclado).
fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> { None }
/// IME (composición de texto: CJK, acentos muertos, emoji). Opt-in vía
/// `ime_allowed()` para no robarle el texto a las apps que sólo leen
/// `on_key`. Flujo: Enabled → Preedit* → Commit/Disabled. Ver §8 (IME).
fn ime_allowed() -> bool { false }
fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> { None }
/// Área del caret en px físicos para ubicar la ventana de candidatos.
fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { None }
fn title() -> &'static str { "llimphi" }
fn app_id() -> Option<&'static str> { None } // app_id del xdg-toplevel en Wayland
fn initial_size() -> (u32, u32) { (960, 540) }
}
Punto de entrada: pub fn run<A: App>() — corre hasta que el usuario cierre la
ventana o la app llame Handle::quit.
Eventos de teclado (KeyEvent):
pub struct KeyEvent {
pub key: Key, // re-export de winit; usar NamedKey para teclas especiales
pub state: KeyState, // Pressed | Released
pub text: Option<String>, // texto resultante con IME/modifiers; None para flechas etc.
pub modifiers: Modifiers, // { shift, ctrl, alt, meta }
pub repeat: bool,
}
Key y NamedKey se re-exportan desde llimphi_ui.
Rueda (WheelDelta { x, y }): normalizado a "líneas". Convención CSS:
y positivo = scroll hacia abajo.
5. Handle — efectos y concurrencia
Handle<Msg> es Send + Clone. Llega a init y update. Es el único modo
legítimo de producir efectos sin romper la pureza de la transición.
impl<Msg: Send + 'static> Handle<Msg> {
pub fn quit(&self); // cierra la ventana / termina el bucle
pub fn dispatch(&self, msg: Msg); // encola un Msg para el próximo turno
pub fn spawn<F: FnOnce() -> Msg + Send + 'static>(&self, f: F); // worker; su Msg reentra al update
pub fn spawn_periodic<F: Fn() -> Msg + Send + 'static>(&self, period: Duration, f: F); // tick periódico
pub fn for_test() -> Self; // handle "muerto" para tests sin event loop
}
spawn — trabajo bloqueante (IO, PAM, parse, efemérides). El Msg que
devuelve la closure se entrega al update en el hilo de UI. Este es el
patrón obligatorio para todo cómputo pesado (§16).
spawn_periodic — feeds a intervalos: ticks de simulación (~11 Hz en
dominium), polling, animaciones por reloj. El thread muere cuando se cierra
el event loop.
6. View<Msg> — el DSL declarativo
Un View = Style de taffy + relleno + texto/imagen/painter + handlers +
hijos. Todo se arma con builders encadenables (self -> Self). Definido en
llimphi-compositor/src/view.rs.
View::new(style: Style) -> View<Msg>
Apariencia
| Método | Efecto |
.fill(Color) | color de fondo |
.hover_fill(Color) | color al pasar el cursor (habilita hit-test de hover) |
.radius(f64) | esquinas redondeadas |
.alpha(f32) | opacidad de todo el subtree [0,1] (capa intermedia — no gratis) |
.transform(Affine) | afín 2D alrededor del centro del rect (estilo CSS transform-origin:50% 50%) |
.clip(bool) | recorta hijos al rect (paint + hit-test) |
.image(Image) | pinta peniko::Image centrada, preservando aspect ratio |
.children(Vec<View<Msg>>) | hijos |
Texto (ver §9)
.text(content, size_px, color) // centrado
.text_aligned(content, size_px, color, Alignment)
.text_aligned_italic(content, size_px, color, Alignment, italic)
.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option<String>)
.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada
.line_height(mult) // override interlínea (default 1.2)
Interacción (ver §8)
.on_click(Msg)
.on_click_at(|lx, ly, w, h| -> Option<Msg>) // posición local + tamaño del rect
.on_right_click(Msg) / .on_right_click_at(...)
.on_middle_click(Msg)
.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
.draggable(|phase: DragPhase, dx, dy| -> Option<Msg>)
.draggable_at(|phase, dx, dy, lx0, ly0| -> Option<Msg>) // + posición inicial del press
.drag_payload(u64) // payload que viaja con el drag
.on_drop(|payload: u64| -> Option<Msg>) // este nodo es drop target
.drop_hover_fill(Color) // resaltado mientras un drag lo sobrevuela
.on_scroll(|dx, dy| -> Option<Msg>) // rueda local (antes del on_wheel global)
.focusable(u64) // nodo enfocable por Tab/click (id opaco)
Pintura custom (ver §10)
.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... })
.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... })
Notas clave:
- Un nodo es draggable o clickable, no ambos:
draggable sobreescribe
on_click.
- Las variantes
*_at ganan sobre las simples si ambas están.
PaintRect { x, y, w, h } es el rect absoluto del nodo en píxeles físicos.
DragPhase = Move (un evento por CursorMoved, dx/dy = delta desde el
evento anterior, no acumulado) | End (al soltar).
7. Layout (taffy / Style)
Style es el taffy::Style directo, re-exportado vía
llimphi_ui::llimphi_layout::taffy::prelude::*. Es Flexbox + CSS Grid puro.
Campos más usados:
Style {
flex_direction: FlexDirection::Row | Column,
size: Size { width, height }, // length(px) | percent(0..1) | Dimension::auto()
min_size, max_size,
flex_grow: f32, flex_shrink: f32,
align_items: Some(AlignItems::{Start,Center,End,Stretch}),
justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}),
gap: Size { width, height },
padding: Rect { left, right, top, bottom }, // con length(px)
margin: Rect { ... },
..Default::default()
}
Helpers de prelude: length(px), percent(frac), auto(), Dimension,
Size, Rect, FlexDirection, AlignItems, JustifyContent.
llimphi-layout además expone:
LayoutTree::new() / .clear() (reuso entre frames), .leaf(style),
.node(style, &children), .compute(...), .compute_with_measure(F).
Rect { x, y, w, h } y ComputedLayout { rects: HashMap<NodeId, Rect> }.
En el 99% de los casos no tocás LayoutTree a mano: lo maneja el runtime al
montar tu View. Sólo armás Styles.
8. Eventos e interacción
| Quiero… | Cómo |
| Botón / fila clickable | .on_click(Msg) (+ .hover_fill para feedback) |
| Saber dónde se clickeó (canvas) | .on_click_at(|lx,ly,w,h| ...) → convertir a coords de mundo |
| Menú contextual | .on_right_click(Msg::OpenMenu{..}), guardar pos en Model, abrir en view_overlay |
| Abrir en pestaña nueva | .on_middle_click(Msg) |
| Preview al pasar el mouse | .on_pointer_enter(Msg) / .on_pointer_leave(Msg) |
| Resize de panel | .draggable(|phase,dx,dy| ...) acumulando delta en el Model |
| Arrastrar entidad de un canvas | .draggable_at(|phase,dx,dy,lx0,ly0| ...) |
| Drag&drop entre zonas | origen: .drag_payload(id); destino: .on_drop(|id| ...) + .drop_hover_fill |
| Scroll global | App::on_wheel(model, delta, cursor, mods) |
| Área de scroll | widget scroll_y(...) (autocontenido) o .on_scroll(|dx,dy| ...) por nodo |
| Teclado | App::on_key(model, &KeyEvent) -> Option<Msg> |
| Foco / Tab | .focusable(id) en los nodos + App::on_focus(model, id) (ver abajo) |
| IME (CJK, acentos) | App::ime_allowed() -> true + App::on_ime(model, &ImeEvent) (ver abajo) |
| Drop de archivos del SO | App::on_file_drop(model, path) |
Patrón overlay (menús/modales): el modelo guarda "menú abierto sí/no".
Mientras esté abierto, view_overlay devuelve Some(view); clicks fuera se
cierran envolviendo los items en un scrim a pantalla completa con
on_click = DismissOverlay. Cuando el modelo dice cerrado, view_overlay
devuelve None.
Scroll (widget llimphi-widget-scroll). scroll_y(offset, content_len, viewport_len, content, on_scroll, &palette) arma un viewport clipeado +
contenido desplazado -offset + barra arrastrable. Es stateless: el offset
vive en tu Model. on_scroll(delta_px) (rueda y arrastre) emite un delta a
sumar; clampealo con scroll::clamp_offset en tu update. Helpers:
ensure_visible(offset, vp, item_top, item_h) para llevar la selección a la
vista (teclado); approach(cur, target, factor) para scroll suave/inercia
(driveado por Handle::spawn_periodic).
Foco y teclado. Marcá los nodos navegables con .focusable(id) (id u64
que vos elegís). El runtime es la única fuente de verdad del foco: lo mueve
con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo
enfocable, y te avisa con App::on_focus(model, Option<u64>). Guardás el id en
tu Model para (a) pintar el ring (if model.focus == Some(id) { .fill(accent) }
en view) y (b) rutear el teclado al campo activo desde on_key. No setees el
foco por tu cuenta vía Msg: quedaría desincronizado del runtime.
IME (composición de texto). Opt-in: ime_allowed() -> true. Con IME activo
el texto compuesto no llega por KeyEvent.text sino por on_ime:
ImeEvent::Enabled → uno o más Preedit{text, cursor} (texto en composición, a
pintar subrayado en el caret) → Commit(text) (insertá como tecleado) o
Disabled. Reportá el área del caret con ime_cursor_area(model) para ubicar
la ventana de candidatos (CJK) junto al cursor.
9. Texto
TextSpec (en compositor) describe el texto de un nodo:
pub struct TextSpec {
pub content: String,
pub size_px: f32,
pub color: Color,
pub alignment: Alignment, // Start | Center | End | Justify
pub italic: bool,
pub font_family: Option<String>, // string CSS con fallbacks
pub line_height: f32, // múltiplo; default 1.2
pub runs: Option<Vec<(usize, usize, Color)>>, // color por rango de BYTES
}
Center es el default (apto para labels). Para editores/párrafos usar
.text_aligned(..., Alignment::Start).
- Multicolor en una sola pasada de shaping:
.text_runs(...) colorea
rangos de bytes — es la base del syntax highlighting (un nodo por línea, no
por token). Anclado arriba-izquierda; el caller dimensiona el rect.
- El runtime mide el texto con parley durante el layout (
compute_with_measure)
para que taffy reserve el alto real del texto envuelto a varias líneas
(evita "textos aplastados").
- Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique.
10. Canvas custom y GPU directo
Dos hooks para pintar primitivas no expresables como composición de Views.
Conviven en el mismo árbol; el runtime pinta toda la pasada vello primero,
luego los gpu_painter en orden DFS.
paint_with — vía vello (el default)
.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| {
// dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`.
// NO dejar push_layer sin pop_layer; NO resetear la scene.
})
Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal.
Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada).
gpu_paint_with — sube vertex buffers directo a wgpu, salta vello
.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| {
// abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello.
// (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC.
})
Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de
nakui, pineal denso. Rango 100 K – 10 M+ primitivos. No soporta texto ni AA
fino ni múltiples grosores de stroke por flush. Para texto encima de un render
GPU, usar view_overlay (segunda Scene vello).
¿Cuándo cada uno?
| Pregunta | vello (paint_with) | GPU directo (gpu_paint_with) |
| Primitivos/frame | < ~500 K rebuild / < ~2 M Scene reusada | 100 K – 10 M+ |
| ¿Cambian cada frame? | sí, rebuild barato | mejor estático (buffer persistente) |
| Curvas Bezier | nativas | hay que teselar |
| Texto | sí | no |
| AA fino | sí (analítico) | no (sin MSAA) |
Default: paint_with salvo que ya midas que el volumen lo justifica
(factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU
expone GpuPipelines/GpuBatch en llimphi-raster (§12).
11. Theme y paletas
llimphi-theme::Theme es un struct de slots semánticos de color. Cuatro presets
const: Theme::dark() (default), light(), aurora(), sunset().
pub struct Theme {
pub name: &'static str,
// fondos
pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus,
pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color,
// texto
pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color,
// bordes y acento
pub border, border_focus, accent: Color,
}
Theme::all() -> Vec<Theme> // orden de rotación canónico
Theme::by_name(name) -> Option<Theme>
Theme::next_after(current_name) -> Theme // para el theme-switcher
Tokens auxiliares en el mismo crate:
motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms} + ease_out_cubic,
ease_in_out_cubic, linear.
alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT} (constantes u8).
radius::{XS=2, SM=4, MD=8, LG=12, XL=20} (f64).
Patrón de widgets: cada widget define su XxxPalette con
Palette::from_theme(&theme). Tu app guarda un Theme en el Model, deriva las
paletas que necesita en view, y se las pasa a los widgets. Para cambiar de
tema, el theme-switcher emite Msg(next_theme) y reconstruís todo.
12. Capas base
llimphi-hal — superficie
Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result<Hal, HalError> // async
trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result<Frame,_>; fn present(frame, hal); }
WinitSurface::new(hal, window: Arc<Window>) -> Result<Self, HalError>
Frame::view() -> &wgpu::TextureView; Frame::size() -> (u32,u32)
Hal::new pide adapter Backends::PRIMARY (Vulkan) y cae a all() sólo si no
hay — no volver a InstanceDescriptor::default(): el backend GL de Mesa
sobre Wayland segfaultea en el teardown. El runtime de llimphi-ui ya maneja
todo esto; sólo tocás HAL si escribís un runtime nuevo.
llimphi-raster — rasterización
Renderer::new(hal) -> Result<Renderer,_>
Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
// GPU directo:
GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout
GpuBatch::new(&pipelines)
.line_width(w) .add_line(p0,p1,color) .add_polyline(&pts,color)
.add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
.primitive_count() -> u32
.flush(device, queue, encoder, view, viewport, load_op)
Re-exporta vello y peniko (Color, Image, Fill, etc.).
llimphi-text — shaping
Typesetter::new() // una por proceso (FontContext es caro)
.layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()>
.layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout<RunBrush>
TextBlock::simple(text, size_px, color, origin)
layout_block(ts, &block) / measure(ts, &block) -> Measurement
draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin)
Alignment::{Start, Center, End, Justify}
llimphi-motion — tweens
trait Lerp { fn lerp(self, other, t: f32) -> Self; } // impl para f32,f64,(f32,f32),(f64,f64),Color
Tween::new(from, to, duration, easing: fn(f32)->f32) // o Tween::idle(value)
tween.value() / .progress() / .done()
animate(handle, duration, make_msg) // arranca los ticks del tween
Patrón: guardás Tween<T> en el Model, animate(...) en el update, la view
lee tween.value() cada repaint. El tween se auto-termina.
llimphi-icons — iconos vectoriales (~23, grid 24×24)
Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right,
Home, Search, Info, Warning, Error, Bell, Settings, More, ...}
icon_view(Icon, color, stroke_width) -> View<Msg>
paint_icon(scene, rect, icon, color, stroke_width) // dentro de un paint_with
stroke_width en unidades del grid 24×24 (1.6 es armónico).
llimphi-surface — texturas externas
ExternalSurface::new(device, queue) // barato de clonar (Arc<Mutex> interno)
.upload(&rgba, w, h) // desde otro hilo/decoder/cámara
.view(style) -> View<Msg> // blittea a su rect en el árbol Elm
.blit(queue, encoder, dst_view, rect, viewport) // o manual desde gpu_paint_with
13. Catálogo de widgets
Los widgets son funciones puras que devuelven View<Msg> (o specs que se
convierten a View). Son stateless: el estado vive en tu Model. Convención:
cada uno trae XxxPalette::from_theme(&Theme). Crates en
widgets/<nombre>/, dep llimphi-widget-<nombre>.
Controles
button — button_view(label, &ButtonPalette, on_click: Msg) -> View;
button_styled(label, style, alignment, &palette, on_click).
field — wrapper de formulario (label + helper/error + requerido).
field_view(FieldSpec { label, control: View<Msg>, required, helper, error, palette }).
text-input — input single-line con estado TextInputState
(new()/masked(), text(), set_text(), apply_key(&KeyEvent) -> bool,
soporta undo/redo + selección con Shift). Render:
text_input_view(&state, placeholder, focused, &palette, on_focus: Msg).
text-area — multilínea con estado TextAreaState (Enter = newline, sin
auto-submit). text_area_view(&state, placeholder, focused, body_height, &palette, on_focus).
slider — sin estado. slider_view(label, value, min, max, &palette, on_change: Fn(DragPhase, delta_value) -> Option<Msg>). El delta viene en
unidades, no píxeles.
switch — switch_view(progress: f32 [0..1], on_toggle: Msg, &palette). La
app guarda el bool y opcionalmente anima progress con un Tween.
segmented — N opciones exclusivas. segmented_view(&[&str], selected: usize, make_msg: Fn(usize)->Msg, &palette).
progress — linear_progress_view(progress, track, fill, height) y
radial_progress_view(progress, track, fill, stroke_ratio). Sin eventos.
spinner — spinner_view(color, stroke_ratio, speed_rev_per_sec). Animado por
reloj absoluto; requiere redraws periódicos (spawn_periodic).
badge — count_badge_view(count, BadgeKind) ("99+" si ≥100) y
dot_badge_view(BadgeKind). BadgeKind::{Info,Success,Warning,Error,Neutral}.
avatar — avatar_view(name, size_px): círculo determinista (color por hash
del nombre + inicial).
tooltip — render puro. tooltip_view(TooltipSpec { anchor, viewport, side: Side, text, palette }). Se monta en view_overlay; la app controla visibilidad
con on_pointer_enter/leave.
empty — empty-state. empty_view(Icon, title, description: Option<&str>, &palette).
skeleton — placeholder con shimmer. skeleton_view, skeleton_box_view(w,h,..),
skeleton_line_view(w,..). Requiere redraws periódicos.
banner — tira de status. banner_view(BannerKind::{Info,Success,Warning,Error}, message).
Contenedores y layout
panel — chrome (gradiente + hairline accent). panel_view(children, PanelStyle);
PanelStyle::{from_theme, from_theme_large, neutral}. panel_signature_painter(style)
para reusar el look en un paint_with.
card — card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette).
stat-card — métrica de dashboard. stat_card_view(label, value, description, accent, &recent_items, &palette).
tabs — tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg, content: View<Msg>, tab_height, palette, tab_width }). Selección la maneja la app.
splitter — divisor draggable de 2 panes. splitter_two(Direction::{Row,Column}, a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette).
PaneSize::{Fixed(px), Flex}. La app acumula el delta en su Model.
scroll — área de scroll vertical con barra arrastrable. scroll_y(offset, content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette).
Stateless (offset en el Model); rueda autocontenida. Helpers: clamp_offset,
ensure_visible (selección a la vista), approach (scroll suave). Ver §8.
tiled — grilla auto cols×rows de tiles con title bar. tiled_view(tiles, &palette),
tiled_view_cols(tiles, cols, &palette), y variantes *_reorderable* con
on_reorder: Fn(from, to)->Option<Msg> (drag-to-swap por la title bar). TileSpec { label, content }.
panes — árbol binario BSP tipo tmux. La app guarda un Layout:
Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second }
layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves()
panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec<Side>,DragPhase,delta)->Option<Msg>,
on_focus: Fn(PaneId)->Msg, &palette)
grid — grilla 2D virtualizada. ventana_visible(total, vp_w, vp_h, scroll_fila, &metrics) -> VisibleWindow para virtualizar, luego grid_view(GridSpec { cells: Vec<GridCell { content, label, selected, on_click }>, cols, metrics, caption, ... }).
list — lista vertical virtualizada. list_view(ListSpec { rows: Vec<ListRow { label, selected, on_click }>, total, caption, truncated_hint, row_height, palette }).
La app prefiltra las filas visibles.
tree — árbol expand/collapse. tree_view(TreeSpec { rows: Vec<TreeRow { label, depth, has_children, expanded, selected, on_toggle, on_select }>, row_height, indent_px, palette }). La app aplana el árbol según nodos expandidos.
navigator — navegador data-agnóstico de nodos en dos modos conmutables
(árbol ↔ grafo, reusa tree + nodegraph). Render-only: la app guarda
expanded/selected/mode. Pasa un bosque de NavNode { id: u64, label, kind: NavKind (Monad|Group|Dir|File|Other), children } y callbacks por id.
navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides },
is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg,
on_select: Fn(u64)->Msg, on_context: Option<Fn(u64)->Msg>)
// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.
app-header — app_header(label, actions: Vec<View<Msg>>, &palette).
status-bar — status_bar_view(left, center, right, &palette) con
StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized().
breadcrumb — breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)
(el último segmento no es clickable).
modal — diálogo centrado con scrim. modal_view(ModalSpec { title, body: View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette }).
ModalButton::{primary, cancel, destructive}(label, msg). Se monta en view_overlay.
toast — notificaciones efímeras bottom-right. La app guarda Vec<Toast>
(Toast::{info,success,warning,error}(id, text, duration)), filtra
is_alive(now), y toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg).
splash — splash de arranque (cuatro cuadrantes andinos). splash_view(started_at: Instant, bg, fg_text); basado en tiempo, requiere redraws.
Ricos / interactivos
nodegraph — lienzo de nodos + cables Bezier. Sin estado (la app guarda
posiciones y Wires).
NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
nodegraph_view(&nodes, &wires, &palette, &metrics,
on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)
timeline — scrub clickeable. timeline_view(progress: f32, &palette, on_seek: Fn(f32 [0..1])->Option<Msg>).
text-editor — editor IDE (capa visual sobre el core agnóstico). La app guarda
EditorState:
EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo();
.add_cursor_at(line,col); .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult;
.ensure_caret_visible(visible_lines)
// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option<Msg>
text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer)
text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer)
text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer)
syntax_palette_dark(&theme) -> SyntaxPalette // en lib.rs del widget
text-editor-core — núcleo agnóstico (sin GPU, sin Llimphi; sólo
peniko::Color). Reutilizable en TUI/web/headless. Tipos clave:
Buffer (sobre ropey): from_str, text, insert(offset,s), delete(s,e),
offset_to_pos, pos_to_offset, slice, line(n).
Pos { line, col }, Selection { anchor, caret }, Cursor { caret, anchor: Option, desired_col } con move_left/right/up/down/word_left/...,
selection_range(&buf), collapse.
- Ops:
replace_selection, delete_backward/forward, indent_or_insert_tab,
insert_newline_auto_indent → devuelven EditDelta { start, removed, inserted, cursor_before, cursor_after } con .apply()/.undo().
UndoStack: push(delta), undo/redo(&mut buf, &mut cursor) -> bool, can_undo/redo.
FindState { query, case_sensitive }: all_matches, find_next, find_prev.
- Matching de brackets:
find_bracket_pair(&buf, &cursor) -> Option<(Pos, Pos)>, Direction.
Clipboard (trait get/set), MemClipboard, NullClipboard.
Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity, message: String, source: Option<String> } (+ ctors error(..), warning(..));
Severity::{Error, Warning, Information, Hint}.
- Highlight tree-sitter:
Language::{Plain, Rust, Python, Wat}
(+ Language::from_cell_language(s)); Highlighter::new(lang) con
.highlight(&mut self, source: &str) -> Vec<Vec<Span>> (un Vec<Span> por
línea), .set_language(lang), .language(); helpers de módulo
invalidate_tree_cache(lang) y apply_pending_edits(lang, &edits) para el
caché incremental. TokenKind, Span, SyntaxPalette::color(kind).
text-editor-lsp — cliente LSP por stdin/stdout. trait LspClient (fire-and-forget
request_* + lecturas de caché latest_*/clear_*): completions, hover,
definition, references, rename, formatting, signature help, document symbols.
RustAnalyzerClient::start(workspace_root); NoopLspClient para tests.
clipboard — portapapeles del sistema vía arboard. SystemClipboard::new(),
is_available(), impl Clipboard. No-op silencioso si no hay display (CI headless).
menubar — barra de menú mac-style. menubar_view(&MenuBarSpec { menu: &AppMenu, open: Option<usize>, theme, viewport, height, on_open: Fn(Option<usize>)->Msg, on_command: Fn(&str)->Msg }); dropdown en view_overlay con menubar_overlay(spec)
o menubar_overlay_animated(spec, active, appear). Navegación por teclado:
menubar_nav, menubar_command_at.
edit-menu — menú estándar de edición sobre un editor.
EditFlags::from_editor(&state, masked), edit_context_menu(anchor, viewport, &theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss) →
ContextMenuSpec. apply(&mut state, EditAction, &mut clipboard) -> ApplyResult.
EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}.
context-menu — menú contextual genérico (look "webpage"). ContextMenuItem:: action(label).with_shortcut(..).icon(..).disabled().destructive().submenu(children)
o ::separator(). context_menu_view(ContextMenuSpec { anchor, viewport, header, items, active, on_pick: Fn(usize)->Msg, on_dismiss, palette }); context_menu_view_ex
con submenús/animación. Se monta en view_overlay con scrim.
theme-switcher — theme_switcher_view(¤t: &Theme, on_change: Fn(Theme)->Msg)
(+ _styled/_flex). Cicla Theme::next_after.
shortcuts-help — overlay "?" con atajos agrupados. shortcuts_help_view( ShortcutsHelpSpec { title, groups: Vec<ShortcutGroup { title, entries: Vec<ShortcutEntry { keys, description }> }>, viewport, on_dismiss, palette }).
wawa-mark — sello vectorial del SO wawa. wawa_mark_view(&WawaMarkPalette);
paint_mark(scene, rect, &palette) para canvas custom. Usar en contenedor cuadrado.
14. Catálogo de módulos
Los módulos encapsulan estado + comportamiento (a diferencia de los widgets).
Todos siguen el mismo contrato:
State + Msg + Action + apply(state, msg, ...) -> Action
+ on_key(state, &KeyEvent) -> Option<Msg>
+ open_shortcut(&KeyEvent) -> bool
+ view(state, ..., to_host: F) -> View<HostMsg>
+ Palette
La app guarda Option<ModuleState> (o el state directo, p. ej. bookmarks),
rutea el atajo de apertura con open_shortcut, rutea teclas con on_key, aplica
Msgs con apply, y monta el view pasando un mapeo to_host: Fn(ModuleMsg) -> HostMsg. Cuando apply devuelve una Action (p. ej. Invoke(id), OpenAt{..},
GoTo{..}), la app ejecuta el efecto. Crates en modules/<nombre>/.
| Módulo | Atajo | Acción que devuelve | Propósito |
| command-palette | Ctrl+Shift+P | Invoke(String) | paleta de comandos fuzzy. El host declara &[Command] |
| file-picker | Ctrl+P | Open(PathBuf) | fuzzy file picker; host pasa &[PathBuf] + root |
| fif (find-in-files) | Ctrl+Shift+F | OpenAt{path,line,col}, Searched{..}, Replaced{..} | buscar/reemplazar; dual-view (dialog + barra). search() / replace_all() hacen el I/O |
| diff-viewer | Ctrl+Shift+D | — | diff side-by-side. DiffState::new(before_label, after_label, before, after) computa con similar |
| mini-map | Ctrl+Shift+M | JumpTo(line) | minimapa del buffer; agnóstico del editor (recibe Snapshot) |
| bookmarks | Ctrl+Alt+B toggle, Ctrl+Shift+B lista, Ctrl+Alt+N/P nav | JumpTo{path,line} | marcadores per-file persistentes (state directo, no Option) |
| symbol-outline | Ctrl+Shift+O | GoTo{line,col} | outline de símbolos; host arma Vec<SymbolItem> (LSP/tree-sitter/custom) |
| selector | — | — | abstracción portátil abrir/guardar: trait Selector (HostSelector con PathBuf, WawaSelector placeholder content-addressed) |
| plugin-host | — | OpenAt{..}, SetStatus(..) | runtime WASM (wasmi) con permisos por bitfield; PluginHost::load_from_dir/invoke(id, cap, args) |
| shuma-term | Ctrl+` | SetStatus(..) | terminal integrada. spawn(cwd) lanza PTY (shuma_exec), vt100::Parser renderiza; Tick drena el PTY |
Patrón típico de integración (command-palette):
struct Model { palette: Option<PaletteState>, commands: Vec<Command>, /* … */ }
enum Msg { Palette(PaletteMsg), /* … */ }
// on_key:
if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); }
if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); }
// update:
Msg::Palette(m) => {
if let Some(state) = model.palette.as_mut() {
match command_palette::apply(state, m, &model.commands) {
PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; }
PaletteAction::Close => model.palette = None,
PaletteAction::None => {}
}
}
}
// view_overlay:
model.palette.as_ref().map(|s|
command_palette::view(s, &model.commands, &palette, Msg::Palette))
15. llimphi-workspace — chasis tipo tmux
Monta cualquier componente en un layout intercambiable con splits resizables
(máquina de estados de foco/split/cierre + chrome estándar). Construido sobre
llimphi-widget-panes.
Workspace::new()
.focused() -> PaneId .count() .leaves() -> Vec<PaneId> .layout() -> &Layout
.focus(id) .split(Axis) -> PaneId .close() -> Option<PaneId> .resize(&path, delta)
.apply(WsMsg) -> WsEffect
enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec<Side>, f32) }
enum WsEffect { None, Created(PaneId), Closed(PaneId) }
workspace_view(&ws, &WorkspacePalette,
leaf: FnMut(PaneId)->View<Host>, // materializa el contenido de cada panel
lift: Fn(WsMsg)->Host) // sube los Msg del chasis a tu Msg
Patrón: enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }. En update,
ws.apply(msg) te avisa con WsEffect::{Created,Closed}(id) para que crees o
destruyas el estado del panel correspondiente.
16. Reglas duras y gotchas
🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE)
Ningún update/init/handler puede ejecutar trabajo síncrono pesado
(efemérides, simulación, IO, parse, embeddings, layout de árboles grandes).
Bloquea el hilo → "Not Responding". init corre dentro de resumed, después
de crear la ventana, así que un cómputo pesado ahí ya congela una ventana
visible.
Patrón (referencia: cosmos-app-llimphi):
// Model: Option<Resultado> (None = "calculando…") + flag dirty + contador de generación.
struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64 }
enum Msg { XComputed(u64, Arc<Resultado>) }
// al FINAL de update() (que tiene el Handle):
if m.x_dirty {
m.x_dirty = false;
m.x_gen = m.x_gen.wrapping_add(1);
let (gen, input) = (m.x_gen, m.input.clone()); // sólo lo que el worker necesita (Send)
handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input))));
}
// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un
// resultado tardío pise a uno más nuevo en drags/toggles rápidos).
Msg::XComputed(gen, x) => if gen == m.x_gen {
m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
}
La generación es imprescindible si el recálculo se dispara seguido. Ver
COMPUTO-FUERA-DEL-HILO-UI.md y su checklist por app.
Otras
- Solvers iterativos (Newton/bisección): cota dura
for _ in 0..N, nunca
loop {} con corte pegado al epsilon de f64 — en debug no converge → loop
infinito.
- Backend GPU: preferir Vulkan (
Backends::PRIMARY); el GL de Mesa sobre
Wayland segfaultea en el teardown. Ya está hecho en Hal::new, no revertir.
- Un nodo es draggable o clickable, no ambos.
alpha y clip crean capas intermedias: tienen costo, usar sólo cuando
hace falta.
paint_with no debe dejar push_layer sin pop_layer ni resetear la
Scene.
- Hit-test respeta
.transform(): un nodo rotado/escalado/trasladado recibe
los clicks donde se ve pintado (el runtime invierte el afín acumulado). Lo que
no se ajusta todavía: la posición local que reciben los handlers *_at se
reporta en coords de pantalla, no en el espacio local del nodo transformado.
- GPUI está extinto: no agregar dependencias ni código GPUI (regla §3 de
CLAUDE.md).
- Texto en regla pesada: crear un
Typesetter por frame es caro
(FontContext::new enumera fuentes del sistema). El runtime ya cachea uno y lo
pasa a paint_with.
17. Comandos y demos
cargo check --workspace # smoke test mínimo (debe pasar siempre)
cargo run -p <crate> --release # correr una app
cargo run -p <crate> --example <demo> --release # correr un demo
# demos del propio framework:
cargo run -p llimphi-ui --example counter --release # bucle Elm completo
cargo run -p llimphi-ui --example editor --release # text field + teclado
cargo run -p llimphi-ui --example gpu_paint_demo --release
cargo run -p llimphi-gallery --release # showcase de TODO el kit
cargo run -p nada --release # editor real para ejercitar widgets
# benchmark GPU directo vs vello:
cargo run -p llimphi-gpu-bench --release
llimphi-gallery (src/main.rs, ~967 líneas) es la referencia viva del
patrón completo: Model/Msg/init/update/view/view_overlay con overlays
mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown).
Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; ? abre
atajos; Esc cierra el overlay activo.
18. Cheat-sheet
// ── App mínima ──────────────────────────────────────────────
impl App for X { type Model; type Msg; init; update; view; }
llimphi_ui::run::<X>();
// ── Nodo ────────────────────────────────────────────────────
View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
.fill(c).hover_fill(c).radius(r).clip(b).alpha(a).transform(xf)
.text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al)
.image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{})
.on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m)
.on_pointer_enter(m) | .on_pointer_leave(m)
.draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|)
.drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c)
.children(vec![..])
// ── Efectos ─────────────────────────────────────────────────
handle.spawn(|| Msg::Done(compute())); // worker → reentra al update
handle.spawn_periodic(dur, || Msg::Tick); // feed periódico
handle.dispatch(Msg::X); handle.quit();
// ── Estilo de layout (taffy prelude) ────────────────────────
length(px) percent(0..1) Dimension::auto()
FlexDirection::{Row,Column} AlignItems::{Start,Center,End,Stretch}
JustifyContent::{Start,Center,End,SpaceBetween}
// ── Theme ───────────────────────────────────────────────────
Theme::dark()/light()/aurora()/sunset(); Theme::next_after(name); XxxPalette::from_theme(&t)
// ── Overlay (menús/modales) ─────────────────────────────────
fn view_overlay(m) -> Option<View<Msg>> { if m.open { Some(menu) } else { None } }
19. Índice de crates
Framework (02_ruway/llimphi/):
llimphi-hal · llimphi-raster · llimphi-text · llimphi-layout ·
llimphi-compositor · llimphi-ui · llimphi-theme · llimphi-motion ·
llimphi-icons · llimphi-surface · llimphi-workspace · llimphi-gallery ·
llimphi-gpu-bench.
Widgets (widgets/, dep llimphi-widget-<n>): app-header · avatar · badge ·
banner · breadcrumb · button · card · clipboard · context-menu · edit-menu ·
empty · field · gallery · grid · list · menubar · modal · navigator · nodegraph ·
panel · panes · progress · segmented · shortcuts-help · skeleton · slider · splash ·
splitter · stat-card · status-bar · switch · tabs · text-area · text-editor ·
text-editor-core · text-editor-lsp · text-input · theme-switcher · tiled ·
timeline · toast · tooltip · tree · wawa-mark.
Módulos (modules/): bookmarks · command-palette · diff-viewer · fif ·
file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline.
Android (android/): clear-screen-android · vello-hello-android ·
vello-text-android.
Documentos hermanos: SDD.md (diseño y roadmap),
COMPUTO-FUERA-DEL-HILO-UI.md (regla de
concurrencia), README.md / LEEME.md (overview).
Las firmas de este manual reflejan el código al 2026-06-01; ante divergencia,
la fuente autoritativa es el lib.rs de cada crate.