1# Manual de Llimphi
2
3> Motor gráfico soberano de tawasuyu. `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**: declaras 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í escribes `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: **70 widgets** (visuales) + **11 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; guardas `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 pub fn for_test_with<F: Fn(Msg) + Send + Sync + 'static>(sink: F) -> Self; // tests que SÍ quieren los Msg
251}
252```
253
254- **`on_modifiers`** (hook del trait `App`) — el `WindowEvent::ModifiersChanged`.
255 Existe porque **los handlers de click llevan un `Msg` plano y no reciben los
256 modificadores**: para hacer Shift+click (rango) o Ctrl+click (aditivo) en una
257 lista, la app guarda el estado en su modelo desde este hook y lo consulta en
258 el `update` del click. Devolvé `None` cuando el cambio no te importa (si no,
259 cada roce de Shift dispara un update y un repintado).
260
261- **`for_test` vs `for_test_with`** — `for_test` **descarta** los `Msg`; ojo con
262 eso: la closure de un `spawn` **sí se ejecuta** (para no perder sus efectos),
263 sólo se tira el mensaje. Un test que necesitaba el resultado terminaba
264 volviendo a hacer el trabajo a mano → el mismo job dos veces, en paralelo, y
265 carreras intermitentes sobre el disco. `for_test_with` entrega cada `Msg` a un
266 sink (típicamente el `Sender` de un canal, detrás de un `Mutex` porque el sink
267 pide `Sync`); el test lo drena y lo realimenta a `update`, que es lo que hace
268 el runtime real. Con eso el trabajo corre **una sola vez** y el test es
269 determinista.
270
271- **`spawn`** — trabajo bloqueante (IO, PAM, parse, efemérides). El `Msg` que
272 devuelve la closure se entrega al `update` en el hilo de UI. **Este es el
273 patrón obligatorio para todo cómputo pesado** (§16).
274- **`spawn_periodic`** — feeds a intervalos: ticks de simulación (~11 Hz en
275 dominium), polling, animaciones por reloj. El thread muere cuando se cierra
276 el event loop.
277
278---
279
280## 6. `View<Msg>` — el DSL declarativo
281
282Un `View` = `Style` de taffy + relleno + texto/imagen/painter + handlers +
283hijos. Todo se arma con builders encadenables (`self -> Self`). Definido en
284`llimphi-compositor/src/view.rs`.
285
286```rust
287View::new(style: Style) -> View<Msg>
288```
289
290### Apariencia
291| Método | Efecto |
292|---|---|
293| `.fill(Color)` | color de fondo |
294| `.fill_gradient(Gradient)` | relleno con gradiente (autoreado en el cuadrado unidad `[0,1]²`, mapeado al rect). Gana sobre `fill`; `hover_fill` lo overridea en hover |
295| `.hover_fill(Color)` | color al pasar el cursor (habilita hit-test de hover) |
296| `.radius(f64)` | esquinas redondeadas (radio uniforme) |
297| `.radius_corners(tl,tr,br,bl)` | radio **por esquina** (CSS `border-radius` 4 valores); override de `.radius`. El borde sigue las 4 esquinas; la sombra usa el radio escalar |
298| `.shadow(Shadow)` | drop shadow (vello `draw_blurred_rounded_rect`). `Shadow::soft(alpha,blur)` + `.offset(dx,dy)`/`.spread(s)` |
299| `.border(width, Color)` | stroke sobre el contorno redondeado, inset hacia adentro (border-box) |
300| `.alpha(f32)` | opacidad de todo el subtree `[0,1]` (capa intermedia — no gratis) |
301| `.transform(Affine)` | afín 2D alrededor del centro del rect (estilo CSS `transform-origin:50% 50%`) |
302| `.animated(key, Duration)` | animación **implícita** estilo Flutter `AnimatedContainer`: si `fill`/`radius` cambian entre frames, el runtime interpola (ease-out cúbico) en vez de saltar. `key` estable entre rebuilds. `.animated_curve(key,dur,fn)` para otra curva |
303| `.clip(bool)` | recorta hijos al rect (paint + hit-test) |
304| `.image(Image)` | pinta `peniko::Image` centrada, preservando aspect ratio |
305| `.children(Vec<View<Msg>>)` | hijos |
306
307### Texto (ver §9)
308```rust
309.text(content, size_px, color) // centrado
310.text_aligned(content, size_px, color, Alignment)
311.text_aligned_italic(content, size_px, color, Alignment, italic)
312.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option<String>)
313.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada
314.line_height(mult) // override interlínea (default 1.2)
315.text_weight(f32) // peso CSS: 400 normal, 600 semibold, 700 bold
316.bold() // atajo de text_weight(700.0)
317.ellipsis(n) // clampa a n líneas terminando en … (n=1 = single-line)
318.max_lines(n) // clampa a n líneas sin glifo (corte seco)
319```
320
321### Interacción (ver §8)
322```rust
323.on_click(Msg)
324.on_click_at(|lx, ly, w, h| -> Option<Msg>) // posición local + tamaño del rect
325.on_right_click(Msg) / .on_right_click_at(...)
326.on_middle_click(Msg)
327.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
328.draggable(|phase: DragPhase, dx, dy| -> Option<Msg>)
329.draggable_at(|phase, dx, dy, lx0, ly0| -> Option<Msg>) // + posición inicial del press
330.drag_payload(u64) // payload que viaja con el drag
331.on_drop(|payload: u64| -> Option<Msg>) // este nodo es drop target
332.drop_hover_fill(Color) // resaltado mientras un drag lo sobrevuela
333.on_scroll(|dx, dy| -> Option<Msg>) // rueda local (antes del on_wheel global)
334.on_scroll_take(|dx, dy| -> (Option<Msg>, rx, ry)) // reparto PARCIAL: consume lo que puede y el
335 // sobrante sigue al ancestro (mismo evento);
336 // scroll_y/lazy_list ya lo usan (scroll_take)
337.on_scale(|phase: GesturePhase, factor, fx, fy| -> Option<Msg>) // pinch-to-zoom (Ctrl+rueda / trackpad)
338.on_double_tap(Msg) / .on_double_tap_at(|lx, ly, w, h| ...) // dos clicks rápidos y cercanos
339.on_long_press(Msg) / .on_long_press_at(|lx, ly, w, h| ...) // mantener ~500 ms quieto
340.focusable(u64) // nodo enfocable por Tab/click (id opaco)
341```
342
343### Pintura custom (ver §10)
344```rust
345.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... })
346.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... })
347```
348
349Notas clave:
350- **Un nodo es draggable *o* clickable**, no ambos: `draggable` sobreescribe
351 `on_click`.
352- Las variantes `*_at` ganan sobre las simples si ambas están.
353- `PaintRect { x, y, w, h }` es el rect **absoluto** del nodo en píxeles físicos.
354- `DragPhase` = `Move` (un evento por `CursorMoved`, `dx/dy` = delta **desde el
355 evento anterior**, no acumulado) | `End` (al soltar).
356- **Gestos (`on_scale`/`on_double_tap`/`on_long_press`)** son **aditivos**: se
357 resuelven con su propio hit-test y no interfieren con `on_click`/`draggable`
358 del mismo nodo. El caso limpio (sin disparos cruzados) es ponerlos en un nodo
359 que **no** tenga `on_click` — p. ej. un canvas con `draggable` (pan) +
360 `on_scale` (zoom) + `on_long_press` (marca). `GesturePhase` = `Begin`/`Update`/
361 `End`; en `on_scale`, `factor` es **multiplicativo incremental** (`>1` agranda)
362 y `(fx, fy)` el focal local — `Ctrl+rueda` lo sintetiza en cualquier desktop
363 (Wayland/Windows no emiten el pinch del trackpad; macOS sí, vía `PinchGesture`).
364 El long-press lo arbitra el tiempo (~500 ms quieto); moverse (>8px) o soltar lo
365 cancela. Demo completo: `cargo run -p llimphi-ui --example gestos --release`.
366
367---
368
369## 7. Layout (`taffy` / `Style`)
370
371`Style` es el `taffy::Style` directo, re-exportado vía
372`llimphi_ui::llimphi_layout::taffy::prelude::*`. Es Flexbox + CSS Grid puro.
373
374Campos más usados:
375```rust
376Style {
377 flex_direction: FlexDirection::Row | Column,
378 size: Size { width, height }, // length(px) | percent(0..1) | Dimension::auto()
379 min_size, max_size,
380 flex_grow: f32, flex_shrink: f32,
381 align_items: Some(AlignItems::{Start,Center,End,Stretch}),
382 justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}),
383 gap: Size { width, height },
384 padding: Rect { left, right, top, bottom }, // con length(px)
385 margin: Rect { ... },
386 ..Default::default()
387}
388```
389
390Helpers de `prelude`: `length(px)`, `percent(frac)`, `auto()`, `Dimension`,
391`Size`, `Rect`, `FlexDirection`, `AlignItems`, `JustifyContent`.
392
393`llimphi-layout` además expone:
394- `LayoutTree::new()` / `.clear()` (reuso entre frames), `.leaf(style)`,
395 `.node(style, &children)`, `.compute(...)`, `.compute_with_measure(F)`.
396- `Rect { x, y, w, h }` y `ComputedLayout { rects: HashMap<NodeId, Rect> }`.
397
398En el 99% de los casos no tocas `LayoutTree` a mano: lo maneja el runtime al
399montar tu `View`. Sólo armás `Style`s.
400
401---
402
403## 8. Eventos e interacción
404
405| Quiero… | Cómo |
406|---|---|
407| Botón / fila clickable | `.on_click(Msg)` (+ `.hover_fill` para feedback) |
408| Saber dónde se clickeó (canvas) | `.on_click_at(\|lx,ly,w,h\| ...)` → convertir a coords de mundo |
409| Menú contextual | `.on_right_click(Msg::OpenMenu{..})`, guardar pos en Model, abrir en `view_overlay` |
410| Abrir en pestaña nueva | `.on_middle_click(Msg)` |
411| Preview al pasar el mouse | `.on_pointer_enter(Msg)` / `.on_pointer_leave(Msg)` |
412| Resize de panel | `.draggable(\|phase,dx,dy\| ...)` acumulando delta en el Model |
413| Arrastrar entidad de un canvas | `.draggable_at(\|phase,dx,dy,lx0,ly0\| ...)` |
414| Drag&drop entre zonas | origen: `.drag_payload(id)`; destino: `.on_drop(\|id\| ...)` + `.drop_hover_fill` |
415| Scroll global | `App::on_wheel(model, delta, cursor, mods)` |
416| Área de scroll | widget `scroll_y(...)` (autocontenido) o `.on_scroll(\|dx,dy\| ...)` por nodo |
417| Zoom de canvas (pinch) | `.on_scale(\|phase,factor,fx,fy\| ...)` → `zoom *= factor`, reajustar pan al focal |
418| Doble-click | `.on_double_tap(Msg)` / `.on_double_tap_at(\|lx,ly,w,h\| ...)` |
419| Long-press (mantener) | `.on_long_press(Msg)` / `.on_long_press_at(\|lx,ly,w,h\| ...)` |
420| Teclado | `App::on_key(model, &KeyEvent) -> Option<Msg>` |
421| Foco / Tab | `.focusable(id)` en los nodos + `App::on_focus(model, id)` (ver abajo) |
422| IME (CJK, acentos) | `App::ime_allowed() -> true` + `App::on_ime(model, &ImeEvent)` (ver abajo) |
423| Drop de archivos del SO | `App::on_file_drop(model, path)` |
424
425**Patrón overlay** (menús/modales): el modelo guarda "menú abierto sí/no".
426Mientras esté abierto, `view_overlay` devuelve `Some(view)`; clicks fuera se
427cierran envolviendo los items en un scrim a pantalla completa con
428`on_click = DismissOverlay`. Cuando el modelo dice cerrado, `view_overlay`
429devuelve `None`.
430
431**Scroll** (widget `llimphi-widget-scroll`). `scroll_y(offset, content_len,
432viewport_len, content, on_scroll, &palette)` arma un viewport clipeado +
433contenido desplazado `-offset` + barra arrastrable. Es **stateless**: el offset
434vive en tu Model. `on_scroll(delta_px)` (rueda y arrastre) emite un delta a
435sumar; clampealo con `scroll::clamp_offset` en tu `update`. Helpers:
436`ensure_visible(offset, vp, item_top, item_h)` para llevar la selección a la
437vista (teclado); `approach(cur, target, factor)` para scroll suave hacia un
438objetivo (driveado por `Handle::spawn_periodic`).
439
440**Scroll 2D / física / slivers** (Tier 5, mismo widget, todo stateless):
441- `scroll_xy(offset:(x,y), content_size:(w,h), viewport_size:(w,h), content,
442 on_scroll:(dx,dy)→Msg, &palette)` — dos ejes, una barra por eje con overflow.
443- **Inercia (fling)**: `fling_step(velocity, dt, friction) → (v', delta)` +
444 `fling_settled(v)` — suelta con velocidad y avanza el offset por frame con el
445 ticker (`spawn_periodic`); `FLING_FRICTION`/`FLING_STOP` defaults. **Bounce**:
446 `rubber_band(overscroll, dim)` amortigua el desplazamiento más allá del tope.
447- **Sliver app-bar colapsable**: `sliver_app_bar(offset, header_max, header_min,
448 header(frac)→View, content, content_len, viewport_len, on_scroll, &palette)` —
449 un único offset colapsa el header (de max a min) y luego scrollea el cuerpo
450 bajo él; `header(frac)` recibe `frac∈[0,1]` para fundir título/subtítulo.
451 Clampeá con `sliver_max_offset(...)`. Helpers puros: `collapsed_height`,
452 `collapse_fraction`, `sticky_y(offset, section_top, section_h, header_h)` para
453 encabezados de sección pegados al tope. Demo: `cargo run -p
454 llimphi-widget-scroll --example scroll_avanzado`.
455
456**Foco y teclado.** Marca los nodos navegables con `.focusable(id)` (id `u64`
457que vos eliges). El runtime es la **única fuente de verdad** del foco: lo mueve
458con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo
459enfocable, y te avisa con `App::on_focus(model, Option<u64>)`. Guardas el id en
460tu Model para (a) pintar el ring (`if model.focus == Some(id) { .fill(accent) }`
461en `view`) y (b) rutear el teclado al campo activo desde `on_key`. No setees el
462foco por tu cuenta vía Msg: quedaría desincronizado del runtime.
463
464**IME** (composición de texto). Opt-in: `ime_allowed() -> true`. Con IME activo
465el texto compuesto **no** llega por `KeyEvent.text` sino por `on_ime`:
466`ImeEvent::Enabled` → uno o más `Preedit{text, cursor}` (texto en composición, a
467pintar subrayado en el caret) → `Commit(text)` (inserta como tecleado) o
468`Disabled`. Reporta el área del caret con `ime_cursor_area(model)` para ubicar
469la ventana de candidatos (CJK) junto al cursor.
470
471---
472
473## 9. Texto
474
475`TextSpec` (en compositor) describe el texto de un nodo:
476```rust
477pub struct TextSpec {
478 pub content: String,
479 pub size_px: f32,
480 pub color: Color,
481 pub alignment: Alignment, // Start | Center | End | Justify
482 pub italic: bool,
483 pub font_family: Option<String>, // string CSS con fallbacks
484 pub line_height: f32, // múltiplo; default 1.2
485 pub runs: Option<Vec<(usize, usize, Color)>>, // color por rango de BYTES
486}
487```
488
489- `Center` es el default (apto para labels). Para editores/párrafos usar
490 `.text_aligned(..., Alignment::Start)`.
491- **Multicolor en una sola pasada de shaping**: `.text_runs(...)` colorea
492 rangos de bytes — es la base del syntax highlighting (un nodo por línea, no
493 por token). Anclado arriba-izquierda; el caller dimensiona el rect.
494- El runtime mide el texto con parley durante el layout (`compute_with_measure`)
495 para que taffy reserve el alto real del texto envuelto a varias líneas
496 (evita "textos aplastados").
497- Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique.
498
499---
500
501## 10. Canvas custom y GPU directo
502
503Dos hooks para pintar primitivas no expresables como composición de `View`s.
504Conviven en el mismo árbol; el runtime pinta **toda la pasada vello primero**,
505luego los `gpu_painter` en orden DFS.
506
507### `paint_with` — vía vello (el default)
508```rust
509.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| {
510 // dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`.
511 // NO dejar push_layer sin pop_layer; NO resetear la scene.
512})
513```
514Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal.
515Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada).
516
517### `gpu_paint_with` — sube vertex buffers directo a wgpu, salta vello
518```rust
519.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| {
520 // abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello.
521 // (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC.
522})
523```
524Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de
525nakui, pineal denso. Rango 100 K – 10 M+ primitivos. **No** soporta texto ni AA
526fino ni múltiples grosores de stroke por flush. Para texto encima de un render
527GPU, usar `view_overlay` (segunda Scene vello).
528
529### UI encima de contenido GPU — `View::over` y `paint_over`
530
531El orden del frame es `[vello base] → [gpu_paint] → [vello over] → [overlay/
532menús]`: **cualquier `View` normal que se solape con un `gpu_paint_with` queda
533tapado por él** (el blit corre después de la pasada base). Dos salidas:
534
535```rust
536// Subárbol entero de Views pintado en la pasada over (fills, texto, widgets,
537// hover — la maquinaria completa). El primitivo para un OSC de reproductor,
538// un HUD, un rail que solapa un canvas GPU. El hit-test no cambia.
539View::new(estilo_absoluto).over(true).children(vec![barra_de_transporte])
540
541// Closure vello suelta en la pasada over (sprites/texto AA sobre celdas
542// instanciadas — dominium, motor voxel).
543.paint_over(|scene, ts, rect| { ... })
544```
545
546Limitación de ambas: la capa over se compone con alpha sobre la intermedia;
547clips/alpha/transform de **ancestros fuera** del subárbol no la afectan. Caso
548canónico de `.over(true)`: el OSC estilo mpv de media-app
549(`02_ruway/media/media-app/src/vista.rs::overlay_bars_view`) y su fondo de
550marca (`src/fondo.rs::fondo_overlay`) flotando sobre el video.
551
552### ¿Cuándo cada uno?
553| Pregunta | vello (`paint_with`) | GPU directo (`gpu_paint_with`) |
554|---|---|---|
555| Primitivos/frame | < ~500 K rebuild / < ~2 M Scene reusada | 100 K – 10 M+ |
556| ¿Cambian cada frame? | sí, rebuild barato | mejor estático (buffer persistente) |
557| Curvas Bezier | nativas | hay que teselar |
558| Texto | sí | no |
559| AA fino | sí (analítico) | no (sin MSAA) |
560
561**Default: `paint_with`** salvo que ya midas que el volumen lo justifica
562(factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU
563expone `GpuPipelines`/`GpuBatch` en `llimphi-raster` (§12).
564
565---
566
567## 11. Theme y paletas
568
569`llimphi-theme::Theme` es un struct de slots semánticos de color. Cuatro presets
570`const`: `Theme::dark()` (default), `light()`, `aurora()`, `sunset()`.
571
572```rust
573pub struct Theme {
574 pub name: &'static str,
575 // fondos
576 pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus,
577 pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color,
578 // texto
579 pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color,
580 // bordes y acento
581 pub border, border_focus, accent: Color,
582}
583
584Theme::all() -> Vec<Theme> // orden de rotación canónico
585Theme::by_name(name) -> Option<Theme>
586Theme::next_after(current_name) -> Theme // para el theme-switcher
587```
588
589Tokens auxiliares en el mismo crate:
590- `motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms}` + `ease_out_cubic`,
591 `ease_in_out_cubic`, `linear`.
592- `alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT}` (constantes `u8`).
593- `radius::{XS=2, SM=4, MD=8, LG=12, XL=20}` (`f64`).
594
595**Patrón de widgets**: cada widget define su `XxxPalette` con
596`Palette::from_theme(&theme)`. Tu app guarda un `Theme` en el Model, deriva las
597paletas que necesita en `view`, y se las pasa a los widgets. Para cambiar de
598tema, el `theme-switcher` emite `Msg(next_theme)` y reconstruyes todo.
599
600---
601
602## 12. Capas base
603
604### `llimphi-hal` — superficie
605```rust
606Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result<Hal, HalError> // async
607trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result<Frame,_>; fn present(frame, hal); }
608WinitSurface::new(hal, window: Arc<Window>) -> Result<Self, HalError>
609Frame::view() -> &wgpu::TextureView; Frame::size() -> (u32,u32)
610```
611`Hal::new` pide adapter `Backends::PRIMARY` (Vulkan) y cae a `all()` sólo si no
612hay — **no volver a `InstanceDescriptor::default()`**: el backend GL de Mesa
613sobre Wayland segfaultea en el teardown. El runtime de `llimphi-ui` ya maneja
614todo esto; sólo tocas HAL si escribes un runtime nuevo.
615
616### `llimphi-raster` — rasterización
617```rust
618Renderer::new(hal) -> Result<Renderer,_>
619Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
620// GPU directo:
621GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout
622GpuBatch::new(&pipelines)
623 .line_width(w) .add_line(p0,p1,color) .add_polyline(&pts,color)
624 .add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
625 .primitive_count() -> u32
626 .flush(device, queue, encoder, view, viewport, load_op)
627```
628Re-exporta `vello` y `peniko` (`Color`, `Image`, `Fill`, etc.).
629
630### `llimphi-image` — decode pipeline
631```rust
632decode_bytes(&[u8]) -> Result<peniko::Image, DecodeError>
633load_path(&Path, max_bytes: u64) -> Result<peniko::Image, DecodeError>
634from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> peniko::Image
635```
636Encapsula el patrón `image::ImageReader + to_rgba8 + Blob + Image::new` que cada
637app que decodifica imágenes tenía duplicado. `max_bytes` aplica al tamaño del
638archivo en disco (no a la imagen decodificada, que en RGBA8 puede ser mucho
639mayor — un PNG 4K ocupa ~64 MB descomprimido); `0` deshabilita el cap.
640Re-exporta `peniko::{Blob, Image, ImageFormat}`. Formatos según las features
641del crate `image` upstream del workspace (hoy: PNG, JPEG, WEBP).
642
643### `llimphi-text` — shaping
644```rust
645Typesetter::new() // una por proceso (FontContext es caro)
646 .layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()>
647 .layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout<RunBrush>
648TextBlock::simple(text, size_px, color, origin)
649layout_block(ts, &block) / measure(ts, &block) -> Measurement
650draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin)
651Alignment::{Start, Center, End, Justify}
652```
653
654### `llimphi-motion` — tweens
655```rust
656trait Lerp { fn lerp(self, other, t: f32) -> Self; } // impl para f32,f64,(f32,f32),(f64,f64),Color
657Tween::new(from, to, duration, easing: fn(f32)->f32) // o Tween::idle(value)
658tween.value() / .progress() / .done()
659animate(handle, duration, make_msg) // arranca los ticks del tween
660```
661Patrón: guardas `Tween<T>` en el Model, `animate(...)` en el update, la `view`
662lee `tween.value()` cada repaint. El tween se auto-termina.
663
664### `llimphi-icons` — iconos vectoriales (~23, grid 24×24)
665```rust
666Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right,
667 Home, Search, Info, Warning, Error, Bell, Settings, More, ...}
668icon_view(Icon, color, stroke_width) -> View<Msg>
669paint_icon(scene, rect, icon, color, stroke_width) // dentro de un paint_with
670```
671`stroke_width` en unidades del grid 24×24 (1.6 es armónico).
672
673### `llimphi-surface` — texturas externas
674```rust
675ExternalSurface::new(device, queue) // barato de clonar (Arc<Mutex> interno)
676 .upload(&rgba, w, h) // desde otro hilo/decoder/cámara
677 .view(style) -> View<Msg> // blittea a su rect en el árbol Elm
678 .blit(queue, encoder, dst_view, rect, viewport) // o manual desde gpu_paint_with
679```
680
681---
682
683## 13. Catálogo de widgets
684
685Los widgets son **funciones puras** que devuelven `View<Msg>` (o specs que se
686convierten a `View`). Son **stateless**: el estado vive en tu Model. Convención:
687cada uno trae `XxxPalette::from_theme(&Theme)`. Crates en
688`widgets/<nombre>/`, dep `llimphi-widget-<nombre>`.
689
690### Controles
691
692**button** — `button_view(label, &ButtonPalette, on_click: Msg) -> View`;
693`button_styled(label, style, alignment, &palette, on_click)`.
694
695**field** — wrapper de formulario (label + helper/error + requerido).
696`field_view(FieldSpec { label, control: View<Msg>, required, helper, error, palette })`.
697
698**text-input** — input single-line **con estado** `TextInputState`
699(`new()`/`masked()`, `text()`, `set_text()`, `apply_key(&KeyEvent) -> bool`,
700soporta undo/redo + selección con Shift). Render:
701`text_input_view(&state, placeholder, focused, &palette, on_focus: Msg)`.
702
703**text-area** — input **multilínea** con paridad real (mismo motor `EditorState`
704que el text-input; NO el viejo `String` plano). El estado es
705`TextInputState::multiline()` (o `llimphi_widget_text_area::new_state()`).
706`text_area_view(&state, placeholder, focused, rows, &palette, |ev| Msg::Area(ev))`
707emite `TextAreaEvent` (Key/Ime/Press(x,y)/Drag/DragEnd/Context) ya envueltos; en
708`update` → `state.handle_area(ev, &mut clipboard)`. Da: **click 2D** (renglón+columna
709por glifos reales), ↑/↓ entre renglones, selección a través de líneas (arrastre 2D,
710doble/triple-click, Shift+flechas), copiar/cortar/pegar, undo/redo, IME, Enter=`\n`,
711**soft-wrap** (la línea larga se envuelve al ancho de la caja) y **scroll vertical**
712(caret a la vista si hay más renglones que `rows`). Caret + selección pintados por
713`paint_over` en coords de renglón visual; fuente única del layout: `visual_layout`
714(render/hit-testing/caret/nav comparten). **↑/↓ navegan por renglón visual** (no por
715línea lógica: en texto envuelto bajan un renglón, no la línea entera), con columna
716meta en px preservada (`move_vertical_visual`; Shift extiende selección). El crate
717`llimphi-widget-text-area` es una fachada que re-exporta esto. Demo:
718`cargo run -p llimphi-widget-text-input --example area_demo`.
719
720**text-area RICO** — el mismo widget con los adornos que antes obligaban a
721escribirse un pintor propio. `text_area_view_rico(&state, focused, rows,
722&palette, &Metricas, &Adornos, wrap)`. **Si necesitás un input mono, coloreado,
723con fantasma o con caret animado, es ESTE, no un `paint_with` a mano.**
724- `Metricas { font_size, line_h, font_family, pad_x, pad_r, pad_y }` —
725 `Metricas::mono(px)` + `.con_zoom(z)`. La usan pintado, ajuste blando y
726 click→columna: una sola, o el caret cae entre glifos que no son los que se ven.
727- `Adornos { tramos, ghost, placeholder, caret, ahora_ms, marco }`.
728 `tramos: Vec<(ini, fin, Color)>` en **bytes del texto completo** (se recortan
729 solos a cada renglón); `ghost` = sufijo tenue tras el caret (sólo si el caret
730 está al final); `Placeholder { texto, color, italic, icono, icono_color, alpha }`;
731 `marco: false` = sin caja propia (para un host que ya tiene la suya — el borde
732 del widget es un relleno **opaco** que taparía un backdrop frosted).
733- `CaretEstilo::vivo()` = parpadeo que queda **sólido** tras cada tecla y
734 **estela** al moverse (el eyecandy en honor a kitty). El reloj lo trae el host:
735 `Adornos::ahora_ms` por cuadro + `state.marcar_actividad(ms)` en cada tecla —
736 el widget no llama a `now()` por dentro (sería intesteable, y un `bool` de fase
737 alternado por un timer externo se muestrea mal cuando el panel repinta lento).
738- Consultas para un host que **crece con el contenido**:
739 `renglones_visuales()`, `renglones_visuales_de(txt)`, `avance_en_renglon()`,
740 `ancho_caracter()`, `fijar_ancho_caja(px)` (sembrar el ancho antes del primer
741 cuadro, o testear el wrap sin GPU).
742`text_area_view` es esta misma vista con `Adornos::default()` — un solo
743renderizador. Uso canónico: el input de la barra de shuma
744(`02_ruway/shuma/sandbox/shuma-module-shell/src/view/input.rs`), que le pasa los
745tokens del shell como `tramos` y monta su propio marco con `marco: false`.
746
747**slider** — sin estado. `slider_view(label, value, min, max, &palette,
748on_change: Fn(DragPhase, delta_value) -> Option<Msg>)`. El delta viene en
749unidades, no píxeles.
750
751**color-picker** — selector RGBA: swatch actual + paleta de chips + barra
752de hue + sliders R/G/B/A (+ campo `#RRGGBB` opcional). Agnóstico, emite
753`[u8; 4]`. `color_picker_view(rgba: [u8;4], swatches: &[[u8;3]] (p.ej.
754DEFAULT_SWATCHES), &ColorPickerPalette, hex: Option<HexField { focused,
755state: Option<&TextInputState>, on_focus }>, on_change: Fn([u8;4]) -> Msg)`.
756Helpers puros: `rgba_to_hex`, `parse_hex(s, cur_alpha)` (acepta 3/6/8
757dígitos, `#` opcional), `color_picker_height(with_hex)` para pre-calcular
758el alto.
759
760**switch** — `switch_view(progress: f32 [0..1], on_toggle: Msg, &palette)`. La
761app guarda el `bool` y opcionalmente anima `progress` con un `Tween`.
762
763**segmented** — N opciones exclusivas. `segmented_view(&[&str], selected: usize,
764make_msg: Fn(usize)->Msg, &palette)`.
765
766**select** — combobox/dropdown moderno (disparador cerrado + menú flotante en
767`view_overlay`). Soporta **ítems ricos** (`SelectItem { label, sublabel, icon
768(glifo unicode), badge: SelectBadge, enabled }`), **selección múltiple**
769(`selected: &[usize]` con check ✓), **búsqueda** in-menu y **fases async**
770(`SelectPhase::{Loading, Error(&str), Ready(&[SelectItem])}`) para listas que
771vienen de `Handle::spawn`. El cerrado se pinta con `select_trigger_view(selected:
772Option<&SelectItem>, placeholder, open, width, &palette, on_toggle: Msg)`; el
773menú con `select_menu_view(SelectMenuSpec { anchor, viewport, width, phase,
774visible: &[orig_idx], active, selected, query, searchable, empty_text, appear,
775on_pick: Fn(orig_idx)->Msg, on_hover, on_dismiss, on_retry, palette })`. La app
776mantiene `query`/`active`/`visible`/`selected` en el Model. Helpers puros para
777el filtro: `filter(items, query) -> Vec<orig_idx>`, `step_active(items, visible,
778current, ±1)` para nav, `resolve(visible, pos) -> Option<orig_idx>`.
779
780**progress** — `linear_progress_view(progress, track, fill, height)` y
781`radial_progress_view(progress, track, fill, stroke_ratio)`. Sin eventos.
782
783**spinner** — `spinner_view(color, stroke_ratio, speed_rev_per_sec)`. Animado por
784reloj absoluto; requiere redraws periódicos (`spawn_periodic`).
785
786**rive-button** — botón reactivo dirigido por **máquina de estados** (`llimphi-anim`,
787estilo Rive) — primer consumidor real de la `StateMachine` fuera del studio.
788**Stateful** (como `text-editor`): guardas `RiveButton` en tu Model. Tres estados
789`idle`/`hover`/`press`, cada uno con su clip Lottie; hover por bool (listeners
790Enter/Exit del motor), press por trigger (`ClipDone` lo devuelve solo). API:
791`RiveButton::new(idle, hover, press, press_secs, blend_secs)` o `::builtin()`
792(clips embebidos); `advance(dt)` desde un tick, `pointer(Option<(f64,f64)>)`,
793`press()` desde el `on_click` del host, `current_state()`/`is_transitioning()`.
794Render: `view(on_pointer: Fn(Option<(f64,f64)>)->Msg, on_click: Msg)`. Demo:
795`cargo run -p llimphi-widget-rive-button --example rive_button_demo --release`.
796
797**badge** — `count_badge_view(count, BadgeKind)` ("99+" si ≥100) y
798`dot_badge_view(BadgeKind)`. `BadgeKind::{Info,Success,Warning,Error,Neutral}`.
799
800**avatar** — `avatar_view(name, size_px)`: círculo determinista (color por hash
801del nombre + inicial).
802
803**tooltip** — render puro. `tooltip_view(TooltipSpec { anchor, viewport, side:
804Side, text, palette })`. Se monta en `view_overlay`; la app controla visibilidad
805con `on_pointer_enter/leave`.
806
807**empty** — empty-state. `empty_view(Icon, title, description: Option<&str>, &palette)`.
808
809**skeleton** — placeholder con shimmer. `skeleton_view`, `skeleton_box_view(w,h,..)`,
810`skeleton_line_view(w,..)`. Requiere redraws periódicos.
811
812**carousel** — pager paginado de N páginas. `carousel_view(CarouselSpec {
813pages, current, wrap: CarouselWrap::{Wrap, Clamp}, show_arrows, palette,
814on_change: Fn(usize) -> Msg })`. Dots indicadores abajo (clickeables para saltar
815a la página i) + flechas opcionales `‹` / `›` a los costados. v1 sin swipe;
816para v2 sumar swipe horizontal usando `draggable_velocity` + `fling_step` para
817snap-on-release.
818
819**fitted-box** — escala un subárbol al slot del padre. `fitted_box((iw, ih),
820BoxFit::{Contain, Cover, Fill, None, ScaleDown}, || inner_view())` aplica un
821transform afín al inner (medido por el seam `LayoutBuilder`) para que entre en
822el slot real, centrado, preservando aspect salvo `Fill`. Útil para imágenes,
823canvases `paint_with` y texto grande que deben caber en celdas chicas sin que el
824caller los re-mida. La función pura `compute_fit(slot, inner, fit) ->
825(sx, sy, dx, dy)` es testeable.
826
827**calendar** — vista mensual 6×7 (`calendar_view(CalendarSpec { view_year,
828view_month, selected, today, week_start, palette, on_select: Fn(NaiveDate)->Msg,
829on_view_change: Fn(year, month)->Msg })`). Header con `< Mes Año >` para navegar
830entre meses, fila de iniciales (`L M M J V S D` ó `D L M M J V S` según
831`WeekStart`), grilla siempre de 6 filas (no reflowea al cambiar de mes). El
832caller le inyecta `today` — el widget no toca el reloj. Base del date-picker:
833combinar con `view_overlay` + un `field`/`button` disparador.
834
835**banner** — tira de status. `banner_view(BannerKind::{Info,Success,Warning,Error}, message)`.
836
837### Contenedores y layout
838
839**panel** — chrome (gradiente + hairline accent). `panel_view(children, PanelStyle)`;
840`PanelStyle::{from_theme, from_theme_large, neutral}`. `panel_signature_painter(style)`
841para reusar el look en un `paint_with`.
842
843**card** — `card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette)`.
844
845**stat-card** — métrica de dashboard. `stat_card_view(label, value, description,
846accent, &recent_items, &palette)`.
847
848**tabs** — `tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg,
849content: View<Msg>, tab_height, palette, tab_width })`. Selección la maneja la app.
850
851**router** — navegación por **pila de rutas** con transiciones de página
852(el `Navigator` de Flutter, a la Elm). La pila vive en el Model; el widget
853pinta sólo la ruta actual y anima el cambio con la infra de ghosts del
854compositor (`animated_switch_styled`). Push entra desde la derecha y lo viejo
855retrocede 30 % (shared-axis X); pop lo inverso; `Zoom` = fade-through;
856`Cover` = **push opaco estilo UIKit** (la nueva tapa sin fade; el pop desliza
857la vieja por encima revelando — scene-split de fantasmas: el saliente se
858pinta DEBAJO del entrante, en su posición z real); `None` = corte seco sin
859captura. Los **hero** cruzan rutas gratis (misma key en ambas páginas).
860```rust
861// Model: nav: RouteStack<Ruta> (enum propio: Clone + PartialEq + Hash)
862m.nav.push(Ruta::Detalle(7)); m.nav.pop(); m.nav.replace(r); m.nav.pop_to_root();
863router_view(key, &m.nav, PageTransition::{None,Fade,Slide,Zoom,Cover}, dur,
864 page: Fn(&Ruta) -> View<Msg>) // mide su slot vía LayoutBuilder
865router_view_sized(key, &m.nav, tr, dur, (w, h), page) // slot explícito — para
866 // páginas que anidan otro layout_builder (límite v1 del seam: sin anidar)
867```
868Demo: `cargo run -p llimphi-widget-router --example router_demo --release`
869(hero volando entre Lista y Detalle; `T` cicla la transición, Esc vuelve).
870
871**hero** — shared-element transition (el Hero de Flutter auténtico): un nodo
872con la misma `key` que aparece en otro rect en un frame posterior **vuela**
873del rect viejo al nuevo (el `HeroRegistry` del runtime interpola `transform`).
874`hero_view(key, child)` (480 ms) / `hero_quick(key, child)` (320 ms), o el
875primitive `View::hero(key, dur)` directo. Key estable y única entre los heroes
876de un mismo frame.
877
878**splitter** — divisor draggable de 2 panes. `splitter_two(Direction::{Row,Column},
879a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette)`.
880`PaneSize::{Fixed(px), Flex}`. La app acumula el delta en su Model.
881
882**scroll** — área de scroll vertical con barra arrastrable. `scroll_y(offset,
883content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette)`.
884Stateless (offset en el Model); rueda autocontenida. Helpers: `clamp_offset`,
885`ensure_visible` (selección a la vista), `approach` (scroll suave). Ver §8.
886
887**lazy-list** — lista **perezosa** llave-en-mano (Bloque 22 de
888`PLAN-NUCLEO-RETENIDO.md`). A diferencia de `list`/`grid` (el caller windowea a
889mano) y de `scroll_y` (recibe el contenido ya construido), recibe un **builder**
890`item: Fn(usize) -> View` + `total` y **sólo llama `item(i)` para la ventana
891visible + overscan** — las filas fuera de pantalla no se construyen, no se
892montan, no se layoutean (costo/frame ∝ lo visible, no ∝ el total). Sin estado
893(offset en el Model); reusa la barra/física de `scroll`.
894```rust
895lazy_list_fixed(total, item_h, offset, viewport_h, overscan,
896 item: Fn(usize)->View, on_scroll: Fn(f32)->Msg, &ScrollPalette) // extent uniforme, ventana O(1)
897lazy_list_varied(offsets: &[f32] /*len total+1, tops acumulados*/, offset, viewport_h,
898 overscan, item, on_scroll, &palette) // extent por fila, O(log n)
899// helpers puros: visible_window(total, item_h, offset, vp, overscan) -> Window{first,count,y0}
900// visible_window_varied(offsets, offset, vp, overscan), content_height(total, item_h)
901// altura MEDIDA (listas heterogéneas cuyo alto depende del contenido/reflow):
902item_offsets(total, ancho, item: Fn(usize)->View) -> Vec<f32> // mide cada ítem → offsets acumulados
903// el caller lo llama al cambiar contenido/ancho, cachea el Vec, y por frame usa
904// lazy_list_varied(&offsets, ...). Usa llimphi_compositor::measured_height(view, ancho).
905// SECCIONES con encabezado sticky llave-en-mano (agenda/contactos):
906lazy_list_sections(rows_por_seccion: &[usize], item_h, header_h, offset, viewport_h,
907 overscan, header: Fn(s)->View, item: Fn(s, fila_local)->View, on_scroll, &palette)
908// el header de la sección activa queda PINEADO al tope y la siguiente lo empuja;
909// darle fill opaco (pinta encima de las filas). Ventana O(#secciones + visibles).
910// puros: section_tops, sections_window -> Vec<SectionUnit>, sticky_section
911// re-exporta de scroll: clamp_offset, fling_step, rubber_band, sticky_y, DEFAULT_OVERSCAN, ScrollPalette
912```
913Demo: `cargo run -p llimphi-widget-lazy-list --example stress_lazy --release -- --nodes 1000000`.
914
915**tiled** — grilla auto cols×rows de tiles con title bar. `tiled_view(tiles, &palette)`,
916`tiled_view_cols(tiles, cols, &palette)`, y variantes `*_reorderable*` con
917`on_reorder: Fn(from, to)->Option<Msg>` (drag-to-swap por la title bar). `TileSpec { label, content }`.
918
919**panes** — árbol binario BSP tipo tmux. La app guarda un `Layout`:
920```rust
921Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second }
922layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves()
923panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec<Side>,DragPhase,delta)->Option<Msg>,
924 on_focus: Fn(PaneId)->Msg, &palette)
925```
926
927**dock-rail** — rail vertical de **dientes** para sidebars acoplables. Cada
928diente es una pestaña: **barra de acento de 3px pegada al borde interno**
929(encendida cuando está activo) + icono centrado. Apilados en una franja
930flotante que **sobresale del panel** hacia el centro — el patrón canónico
931de cosmos (`01_yachay/cosmos/cosmos-app-llimphi/src/chrome.rs`:
932`dock_rail_overlay` / `dock_panel_for`). Cada item se identifica por un `u64`
933opaco; el caller mantiene el orden, qué está activo, y el mapping
934`u64 ↔ DominioPropio` (típicamente vía `enum::{to_u64,from_u64}`). Cada
935diente es **arrastrable** (su id como payload) y el rail entero es **drop
936target** → mover un diente de un sidebar a otro soltándolo sobre el rail
937opuesto. Render-only, agnóstico del `Msg`.
938```rust
939DockRailItem { id: u64, active: bool }
940dock_rail_view(&items, width: f32, &DockRailPalette,
941 make_icon: Fn(id, size_px, color) -> View<Msg>,
942 on_activate: Fn(id) -> Msg, // dispara en el press, no pelea con el drag
943 on_drop: Fn(payload_id) -> Option<Msg>) // None = el rail no acepta drops
944```
945Layout canónico: el rail va como **overlay absoluto** pegado al borde interno
946del centro (los dientes sobresalen sobre la rueda/canvas), y el panel del
947item activo va al costado como un pane en el área resizable. NO es una lista
948con rótulo: leer cosmos antes de inventar UX (lección 2026-06-05).
949
950**toolbar** — barra de herramientas moderna: **grupos** de botones-ícono
951planos (hover redondeado; activo = fondo de selección + ícono en acento;
952deshabilitado atenuado y sin click) con separadores sutiles entre grupos.
953Los íconos los dibuja el caller vía closure (mismo contrato que dock-rail);
954los grupos son **datos** (`Vec<ToolbarGroup>`) → la barra es componible y
955configurable por construcción. Uso canónico:
956`nahual-shell-llimphi::shell_toolbar` (navegación + modos de vista + acciones).
957```rust
958ToolbarItem::new(icon: Fn(size_px, color) -> View<Msg>, on_click: Msg)
959 .with_label("subir").active(bool).enabled(bool)
960ToolbarGroup::new(vec![items...])
961toolbar_view(groups: Vec<ToolbarGroup<Msg>>, height: f32, &ToolbarPalette) -> View<Msg>
962```
963
964**grid** — grilla 2D virtualizada. `ventana_visible(total, vp_w, vp_h, scroll_fila,
965&metrics) -> VisibleWindow` para virtualizar, luego `grid_view(GridSpec { cells:
966Vec<GridCell { content, label, selected, on_click }>, cols, metrics, caption, ... })`.
967
968**list** — lista vertical virtualizada. `list_view(ListSpec { rows: Vec<ListRow {
969label, selected, on_click }>, total, caption, truncated_hint, row_height, palette })`.
970La app prefiltra las filas visibles.
971
972**tree** — árbol expand/collapse. `tree_view(TreeSpec { rows: Vec<TreeRow { label,
973depth, has_children, expanded, selected, on_toggle, on_select }>, row_height,
974indent_px, palette })`. La app aplana el árbol según nodos expandidos.
975
976**navigator** — navegador data-agnóstico de nodos en dos modos conmutables
977(**árbol** ↔ **grafo**, reusa tree + nodegraph). Render-only: la app guarda
978`expanded`/`selected`/`mode`. Pasa un bosque de `NavNode { id: u64, label,
979kind: NavKind (Monad|Group|Dir|File|Other), children }` y callbacks por id.
980```rust
981navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides },
982 is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg,
983 on_select: Fn(u64)->Msg, on_context: Option<Fn(u64)->Msg>)
984// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
985// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
986// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.
987```
988
989**app-header** — `app_header(label, actions: Vec<View<Msg>>, &palette)`.
990
991**status-bar** — `status_bar_view(left, center, right, &palette)` con
992`StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized()`.
993
994**breadcrumb** — `breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)`
995(el último segmento no es clickable).
996
997**modal** — diálogo centrado con scrim. `modal_view(ModalSpec { title, body:
998View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette })`.
999`ModalButton::{primary, cancel, destructive}(label, msg)`. Se monta en `view_overlay`.
1000
1001**toast** — notificaciones efímeras bottom-right. La app guarda `Vec<Toast>`
1002(`Toast::{info,success,warning,error}(id, text, duration)`), filtra
1003`is_alive(now)`, y `toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg)`.
1004
1005**splash** — splash de arranque (cuatro cuadrantes andinos). `splash_view(started_at:
1006Instant, bg, fg_text)`; basado en tiempo, requiere redraws.
1007
1008### Ricos / interactivos
1009
1010**nodegraph** — lienzo de nodos + cables Bezier. Sin estado (la app guarda
1011posiciones y `Wire`s).
1012```rust
1013NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
1014Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
1015nodegraph_view(&nodes, &wires, &palette, &metrics,
1016 on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
1017 on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
1018// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)
1019```
1020
1021**timeline** — scrub clickeable. `timeline_view(progress: f32, &palette,
1022on_seek: Fn(f32 [0..1])->Option<Msg>)`.
1023
1024**transport** — botones de **transporte de reproductor** (play/pause/prev/
1025next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq). Stateless:
1026el caller arma un `TransportButton` por cada botón con el flag que define su
1027estado activo (p.ej. `PlayPause { playing }`, `Mute { muted }`,
1028`Record { recording }`), lo pasa al widget, y recibe el `TransportAction`
1029semántico en un closure cuando el usuario clickea. El widget elige el icono
1030(de `llimphi-icons`), el color activo, el rojo de REC y arma la acción
1031(p.ej. `SeekBack { secs }` → `TransportAction::SeekBy(-secs)`). Quien
1032traduce `TransportAction` al comando del dominio (`MediaCommand`, etc.) es
1033la app.
1034```rust
1035TransportPalette { bg, bg_active, bg_hover, fg, fg_active, fg_record,
1036 btn_w, btn_h, radius, icon_stroke, gap }
1037TransportPalette::from_theme(&Theme)
1038TransportAction::{TogglePlay, Stop, Prev, Next, SeekBy(i64), VolumeBy(f32),
1039 ToggleMute, CycleRepeat, ToggleShuffle, SpeedStep(i32),
1040 SpeedReset, Snapshot, ToggleRecord, ToggleEqualizer}
1041TransportButton::{PlayPause{playing}, Stop, Prev, Next,
1042 SeekBack{secs}, SeekForward{secs},
1043 VolumeDown{step}, VolumeUp{step},
1044 Mute{muted}, Repeat{active}, Shuffle{active},
1045 SpeedDown, SpeedUp, SpeedReset{is_default},
1046 Snapshot, Record{recording}, Equalizer{enabled}}
1047transport_button_view(button, &palette, on_action: Fn(TransportAction)->Msg) -> View<Msg>
1048```
1049Para una fila, el caller mapea su `Vec<TransportButton>` con
1050`.iter().map(|b| transport_button_view(*b, &pal, on_action.clone()))` y los
1051pone como children de una `View` con `flex_direction: Row` y `gap:
1052palette.gap`. Consumidor de referencia: `02_ruway/media/media-app::bar_item_view`.
1053
1054**waveform** — visor de **forma de onda en vivo**. Stateless y agnóstico de
1055cpal/AudioProbe: el caller pasa un closure `Fn(&mut Vec<f32>) -> u16` que
1056rellena un buffer con los últimos samples intercalados y devuelve cuántos
1057**canales** trae; el widget hace el fold a mono y dibuja un **envelope
1058min/max por columna** (polígono cerrado con relleno tenue + stroke top/bot)
1059sobre una línea central que siempre está como "ground" — paint-only, sin
1060mouse. Si el closure devuelve `0` canales, el widget pinta sólo la línea
1061central ("visor vivo, sin señal").
1062```rust
1063WaveformPalette { bg, center, stroke, fill, radius, pad_x, pad_y, stroke_w }
1064WaveformPalette::from_theme(&Theme) // bg=bg_panel_alt, stroke=accent, fill=accent@α70
1065waveform_view(source: Fn(&mut Vec<f32>) -> u16 + Send + Sync + 'static,
1066 palette: &WaveformPalette) -> View<Msg>
1067```
1068Consumidor de referencia: `02_ruway/media/media-app::waveform_panel` (cablea
1069un `AudioProbe::snapshot`).
1070
1071**table** — tabla **editable** de celdas-texto: filas/columnas + agregar/quitar
1072fila + foco por celda. Stateless — la app posee el foco y el buffer de la
1073celda en edición; el resto se pinta desde `&rows`. Pensado para datos planos
1074editables (paramétricos, scratchpad, listas curables); no es la `list`/`tree`
1075virtualizada read-only ni un grid de hoja de cálculo.
1076```rust
1077table_view(&[String], headers, // [] = sin header
1078 &[Vec<String>] rows,
1079 focused: Option<(row, col)>,
1080 focused_state: Option<&TextInputState>, // buffer prestado de la celda en edición
1081 add_label: &str, &TablePalette,
1082 on_focus_cell: Fn(row, col) -> Msg, // clic en celda
1083 on_remove_row: Fn(row) -> Msg,
1084 on_add_row: Fn() -> Msg)
1085list_view(&[String], items, focused_row, focused_state, // sola columna sin header
1086 add_label, &palette, on_focus_cell: Fn(row)->Msg, on_remove_row, on_add_row)
1087table_height(n_rows, has_header) -> f32 // pre-calcular alto del bloque
1088```
1089La variante `list_view` no se confunde con la `list` virtualizada de §13:
1090ésta es **una sola columna editable** que delega en `table_view([], ..)`.
1091
1092**text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda
1093`EditorState`:
1094```rust
1095EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo();
1096.add_cursor_at(line,col); .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult;
1097.ensure_caret_visible(visible_lines)
1098// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option<Msg>
1099text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer)
1100text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer)
1101text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer)
1102syntax_palette_dark(&theme) -> SyntaxPalette // en lib.rs del widget
1103```
1104
1105**text-editor-core** — núcleo **agnóstico** (sin GPU, sin Llimphi; sólo
1106`peniko::Color`). Reutilizable en TUI/web/headless. Tipos clave:
1107- `Buffer` (sobre `ropey`): `from_str`, `text`, `insert(offset,s)`, `delete(s,e)`,
1108 `offset_to_pos`, `pos_to_offset`, `slice`, `line(n)`.
1109- `Pos { line, col }`, `Selection { anchor, caret }`, `Cursor { caret, anchor:
1110 Option, desired_col }` con `move_left/right/up/down/word_left/...`,
1111 `selection_range(&buf)`, `collapse`.
1112- Ops: `replace_selection`, `delete_backward/forward`, `indent_or_insert_tab`,
1113 `insert_newline_auto_indent` → devuelven `EditDelta { start, removed, inserted,
1114 cursor_before, cursor_after }` con `.apply()/.undo()`.
1115- `UndoStack`: `push(delta)`, `undo/redo(&mut buf, &mut cursor) -> bool`, `can_undo/redo`.
1116- `FindState { query, case_sensitive }`: `all_matches`, `find_next`, `find_prev`.
1117- Matching de brackets: `find_bracket_pair(&buf, &cursor) -> Option<(Pos, Pos)>`, `Direction`.
1118- `Clipboard` (trait `get/set`), `MemClipboard`, `NullClipboard`.
1119- `Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity,
1120 message: String, source: Option<String> }` (+ ctors `error(..)`, `warning(..)`);
1121 `Severity::{Error, Warning, Information, Hint}`.
1122- Highlight tree-sitter: `Language::{Plain, Rust, Python, Wat}`
1123 (+ `Language::from_cell_language(s)`); `Highlighter::new(lang)` con
1124 `.highlight(&mut self, source: &str) -> Vec<Vec<Span>>` (un `Vec<Span>` **por
1125 línea**), `.set_language(lang)`, `.language()`; helpers de módulo
1126 `invalidate_tree_cache(lang)` y `apply_pending_edits(lang, &edits)` para el
1127 caché incremental. `TokenKind`, `Span`, `SyntaxPalette::color(kind)`.
1128
1129**text-editor-lsp** — cliente LSP por stdin/stdout. `trait LspClient` (fire-and-forget
1130`request_*` + lecturas de caché `latest_*`/`clear_*`): completions, hover,
1131definition, references, rename, formatting, signature help, document symbols.
1132`RustAnalyzerClient::start(workspace_root)`; `NoopLspClient` para tests.
1133
1134**terminal** — superficie de **scrollback infinita virtualizada** (modo línea /
1135bloques / grilla GPU). Reemplaza al `output_pane` clásico: el costo de render
1136es **constante** a scrollback ilimitado — sólo se materializa la ventana visible
1137— bajo un `scroll_y` **propio del widget** (no `transform` del padre, la
1138anti-feature del SDD). Núcleo agnóstico, el caller arma el modelo y el widget
1139pinta (Regla 2). Diseño completo: `02_ruway/shuma/SDD-TERMINAL.md`. Consumidor
1140de referencia: `shuma-module-shell::output_pane_surface`.
1141- **Store** (`Scrollback::new(limit_bytes)`): append-only por línea con índice
1142 de offsets O(1), cap por memoria con recorte de frente, `line_id` global
1143 **estable** que sobrevive al recorte (`index_of_id`), `push_line`,
1144 `slice_text`, `clear`. `dropped()` / `total_pushed()` para el `numbering`
1145 global.
1146- **Spill a disco** (`SpillStore::create(path)` + `Scrollback::enable_spill(spill)`):
1147 cada línea recortada del frente se appendea al archive; lookup O(1) con
1148 `read_spilled(global_id)` + helpers `has_spill`/`spilled_count`/`spill_path`
1149 para que el host exponga UX (chip de status, builtin `:scrollback open`,
1150 prepend del archive al view).
1151- **Modo línea** (`line_surface(store, scroll_y, viewport_h, metrics, &palette,
1152 line_style: Fn(usize, &str) -> LineStyle, on_scroll: Fn(f32) -> Msg, measure:
1153 Option<Arc<Mutex<f32>>>)`): materializa sólo la ventana visible; scroll
1154 sub-renglón (`partial_px`), numeración global 1-based, color base + runs +
1155 tinte de fondo inyectados por el caller en `LineStyle`. Helpers puros:
1156 `visible_window`, `content_height`, `scroll_to_bottom`. `measure` opcional
1157 publica el `viewport_h` real medido al host (regla del SDD: verificar con
1158 viewport real).
1159- **Modo bloques** (`block_surface(store, items: Vec<Item<Msg>>, ...)`): el
1160 stream es una secuencia de `Item::chrome(height, view)` (header/badge/etapa
1161 de alto fijo que el caller pinta) + `Item::lines(start, end)` (rango del
1162 store). Virtualiza sobre alturas mixtas con búsqueda binaria (O(log n) en
1163 bloques) — dentro de un `Lines` enorme sólo se materializan las sub-filas
1164 visibles. Colapsar = no emitir el `Lines`. Helpers: `blocks_height`,
1165 `blocks_scroll_to_bottom`, `gutter_width(&store, metrics)`,
1166 `line_top_in_content(&items_geo, row_h, target_line)`.
1167- **Selección + copy** (`block_surface_with_selection` + `SelectionConfig { range:
1168 Option<&SelectionRange>, on_drag: Fn(DragPhase, lx0, ly0, dx, dy) -> Option<Msg>,
1169 on_double_click: Fn(lx, ly, w, h) -> Option<Msg> }`): el widget pinta el
1170 overlay translúcido y dispara callbacks; el caller mantiene el rango
1171 (`SelectionRange { anchor: Point, head: Point }`, `Point { line, col }` con
1172 `col` en **bytes** UTF-8) en su Model y resuelve clicks con `point_at(items,
1173 scroll_y, viewport_h, metrics, gutter_w, store, lx, ly)` —o su variante
1174 `Copy`-stashable `point_at_geo(&[ItemGeo], ...)` para resolver el frame
1175 anterior. `selection_rects` para overlays custom.
1176- **Find** (`find_matches(&store, query, FindOpts { case_insensitive })`):
1177 búsqueda sobre el scrollback **completo** (no sólo el viewport), O(total_bytes);
1178 `FindMatch { line, start, end }` con `start`/`end` en bytes UTF-8 slice-safe.
1179 Nav: `next_match(&matches, current)` / `prev_match`. El caller resuelve el
1180 `line` a su `scroll_y` con `line_top_in_content`.
1181- **Grilla GPU** (modo TUI, Fase 4): `CellPipeline::new(device, format)` +
1182 `GlyphAtlas` rasterizan cada celda con **quads instanciados** y atlas de
1183 glifos (precedente: `atlas` de wawa, Fontdue) — una sola draw call por
1184 viewport, escala a 200×80 sin penalty. Tipos: `CellInstance { cell_x/y,
1185 uv_x/y/w/h, fg_rgba, bg_rgba }` (POD 32 B), `CellUniforms`, `pack_rgba`,
1186 `instances_to_bytes`, `CELL_WGSL`, `CellPipeline::create_atlas_texture`.
1187 Wireado al widget contenedor vía `gpu_paint_with`. Activo en shuma con
1188 `SHUMA_GPU_GRID=1`.
1189
1190Headless dumps:
1191```bash
1192cargo run -p llimphi-widget-terminal --example dump_terminal # modo línea, 1M renglones
1193cargo run -p llimphi-widget-terminal --example dump_blocks # bloques + flood 500k
1194```
1195
1196**clipboard** — portapapeles del sistema vía `arboard`. `SystemClipboard::new()`,
1197`is_available()`, impl `Clipboard`. No-op silencioso si no hay display (CI headless).
1198
1199**menubar** — barra de menú mac-style. `menubar_view(&MenuBarSpec { menu: &AppMenu,
1200open: Option<usize>, theme, viewport, height, on_open: Fn(Option<usize>)->Msg,
1201on_command: Fn(&str)->Msg })`; dropdown en `view_overlay` con `menubar_overlay(spec)`
1202o `menubar_overlay_animated(spec, active, appear)`. Navegación por teclado:
1203`menubar_nav`, `menubar_command_at`.
1204
1205**edit-menu** — menú estándar de edición sobre un editor.
1206`EditFlags::from_editor(&state, masked)`, `edit_context_menu(anchor, viewport,
1207&theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss)` →
1208`ContextMenuSpec`. `apply(&mut state, EditAction, &mut clipboard) -> ApplyResult`.
1209`EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}`.
1210
1211**context-menu** — menú contextual genérico (panel elevado con sombra + borde +
1212esquinas redondeadas). `ContextMenuItem::action(label).with_shortcut(..).icon(..)
1213.checked(bool).disabled().destructive().submenu(children)` o `::separator()`
1214(`checked` pinta ✓ en el gutter y alterna estado). `context_menu_view(ContextMenuSpec
1215{ anchor, viewport, header, items, active, on_pick: Fn(usize)->Msg, on_dismiss,
1216palette })`; `context_menu_view_ex` con submenús (flyout que **flipea** a la izq. si
1217no cabe) + animación de aparición. Se monta en `view_overlay` con scrim. **Teclado
1218(helpers puros que el host cablea):** `step_active(items, cur, ±1)` (↑/↓, saltea
1219sep/disabled), `edge_active(items, to_end)` (Inicio/Fin), `type_ahead(items, cur, ch)`
1220(mnemónico por 1ª letra). Submenú por teclado = `step_active` sobre `parent.children`.
1221Demo completo: `cargo run -p llimphi-widget-context-menu --example menu_demo`.
1222
1223**theme-switcher** — `theme_switcher_view(¤t: &Theme, on_change: Fn(Theme)->Msg)`
1224(+ `_styled`/`_flex`). Cicla `Theme::next_after`.
1225
1226**shortcuts-help** — overlay "?" con atajos agrupados. `shortcuts_help_view(
1227ShortcutsHelpSpec { title, groups: Vec<ShortcutGroup { title, entries:
1228Vec<ShortcutEntry { keys, description }> }>, viewport, on_dismiss, palette })`.
1229
1230**wawa-mark** — sello vectorial del SO wawa. `wawa_mark_view(&WawaMarkPalette)`;
1231`paint_mark(scene, rect, &palette)` para canvas custom. Usar en contenedor cuadrado.
1232
1233---
1234
1235## 14. Catálogo de módulos
1236
1237Los módulos encapsulan **estado + comportamiento** (a diferencia de los widgets).
1238Todos siguen el mismo contrato:
1239
1240```
1241State + Msg + Action + apply(state, msg, ...) -> Action
1242 + on_key(state, &KeyEvent) -> Option<Msg>
1243 + open_shortcut(&KeyEvent) -> bool
1244 + view(state, ..., to_host: F) -> View<HostMsg>
1245 + Palette
1246```
1247
1248La app guarda `Option<ModuleState>` (o el state directo, p. ej. bookmarks),
1249rutea el atajo de apertura con `open_shortcut`, rutea teclas con `on_key`, aplica
1250`Msg`s con `apply`, y monta el `view` pasando un mapeo `to_host: Fn(ModuleMsg) ->
1251HostMsg`. Cuando `apply` devuelve una `Action` (p. ej. `Invoke(id)`, `OpenAt{..}`,
1252`GoTo{..}`), la app ejecuta el efecto. Crates en `modules/<nombre>/`.
1253
1254| Módulo | Atajo | Acción que devuelve | Propósito |
1255|---|---|---|---|
1256| **command-palette** | `Ctrl+Shift+P` | `Invoke(String)` | paleta de comandos fuzzy. El host declara `&[Command]` |
1257| **file-picker** | `Ctrl+P` | `Open(PathBuf)` | fuzzy file picker; host pasa `&[PathBuf]` + `root` |
1258| **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 |
1259| **diff-viewer** | `Ctrl+Shift+D` | — | diff side-by-side. `DiffState::new(before_label, after_label, before, after)` computa con `similar` |
1260| **mini-map** | `Ctrl+Shift+M` | `JumpTo(line)` | minimapa del buffer; agnóstico del editor (recibe `Snapshot`) |
1261| **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) |
1262| **symbol-outline** | `Ctrl+Shift+O` | `GoTo{line,col}` | outline de símbolos; host arma `Vec<SymbolItem>` (LSP/tree-sitter/custom) |
1263| **selector** | — | — | abstracción portátil abrir/guardar: `trait Selector` (`HostSelector` con PathBuf, `WawaSelector` placeholder content-addressed) |
1264| **plugin-host** | — | `OpenAt{..}`, `SetStatus(..)` | runtime WASM (wasmi) con permisos por bitfield; `PluginHost::load_from_dir`/`invoke(id, cap, args)` |
1265| **shuma-term** | `` Ctrl+` `` | `SetStatus(..)` | terminal integrada. `spawn(cwd)` lanza PTY (`shuma_exec`), `vt100::Parser` renderiza; `Tick` drena el PTY |
1266
1267Patrón típico de integración (command-palette):
1268```rust
1269struct Model { palette: Option<PaletteState>, commands: Vec<Command>, /* … */ }
1270enum Msg { Palette(PaletteMsg), /* … */ }
1271
1272// on_key:
1273if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); }
1274if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); }
1275
1276// update:
1277Msg::Palette(m) => {
1278 if let Some(state) = model.palette.as_mut() {
1279 match command_palette::apply(state, m, &model.commands) {
1280 PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; }
1281 PaletteAction::Close => model.palette = None,
1282 PaletteAction::None => {}
1283 }
1284 }
1285}
1286
1287// view_overlay:
1288model.palette.as_ref().map(|s|
1289 command_palette::view(s, &model.commands, &palette, Msg::Palette))
1290```
1291
1292---
1293
1294## 15. `llimphi-workspace` — chasis tipo tmux
1295
1296Monta cualquier componente en un layout intercambiable con splits resizables
1297(máquina de estados de foco/split/cierre + chrome estándar). Construido sobre
1298`llimphi-widget-panes`.
1299
1300```rust
1301Workspace::new()
1302 .focused() -> PaneId .count() .leaves() -> Vec<PaneId> .layout() -> &Layout
1303 .focus(id) .split(Axis) -> PaneId .close() -> Option<PaneId> .resize(&path, delta)
1304 .apply(WsMsg) -> WsEffect
1305
1306enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec<Side>, f32) }
1307enum WsEffect { None, Created(PaneId), Closed(PaneId) }
1308
1309workspace_view(&ws, &WorkspacePalette,
1310 leaf: FnMut(PaneId)->View<Host>, // materializa el contenido de cada panel
1311 lift: Fn(WsMsg)->Host) // sube los Msg del chasis a tu Msg
1312```
1313
1314Patrón: `enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }`. En `update`,
1315`ws.apply(msg)` te avisa con `WsEffect::{Created,Closed}(id)` para que crees o
1316destruyas el estado del panel correspondiente.
1317
1318---
1319
1320## 16. Reglas duras y gotchas
1321
1322### 🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE)
1323Ningún `update`/`init`/handler puede ejecutar trabajo **síncrono** pesado
1324(efemérides, simulación, IO, parse, embeddings, layout de árboles grandes).
1325Bloquea el hilo → "Not Responding". **`init` corre dentro de `resumed`, después
1326de crear la ventana**, así que un cómputo pesado ahí ya congela una ventana
1327visible.
1328
1329Patrón (referencia: `cosmos-app-llimphi`):
1330```rust
1331// Model: Option<Resultado> (None = "calculando…") + flag dirty + contador de generación.
1332struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64 }
1333enum Msg { XComputed(u64, Arc<Resultado>) }
1334
1335// al FINAL de update() (que tiene el Handle):
1336if m.x_dirty {
1337 m.x_dirty = false;
1338 m.x_gen = m.x_gen.wrapping_add(1);
1339 let (gen, input) = (m.x_gen, m.input.clone()); // sólo lo que el worker necesita (Send)
1340 handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input))));
1341}
1342// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un
1343// resultado tardío pise a uno más nuevo en drags/toggles rápidos).
1344Msg::XComputed(gen, x) => if gen == m.x_gen {
1345 m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
1346}
1347```
1348La **generación** es imprescindible si el recálculo se dispara seguido. Ver
1349[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) y su checklist por app.
1350
1351### Otras
1352- **Solvers iterativos** (Newton/bisección): cota dura `for _ in 0..N`, nunca
1353 `loop {}` con corte pegado al epsilon de f64 — en debug no converge → loop
1354 infinito.
1355- **Backend GPU**: preferir Vulkan (`Backends::PRIMARY`); el GL de Mesa sobre
1356 Wayland segfaultea en el teardown. Ya está hecho en `Hal::new`, no revertir.
1357- **Un nodo es draggable o clickable**, no ambos.
1358- **`alpha` y `clip`** crean capas intermedias: tienen costo, usar sólo cuando
1359 hace falta.
1360- **`paint_with`** no debe dejar `push_layer` sin `pop_layer` ni resetear la
1361 Scene.
1362- **Hit-test respeta `.transform()`**: un nodo rotado/escalado/trasladado recibe
1363 los clicks donde se ve pintado (el runtime invierte el afín acumulado). Lo que
1364 **no** se ajusta todavía: la posición local que reciben los handlers `*_at` se
1365 reporta en coords de pantalla, no en el espacio local del nodo transformado.
1366- **GPUI está extinto**: no agregar dependencias ni código GPUI (regla §3 de
1367 `CLAUDE.md`).
1368- **Texto en regla pesada**: crear un `Typesetter` por frame es caro
1369 (`FontContext::new` enumera fuentes del sistema). El runtime ya cachea uno y lo
1370 pasa a `paint_with`.
1371
1372---
1373
1374## 16.bis Tests de regresión visual — `llimphi-test` (golden-image)
1375
1376Para certificar que un widget/pantalla **no cambió de aspecto** sin renderizar-y-
1377mirar a mano (CLAUDE.md §8), usar el arnés `llimphi-test`. Renderiza un `View`
1378headless por el adapter de **software** (llvmpipe/lavapipe → mismo píxel host-a-
1379host y en CI) y lo diffea contra un golden con tolerancia perceptual.
1380
1381```rust
1382// en tests/<algo>.rs de tu crate
1383use llimphi_test::assert_golden;
1384
1385#[test]
1386fn mi_card() {
1387 let v = mi_widget::card(/* … */);
1388 assert_golden!("mi_card", v, 400, 240); // vs tests/goldens/mi_card.png
1389}
1390```
1391
1392- **Generar/actualizar goldens:** `LLIMPHI_GOLDEN_UPDATE=1 cargo test -p <crate>`
1393 (escribe los PNG en `tests/goldens/`; commitéalos).
1394- **En fallo:** escribe `mi_card.actual.png` + `mi_card.diff.png` (heatmap) al lado
1395 del golden y paniquea con `DiffStats { differing_pixels, max_delta, fraction }`
1396 — la stat numérica **es** la evidencia, no hace falta abrir el PNG.
1397- **Tolerancia:** `GoldenConfig { max_channel_delta, max_diff_fraction }` (default
1398 casi bit-exacto). Para control fino: `Harness::render(...)` + `diff(...)` +
1399 `compare_golden(path, &rendered, &cfg)` sueltos.
1400- **Determinismo:** el adapter de software es bit-exacto (el self-test
1401 `readback_es_determinista` verifica 0px entre dos renders). Si la máquina no
1402 tiene lavapipe, cae al adapter normal y la tolerancia absorbe la diferencia.
1403- **Path GPU-directo** (`GpuBatch`/`gpu_paint_with`, que no pasa por vello):
1404 `Harness::render_gpu(w, h, bg, |device, queue, encoder, view, vp| { … })` —
1405 misma firma que `View::gpu_paint_with`; limpia a `bg` y te deja pintar directo,
1406 después lee de vuelta para el golden (ver `llimphi-test/tests/gpu_golden.rs`).
1407
1408---
1409
1410## 17. Comandos y demos
1411
1412```bash
1413cargo check --workspace # smoke test mínimo (debe pasar siempre)
1414cargo run -p <crate> --release # correr una app
1415cargo run -p <crate> --example <demo> --release # correr un demo
1416
1417# demos del propio framework:
1418cargo run -p llimphi-ui --example counter --release # bucle Elm completo
1419cargo run -p llimphi-ui --example editor --release # text field + teclado
1420cargo run -p llimphi-ui --example gpu_paint_demo --release
1421cargo run -p llimphi-gallery --release # showcase de TODO el kit
1422cargo run -p nada --release # editor real para ejercitar widgets
1423
1424# benchmark GPU directo vs vello:
1425cargo run -p llimphi-gpu-bench --release
1426
1427# perfilador por frame (a stderr cada 60 frames: media/p95 por etapa):
1428LLIMPHI_PERF=1 cargo run -p <crate> --release
1429# ...con conteo de asignaciones de heap por frame y su desglose por etapa:
1430LLIMPHI_PERF=1 cargo run -p <crate> --release --features llimphi-ui/perf-alloc
1431```
1432
1433`LLIMPHI_PERF=N` reporta cada N frames (default 60) el desglose
1434`view/mount/layout/paint/raster/gpu/present` en µs + `layout-reuso`, el caché de
1435`repaint_boundary` y —si está la feature `perf-alloc`— `alloc <media>/<p95>
1436(<KB>) [view … mount … layout … paint …]`. Sin esa feature el tramo de allocs
1437dice `n/a` en vez de ceros falsos. El contador es
1438`llimphi_ui::alloc::ContadorAlloc`: si tu app ya elige su allocator, instalalo a
1439mano (`#[global_allocator]`) en vez de usar la feature. El camino `cache_hit`
1440(frame retenido entero) **no** se muestrea a propósito.
1441
1442`llimphi-gallery` (`src/main.rs`, ~967 líneas) es la **referencia viva** del
1443patrón completo: `Model`/`Msg`/`init`/`update`/`view`/`view_overlay` con overlays
1444mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown).
1445Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; `?` abre
1446atajos; `Esc` cierra el overlay activo.
1447
1448---
1449
1450## 18. Cheat-sheet
1451
1452```rust
1453// ── App mínima ──────────────────────────────────────────────
1454impl App for X { type Model; type Msg; init; update; view; }
1455llimphi_ui::run::<X>();
1456
1457// ── Nodo ────────────────────────────────────────────────────
1458View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
1459 .fill(c).fill_gradient(g).hover_fill(c).radius(r).radius_corners(tl,tr,br,bl).shadow(sh).border(w,c).clip(b).alpha(a).transform(xf).animated(key,dur)
1460 .text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al) | .text_weight(w) | .bold() | .ellipsis(n) | .max_lines(n)
1461 .image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{})
1462 .on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m)
1463 .on_pointer_enter(m) | .on_pointer_leave(m)
1464 .draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|)
1465 .drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c)
1466 .children(vec![..])
1467
1468// ── Efectos ─────────────────────────────────────────────────
1469handle.spawn(|| Msg::Done(compute())); // worker → reentra al update
1470handle.spawn_periodic(dur, || Msg::Tick); // feed periódico
1471handle.dispatch(Msg::X); handle.quit();
1472
1473// ── Estilo de layout (taffy prelude) ────────────────────────
1474length(px) percent(0..1) Dimension::auto()
1475FlexDirection::{Row,Column} AlignItems::{Start,Center,End,Stretch}
1476JustifyContent::{Start,Center,End,SpaceBetween}
1477
1478// ── Theme ───────────────────────────────────────────────────
1479Theme::dark()/light()/aurora()/sunset(); Theme::next_after(name); XxxPalette::from_theme(&t)
1480
1481// ── Overlay (menús/modales) ─────────────────────────────────
1482fn view_overlay(m) -> Option<View<Msg>> { if m.open { Some(menu) } else { None } }
1483```
1484
1485---
1486
1487## 19. Índice de crates
1488
1489**Framework** (`02_ruway/llimphi/`):
1490`llimphi-hal` · `llimphi-raster` · `llimphi-text` · `llimphi-layout` ·
1491`llimphi-compositor` · `llimphi-ui` · `llimphi-theme` · `llimphi-motion` ·
1492`llimphi-icons` · `llimphi-surface` · `llimphi-workspace` · `llimphi-gallery` ·
1493`llimphi-gpu-bench`.
1494
1495**Widgets** (`widgets/`, dep `llimphi-widget-<n>`): app-header · avatar · badge ·
1496banner · breadcrumb · button · card · clipboard · color-picker · context-menu ·
1497dock-rail · edit-menu · empty · field · gallery · grid · lazy-list · list · menubar · modal ·
1498navigator · nodegraph · panel · panes · progress · segmented · select ·
1499shortcuts-help · skeleton · slider · splash · splitter · stat-card · status-bar ·
1500switch · table · tabs · terminal · text-area · text-editor · text-editor-core ·
1501text-editor-lsp · text-input · theme-switcher · tiled · timeline · toast ·
1502toolbar · tooltip · transport · tree · waveform · wawa-mark.
1503
1504**Módulos** (`modules/`): bookmarks · command-palette · diff-viewer · fif ·
1505file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline.
1506
1507**Android** (`android/`): clear-screen-android · vello-hello-android ·
1508vello-text-android.
1509
1510---
1511
1512> Documentos hermanos: [`SDD.md`](SDD.md) (diseño y roadmap),
1513> [`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) (regla de
1514> concurrencia), [`README.md`](README.md) / [`LEEME.md`](LEEME.md) (overview).
1515> Las firmas de este manual reflejan el código al 2026-06-01; ante divergencia,
1516> la fuente autoritativa es el `lib.rs` de cada crate.
MANUAL.md
Manual de Llimphi
Motor gráfico soberano de tawasuyu. 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: declaras 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í escribes 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: 70 widgets (visuales) + 11 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; guardas `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
pub fn for_test_with<F: Fn(Msg) + Send + Sync + 'static>(sink: F) -> Self; // tests que SÍ quieren los Msg
}
-
on_modifiers (hook del trait App) — el WindowEvent::ModifiersChanged.
Existe porque los handlers de click llevan un Msg plano y no reciben los
modificadores: para hacer Shift+click (rango) o Ctrl+click (aditivo) en una
lista, la app guarda el estado en su modelo desde este hook y lo consulta en
el update del click. Devolvé None cuando el cambio no te importa (si no,
cada roce de Shift dispara un update y un repintado).
-
for_test vs for_test_with — for_test descarta los Msg; ojo con
eso: la closure de un spawn sí se ejecuta (para no perder sus efectos),
sólo se tira el mensaje. Un test que necesitaba el resultado terminaba
volviendo a hacer el trabajo a mano → el mismo job dos veces, en paralelo, y
carreras intermitentes sobre el disco. for_test_with entrega cada Msg a un
sink (típicamente el Sender de un canal, detrás de un Mutex porque el sink
pide Sync); el test lo drena y lo realimenta a update, que es lo que hace
el runtime real. Con eso el trabajo corre una sola vez y el test es
determinista.
-
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 |
.fill_gradient(Gradient) | relleno con gradiente (autoreado en el cuadrado unidad [0,1]², mapeado al rect). Gana sobre fill; hover_fill lo overridea en hover |
.hover_fill(Color) | color al pasar el cursor (habilita hit-test de hover) |
.radius(f64) | esquinas redondeadas (radio uniforme) |
.radius_corners(tl,tr,br,bl) | radio por esquina (CSS border-radius 4 valores); override de .radius. El borde sigue las 4 esquinas; la sombra usa el radio escalar |
.shadow(Shadow) | drop shadow (vello draw_blurred_rounded_rect). Shadow::soft(alpha,blur) + .offset(dx,dy)/.spread(s) |
.border(width, Color) | stroke sobre el contorno redondeado, inset hacia adentro (border-box) |
.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%) |
.animated(key, Duration) | animación implícita estilo Flutter AnimatedContainer: si fill/radius cambian entre frames, el runtime interpola (ease-out cúbico) en vez de saltar. key estable entre rebuilds. .animated_curve(key,dur,fn) para otra curva |
.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)
.text_weight(f32) // peso CSS: 400 normal, 600 semibold, 700 bold
.bold() // atajo de text_weight(700.0)
.ellipsis(n) // clampa a n líneas terminando en … (n=1 = single-line)
.max_lines(n) // clampa a n líneas sin glifo (corte seco)
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)
.on_scroll_take(|dx, dy| -> (Option<Msg>, rx, ry)) // reparto PARCIAL: consume lo que puede y el
// sobrante sigue al ancestro (mismo evento);
// scroll_y/lazy_list ya lo usan (scroll_take)
.on_scale(|phase: GesturePhase, factor, fx, fy| -> Option<Msg>) // pinch-to-zoom (Ctrl+rueda / trackpad)
.on_double_tap(Msg) / .on_double_tap_at(|lx, ly, w, h| ...) // dos clicks rápidos y cercanos
.on_long_press(Msg) / .on_long_press_at(|lx, ly, w, h| ...) // mantener ~500 ms quieto
.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).
- Gestos (
on_scale/on_double_tap/on_long_press) son aditivos: se
resuelven con su propio hit-test y no interfieren con on_click/draggable
del mismo nodo. El caso limpio (sin disparos cruzados) es ponerlos en un nodo
que no tenga on_click — p. ej. un canvas con draggable (pan) +
on_scale (zoom) + on_long_press (marca). GesturePhase = Begin/Update/
End; en on_scale, factor es multiplicativo incremental (>1 agranda)
y (fx, fy) el focal local — Ctrl+rueda lo sintetiza en cualquier desktop
(Wayland/Windows no emiten el pinch del trackpad; macOS sí, vía PinchGesture).
El long-press lo arbitra el tiempo (~500 ms quieto); moverse (>8px) o soltar lo
cancela. Demo completo: cargo run -p llimphi-ui --example gestos --release.
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 tocas 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 |
| Zoom de canvas (pinch) | .on_scale(|phase,factor,fx,fy| ...) → zoom *= factor, reajustar pan al focal |
| Doble-click | .on_double_tap(Msg) / .on_double_tap_at(|lx,ly,w,h| ...) |
| Long-press (mantener) | .on_long_press(Msg) / .on_long_press_at(|lx,ly,w,h| ...) |
| 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 hacia un
objetivo (driveado por Handle::spawn_periodic).
Scroll 2D / física / slivers (Tier 5, mismo widget, todo stateless):
scroll_xy(offset:(x,y), content_size:(w,h), viewport_size:(w,h), content, on_scroll:(dx,dy)→Msg, &palette) — dos ejes, una barra por eje con overflow.
- Inercia (fling):
fling_step(velocity, dt, friction) → (v', delta) +
fling_settled(v) — suelta con velocidad y avanza el offset por frame con el
ticker (spawn_periodic); FLING_FRICTION/FLING_STOP defaults. Bounce:
rubber_band(overscroll, dim) amortigua el desplazamiento más allá del tope.
- Sliver app-bar colapsable:
sliver_app_bar(offset, header_max, header_min, header(frac)→View, content, content_len, viewport_len, on_scroll, &palette) —
un único offset colapsa el header (de max a min) y luego scrollea el cuerpo
bajo él; header(frac) recibe frac∈[0,1] para fundir título/subtítulo.
Clampeá con sliver_max_offset(...). Helpers puros: collapsed_height,
collapse_fraction, sticky_y(offset, section_top, section_h, header_h) para
encabezados de sección pegados al tope. Demo: cargo run -p llimphi-widget-scroll --example scroll_avanzado.
Foco y teclado. Marca los nodos navegables con .focusable(id) (id u64
que vos eliges). 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>). Guardas 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) (inserta como tecleado) o
Disabled. Reporta 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).
UI encima de contenido GPU — View::over y paint_over
El orden del frame es [vello base] → [gpu_paint] → [vello over] → [overlay/ menús]: cualquier View normal que se solape con un gpu_paint_with queda
tapado por él (el blit corre después de la pasada base). Dos salidas:
// Subárbol entero de Views pintado en la pasada over (fills, texto, widgets,
// hover — la maquinaria completa). El primitivo para un OSC de reproductor,
// un HUD, un rail que solapa un canvas GPU. El hit-test no cambia.
View::new(estilo_absoluto).over(true).children(vec![barra_de_transporte])
// Closure vello suelta en la pasada over (sprites/texto AA sobre celdas
// instanciadas — dominium, motor voxel).
.paint_over(|scene, ts, rect| { ... })
Limitación de ambas: la capa over se compone con alpha sobre la intermedia;
clips/alpha/transform de ancestros fuera del subárbol no la afectan. Caso
canónico de .over(true): el OSC estilo mpv de media-app
(02_ruway/media/media-app/src/vista.rs::overlay_bars_view) y su fondo de
marca (src/fondo.rs::fondo_overlay) flotando sobre el video.
¿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 reconstruyes 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 tocas HAL si escribes 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-image — decode pipeline
decode_bytes(&[u8]) -> Result<peniko::Image, DecodeError>
load_path(&Path, max_bytes: u64) -> Result<peniko::Image, DecodeError>
from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> peniko::Image
Encapsula el patrón image::ImageReader + to_rgba8 + Blob + Image::new que cada
app que decodifica imágenes tenía duplicado. max_bytes aplica al tamaño del
archivo en disco (no a la imagen decodificada, que en RGBA8 puede ser mucho
mayor — un PNG 4K ocupa ~64 MB descomprimido); 0 deshabilita el cap.
Re-exporta peniko::{Blob, Image, ImageFormat}. Formatos según las features
del crate image upstream del workspace (hoy: PNG, JPEG, WEBP).
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: guardas 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 — input multilínea con paridad real (mismo motor EditorState
que el text-input; NO el viejo String plano). El estado es
TextInputState::multiline() (o llimphi_widget_text_area::new_state()).
text_area_view(&state, placeholder, focused, rows, &palette, |ev| Msg::Area(ev))
emite TextAreaEvent (Key/Ime/Press(x,y)/Drag/DragEnd/Context) ya envueltos; en
update → state.handle_area(ev, &mut clipboard). Da: click 2D (renglón+columna
por glifos reales), ↑/↓ entre renglones, selección a través de líneas (arrastre 2D,
doble/triple-click, Shift+flechas), copiar/cortar/pegar, undo/redo, IME, Enter=\n,
soft-wrap (la línea larga se envuelve al ancho de la caja) y scroll vertical
(caret a la vista si hay más renglones que rows). Caret + selección pintados por
paint_over en coords de renglón visual; fuente única del layout: visual_layout
(render/hit-testing/caret/nav comparten). ↑/↓ navegan por renglón visual (no por
línea lógica: en texto envuelto bajan un renglón, no la línea entera), con columna
meta en px preservada (move_vertical_visual; Shift extiende selección). El crate
llimphi-widget-text-area es una fachada que re-exporta esto. Demo:
cargo run -p llimphi-widget-text-input --example area_demo.
text-area RICO — el mismo widget con los adornos que antes obligaban a
escribirse un pintor propio. text_area_view_rico(&state, focused, rows, &palette, &Metricas, &Adornos, wrap). Si necesitás un input mono, coloreado,
con fantasma o con caret animado, es ESTE, no un paint_with a mano.
Metricas { font_size, line_h, font_family, pad_x, pad_r, pad_y } —
Metricas::mono(px) + .con_zoom(z). La usan pintado, ajuste blando y
click→columna: una sola, o el caret cae entre glifos que no son los que se ven.
Adornos { tramos, ghost, placeholder, caret, ahora_ms, marco }.
tramos: Vec<(ini, fin, Color)> en bytes del texto completo (se recortan
solos a cada renglón); ghost = sufijo tenue tras el caret (sólo si el caret
está al final); Placeholder { texto, color, italic, icono, icono_color, alpha };
marco: false = sin caja propia (para un host que ya tiene la suya — el borde
del widget es un relleno opaco que taparía un backdrop frosted).
CaretEstilo::vivo() = parpadeo que queda sólido tras cada tecla y
estela al moverse (el eyecandy en honor a kitty). El reloj lo trae el host:
Adornos::ahora_ms por cuadro + state.marcar_actividad(ms) en cada tecla —
el widget no llama a now() por dentro (sería intesteable, y un bool de fase
alternado por un timer externo se muestrea mal cuando el panel repinta lento).
- Consultas para un host que crece con el contenido:
renglones_visuales(), renglones_visuales_de(txt), avance_en_renglon(),
ancho_caracter(), fijar_ancho_caja(px) (sembrar el ancho antes del primer
cuadro, o testear el wrap sin GPU).
text_area_view es esta misma vista con Adornos::default() — un solo
renderizador. Uso canónico: el input de la barra de shuma
(02_ruway/shuma/sandbox/shuma-module-shell/src/view/input.rs), que le pasa los
tokens del shell como tramos y monta su propio marco con marco: false.
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.
color-picker — selector RGBA: swatch actual + paleta de chips + barra
de hue + sliders R/G/B/A (+ campo #RRGGBB opcional). Agnóstico, emite
[u8; 4]. color_picker_view(rgba: [u8;4], swatches: &[[u8;3]] (p.ej. DEFAULT_SWATCHES), &ColorPickerPalette, hex: Option<HexField { focused, state: Option<&TextInputState>, on_focus }>, on_change: Fn([u8;4]) -> Msg).
Helpers puros: rgba_to_hex, parse_hex(s, cur_alpha) (acepta 3/6/8
dígitos, # opcional), color_picker_height(with_hex) para pre-calcular
el alto.
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).
select — combobox/dropdown moderno (disparador cerrado + menú flotante en
view_overlay). Soporta ítems ricos (SelectItem { label, sublabel, icon (glifo unicode), badge: SelectBadge, enabled }), selección múltiple
(selected: &[usize] con check ✓), búsqueda in-menu y fases async
(SelectPhase::{Loading, Error(&str), Ready(&[SelectItem])}) para listas que
vienen de Handle::spawn. El cerrado se pinta con select_trigger_view(selected: Option<&SelectItem>, placeholder, open, width, &palette, on_toggle: Msg); el
menú con select_menu_view(SelectMenuSpec { anchor, viewport, width, phase, visible: &[orig_idx], active, selected, query, searchable, empty_text, appear, on_pick: Fn(orig_idx)->Msg, on_hover, on_dismiss, on_retry, palette }). La app
mantiene query/active/visible/selected en el Model. Helpers puros para
el filtro: filter(items, query) -> Vec<orig_idx>, step_active(items, visible, current, ±1) para nav, resolve(visible, pos) -> Option<orig_idx>.
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).
rive-button — botón reactivo dirigido por máquina de estados (llimphi-anim,
estilo Rive) — primer consumidor real de la StateMachine fuera del studio.
Stateful (como text-editor): guardas RiveButton en tu Model. Tres estados
idle/hover/press, cada uno con su clip Lottie; hover por bool (listeners
Enter/Exit del motor), press por trigger (ClipDone lo devuelve solo). API:
RiveButton::new(idle, hover, press, press_secs, blend_secs) o ::builtin()
(clips embebidos); advance(dt) desde un tick, pointer(Option<(f64,f64)>),
press() desde el on_click del host, current_state()/is_transitioning().
Render: view(on_pointer: Fn(Option<(f64,f64)>)->Msg, on_click: Msg). Demo:
cargo run -p llimphi-widget-rive-button --example rive_button_demo --release.
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.
carousel — pager paginado de N páginas. carousel_view(CarouselSpec { pages, current, wrap: CarouselWrap::{Wrap, Clamp}, show_arrows, palette, on_change: Fn(usize) -> Msg }). Dots indicadores abajo (clickeables para saltar
a la página i) + flechas opcionales ‹ / › a los costados. v1 sin swipe;
para v2 sumar swipe horizontal usando draggable_velocity + fling_step para
snap-on-release.
fitted-box — escala un subárbol al slot del padre. fitted_box((iw, ih), BoxFit::{Contain, Cover, Fill, None, ScaleDown}, || inner_view()) aplica un
transform afín al inner (medido por el seam LayoutBuilder) para que entre en
el slot real, centrado, preservando aspect salvo Fill. Útil para imágenes,
canvases paint_with y texto grande que deben caber en celdas chicas sin que el
caller los re-mida. La función pura compute_fit(slot, inner, fit) -> (sx, sy, dx, dy) es testeable.
calendar — vista mensual 6×7 (calendar_view(CalendarSpec { view_year, view_month, selected, today, week_start, palette, on_select: Fn(NaiveDate)->Msg, on_view_change: Fn(year, month)->Msg })). Header con < Mes Año > para navegar
entre meses, fila de iniciales (L M M J V S D ó D L M M J V S según
WeekStart), grilla siempre de 6 filas (no reflowea al cambiar de mes). El
caller le inyecta today — el widget no toca el reloj. Base del date-picker:
combinar con view_overlay + un field/button disparador.
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.
router — navegación por pila de rutas con transiciones de página
(el Navigator de Flutter, a la Elm). La pila vive en el Model; el widget
pinta sólo la ruta actual y anima el cambio con la infra de ghosts del
compositor (animated_switch_styled). Push entra desde la derecha y lo viejo
retrocede 30 % (shared-axis X); pop lo inverso; Zoom = fade-through;
Cover = push opaco estilo UIKit (la nueva tapa sin fade; el pop desliza
la vieja por encima revelando — scene-split de fantasmas: el saliente se
pinta DEBAJO del entrante, en su posición z real); None = corte seco sin
captura. Los hero cruzan rutas gratis (misma key en ambas páginas).
// Model: nav: RouteStack<Ruta> (enum propio: Clone + PartialEq + Hash)
m.nav.push(Ruta::Detalle(7)); m.nav.pop(); m.nav.replace(r); m.nav.pop_to_root();
router_view(key, &m.nav, PageTransition::{None,Fade,Slide,Zoom,Cover}, dur,
page: Fn(&Ruta) -> View<Msg>) // mide su slot vía LayoutBuilder
router_view_sized(key, &m.nav, tr, dur, (w, h), page) // slot explícito — para
// páginas que anidan otro layout_builder (límite v1 del seam: sin anidar)
Demo: cargo run -p llimphi-widget-router --example router_demo --release
(hero volando entre Lista y Detalle; T cicla la transición, Esc vuelve).
hero — shared-element transition (el Hero de Flutter auténtico): un nodo
con la misma key que aparece en otro rect en un frame posterior vuela
del rect viejo al nuevo (el HeroRegistry del runtime interpola transform).
hero_view(key, child) (480 ms) / hero_quick(key, child) (320 ms), o el
primitive View::hero(key, dur) directo. Key estable y única entre los heroes
de un mismo frame.
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.
lazy-list — lista perezosa llave-en-mano (Bloque 22 de
PLAN-NUCLEO-RETENIDO.md). A diferencia de list/grid (el caller windowea a
mano) y de scroll_y (recibe el contenido ya construido), recibe un builder
item: Fn(usize) -> View + total y sólo llama item(i) para la ventana
visible + overscan — las filas fuera de pantalla no se construyen, no se
montan, no se layoutean (costo/frame ∝ lo visible, no ∝ el total). Sin estado
(offset en el Model); reusa la barra/física de scroll.
lazy_list_fixed(total, item_h, offset, viewport_h, overscan,
item: Fn(usize)->View, on_scroll: Fn(f32)->Msg, &ScrollPalette) // extent uniforme, ventana O(1)
lazy_list_varied(offsets: &[f32] /*len total+1, tops acumulados*/, offset, viewport_h,
overscan, item, on_scroll, &palette) // extent por fila, O(log n)
// helpers puros: visible_window(total, item_h, offset, vp, overscan) -> Window{first,count,y0}
// visible_window_varied(offsets, offset, vp, overscan), content_height(total, item_h)
// altura MEDIDA (listas heterogéneas cuyo alto depende del contenido/reflow):
item_offsets(total, ancho, item: Fn(usize)->View) -> Vec<f32> // mide cada ítem → offsets acumulados
// el caller lo llama al cambiar contenido/ancho, cachea el Vec, y por frame usa
// lazy_list_varied(&offsets, ...). Usa llimphi_compositor::measured_height(view, ancho).
// SECCIONES con encabezado sticky llave-en-mano (agenda/contactos):
lazy_list_sections(rows_por_seccion: &[usize], item_h, header_h, offset, viewport_h,
overscan, header: Fn(s)->View, item: Fn(s, fila_local)->View, on_scroll, &palette)
// el header de la sección activa queda PINEADO al tope y la siguiente lo empuja;
// darle fill opaco (pinta encima de las filas). Ventana O(#secciones + visibles).
// puros: section_tops, sections_window -> Vec<SectionUnit>, sticky_section
// re-exporta de scroll: clamp_offset, fling_step, rubber_band, sticky_y, DEFAULT_OVERSCAN, ScrollPalette
Demo: cargo run -p llimphi-widget-lazy-list --example stress_lazy --release -- --nodes 1000000.
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)
dock-rail — rail vertical de dientes para sidebars acoplables. Cada
diente es una pestaña: barra de acento de 3px pegada al borde interno
(encendida cuando está activo) + icono centrado. Apilados en una franja
flotante que sobresale del panel hacia el centro — el patrón canónico
de cosmos (01_yachay/cosmos/cosmos-app-llimphi/src/chrome.rs:
dock_rail_overlay / dock_panel_for). Cada item se identifica por un u64
opaco; el caller mantiene el orden, qué está activo, y el mapping
u64 ↔ DominioPropio (típicamente vía enum::{to_u64,from_u64}). Cada
diente es arrastrable (su id como payload) y el rail entero es drop
target → mover un diente de un sidebar a otro soltándolo sobre el rail
opuesto. Render-only, agnóstico del Msg.
DockRailItem { id: u64, active: bool }
dock_rail_view(&items, width: f32, &DockRailPalette,
make_icon: Fn(id, size_px, color) -> View<Msg>,
on_activate: Fn(id) -> Msg, // dispara en el press, no pelea con el drag
on_drop: Fn(payload_id) -> Option<Msg>) // None = el rail no acepta drops
Layout canónico: el rail va como overlay absoluto pegado al borde interno
del centro (los dientes sobresalen sobre la rueda/canvas), y el panel del
item activo va al costado como un pane en el área resizable. NO es una lista
con rótulo: leer cosmos antes de inventar UX (lección 2026-06-05).
toolbar — barra de herramientas moderna: grupos de botones-ícono
planos (hover redondeado; activo = fondo de selección + ícono en acento;
deshabilitado atenuado y sin click) con separadores sutiles entre grupos.
Los íconos los dibuja el caller vía closure (mismo contrato que dock-rail);
los grupos son datos (Vec<ToolbarGroup>) → la barra es componible y
configurable por construcción. Uso canónico:
nahual-shell-llimphi::shell_toolbar (navegación + modos de vista + acciones).
ToolbarItem::new(icon: Fn(size_px, color) -> View<Msg>, on_click: Msg)
.with_label("subir").active(bool).enabled(bool)
ToolbarGroup::new(vec![items...])
toolbar_view(groups: Vec<ToolbarGroup<Msg>>, height: f32, &ToolbarPalette) -> View<Msg>
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>).
transport — botones de transporte de reproductor (play/pause/prev/
next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq). Stateless:
el caller arma un TransportButton por cada botón con el flag que define su
estado activo (p.ej. PlayPause { playing }, Mute { muted },
Record { recording }), lo pasa al widget, y recibe el TransportAction
semántico en un closure cuando el usuario clickea. El widget elige el icono
(de llimphi-icons), el color activo, el rojo de REC y arma la acción
(p.ej. SeekBack { secs } → TransportAction::SeekBy(-secs)). Quien
traduce TransportAction al comando del dominio (MediaCommand, etc.) es
la app.
TransportPalette { bg, bg_active, bg_hover, fg, fg_active, fg_record,
btn_w, btn_h, radius, icon_stroke, gap }
TransportPalette::from_theme(&Theme)
TransportAction::{TogglePlay, Stop, Prev, Next, SeekBy(i64), VolumeBy(f32),
ToggleMute, CycleRepeat, ToggleShuffle, SpeedStep(i32),
SpeedReset, Snapshot, ToggleRecord, ToggleEqualizer}
TransportButton::{PlayPause{playing}, Stop, Prev, Next,
SeekBack{secs}, SeekForward{secs},
VolumeDown{step}, VolumeUp{step},
Mute{muted}, Repeat{active}, Shuffle{active},
SpeedDown, SpeedUp, SpeedReset{is_default},
Snapshot, Record{recording}, Equalizer{enabled}}
transport_button_view(button, &palette, on_action: Fn(TransportAction)->Msg) -> View<Msg>
Para una fila, el caller mapea su Vec<TransportButton> con
.iter().map(|b| transport_button_view(*b, &pal, on_action.clone())) y los
pone como children de una View con flex_direction: Row y gap: palette.gap. Consumidor de referencia: 02_ruway/media/media-app::bar_item_view.
waveform — visor de forma de onda en vivo. Stateless y agnóstico de
cpal/AudioProbe: el caller pasa un closure Fn(&mut Vec<f32>) -> u16 que
rellena un buffer con los últimos samples intercalados y devuelve cuántos
canales trae; el widget hace el fold a mono y dibuja un envelope
min/max por columna (polígono cerrado con relleno tenue + stroke top/bot)
sobre una línea central que siempre está como "ground" — paint-only, sin
mouse. Si el closure devuelve 0 canales, el widget pinta sólo la línea
central ("visor vivo, sin señal").
WaveformPalette { bg, center, stroke, fill, radius, pad_x, pad_y, stroke_w }
WaveformPalette::from_theme(&Theme) // bg=bg_panel_alt, stroke=accent, fill=accent@α70
waveform_view(source: Fn(&mut Vec<f32>) -> u16 + Send + Sync + 'static,
palette: &WaveformPalette) -> View<Msg>
Consumidor de referencia: 02_ruway/media/media-app::waveform_panel (cablea
un AudioProbe::snapshot).
table — tabla editable de celdas-texto: filas/columnas + agregar/quitar
fila + foco por celda. Stateless — la app posee el foco y el buffer de la
celda en edición; el resto se pinta desde &rows. Pensado para datos planos
editables (paramétricos, scratchpad, listas curables); no es la list/tree
virtualizada read-only ni un grid de hoja de cálculo.
table_view(&[String], headers, // [] = sin header
&[Vec<String>] rows,
focused: Option<(row, col)>,
focused_state: Option<&TextInputState>, // buffer prestado de la celda en edición
add_label: &str, &TablePalette,
on_focus_cell: Fn(row, col) -> Msg, // clic en celda
on_remove_row: Fn(row) -> Msg,
on_add_row: Fn() -> Msg)
list_view(&[String], items, focused_row, focused_state, // sola columna sin header
add_label, &palette, on_focus_cell: Fn(row)->Msg, on_remove_row, on_add_row)
table_height(n_rows, has_header) -> f32 // pre-calcular alto del bloque
La variante list_view no se confunde con la list virtualizada de §13:
ésta es una sola columna editable que delega en table_view([], ..).
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.
terminal — superficie de scrollback infinita virtualizada (modo línea /
bloques / grilla GPU). Reemplaza al output_pane clásico: el costo de render
es constante a scrollback ilimitado — sólo se materializa la ventana visible
— bajo un scroll_y propio del widget (no transform del padre, la
anti-feature del SDD). Núcleo agnóstico, el caller arma el modelo y el widget
pinta (Regla 2). Diseño completo: 02_ruway/shuma/SDD-TERMINAL.md. Consumidor
de referencia: shuma-module-shell::output_pane_surface.
- Store (
Scrollback::new(limit_bytes)): append-only por línea con índice
de offsets O(1), cap por memoria con recorte de frente, line_id global
estable que sobrevive al recorte (index_of_id), push_line,
slice_text, clear. dropped() / total_pushed() para el numbering
global.
- Spill a disco (
SpillStore::create(path) + Scrollback::enable_spill(spill)):
cada línea recortada del frente se appendea al archive; lookup O(1) con
read_spilled(global_id) + helpers has_spill/spilled_count/spill_path
para que el host exponga UX (chip de status, builtin :scrollback open,
prepend del archive al view).
- Modo línea (
line_surface(store, scroll_y, viewport_h, metrics, &palette, line_style: Fn(usize, &str) -> LineStyle, on_scroll: Fn(f32) -> Msg, measure: Option<Arc<Mutex<f32>>>)): materializa sólo la ventana visible; scroll
sub-renglón (partial_px), numeración global 1-based, color base + runs +
tinte de fondo inyectados por el caller en LineStyle. Helpers puros:
visible_window, content_height, scroll_to_bottom. measure opcional
publica el viewport_h real medido al host (regla del SDD: verificar con
viewport real).
- Modo bloques (
block_surface(store, items: Vec<Item<Msg>>, ...)): el
stream es una secuencia de Item::chrome(height, view) (header/badge/etapa
de alto fijo que el caller pinta) + Item::lines(start, end) (rango del
store). Virtualiza sobre alturas mixtas con búsqueda binaria (O(log n) en
bloques) — dentro de un Lines enorme sólo se materializan las sub-filas
visibles. Colapsar = no emitir el Lines. Helpers: blocks_height,
blocks_scroll_to_bottom, gutter_width(&store, metrics),
line_top_in_content(&items_geo, row_h, target_line).
- Selección + copy (
block_surface_with_selection + SelectionConfig { range: Option<&SelectionRange>, on_drag: Fn(DragPhase, lx0, ly0, dx, dy) -> Option<Msg>, on_double_click: Fn(lx, ly, w, h) -> Option<Msg> }): el widget pinta el
overlay translúcido y dispara callbacks; el caller mantiene el rango
(SelectionRange { anchor: Point, head: Point }, Point { line, col } con
col en bytes UTF-8) en su Model y resuelve clicks con point_at(items, scroll_y, viewport_h, metrics, gutter_w, store, lx, ly) —o su variante
Copy-stashable point_at_geo(&[ItemGeo], ...) para resolver el frame
anterior. selection_rects para overlays custom.
- Find (
find_matches(&store, query, FindOpts { case_insensitive })):
búsqueda sobre el scrollback completo (no sólo el viewport), O(total_bytes);
FindMatch { line, start, end } con start/end en bytes UTF-8 slice-safe.
Nav: next_match(&matches, current) / prev_match. El caller resuelve el
line a su scroll_y con line_top_in_content.
- Grilla GPU (modo TUI, Fase 4):
CellPipeline::new(device, format) +
GlyphAtlas rasterizan cada celda con quads instanciados y atlas de
glifos (precedente: atlas de wawa, Fontdue) — una sola draw call por
viewport, escala a 200×80 sin penalty. Tipos: CellInstance { cell_x/y, uv_x/y/w/h, fg_rgba, bg_rgba } (POD 32 B), CellUniforms, pack_rgba,
instances_to_bytes, CELL_WGSL, CellPipeline::create_atlas_texture.
Wireado al widget contenedor vía gpu_paint_with. Activo en shuma con
SHUMA_GPU_GRID=1.
Headless dumps:
cargo run -p llimphi-widget-terminal --example dump_terminal # modo línea, 1M renglones
cargo run -p llimphi-widget-terminal --example dump_blocks # bloques + flood 500k
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 (panel elevado con sombra + borde +
esquinas redondeadas). ContextMenuItem::action(label).with_shortcut(..).icon(..) .checked(bool).disabled().destructive().submenu(children) o ::separator()
(checked pinta ✓ en el gutter y alterna estado). context_menu_view(ContextMenuSpec { anchor, viewport, header, items, active, on_pick: Fn(usize)->Msg, on_dismiss, palette }); context_menu_view_ex con submenús (flyout que flipea a la izq. si
no cabe) + animación de aparición. Se monta en view_overlay con scrim. Teclado
(helpers puros que el host cablea): step_active(items, cur, ±1) (↑/↓, saltea
sep/disabled), edge_active(items, to_end) (Inicio/Fin), type_ahead(items, cur, ch)
(mnemónico por 1ª letra). Submenú por teclado = step_active sobre parent.children.
Demo completo: cargo run -p llimphi-widget-context-menu --example menu_demo.
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.
16.bis Tests de regresión visual — llimphi-test (golden-image)
Para certificar que un widget/pantalla no cambió de aspecto sin renderizar-y-
mirar a mano (CLAUDE.md §8), usar el arnés llimphi-test. Renderiza un View
headless por el adapter de software (llvmpipe/lavapipe → mismo píxel host-a-
host y en CI) y lo diffea contra un golden con tolerancia perceptual.
// en tests/<algo>.rs de tu crate
use llimphi_test::assert_golden;
#[test]
fn mi_card() {
let v = mi_widget::card(/* … */);
assert_golden!("mi_card", v, 400, 240); // vs tests/goldens/mi_card.png
}
- Generar/actualizar goldens:
LLIMPHI_GOLDEN_UPDATE=1 cargo test -p <crate>
(escribe los PNG en tests/goldens/; commitéalos).
- En fallo: escribe
mi_card.actual.png + mi_card.diff.png (heatmap) al lado
del golden y paniquea con DiffStats { differing_pixels, max_delta, fraction }
— la stat numérica es la evidencia, no hace falta abrir el PNG.
- Tolerancia:
GoldenConfig { max_channel_delta, max_diff_fraction } (default
casi bit-exacto). Para control fino: Harness::render(...) + diff(...) +
compare_golden(path, &rendered, &cfg) sueltos.
- Determinismo: el adapter de software es bit-exacto (el self-test
readback_es_determinista verifica 0px entre dos renders). Si la máquina no
tiene lavapipe, cae al adapter normal y la tolerancia absorbe la diferencia.
- Path GPU-directo (
GpuBatch/gpu_paint_with, que no pasa por vello):
Harness::render_gpu(w, h, bg, |device, queue, encoder, view, vp| { … }) —
misma firma que View::gpu_paint_with; limpia a bg y te deja pintar directo,
después lee de vuelta para el golden (ver llimphi-test/tests/gpu_golden.rs).
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
# perfilador por frame (a stderr cada 60 frames: media/p95 por etapa):
LLIMPHI_PERF=1 cargo run -p <crate> --release
# ...con conteo de asignaciones de heap por frame y su desglose por etapa:
LLIMPHI_PERF=1 cargo run -p <crate> --release --features llimphi-ui/perf-alloc
LLIMPHI_PERF=N reporta cada N frames (default 60) el desglose
view/mount/layout/paint/raster/gpu/present en µs + layout-reuso, el caché de
repaint_boundary y —si está la feature perf-alloc— alloc <media>/<p95> (<KB>) [view … mount … layout … paint …]. Sin esa feature el tramo de allocs
dice n/a en vez de ceros falsos. El contador es
llimphi_ui::alloc::ContadorAlloc: si tu app ya elige su allocator, instalalo a
mano (#[global_allocator]) en vez de usar la feature. El camino cache_hit
(frame retenido entero) no se muestrea a propósito.
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).fill_gradient(g).hover_fill(c).radius(r).radius_corners(tl,tr,br,bl).shadow(sh).border(w,c).clip(b).alpha(a).transform(xf).animated(key,dur)
.text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al) | .text_weight(w) | .bold() | .ellipsis(n) | .max_lines(n)
.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 · color-picker · context-menu ·
dock-rail · edit-menu · empty · field · gallery · grid · lazy-list · list · menubar · modal ·
navigator · nodegraph · panel · panes · progress · segmented · select ·
shortcuts-help · skeleton · slider · splash · splitter · stat-card · status-bar ·
switch · table · tabs · terminal · text-area · text-editor · text-editor-core ·
text-editor-lsp · text-input · theme-switcher · tiled · timeline · toast ·
toolbar · tooltip · transport · tree · waveform · 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.