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)
3416.bis. [Tests de regresión visual (golden-image)](#16bis-tests-de-regresión-visual--llimphi-test-golden-image)
3516.ter. [Semántica accesible y tests por consulta](#16ter-semántica-accesible--tests-por-consulta)
3617. [Comandos y demos](#17-comandos-y-demos)
3718. [Cheat-sheet](#18-cheat-sheet)
3819. [Índice de crates](#19-índice-de-crates)
39
40---
41
42## 1. Modelo mental en 60 segundos
43
44Llimphi es **Elm sobre la GPU**. Una app es un tipo que implementa el trait
45`App` con cuatro piezas:
46
47- `Model` — estado **inmutable** de la app.
48- `Msg` — todo lo que puede pasar (`Clone + Send`).
49- `update(model, msg, handle) -> model` — transición **pura** que devuelve un
50 modelo nuevo.
51- `view(&model) -> View<Msg>` — función **pura** que describe la pantalla como
52 un árbol de `View`.
53
54El runtime hace el bucle: un evento (click/tecla/rueda) produce un `Msg`,
55`update` deriva el nuevo `Model`, `view` reconstruye el árbol, `taffy` calcula
56las cajas, `vello` rasteriza, y se hace swap del frame. **No hay mutabilidad
57compartida, no hay vDOM ajeno, no hay callbacks imperativos**: declaras qué se
58ve y qué `Msg` emite cada nodo.
59
60```
61 evento ─▶ Msg ─▶ update(model,msg) ─▶ model' ─▶ view(model') ─▶ View<Msg>
62 │
63 present ◀─ raster(vello) ◀─ layout(taffy) ◀──────────────────────┘
64```
65
66Tres reglas de oro:
671. **`view` es pura** — no muta nada, sólo lee el modelo y arma el árbol.
682. **Cómputo pesado va a un worker** vía `Handle::spawn`, nunca síncrono en
69 `update`/`init`/handlers (congela la ventana → "Not Responding").
703. **Widgets son visuales y stateless**; el estado vive en tu `Model`.
71 **Módulos** sí encapsulan estado + comportamiento.
72
73---
74
75## 2. Arquitectura — las capas
76
77```
784. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent)
79 └ llimphi-compositor . árbol View<Msg>, mount sobre taffy, paint, hit-test (winit-free)
803. llimphi-layout ....... motor de layout (taffy: flexbox + grid)
812. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo
821. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji
83 └ llimphi-glifos ..... atlas de cobertura: el texto del camino GPU-directo
840. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer)
85```
86
87El **split compositor/runtime** (2026-05-31) es importante: `llimphi-compositor`
88es *winit-free* (sólo `View`, `mount`, `paint`, hit-test). `llimphi-ui` lo corre
89sobre winit y **re-exporta todo el compositor**, así escribes `llimphi_ui::View`
90sin enterarte del split. Esto habilita un futuro runtime sobre el framebuffer
91del kernel `wawa` reusando el mismo compositor.
92
93Auxiliares: `llimphi-theme` (paletas), `llimphi-motion` (tweens),
94`llimphi-icons` (iconos vectoriales), `llimphi-surface` (texturas externas),
95`llimphi-workspace` (chasis tmux), `llimphi-gallery` (showcase).
96
97Catálogo: **79 widgets** (visuales, crates `llimphi-widget-*`) + **11 módulos**
98(features con estado). `ls -d widgets/*/` da **79** carpetas: la que sobra es
99`llimphi-clipboard`, que vive ahí sin ser un widget. Recontado el 2026-08-27;
100el número sale del comando, no de esta línea.
101
102---
103
104## 3. Quickstart — la app mínima
105
106```rust
107use llimphi_ui::llimphi_layout::taffy::prelude::*;
108use llimphi_ui::llimphi_raster::peniko::Color;
109use llimphi_ui::{App, Handle, View};
110
111#[derive(Clone)]
112enum Msg { Increment, Reset }
113
114struct Counter;
115
116impl App for Counter {
117 type Model = u32;
118 type Msg = Msg;
119
120 fn title() -> &'static str { "llimphi · counter" }
121
122 fn init(_: &Handle<Self::Msg>) -> Self::Model { 0 }
123
124 fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
125 match msg {
126 Msg::Increment => model.saturating_add(1),
127 Msg::Reset => 0,
128 }
129 }
130
131 fn view(model: &Self::Model) -> View<Self::Msg> {
132 let boton = View::new(Style {
133 size: Size { width: length(160.0), height: length(56.0) },
134 align_items: Some(AlignItems::Center),
135 justify_content: Some(JustifyContent::Center),
136 ..Default::default()
137 })
138 .fill(Color::from_rgba8(60, 200, 130, 255))
139 .radius(12.0)
140 .text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255))
141 .on_click(Msg::Increment);
142
143 View::new(Style {
144 flex_direction: FlexDirection::Column,
145 size: Size { width: percent(1.0), height: percent(1.0) },
146 align_items: Some(AlignItems::Center),
147 justify_content: Some(JustifyContent::Center),
148 gap: Size { width: length(0.0), height: length(24.0) },
149 ..Default::default()
150 })
151 .fill(Color::from_rgba8(20, 24, 32, 255))
152 .children(vec![
153 View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE),
154 boton,
155 ])
156 }
157}
158
159fn main() { llimphi_ui::run::<Counter>(); }
160```
161
162`Cargo.toml`:
163```toml
164[dependencies]
165llimphi-ui = { workspace = true }
166llimphi-theme = { workspace = true }
167# + los widgets/modules que uses:
168# llimphi-widget-button = { workspace = true }
169```
170
171Corre con `cargo run -p <tu-crate> --release`. El ejemplo vivo está en
172`llimphi-ui/examples/counter.rs`.
173
174---
175
176## 4. El trait `App` (bucle Elm)
177
178Definido en `llimphi-ui/src/lib.rs`. El estado es inmutable; cada evento
179produce un `Model` nuevo.
180
181```rust
182pub trait App: 'static {
183 type Model: 'static;
184 type Msg: Clone + Send + 'static;
185
186 fn init(handle: &Handle<Self::Msg>) -> Self::Model;
187 fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
188 fn view(model: &Self::Model) -> View<Self::Msg>;
189
190 // --- Todo lo de abajo tiene default; sobreescribí lo que necesites ---
191
192 fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> { None }
193
194 fn on_wheel(_model: &Self::Model, _delta: WheelDelta,
195 _cursor: (f32, f32), _modifiers: Modifiers) -> Option<Self::Msg> { None }
196
197 /// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta
198 /// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo
199 /// vidrio"). La transición la maneja tu Model.
200 fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> { None }
201
202 /// Drag&drop de archivos desde el file manager. Un evento por archivo.
203 fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> { None }
204
205 /// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El
206 /// runtime administra el foco; guardas `id` en tu Model para pintar el ring
207 /// y rutear el teclado. Ver §8 (Foco y teclado).
208 fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> { None }
209
210 /// IME (composición de texto: CJK, acentos muertos, emoji). Opt-in vía
211 /// `ime_allowed()` para no robarle el texto a las apps que sólo leen
212 /// `on_key`. Flujo: Enabled → Preedit* → Commit/Disabled. Ver §8 (IME).
213 fn ime_allowed() -> bool { false }
214 fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> { None }
215 /// Área del caret en px físicos para ubicar la ventana de candidatos.
216 fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { None }
217
218 fn title() -> &'static str { "llimphi" }
219 fn app_id() -> Option<&'static str> { None } // app_id del xdg-toplevel en Wayland
220 fn initial_size() -> (u32, u32) { (960, 540) }
221}
222```
223
224Punto de entrada: `pub fn run<A: App>()` — corre hasta que el usuario cierre la
225ventana o la app llame `Handle::quit`.
226
227**Eventos de teclado** (`KeyEvent`):
228```rust
229pub struct KeyEvent {
230 pub key: Key, // re-export de winit; usar NamedKey para teclas especiales
231 pub state: KeyState, // Pressed | Released
232 pub text: Option<String>, // texto resultante con IME/modifiers; None para flechas etc.
233 pub modifiers: Modifiers, // { shift, ctrl, alt, meta }
234 pub repeat: bool,
235}
236```
237`Key` y `NamedKey` se re-exportan desde `llimphi_ui`.
238
239**Rueda** (`WheelDelta { x, y }`): normalizado a "líneas". Convención CSS:
240`y` positivo = scroll hacia abajo.
241
242**El sentido (natural / clásico) va por CLASE de dispositivo y no lo decide la
243app.** `llimphi_entrada::PoliticaRueda` tiene dos sentidos —uno para la rueda del
244ratón, otro para el touchpad—, y lo aplican las tres fronteras donde nace un
245`WheelDelta`: el runner de ventana, el de subventana y el de capa. Una `App`
246recibe el delta ya en el sentido que el humano eligió; **no lo inviertas de
247nuevo** (invertir dos veces cancela el ajuste). Invertir para *zoom* —rueda
248arriba = acercar— sí es tuyo: eso es semántica de la app, no sentido de scroll.
249
250La política la siembra el runtime al arrancar (`llimphi_ui::sembrar_politica_rueda`).
251En tawasuyu sale del bus `wawa-config` (`entrada.rueda_raton` / `entrada.rueda_touchpad`,
252que escribe wawa-panel) gracias a la feature `config-wawa`, encendida en el
253`[workspace.dependencies]` de la raíz; fuera del monorepo se fija a mano con
254`llimphi_entrada::fijar_politica_rueda`. Bajo `mirada` el runtime se queda neutro
255a propósito: ahí lo aplica el compositor en libinput, por dispositivo, y así lo
256obedece toda la sesión.
257
258---
259
260## 5. `Handle` — efectos y concurrencia
261
262`Handle<Msg>` es `Send + Clone`. Llega a `init` y `update`. Es el único modo
263legítimo de producir efectos sin romper la pureza de la transición.
264
265```rust
266impl<Msg: Send + 'static> Handle<Msg> {
267 pub fn quit(&self); // cierra la ventana / termina el bucle
268 pub fn dispatch(&self, msg: Msg); // encola un Msg para el próximo turno
269 pub fn spawn<F: FnOnce() -> Msg + Send + 'static>(&self, f: F); // worker; su Msg reentra al update
270 pub fn spawn_periodic<F: Fn() -> Msg + Send + 'static>(&self, period: Duration, f: F); // tick periódico
271 pub fn for_test() -> Self; // handle "muerto" para tests sin event loop
272 pub fn for_test_with<F: Fn(Msg) + Send + Sync + 'static>(sink: F) -> Self; // tests que SÍ quieren los Msg
273
274 // --- ventana (sólo la primaria; en un handle `lift`ado son no-op) ---
275 pub fn set_fullscreen(&self, on: bool); // pantalla completa sin bordes, en su monitor
276 pub fn set_minimized(&self, on: bool); // minimizar / restaurar
277 pub fn set_content_type(&self, k: ContentType); // qué mostrás: None/Photo/Video/Game
278 pub fn open_window(&self, key: u64, title: impl Into<String>, w: u32, h: u32); // ventana OS secundaria
279 pub fn close_window(&self, key: u64);
280}
281```
282
283- **`on_modifiers`** (hook del trait `App`) — el `WindowEvent::ModifiersChanged`.
284 Existe porque **los handlers de click llevan un `Msg` plano y no reciben los
285 modificadores**: para hacer Shift+click (rango) o Ctrl+click (aditivo) en una
286 lista, la app guarda el estado en su modelo desde este hook y lo consulta en
287 el `update` del click. Devolvé `None` cuando el cambio no te importa (si no,
288 cada roce de Shift dispara un update y un repintado).
289
290- **`for_test` vs `for_test_with`** — `for_test` **descarta** los `Msg`; ojo con
291 eso: la closure de un `spawn` **sí se ejecuta** (para no perder sus efectos),
292 sólo se tira el mensaje. Un test que necesitaba el resultado terminaba
293 volviendo a hacer el trabajo a mano → el mismo job dos veces, en paralelo, y
294 carreras intermitentes sobre el disco. `for_test_with` entrega cada `Msg` a un
295 sink (típicamente el `Sender` de un canal, detrás de un `Mutex` porque el sink
296 pide `Sync`); el test lo drena y lo realimenta a `update`, que es lo que hace
297 el runtime real. Con eso el trabajo corre **una sola vez** y el test es
298 determinista.
299
300- **`set_fullscreen`** — `Fullscreen::Borderless` sobre el monitor donde ya
301 está la ventana (no cambia el modo de video). Es una **petición** al
302 compositor: el estado vive en el modelo de la app, que decide cuándo entra y
303 cuándo sale. El resize llega por el camino normal (`Resized`), no hay que
304 tocar la surface.
305
306 **Y el compositor también lo cambia por su cuenta** — `F11` es suyo en mirada
307 desde el 2026-09-05, para TODAS las apps: la ventana se va a pantalla completa
308 sin que la app pida nada. Para eso está **`App::on_fullscreen(model, bool)`**,
309 que el runtime dispara cuando el estado del último `configure` difiere del que
310 la app conocía. Es el enganche para resaltar distinto —esconder barras,
311 estirar el contenido, sacar márgenes y esquinas redondeadas— venga la orden de
312 donde venga; el ejemplo canónico a copiar es `media-app`
313 (`modelo.rs::on_fullscreen` → `Msg::FullscreenChanged`). Dos reglas:
314 **idempotente** (también se dispara para tu propio cambio: si el modelo ya
315 está así, devolvé `None`) y **sin `set_fullscreen` adentro** (el compositor ya
316 lo hizo; volver a pedírselo es contestarle a un eco). Sin implementarlo no se
317 rompe nada: la ventana se pone a pantalla completa igual, con el aspecto de
318 siempre.
319- **`set_content_type`** — declara **qué** muestra la ventana
320 (`wp_content_type_v1`). No cambia nada de cómo pintás: es una pista para que
321 el compositor ajuste su política. mirada le **cede la pantalla** al contenido
322 con ritmo propio —deja de animar su fondo de marca, que le cuesta una
323 recomposición completa por latido— y con lo mismo decide VRR y tearing. Un
324 reproductor declara `Video` al arrancar y vuelve a `Photo` al pausar (un video
325 en pausa **es** una imagen fija) o a `None` al cerrar el medio; repetir el
326 mismo valor es gratis. Fuera de Wayland, o con un compositor que no exponga el
327 protocolo, es un no-op silencioso (avisa una vez por stderr).
328
329 Winit no habla ese protocolo: por debajo se monta una segunda conexión
330 wayland-client sobre el **mismo** `wl_display` y se envuelve la `wl_surface`
331 de winit — el mismo cruce que validó el spike `llimphi-video-plane`. La pista
332 es estado doble-buffereado, así que viaja con el **próximo cuadro** que
333 pintes. Ejemplo vivo: `cargo run -p llimphi-ui --example content_type`.
334- **`spawn`** — trabajo bloqueante (IO, PAM, parse, efemérides). El `Msg` que
335 devuelve la closure se entrega al `update` en el hilo de UI. **Este es el
336 patrón obligatorio para todo cómputo pesado** (§16).
337- **`spawn_periodic`** — feeds a intervalos: ticks de simulación (~11 Hz en
338 dominium), polling, animaciones por reloj. El thread muere cuando se cierra
339 el event loop.
340
341---
342
343## 6. `View<Msg>` — el DSL declarativo
344
345Un `View` = `Style` de taffy + relleno + texto/imagen/painter + handlers +
346hijos. Todo se arma con builders encadenables (`self -> Self`). Definido en
347`llimphi-compositor/src/view.rs`.
348
349```rust
350View::new(style: Style) -> View<Msg>
351```
352
353### Apariencia
354| Método | Efecto |
355|---|---|
356| `.fill(Color)` | color de fondo |
357| `.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 |
358| `.hover_fill(Color)` | color al pasar el cursor (habilita hit-test de hover) |
359| `.radius(f64)` | esquinas redondeadas (radio uniforme) |
360| `.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 |
361| `.shadow(Shadow)` | drop shadow (vello `draw_blurred_rounded_rect`). `Shadow::soft(alpha,blur)` + `.offset(dx,dy)`/`.spread(s)` |
362| `.border(width, Color)` | stroke sobre el contorno redondeado, inset hacia adentro (border-box) |
363| `.alpha(f32)` | opacidad de todo el subtree `[0,1]` (capa intermedia — no gratis) |
364| `.transform(Affine)` | afín 2D alrededor del centro del rect (estilo CSS `transform-origin:50% 50%`) |
365| `.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 |
366| `.clip(bool)` | recorta hijos al rect (paint + hit-test) |
367| `.image(Image)` | pinta `peniko::Image` centrada, preservando aspect ratio |
368| `.children(Vec<View<Msg>>)` | hijos |
369
370### Texto (ver §9)
371```rust
372.text(content, size_px, color) // centrado
373.text_aligned(content, size_px, color, Alignment)
374.text_aligned_italic(content, size_px, color, Alignment, italic)
375.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option<String>)
376.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada
377.line_height(mult) // override interlínea (default 1.2)
378.text_weight(f32) // peso CSS: 400 normal, 600 semibold, 700 bold
379.bold() // atajo de text_weight(700.0)
380.ellipsis(n) // clampa a n líneas terminando en … (n=1 = single-line)
381.max_lines(n) // clampa a n líneas sin glifo (corte seco)
382```
383
384### Interacción (ver §8)
385```rust
386.on_click(Msg)
387.on_click_at(|lx, ly, w, h| -> Option<Msg>) // posición local + tamaño del rect
388.on_right_click(Msg) / .on_right_click_at(...)
389.on_middle_click(Msg)
390.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
391.draggable(|phase: DragPhase, dx, dy| -> Option<Msg>)
392.draggable_at(|phase, dx, dy, lx0, ly0| -> Option<Msg>) // + posición inicial del press
393.drag_payload(u64) // payload que viaja con el drag
394.on_drop(|payload: u64| -> Option<Msg>) // este nodo es drop target
395.drop_hover_fill(Color) // resaltado mientras un drag lo sobrevuela
396.on_scroll(|dx, dy| -> Option<Msg>) // rueda local (antes del on_wheel global)
397.on_scroll_take(|dx, dy| -> (Option<Msg>, rx, ry)) // reparto PARCIAL: consume lo que puede y el
398 // sobrante sigue al ancestro (mismo evento);
399 // scroll_y/lazy_list ya lo usan (scroll_take)
400.on_scale(|phase: GesturePhase, factor, fx, fy| -> Option<Msg>) // pinch-to-zoom (Ctrl+rueda / trackpad)
401.on_double_tap(Msg) / .on_double_tap_at(|lx, ly, w, h| ...) // dos clicks rápidos y cercanos
402.double_tap_exclusive() // …y que ARBITRE contra el click del nodo
403.on_long_press(Msg) / .on_long_press_at(|lx, ly, w, h| ...) // mantener ~500 ms quieto
404.focusable(u64) // nodo enfocable por Tab/click (id opaco)
405```
406
407### Pintura custom (ver §10)
408```rust
409.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... })
410.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... })
411```
412
413Notas clave:
414- **Un nodo es draggable *o* clickable**, no ambos: `draggable` sobreescribe
415 `on_click`.
416- Las variantes `*_at` ganan sobre las simples si ambas están.
417- `PaintRect { x, y, w, h }` es el rect **absoluto** del nodo en píxeles físicos.
418- `DragPhase` = `Move` (un evento por `CursorMoved`, `dx/dy` = delta **desde el
419 evento anterior**, no acumulado) | `End` (al soltar).
420- **`double_tap_exclusive()`** rompe esa aditividad **para el doble-tap**, y es
421 la salida para el nodo que necesita las dos cosas en el mismo botón: el
422 runtime **retiene** el click del primer tap hasta que vence la ventana del
423 doble (400 ms) y lo **descarta** si el segundo llega. Cuesta latencia —el
424 click de un toque suelto sale al vencer, no al soltar—, por eso es opt-in: sin
425 esto una fila que elige con un click y abre con dos sigue disparando los dos, y
426 esa es la semántica normal. Lo estrena el **diente** del `rag-sidebar` (un
427 click despliega su panel, dos alternan el eje Flota/Fijo): sin arbitrar, el
428 doble desplegaba y replegaba el panel de paso, y en FIJO eso suelta y vuelve a
429 tomar la franja reservada del escritorio. Vale en los tres bucles (ventana,
430 `llimphi-layer` y `pata`).
431- **Gestos (`on_scale`/`on_double_tap`/`on_long_press`)** son **aditivos**: se
432 resuelven con su propio hit-test y no interfieren con `on_click`/`draggable`
433 del mismo nodo. El caso limpio (sin disparos cruzados) es ponerlos en un nodo
434 que **no** tenga `on_click` — p. ej. un canvas con `draggable` (pan) +
435 `on_scale` (zoom) + `on_long_press` (marca). `GesturePhase` = `Begin`/`Update`/
436 `End`; en `on_scale`, `factor` es **multiplicativo incremental** (`>1` agranda)
437 y `(fx, fy)` el focal local — `Ctrl+rueda` lo sintetiza en cualquier desktop
438 (Wayland/Windows no emiten el pinch del trackpad; macOS sí, vía `PinchGesture`).
439 El long-press lo arbitra el tiempo (~500 ms quieto); moverse (>8px) o soltar lo
440 cancela. Demo completo: `cargo run -p llimphi-ui --example gestos --release`.
441
442---
443
444## 7. Layout (`taffy` / `Style`)
445
446`Style` es el `taffy::Style` directo, re-exportado vía
447`llimphi_ui::llimphi_layout::taffy::prelude::*`. Es Flexbox + CSS Grid puro.
448
449Campos más usados:
450```rust
451Style {
452 flex_direction: FlexDirection::Row | Column,
453 size: Size { width, height }, // length(px) | percent(0..1) | Dimension::auto()
454 min_size, max_size,
455 flex_grow: f32, flex_shrink: f32,
456 align_items: Some(AlignItems::{Start,Center,End,Stretch}),
457 justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}),
458 gap: Size { width, height },
459 padding: Rect { left, right, top, bottom }, // con length(px)
460 margin: Rect { ... },
461 ..Default::default()
462}
463```
464
465Helpers de `prelude`: `length(px)`, `percent(frac)`, `auto()`, `Dimension`,
466`Size`, `Rect`, `FlexDirection`, `AlignItems`, `JustifyContent`.
467
468`llimphi-layout` además expone:
469- `LayoutTree::new()` / `.clear()` (reuso entre frames), `.leaf(style)`,
470 `.node(style, &children)`, `.compute(...)`, `.compute_with_measure(F)`.
471- `Rect { x, y, w, h }` y `ComputedLayout { rects: HashMap<NodeId, Rect> }`.
472
473En el 99% de los casos no tocas `LayoutTree` a mano: lo maneja el runtime al
474montar tu `View`. Sólo armás `Style`s.
475
476---
477
478## 8. Eventos e interacción
479
480| Quiero… | Cómo |
481|---|---|
482| Botón / fila clickable | `.on_click(Msg)` (+ `.hover_fill` para feedback) |
483| Saber dónde se clickeó (canvas) | `.on_click_at(\|lx,ly,w,h\| ...)` → convertir a coords de mundo |
484| Menú contextual | `.on_right_click(Msg::OpenMenu{..})`, guardar pos en Model, abrir en `view_overlay` |
485| Abrir en pestaña nueva | `.on_middle_click(Msg)` |
486| Preview al pasar el mouse | `.on_pointer_enter(Msg)` / `.on_pointer_leave(Msg)` |
487| Resize de panel | `.draggable(\|phase,dx,dy\| ...)` acumulando delta en el Model |
488| Arrastrar entidad de un canvas | `.draggable_at(\|phase,dx,dy,lx0,ly0\| ...)` |
489| Drag&drop entre zonas | origen: `.drag_payload(id)`; destino: `.on_drop(\|id\| ...)` + `.drop_hover_fill` |
490| Scroll global | `App::on_wheel(model, delta, cursor, mods)` |
491| Área de scroll | widget `scroll_y(...)` (autocontenido) o `.on_scroll(\|dx,dy\| ...)` por nodo |
492| Zoom de canvas (pinch) | `.on_scale(\|phase,factor,fx,fy\| ...)` → `zoom *= factor`, reajustar pan al focal |
493| Doble-click | `.on_double_tap(Msg)` / `.on_double_tap_at(\|lx,ly,w,h\| ...)`; `+ .double_tap_exclusive()` si el mismo nodo tiene click |
494| Long-press (mantener) | `.on_long_press(Msg)` / `.on_long_press_at(\|lx,ly,w,h\| ...)` |
495| Teclado | `App::on_key(model, &KeyEvent) -> Option<Msg>` |
496| Foco / Tab | `.focusable(id)` en los nodos + `App::on_focus(model, id)` (ver abajo) |
497| IME (CJK, acentos) | `App::ime_allowed() -> true` + `App::on_ime(model, &ImeEvent)` (ver abajo) |
498| Drop de archivos del SO | `App::on_file_drop(model, path)` |
499
500**Patrón overlay** (menús/modales): el modelo guarda "menú abierto sí/no".
501Mientras esté abierto, `view_overlay` devuelve `Some(view)`; clicks fuera se
502cierran envolviendo los items en un scrim a pantalla completa con
503`on_click = DismissOverlay`. Cuando el modelo dice cerrado, `view_overlay`
504devuelve `None`.
505
506**Scroll** (widget `llimphi-widget-scroll`). `scroll_y(offset, content_len,
507viewport_len, content, on_scroll, &palette)` arma un viewport clipeado +
508contenido desplazado `-offset` + barra arrastrable. Es **stateless**: el offset
509vive en tu Model. `on_scroll(delta_px)` (rueda y arrastre) emite un delta a
510sumar; clampealo con `scroll::clamp_offset` en tu `update`. Helpers:
511`ensure_visible(offset, vp, item_top, item_h)` para llevar la selección a la
512vista (teclado); `approach(cur, target, factor)` para scroll suave hacia un
513objetivo (driveado por `Handle::spawn_periodic`).
514
515**Scroll 2D / física / slivers** (Tier 5, mismo widget, todo stateless):
516- `scroll_xy(offset:(x,y), content_size:(w,h), viewport_size:(w,h), content,
517 on_scroll:(dx,dy)→Msg, &palette)` — dos ejes, una barra por eje con overflow.
518- **Inercia (fling)**: `fling_step(velocity, dt, friction) → (v', delta)` +
519 `fling_settled(v)` — suelta con velocidad y avanza el offset por frame con el
520 ticker (`spawn_periodic`); `FLING_FRICTION`/`FLING_STOP` defaults. **Bounce**:
521 `rubber_band(overscroll, dim)` amortigua el desplazamiento más allá del tope.
522- **Sliver app-bar colapsable**: `sliver_app_bar(offset, header_max, header_min,
523 header(frac)→View, content, content_len, viewport_len, on_scroll, &palette)` —
524 un único offset colapsa el header (de max a min) y luego scrollea el cuerpo
525 bajo él; `header(frac)` recibe `frac∈[0,1]` para fundir título/subtítulo.
526 Clampeá con `sliver_max_offset(...)`. Helpers puros: `collapsed_height`,
527 `collapse_fraction`, `sticky_y(offset, section_top, section_h, header_h)` para
528 encabezados de sección pegados al tope. Demo: `cargo run -p
529 llimphi-widget-scroll --example scroll_avanzado`.
530
531**Foco y teclado.** Marca los nodos navegables con `.focusable(id)` (id `u64`
532que vos eliges). El runtime es la **única fuente de verdad** del foco: lo mueve
533con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo
534enfocable, y te avisa con `App::on_focus(model, Option<u64>)`. Guardas el id en
535tu Model para (a) pintar el ring (`if model.focus == Some(id) { .fill(accent) }`
536en `view`) y (b) rutear el teclado al campo activo desde `on_key`. No setees el
537foco por tu cuenta vía Msg: quedaría desincronizado del runtime.
538
539**IME** (composición de texto). Opt-in: `ime_allowed() -> true`. Con IME activo
540el texto compuesto **no** llega por `KeyEvent.text` sino por `on_ime`:
541`ImeEvent::Enabled` → uno o más `Preedit{text, cursor}` (texto en composición, a
542pintar subrayado en el caret) → `Commit(text)` (inserta como tecleado) o
543`Disabled`. Reporta el área del caret con `ime_cursor_area(model)` para ubicar
544la ventana de candidatos (CJK) junto al cursor.
545
546---
547
548## 9. Texto
549
550`TextSpec` (en compositor) describe el texto de un nodo:
551```rust
552pub struct TextSpec {
553 pub content: String,
554 pub size_px: f32,
555 pub color: Color,
556 pub alignment: Alignment, // Start | Center | End | Justify
557 pub italic: bool,
558 pub font_family: Option<String>, // string CSS con fallbacks
559 pub line_height: f32, // múltiplo; default 1.2
560 pub runs: Option<Vec<(usize, usize, Color)>>, // color por rango de BYTES
561}
562```
563
564- `Center` es el default (apto para labels). Para editores/párrafos usar
565 `.text_aligned(..., Alignment::Start)`.
566- **Multicolor en una sola pasada de shaping**: `.text_runs(...)` colorea
567 rangos de bytes — es la base del syntax highlighting (un nodo por línea, no
568 por token). Anclado arriba-izquierda; el caller dimensiona el rect.
569- El runtime mide el texto con parley durante el layout (`compute_with_measure`)
570 para que taffy reserve el alto real del texto envuelto a varias líneas
571 (evita "textos aplastados").
572- Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique.
573
574---
575
576## 10. Canvas custom y GPU directo
577
578Dos hooks para pintar primitivas no expresables como composición de `View`s.
579Conviven en el mismo árbol; el runtime pinta **toda la pasada vello primero**,
580luego los `gpu_painter` en orden DFS.
581
582### `paint_with` — vía vello (el default)
583```rust
584.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| {
585 // dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`.
586 // NO dejar push_layer sin pop_layer; NO resetear la scene.
587})
588```
589Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal.
590Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada).
591
592⚠️ **Pasado el techo, vello devuelve `Ok` y no pinta un solo píxel.** No tira
593`Err`, no loguea, no panickea: `render_to_texture` vuelve bien y la textura
594queda como estaba. Medido el 2026-08-28 con `pukllay-mayu-spike` (hebras
595Bézier con stroke, llvmpipe): el corte cae entre **324 K y 336 K cubics** por
596cuadro con un trazo por camino, y entre **120 K y 180 K trazos** cuando cada
597cubic es su propio trazo — o sea que el techo depende de la *forma* de la
598escena, no de un solo número. Consecuencia práctica: **un canvas que crece sin
599tope no falla ruidosamente, se apaga**, y encima parece rápido (la fila del
600techo mide el cuadro que no dibujó). Si tu `paint_with` escala con los datos,
601contá píxeles pintados o hasheá el cuadro además de mirar los ms — un contador
602que no distingue «no dibujó» de «dibujó poco» miente. El arnés de referencia
603está en `02_ruway/pukllay/pukllay-mayu-spike/src/main.rs::contar_pintados`.
604
605### `gpu_paint_with` — sube vertex buffers directo a wgpu, salta vello
606```rust
607.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| {
608 // abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello.
609 // (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC.
610})
611```
612Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de
613nakui, pineal denso. Rango 100 K – 10 M+ primitivos. **Sí** soporta texto desde
6142026-08-05 (§12, `llimphi-glifos` + `add_glyph`) y múltiples grosores de stroke
615por flush desde el mismo día. Lo que sigue sin soportar es el AA analítico de
616curvas: los bordes de rects/líneas/tris salen del MSAA 4×, no de cobertura
617exacta. Para texto **con brush** (gradiente, imagen) o decoraciones sigue
618haciendo falta la Scene vello de `view_overlay`.
619
620### UI encima de contenido GPU — `View::over` y `paint_over`
621
622El orden del frame es `[vello base] → [gpu_paint] → [vello over] → [overlay/
623menús]`: **cualquier `View` normal que se solape con un `gpu_paint_with` queda
624tapado por él** (el blit corre después de la pasada base). Dos salidas:
625
626```rust
627// Subárbol entero de Views pintado en la pasada over (fills, texto, widgets,
628// hover — la maquinaria completa). El primitivo para un OSC de reproductor,
629// un HUD, un rail que solapa un canvas GPU. El hit-test no cambia.
630View::new(estilo_absoluto).over(true).children(vec![barra_de_transporte])
631
632// Closure vello suelta en la pasada over (sprites/texto AA sobre celdas
633// instanciadas — dominium, motor voxel).
634.paint_over(|scene, ts, rect| { ... })
635```
636
637Limitación de ambas: la capa over se compone con alpha sobre la intermedia;
638clips/alpha/transform de **ancestros fuera** del subárbol no la afectan. Caso
639canónico de `.over(true)`: el OSC estilo mpv de media-app
640(`02_ruway/media/media-app/src/vista.rs::overlay_bars_view`) y su fondo de
641marca (`src/fondo.rs::fondo_overlay`) flotando sobre el video.
642
643### ¿Cuándo cada uno?
644| Pregunta | vello (`paint_with`) | GPU directo (`gpu_paint_with`) |
645|---|---|---|
646| Primitivos/frame | < ~500 K rebuild / < ~2 M Scene reusada | 100 K – 10 M+ |
647| ¿Cambian cada frame? | sí, rebuild barato | mejor estático (buffer persistente) |
648| Curvas Bezier | nativas | hay que teselar |
649| Texto | sí, con brush | sí, color sólido (atlas de cobertura) |
650| AA fino | sí (analítico) | bordes por MSAA 4×; el texto trae el suyo |
651
652**Default: `paint_with`** salvo que ya midas que el volumen lo justifica
653(factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU
654expone `GpuPipelines`/`GpuBatch` en `llimphi-raster` (§12).
655
656---
657
658## 11. Theme y paletas
659
660`llimphi-theme::Theme` es un struct de slots semánticos de color. Cuatro presets
661`const`: `Theme::dark()` (default), `light()`, `aurora()`, `sunset()`.
662
663```rust
664pub struct Theme {
665 pub name: &'static str,
666 // fondos
667 pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus,
668 pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color,
669 // texto
670 pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color,
671 // bordes y acento
672 pub border, border_focus, accent: Color,
673}
674
675Theme::all() -> Vec<Theme> // orden de rotación canónico
676Theme::by_name(name) -> Option<Theme>
677Theme::next_after(current_name) -> Theme // para el theme-switcher
678```
679
680Tokens auxiliares en el mismo crate:
681- `motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms}` + `ease_out_cubic`,
682 `ease_in_out_cubic`, `linear`.
683- `alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT}` (constantes `u8`).
684- `radius::{XS=2, SM=4, MD=8, LG=12, XL=20}` (`f64`).
685
686**Patrón de widgets**: cada widget define su `XxxPalette` con
687`Palette::from_theme(&theme)`. Tu app guarda un `Theme` en el Model, deriva las
688paletas que necesita en `view`, y se las pasa a los widgets. Para cambiar de
689tema, el `theme-switcher` emite `Msg(next_theme)` y reconstruyes todo.
690
691---
692
693## 12. Capas base
694
695### `llimphi-hal` — superficie
696```rust
697Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result<Hal, HalError> // async
698trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result<Frame,_>; fn present(frame, hal); }
699WinitSurface::new(hal, window: Arc<Window>) -> Result<Self, HalError>
700Frame::view() -> &wgpu::TextureView; Frame::size() -> (u32,u32)
701```
702`Hal::new` pide adapter `Backends::PRIMARY` (Vulkan) y cae a `all()` sólo si no
703hay — **no volver a `InstanceDescriptor::default()`**: el backend GL de Mesa
704sobre Wayland segfaultea en el teardown. El runtime de `llimphi-ui` ya maneja
705todo esto; sólo tocas HAL si escribes un runtime nuevo.
706
707### `llimphi-raster` — rasterización
708```rust
709Renderer::new(hal) -> Result<Renderer,_>
710Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
711// GPU directo:
712GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout
713GpuBatch::new(&pipelines)
714 .line_width(w) // pluma: vale para lo que se agregue DESPUÉS
715 .add_line(p0,p1,color) .add_polyline(&pts,color)
716 .add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
717 .add_disc(cx,cy,r,color) .add_ring(cx,cy,r,stroke,color)
718 .atlas(&AtlasTextura) // sin esto los quads NO se dibujan
719 .add_glyph(x,y,w,h,(u0,v0,u1,v1),color) // quad texturado: el TEXTO
720 .scissor(x,y,w,h) // sólo se toca ese rect (damage)
721 .max_buffer_bytes(bytes) // baja el techo por buffer; normalmente no se toca
722 .primitive_count() -> u32
723 .flush(device, queue, encoder, view, viewport, load_op)
724units_per_chunk(stride, max_bytes, multiplo) -> u32
725RECT_INSTANCE_STRIDE / LINE_INSTANCE_STRIDE / DISC_INSTANCE_STRIDE / TRI_VERTEX_STRIDE
726QUAD_INSTANCE_STRIDE
727// la textura de cobertura contra la que se muestrean los quads:
728AtlasTextura::new(device, &pipelines, lado) -> Self
729 .subir(queue, pixeles, (x,y,w,h)) // sube SÓLO ese rect, no el atlas entero
730```
731Re-exporta `vello` y `peniko` (`Color`, `Image`, `Fill`, etc.).
732
733Cuatro cosas del `flush` que conviene saber y no se ven en la firma:
734
735- **`line_width` es una pluma, no un modo del lote (cambió 2026-08-05).** Cada
736 línea se queda con el grosor que estaba puesto cuando se la agregó, así que
737 una grilla fina y una curva gruesa entran en el mismo `flush`. Antes el grosor
738 era un uniforme del lote entero y dibujar dos grosores obligaba a cortar —
739 y cortar no cuesta una draw call más: **cuesta el ciclo entero otra vez**,
740 porque cada `flush` arma su propia textura MSAA 4×, la resuelve y la compone
741 (2 texturas + 2 render passes por `flush`). El precio es 4 B más por línea
742 (36 en vez de 32), que baja el techo de un solo buffer de 8,4 M de líneas a
743 7,4 M; el troceado ya lo maneja.
744
745- **No hay techo de primitivas.** `max_buffer_size` del dispositivo (256 MB en
746 una Iris Xe) topa a ~8,4 M de instancias en **una** reserva, y pasado eso wgpu
747 rechaza: la app no se pone lenta, se cae. `flush` parte cada tipo en lotes y
748 dibuja uno por lote en orden, así que el alpha-over sale idéntico. En el caso
749 normal es un lote y las mismas cuatro draw calls. `max_buffer_bytes` existe
750 sobre todo para **testear** el troceado sin reservar cientos de MB.
751- **`scissor` recorta el costo, no sólo el dibujo.** Las texturas intermedias
752 (MSAA 4× + resolve) se crean del tamaño del rect sucio, porque el
753 `LoadOp::Clear` del attachment y el resolve son de toda la superficie y el
754 scissor no los toca. Sin eso, pintar un cursor de 32 px costaba limpiar y
755 resolver la pantalla entera. El rect se pide siempre en **coordenadas de
756 viewport**; `flush` baja el origen a par (las derivadas de `fwidth`, de las
757 que sale el AA del disco, se calculan por bloques de 2×2 alineados al target:
758 un corrimiento impar cambiaría la orla), así que puede tocar una fila y una
759 columna de más.
760- **Los quads sin atlas no se dibujan, y no revientan.** `add_glyph` acumula
761 igual, pero si nadie llamó a `atlas()` el `flush` los saltea en silencio. La
762 alternativa —un panic adentro del `flush`— sería reventar el frame de una app
763 porque alguien pidió texto antes de tener el atlas listo. Que falte el texto es
764 un síntoma leíble; que se caiga la ventana, no.
765
766### `llimphi-glifos` — el texto del camino GPU-directo
767```rust
768AtlasGlifos::new(lado) -> Self // atlas cuadrado de cobertura, 1 B/px
769 .pixeles() -> &[u8] .lado() -> u32 .version() -> u64 .entradas() -> usize
770 .tomar_sucio() -> Option<Sucio> // qué cambió desde la última vez, y limpia
771 .limpiar() // única salida cuando `fallos` sube
772 .fallos / .aciertos / .rasterizados // contadores públicos
773quads_de_layout(&mut atlas, &layout, (x,y), &mut Vec<QuadGlifo>) -> usize
774quads_de_run(&mut atlas, &glyph_run, (x,y), &mut Vec<QuadGlifo>) // multicolor
775```
776Rasteriza cada glifo **una sola vez** a un mapa de 8 bits y lo empaqueta por
777estantes; de ahí en más pintar texto es pintar quads. Un renglón de 80
778caracteres pasa de 80 contornos rellenos a 80 instancias de 48 B.
779
780Lo que hay que saber antes de usarlo:
781
782- **No shapea.** Recibe un `parley::Layout` ya resuelto —el que arma
783 `llimphi_text::Typesetter`—, así que el texto por GPU-directo y el texto por
784 vello parten del **mismo** layout. Certificado:
785 `llimphi-test/tests/texto_gpu_directo.rs` pone los dos caminos sobre el mismo
786 layout y mide `Δ64:0` — ningún píxel difiere más de 64/255 — con la caja de lo
787 pintado coincidente y 0,31 % de tinta de diferencia.
788- **Un color sólido por run.** El atlas guarda cobertura, no color, así que el
789 mismo glifo cacheado sirve para cualquier color sin re-rasterizar — pero un
790 brush (gradiente, imagen recortada al texto) no entra. Eso sigue siendo vello.
791- **Ni emoji ni glifos de color**: el atlas es de un canal. Pedirían un atlas
792 RGBA y un pipeline propio.
793- **No hintea**, a propósito: vello rasteriza el contorno crudo, y hintear haría
794 que el mismo texto saliera con métricas distintas por cada camino.
795- **No crece ni desaloja.** Lleno, devuelve `None` y suma a `fallos`. La salida
796 es `limpiar()` y volver a llenar. Una política de desalojo sin una app que la
797 presione se diseña a ciegas.
798- **Subí sólo el rect sucio.** `tomar_sucio()` + `AtlasTextura::subir` es el
799 camino esperado; subir el atlas entero por frame anula el punto de cachear (un
800 atlas de 1024² son 1 MiB por cuadro).
801
802**Hay DOS atlas de glifos en llimphi, y hay que saber cuál usar.** El otro es
803`llimphi_widget_terminal::GlyphAtlas`, anterior (Fase 4 del SDD-TERMINAL) y vivo:
804lo consume el modo TUI de shuma. `llimphi-glifos` se escribió sin advertirlo.
805
806Desde el 2026-08-05 **comparten rasterizador** (`llimphi_glifos::Rasterizador`,
807swash/zeno); antes el terminal tenía `fontdue` propio. Lo que sigue habiendo dos
808es el **empaquetado**, y eso es de diseño:
809
810| | `GlyphAtlas` (widget terminal) | `AtlasGlifos` (`llimphi-glifos`) |
811|---|---|---|
812| clave | `char` (charmap, sin shaping) | `(fuente, id, tamaño, subpíxel, síntesis)` |
813| empaquetado | grilla de celdas iguales | estantes de tamaño variable |
814| subpíxel | no | 4×4 |
815| fuentes | una, monoespaciada | cualquiera, varias por renglón |
816
817**Cuál usar:** una grilla de terminal, `GlyphAtlas` — puede asumir monoespaciado,
818celdas enteras y cero shaping, y esas suposiciones son parte de por qué es
819rápido. Cualquier otro texto, `llimphi-glifos`.
820
821**Si empaquetás a tu manera**, no escribas un tercer rasterizador:
822`Rasterizador::glifo(datos, ClaveGlifo::de_glifo(id, tam))` devuelve el
823`MapaGlifo` (cobertura + colocación) y vos lo ponés donde quieras. Y para lo que
824no necesita shaping hay `metricas`, `id_de_caracter` y `avance`.
825
826**La migración se hizo contra dos oráculos, no a ojo:**
827- `tests/metricas_fontdue.rs` — `fontdue` sobrevive como **dev-dependency** para
828 esto: la celda sale **idéntica** en 11/12/13/14/16/18 px, y de 42 glifos
829 comparados sólo **1** difiere (1 px de caja, el `0` a 13 px).
830- `tests/dos_atlas.rs` — los dos empaquetados entregan el mismo glifo.
831
832Y un tropiezo que dejó regla: la primera corrida de `dos_atlas` daba **44 %** de
833diferencia. No era el rasterizador — `TextBlock::simple` deja
834`font_family: None` y parley cae a **sans-serif**, así que comparaba dos
835tipografías. **Si comparás glifos, fijá la familia primero.**
836
837**¿Cuándo conviene?** Medido en metal (Iris Xe/Vulkan, `llimphi-gpu-bench`
838sección «Texto», 12.096 glifos): el total baja **3,6-5,0×** contra vello, y lo
839que importa es la *forma* — el costo del atlas es casi plano con la cantidad de
840texto (5,0 → 5,5 ms entre 7.200 y 12.096 glifos) mientras vello crece lineal
841(13,6 → 26,4 ms). Pero la mitad de **CPU** del atlas es **peor** que la de vello
842(~0,7×): vello apila un id y una posición por glifo, el atlas hace una búsqueda
843en tabla y arma un quad. Toda la ganancia viene del lado GPU.
844
845O sea: el atlas es para **mucho texto que se repinta seguido** — grilla de
846terminal, editor, etiquetas sobre un canvas denso. Para un rótulo suelto no
847compra nada y encima paga el ciclo fijo del `flush` (MSAA + resolve +
848composite). **El default sigue siendo vello.**
849
850### `llimphi-image` — decode pipeline
851```rust
852decode_bytes(&[u8]) -> Result<peniko::Image, DecodeError>
853load_path(&Path, max_bytes: u64) -> Result<peniko::Image, DecodeError>
854from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> peniko::Image
855// mismo decode, pero además dice de qué espacio venía (para editores):
856decode_bytes_con_origen(&[u8]) -> Result<Decodificada, DecodeError>
857load_path_con_origen(&Path, max_bytes: u64) -> Result<Decodificada, DecodeError>
858Decodificada { imagen: Image, origen: Option<Perfil>, convertida: bool }
859Decodificada::devolver_al_origen(&self, rgba: &mut [u8]) -> bool
860// perfiles de color (mod perfil):
861perfil::leer_icc(&[u8]) -> Option<Perfil> // matrix-shaper; None si no lo es
862perfil::convertir_a_srgb(&mut [u8], &Perfil) -> bool // entrada: del perfil a sRGB
863perfil::convertir_desde_srgb(&mut [u8], &Perfil) -> bool // salida: de sRGB al perfil
864perfil::matriz_a_srgb(&[f32;9]) -> [f32;9] / matriz_desde_srgb(&[f32;9]) -> Option<[f32;9]>
865perfil::es_identidad(&[f32;9], tol) -> bool
866Curva::{Lineal, Gamma(f32), Tabla(Vec<u16>), Parametrica{tipo, p}}
867Curva::a_lineal(v) -> f32 / Curva::desde_lineal(lin) -> f32 // una es inversa de la otra
868```
869Encapsula el patrón `image::ImageReader + to_rgba8 + Blob + Image::new` que cada
870app que decodifica imágenes tenía duplicado. `max_bytes` aplica al tamaño del
871archivo en disco (no a la imagen decodificada, que en RGBA8 puede ser mucho
872mayor — un PNG 4K ocupa ~64 MB descomprimido); `0` deshabilita el cap.
873Re-exporta `peniko::{Blob, Image, ImageFormat}`. Formatos según las features
874del crate `image` upstream del workspace (hoy: PNG, JPEG, WEBP).
875
876**Gestión de color (2026-08-05).** `decode_bytes` / `load_path` leen el perfil
877ICC embebido y llevan la imagen a **sRGB**. Antes se tiraba: una foto Display P3
878—lo que saca cualquier teléfono desde 2016— se pintaba **sobresaturada**, porque
879sus rojos, que son más rojos que los de sRGB, se mostraban como si fueran los de
880sRGB. Nadie tiene que hacer nada: el caller sigue recibiendo RGBA8 sRGB.
881
882- Cubre perfiles **matrix-shaper** (matriz 3×3 + curva), que es lo que son sRGB,
883 Display P3, Adobe RGB, Rec.2020 y casi todos los de pantalla y cámara.
884- **No** cubre los basados en LUT (`A2B0`), como los CMYK de imprenta: ahí no
885 convierte y deja la imagen como estaba, que es mejor que inventar.
886- Sin perfil se asume sRGB (la convención de la web). Con perfil sRGB, la matriz
887 da la identidad y **no se recorre un solo píxel**.
888- El recorte a gamut es por saturación de canal: un rojo P3 puro no existe en
889 sRGB y termina en el rojo sRGB más cercano.
890**Volver al espacio de origen (2026-08-05).** Un editor no puede conformarse con
891recibir sRGB: si `tullpu` abre una foto Display P3 y la guarda, tiene que
892guardarla **en P3**, no degradar el archivo del usuario por el solo hecho de
893haberlo abierto. Para eso están `decode_bytes_con_origen` / `load_path_con_origen`,
894que devuelven una `Decodificada` con el perfil del archivo al lado de los píxeles.
895
896```rust
897let d = llimphi_image::load_path_con_origen(ruta, 64 << 20)?;
898let mut px: Vec<u8> = d.imagen.image.data.as_ref().to_vec(); // sRGB, para editar
899// …edición…
900d.devolver_al_origen(&mut px); // false si no hay a dónde volver: se guarda sRGB
901```
902
903- `origen: None` **no** significa «era sRGB»: significa que no había perfil o que
904 era de los que este crate no entiende (CMYK/LUT). En los dos casos los píxeles
905 quedaron como venían y no hay a dónde volver.
906- `convertida: false` con `origen: Some(…)` es el caso corriente: el archivo ya
907 venía en sRGB y no se recorrió un píxel.
908- **La ida y vuelta no es sin pérdida, por dos razones distintas.** La segunda
909 cuantización a 8 bits mete ±1. Y lo que se recortó al **entrar** (un rojo P3 que
910 en sRGB no existe) no vuelve: se perdió en el recorte, no al salir. Editar un P3
911 sin pérdida pide trabajar en P3; el `Perfil` expuesto es lo que lo habilita.
912- Quien sólo va a pintar sigue usando `decode_bytes` / `load_path` y no se entera:
913 son las mismas funciones, que ahora delegan y tiran el perfil.
914
915### `llimphi-text` — shaping
916```rust
917Typesetter::new() // una por proceso (FontContext es caro)
918 .layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()>
919 .layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout<RunBrush>
920TextBlock::simple(text, size_px, color, origin)
921layout_block(ts, &block) / measure(ts, &block) -> Measurement
922draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin)
923Alignment::{Start, Center, End, Justify}
924```
925
926### `llimphi-motion` — tweens
927```rust
928trait Lerp { fn lerp(self, other, t: f32) -> Self; } // impl para f32,f64,(f32,f32),(f64,f64),Color
929Tween::new(from, to, duration, easing: fn(f32)->f32) // o Tween::idle(value)
930tween.value() / .progress() / .done()
931animate(handle, duration, make_msg) // arranca los ticks del tween
932```
933Patrón: guardas `Tween<T>` en el Model, `animate(...)` en el update, la `view`
934lee `tween.value()` cada repaint. El tween se auto-termina.
935
936**Y `llimphi_motion::morfo` — transformar una forma en otra** (el escalón sobre
937el tween: interpola la *figura*, no un número):
938```rust
939Silueta::nueva([contorno, hueco]) // contornos en CAJA UNIDAD (0..1)
940morfo(&a, &b, t) -> Vec<Vec<(f64,f64)>> // escalás al PaintRect y rellenás con EvenOdd
941banda(&polilinea, grosor) / rect_redondo(..) // un trazo o un chip, como contorno
942Transicion::nueva(destino, dur_ms) // .ir_a(destino, ahora_ms) / .valor(ahora) / .activa(ahora)
943```
944Los contornos que sobran **se cierran sobre el centroide** del otro (el hueco de
945la chakana desapareciendo hacia el chevron): eso es el cambio de topología, y es
946lo que separa esto de un crossfade. Consumidores: el PS1 de pata
947(`render::task_manager::silueta_chakana`/`silueta_chevron`) y el botón del canto
948del input (`shuma_voz_ui::boton_voz_o_enviar`, micrófono ↔ avioncito).
949
950`Transicion` mide contra el **reloj de pared** porque `.animated_*` es inerte
951bajo pata (capa layer-shell); mientras `.activa()` diga que sí, el host tiene que
952seguir pidiendo cuadros o el morfo se congela a mitad.
953
954### `llimphi-icons` — iconos vectoriales (grid 24×24)
955```rust
956Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right,
957 Home, Search, Info, Warning, Error, Bell, Settings, More, ...}
958// Herramientas de pintura (2026-08-28), para la caja de un editor de imágenes:
959Icon::{Move, Eyedropper, Marquee, Bucket, Brush, Eraser, Gradient, Wand, Lasso,
960 Polygon, Type, Stamp, Bandage, Pen, Layers}
961// Y sus CONTROLES (2026-08-29), para que un panel de acciones se lea de un
962// vistazo en vez de renglón por renglón:
963Icon::{SelectAll, Expand, Contract, Scissors, Paste, Duplicate, Crop, Trim, Mask,
964 Invert, Mirror, MirrorV, Square, Circle, RoundedSquare, Star, Hexagon,
965 Line, Flatten, RotateCw, RotateCcw, Shadow, Brightness, Contrast,
966 ColorWheel, Blur, Levels, Curve, Segment, Upscale}
967icon_view(Icon, color, stroke_width) -> View<Msg>
968paint_icon(scene, rect, icon, color, stroke_width) // dentro de un paint_with
969```
970`stroke_width` en unidades del grid 24×24 (1.6 es armónico). Si a una app le
971falta uno, **se agrega acá** (lo dice el propio crate): la consistencia visual
972pesa más que el aislamiento, y un ícono en el crate de la app no lo encuentra
973nadie más.
974
975### `llimphi-surface` — texturas externas
976```rust
977ExternalSurface::new(device, queue) // barato de clonar (Arc<Mutex> interno)
978 .upload(&rgba, w, h) // desde otro hilo/decoder/cámara
979 .view(style) -> View<Msg> // blittea a su rect en el árbol Elm
980 .blit(queue, encoder, dst_view, rect, viewport) // o manual desde gpu_paint_with
981```
982
983---
984
985## 13. Catálogo de widgets
986
987Los widgets son **funciones puras** que devuelven `View<Msg>` (o specs que se
988convierten a `View`). Son **stateless**: el estado vive en tu Model. Convención:
989cada uno trae `XxxPalette::from_theme(&Theme)`. Crates en
990`widgets/<nombre>/`, dep `llimphi-widget-<nombre>`.
991
992### Controles
993
994**button** — `button_view(label, &ButtonPalette, on_click: Msg) -> View`;
995`button_styled(label, style, alignment, &palette, on_click)`.
996
997**field** — wrapper de formulario (label + helper/error + requerido).
998`field_view(FieldSpec { label, control: View<Msg>, required, helper, error, palette })`.
999
1000**text-input** — input single-line **con estado** `TextInputState`
1001(`new()`/`masked()`, `text()`, `set_text()`, `apply_key(&KeyEvent) -> bool`,
1002soporta undo/redo + selección con Shift). Render:
1003`text_input_view(&state, placeholder, focused, &palette, on_focus: Msg)`.
1004
1005**text-area** — input **multilínea** con paridad real (mismo motor `EditorState`
1006que el text-input; NO el viejo `String` plano). El estado es
1007`TextInputState::multiline()` (o `llimphi_widget_text_area::new_state()`).
1008`text_area_view(&state, placeholder, focused, rows, &palette, |ev| Msg::Area(ev))`
1009emite `TextAreaEvent` (Key/Ime/Press(x,y)/Drag/DragEnd/Context) ya envueltos; en
1010`update` → `state.handle_area(ev, &mut clipboard)`. Da: **click 2D** (renglón+columna
1011por glifos reales), ↑/↓ entre renglones, selección a través de líneas (arrastre 2D,
1012doble/triple-click, Shift+flechas), copiar/cortar/pegar, undo/redo, IME, Enter=`\n`,
1013**soft-wrap** (la línea larga se envuelve al ancho de la caja) y **scroll vertical**
1014(caret a la vista si hay más renglones que `rows`). Caret + selección pintados por
1015`paint_over` en coords de renglón visual; fuente única del layout: `visual_layout`
1016(render/hit-testing/caret/nav comparten). **↑/↓ navegan por renglón visual** (no por
1017línea lógica: en texto envuelto bajan un renglón, no la línea entera), con columna
1018meta en px preservada (`move_vertical_visual`; Shift extiende selección). El crate
1019`llimphi-widget-text-area` es una fachada que re-exporta esto. Demo:
1020`cargo run -p llimphi-widget-text-input --example area_demo`.
1021
1022**text-area RICO** — el mismo widget con los adornos que antes obligaban a
1023escribirse un pintor propio. `text_area_view_rico(&state, focused, rows,
1024&palette, &Metricas, &Adornos, wrap)`. **Si necesitás un input mono, coloreado,
1025con fantasma o con caret animado, es ESTE, no un `paint_with` a mano.**
1026- `Metricas { font_size, line_h, font_family, pad_x, pad_r, pad_y }` —
1027 `Metricas::mono(px)` + `.con_zoom(z)`. La usan pintado, ajuste blando y
1028 click→columna: una sola, o el caret cae entre glifos que no son los que se ven.
1029- `Adornos { tramos, ghost, placeholder, caret, ahora_ms, marco }`.
1030 `tramos: Vec<(ini, fin, Color)>` en **bytes del texto completo** (se recortan
1031 solos a cada renglón); `ghost` = sufijo tenue tras el caret (sólo si el caret
1032 está al final); `Placeholder { texto, color, italic, icono, icono_color, alpha }`;
1033 `marco: false` = sin caja propia (para un host que ya tiene la suya — el borde
1034 del widget es un relleno **opaco** que taparía un backdrop frosted).
1035- `CaretEstilo::vivo()` = parpadeo que queda **sólido** tras cada tecla y
1036 **estela** al moverse (el eyecandy en honor a kitty). El reloj lo trae el host:
1037 `Adornos::ahora_ms` por cuadro + `state.marcar_actividad(ms)` en cada tecla —
1038 el widget no llama a `now()` por dentro (sería intesteable, y un `bool` de fase
1039 alternado por un timer externo se muestrea mal cuando el panel repinta lento).
1040- Consultas para un host que **crece con el contenido**:
1041 `renglones_visuales()`, `renglones_visuales_de(txt)`, `avance_en_renglon()`,
1042 `ancho_caracter()`, `fijar_ancho_caja(px)` (sembrar el ancho antes del primer
1043 cuadro, o testear el wrap sin GPU).
1044`text_area_view` es esta misma vista con `Adornos::default()` — un solo
1045renderizador. Uso canónico: el input de la barra de shuma
1046(`02_ruway/shuma/sandbox/shuma-module-shell/src/view/input.rs`), que le pasa los
1047tokens del shell como `tramos` y monta su propio marco con `marco: false`.
1048
1049**slider** — sin estado. `slider_view(label, value, min, max, &palette,
1050on_change: Fn(DragPhase, delta_value) -> Option<Msg>)`. El delta viene en
1051unidades, no píxeles.
1052
1053**color-picker** — selector RGBA: swatch actual + paleta de chips + barra
1054de hue + sliders R/G/B/A (+ campo `#RRGGBB` opcional). Agnóstico, emite
1055`[u8; 4]`. `color_picker_view(rgba: [u8;4], swatches: &[[u8;3]] (p.ej.
1056DEFAULT_SWATCHES), &ColorPickerPalette, hex: Option<HexField { focused,
1057state: Option<&TextInputState>, on_focus }>, on_change: Fn([u8;4]) -> Msg)`.
1058Helpers puros: `rgba_to_hex`, `parse_hex(s, cur_alpha)` (acepta 3/6/8
1059dígitos, `#` opcional), `color_picker_height(with_hex)` para pre-calcular
1060el alto.
1061
1062**switch** — `switch_view(progress: f32 [0..1], on_toggle: Msg, &palette)`. La
1063app guarda el `bool` y opcionalmente anima `progress` con un `Tween`.
1064
1065**segmented** — N opciones exclusivas. `segmented_view(&[&str], selected: usize,
1066make_msg: Fn(usize)->Msg, &palette)`.
1067
1068**select** — combobox/dropdown moderno (disparador cerrado + menú flotante en
1069`view_overlay`). Soporta **ítems ricos** (`SelectItem { label, sublabel, icon
1070(glifo unicode), badge: SelectBadge, enabled }`), **selección múltiple**
1071(`selected: &[usize]` con check ✓), **búsqueda** in-menu y **fases async**
1072(`SelectPhase::{Loading, Error(&str), Ready(&[SelectItem])}`) para listas que
1073vienen de `Handle::spawn`. El cerrado se pinta con `select_trigger_view(selected:
1074Option<&SelectItem>, placeholder, open, width, &palette, on_toggle: Msg)`; el
1075menú con `select_menu_view(SelectMenuSpec { anchor, viewport, width, phase,
1076visible: &[orig_idx], active, selected, query, searchable, empty_text, appear,
1077on_pick: Fn(orig_idx)->Msg, on_hover, on_dismiss, on_retry, palette })`. La app
1078mantiene `query`/`active`/`visible`/`selected` en el Model. Helpers puros para
1079el filtro: `filter(items, query) -> Vec<orig_idx>`, `step_active(items, visible,
1080current, ±1)` para nav, `resolve(visible, pos) -> Option<orig_idx>`.
1081⚠️ `select_menu_view` es **sólo para overlay**: devuelve un scrim de 100 %×100 %
1082con el panel `Position::Absolute` adentro. Sin overlay donde colgarlo —una
1083surface de layer-shell con su propio tamaño y el clip de un scroll— va
1084`select_menu_inline_view(spec)`, que entrega el mismo panel **en el flujo**, con
1085alto declarado, y empuja lo de abajo (sin scrim: cerrar es volver a apretar el
1086disparador; ignora `anchor`/`viewport`). Meter el flotante en el flujo pinta el
1087panel fuera de los límites de su padre y **el contenido siguiente lo tapa**:
1088medido con el dedo, 2 de 4 filas inalcanzables (pata, panel de video, 5-oct).
1089
1090**progress** — `linear_progress_view(progress, track, fill, height)` y
1091`radial_progress_view(progress, track, fill, stroke_ratio)`. Sin eventos.
1092
1093**spinner** — `spinner_view(color, stroke_ratio, speed_rev_per_sec)`. Animado por
1094reloj absoluto; requiere redraws periódicos (`spawn_periodic`).
1095
1096**rive-button** — botón reactivo dirigido por **máquina de estados** (`llimphi-anim`,
1097estilo Rive) — primer consumidor real de la `StateMachine` fuera del studio.
1098**Stateful** (como `text-editor`): guardas `RiveButton` en tu Model. Tres estados
1099`idle`/`hover`/`press`, cada uno con su clip Lottie; hover por bool (listeners
1100Enter/Exit del motor), press por trigger (`ClipDone` lo devuelve solo). API:
1101`RiveButton::new(idle, hover, press, press_secs, blend_secs)` o `::builtin()`
1102(clips embebidos); `advance(dt)` desde un tick, `pointer(Option<(f64,f64)>)`,
1103`press()` desde el `on_click` del host, `current_state()`/`is_transitioning()`.
1104Render: `view(on_pointer: Fn(Option<(f64,f64)>)->Msg, on_click: Msg)`. Demo:
1105`cargo run -p llimphi-widget-rive-button --example rive_button_demo --release`.
1106
1107**badge** — `count_badge_view(count, BadgeKind)` ("99+" si ≥100) y
1108`dot_badge_view(BadgeKind)`. `BadgeKind::{Info,Success,Warning,Error,Neutral}`.
1109
1110**avatar** — `avatar_view(name, size_px)`: círculo determinista (color por hash
1111del nombre + inicial).
1112
1113**tooltip** — render puro. `tooltip_view(TooltipSpec { anchor, viewport, side:
1114Side, text, palette })`. Se monta en `view_overlay`; la app controla visibilidad
1115con `on_pointer_enter/leave`.
1116
1117**empty** — empty-state. `empty_view(Icon, title, description: Option<&str>, &palette)`.
1118
1119**skeleton** — placeholder con shimmer: una banda de gradiente `[low, high,
1120low]` que **cruza** el rect. `skeleton_view`, `skeleton_box_view(w,h,..)`,
1121`skeleton_line_view(w,..)`. Requiere redraws periódicos (`spawn_periodic(50ms)`
1122mientras haya skeletons visibles). Esas tres arrancan reloj propio y están
1123gateadas a `std`; `skeleton_view_en(inicio, ahora, &pal)` recibe el instante por
1124argumento y es la que sirve **sin `std`** (jaula/kernel), misma doctrina que el
1125`today` del calendar. La aritmética es pura y testeable: `banda_shimmer(ancho,
1126progreso) -> (izq, der)` —la banda entra y sale entera del rect, con piso de
1127ancho para que en un avatar chico el destello no sea un píxel— y
1128`progreso_shimmer(segundos) -> [0,1)`.
1129
1130**carousel** — pager paginado de N páginas. `carousel_view(CarouselSpec {
1131pages, current, wrap: CarouselWrap::{Wrap, Clamp}, show_arrows, palette,
1132on_change: Fn(usize) -> Msg })`. Dots indicadores abajo (clickeables para saltar
1133a la página i) + flechas opcionales `‹` / `›` a los costados. v1 sin swipe;
1134para v2 sumar swipe horizontal usando `draggable_velocity` + `fling_step` para
1135snap-on-release.
1136
1137**fitted-box** — escala un subárbol al slot del padre. `fitted_box((iw, ih),
1138BoxFit::{Contain, Cover, Fill, None, ScaleDown}, || inner_view())` aplica un
1139transform afín al inner (medido por el seam `LayoutBuilder`) para que entre en
1140el slot real, centrado, preservando aspect salvo `Fill`. Útil para imágenes,
1141canvases `paint_with` y texto grande que deben caber en celdas chicas sin que el
1142caller los re-mida. La función pura `compute_fit(slot, inner, fit) ->
1143(sx, sy, dx, dy)` es testeable.
1144
1145**calendar** — vista mensual 6×7 (`calendar_view(CalendarSpec { view_year,
1146view_month, selected, today, week_start, palette, on_select: Fn(NaiveDate)->Msg,
1147on_view_change: Fn(year, month)->Msg })`). Header con `< Mes Año >` para navegar
1148entre meses, fila de iniciales (`L M M J V S D` ó `D L M M J V S` según
1149`WeekStart`), grilla siempre de 6 filas (no reflowea al cambiar de mes). El
1150caller le inyecta `today` — el widget no toca el reloj. Base del date-picker:
1151combinar con `view_overlay` + un `field`/`button` disparador.
1152`calendar_view_ext(spec, CalendarOpciones { limites, rango, navegacion_anual })`
1153suma lo que un `<input type=date>` sí tiene, sin tocar a quien ya usa
1154`calendar_view` (el `Default` de las opciones **es** el calendario de siempre):
1155`Limites{min,max}` inclusive apaga los días fuera de rango (`aria_disabled`, sin
1156click) y las flechas cuyo mes destino esté entero afuera (`mes_habil`);
1157`RangoSeleccion` pinta la banda entre dos extremos y avanza sola con
1158`.click(fecha)` (1º fija inicio, 2º cierra **ordenando**, 3º empieza de nuevo;
1159dos clicks al mismo día = rango de un día), con `contiene`/`es_extremo`/`dias`/
1160`recortado(&limites)` —que **interseca**, no clampea cada punta— para el host;
1161`navegacion_anual` agrega una fila `« año »` arriba de la de mes (fila aparte y
1162no cuatro flechas en una: el ancho lo fija la grilla y «septiembre» no entraría).
1163Teclado: `cal_move_desde_tecla(&KeyEvent) -> Option<CalMove>` es el mapa del
1164picker nativo (flechas = día/semana, PageUp/Down = mes, **Ctrl**+PageUp/Down =
1165año, Home/End = bordes del mes) y `nav_date_limitada(fecha, mov, &limites)`
1166aterriza en el borde en vez de salirse.
1167
1168**accordion** — secciones plegables (expansion panels).
1169`accordion_view(AccordionSpec{secciones, abiertas: &[usize], modo, on_toggle,
1170palette})` con `Modo::{Una, Varias}`; la máquina de qué queda abierto es
1171`alternar(&abiertas, i, modo) -> Vec<usize>` (pura) y `todas(n, modo)` da el
1172«expandir/plegar todo» — en `Modo::Una` devuelve **una**, porque devolver todas
1173dejaría el `Model` en un estado que el propio modo prohíbe. El cuerpo llega como
1174`Fn() -> View` y **no se construye si la sección está cerrada**: en un acordeón
1175de veinte secciones con formularios adentro, armar los diecinueve invisibles es
1176el costo real. `Seccion::con_resumen("Visa ••4242")` muestra el dato a la
1177derecha **sólo cuando está cerrada**.
1178
1179**stepper** — asistente por pasos. `stepper_view(StepperSpec{pasos: &[Paso],
1180actual, cuerpo, on_ir, on_finalizar, palette})`; `Paso::{pendiente, actual,
1181hecho, con_error}`. La regla de navegación es pura: `puede_ir_a(pasos, actual,
1182destino)` —atrás siempre, adelante sólo si el actual está `Hecho`, de un salto
1183sólo si **todos** los intermedios lo están—, más `siguiente`/`anterior`/
1184`es_ultimo`/`hechos`. En el último paso el botón dice **Finalizar** y emite otro
1185`Msg`. Los botones del pie y los pasos inalcanzables se **apagan** con
1186`aria_disabled`, no desaparecen ni quedan mudos.
1187
1188**form** — **validación** de formularios sobre el `field`. Tres piezas, y la
1189tercera es la que se hace mal en todos lados:
1190```rust
1191Formulario::new(vec![ // qué se pide
1192 CampoDef::new(Id::Correo, "Correo").reglas(vec![Regla::Requerido, Regla::Correo]),
1193 CampoDef::new(Id::Edad, "Edad").regla(Regla::Rango(18.0, 120.0)).ayuda("…"),
1194])
1195form.validar(&[(Id::Correo, &m.correo), …]) -> Errores<Id> // qué está mal: TODOS
1196estado.tocar(id) · estado.enviar() · estado.visibles(&errores) // cuándo se dice
1197campo_view(&form, id, control, &visibles, &pal) // etiqueta + asterisco + error
1198resumen_errores_view(&visibles, Msg::IrA, &pal) -> Option<View>
1199```
1200`Regla::{Requerido, LargoMin, LargoMax, Numero, Rango, Correo, Propia(fn)}`. Un
1201valor **vacío pasa todas menos `Requerido`** (si no, un campo opcional en blanco
1202se queja de formato). El error se muestra al **salir** del campo o al **intentar
1203enviar**, nunca mientras se tipea por primera vez; tras el submit se muestran
1204todos, porque esconder alguno sería mentir sobre por qué no se envió.
1205`campo_view` saca del formulario la etiqueta, el **asterisco de obligatorio**
1206(derivado de las reglas, no de un flag aparte) y el error: los tres dejan de
1207escribirse dos veces. `resumen_errores_view` devuelve `None` si no hay nada que
1208decir, y cada ítem es clickeable para ir al campo — en un formulario largo el
1209error puede quedar fuera de pantalla y sólo se ve que el botón no hizo nada.
1210
1211**datetime-picker** — pickers de **fecha** y **hora**, los dos con la misma
1212forma: un `*_trigger` en el formulario (muestra el valor formateado o el
1213placeholder, emite `Msg` al click) y un `*_popup` que el host monta en
1214`App::view_overlay` con scrim que cierra al click-fuera. Sin estado propio: el
1215valor, si está abierto y el mes en foco viven en el `Model`.
1216```rust
1217date_picker_trigger(DateTrigger{valor, formato, placeholder, enabled, on_open, palette})
1218date_picker_popup(DatePopup{anchor, viewport, valor, view:(año,mes), today,
1219 week_start, opciones: CalendarOpciones, on_pick, on_view_change, on_dismiss,
1220 on_clear, acciones: Acciones::hoy_y_limpiar(), palette, theme})
1221time_picker_trigger(TimeTrigger{..}) · time_picker_popup(TimePopup{.., paso_minutos})
1222formatear_fecha(d, FormatoFecha::{Iso,DiaMesAnio,Largo}) · parsear_fecha(&str)
1223Hora{h,m}: formatear/parsear (FormatoHora::{H24,H12}) · mover(campo,±1,paso) · al_paso
1224```
1225El de fecha **compone el `calendar`**, así que hereda límites min/max, rango,
1226salto de año y mapa de teclas — no reimplementa la grilla. El de hora es grilla
1227de horas + minutos por paso: elegir la hora **conserva** el minuto y viceversa.
1228`parsear_fecha` acepta ISO y día-primero con `-`, `/` o `.`, y **rechaza** el año
1229de dos dígitos (adivinar en una fecha es peor que devolver `None`). `Hora`
1230resuelve el caso que todos yerran: medianoche es 12 AM y mediodía 12 PM, no 0.
1231
1232**banner** — tira de status. `banner_view(BannerKind::{Info,Success,Warning,Error}, message)`.
1233
1234### Contenedores y layout
1235
1236**panel** — chrome (gradiente + hairline accent). `panel_view(children, PanelStyle)`;
1237`PanelStyle::{from_theme, from_theme_large, neutral}`. `panel_signature_painter(style)`
1238para reusar el look en un `paint_with`.
1239
1240**card** — `card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette)`.
1241
1242**stat-card** — métrica de dashboard. `stat_card_view(label, value, description,
1243accent, &recent_items, &palette)`.
1244
1245**tabs** — `tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg,
1246content: View<Msg>, tab_height, palette, tab_width })`. Selección la maneja la app.
1247`tabs_view_sliding(spec, indicator_t)` cambia el acento por-tab (que se prende de
1248golpe) por un **realce único que se desliza** hasta `indicator_t` — el índice
1249animado que el host tween-ea con `llimphi-motion` hacia el activo. Como cada tab
1250mide lo que dice su label, interpola **posición y ancho** a la vez
1251(`indicator_bounds_px(&anchos, t)`; los anchos con `tab_widths(&labels,
1252tab_width, closable)`, la misma cuenta que el render y que `strip_width`), así se
1253estira al ir hacia un tab más largo. Con overflow viaja dentro de la tira, o sea
1254que el `strip_offset` lo arrastra solo. `indicator_t = active as f32` da la
1255posición estática, para un host que no quiera animar.
1256
1257**router** — navegación por **pila de rutas** con transiciones de página
1258(el `Navigator` de Flutter, a la Elm). La pila vive en el Model; el widget
1259pinta sólo la ruta actual y anima el cambio con la infra de ghosts del
1260compositor (`animated_switch_styled`). Push entra desde la derecha y lo viejo
1261retrocede 30 % (shared-axis X); pop lo inverso; `Zoom` = fade-through;
1262`Cover` = **push opaco estilo UIKit** (la nueva tapa sin fade; el pop desliza
1263la vieja por encima revelando — scene-split de fantasmas: el saliente se
1264pinta DEBAJO del entrante, en su posición z real); `None` = corte seco sin
1265captura. Los **hero** cruzan rutas gratis (misma key en ambas páginas).
1266```rust
1267// Model: nav: RouteStack<Ruta> (enum propio: Clone + PartialEq + Hash)
1268m.nav.push(Ruta::Detalle(7)); m.nav.pop(); m.nav.replace(r); m.nav.pop_to_root();
1269router_view(key, &m.nav, PageTransition::{None,Fade,Slide,Zoom,Cover}, dur,
1270 page: Fn(&Ruta) -> View<Msg>) // mide su slot vía LayoutBuilder
1271router_view_sized(key, &m.nav, tr, dur, (w, h), page) // slot explícito — para
1272 // páginas que anidan otro layout_builder (límite v1 del seam: sin anidar)
1273```
1274Demo: `cargo run -p llimphi-widget-router --example router_demo --release`
1275(hero volando entre Lista y Detalle; `T` cicla la transición, Esc vuelve).
1276
1277**hero** — shared-element transition (el Hero de Flutter auténtico): un nodo
1278con la misma `key` que aparece en otro rect en un frame posterior **vuela**
1279del rect viejo al nuevo (el `HeroRegistry` del runtime interpola `transform`).
1280`hero_view(key, child)` (480 ms) / `hero_quick(key, child)` (320 ms), o el
1281primitive `View::hero(key, dur)` directo. Key estable y única entre los heroes
1282de un mismo frame.
1283
1284**splitter** — divisor draggable de 2 panes. `splitter_two(Direction::{Row,Column},
1285a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette)`.
1286`PaneSize::{Fixed(px), Flex}`. La app acumula el delta en su Model (con
1287`apply_resize(actual, delta, min, max)`, que clampea por paso).
1288`splitter_two_ext(…, SplitterOpciones { on_toggle, focus_id })` agrega el
1289**doble-click** sobre el divisor y lo hace **enfocable**, que es la puerta del
1290resize por teclado: con el foco puesto, el host traduce la tecla con
1291`ajuste_desde_tecla(&ev, direction) -> Option<AjusteSplitter>` (las flechas del
1292eje del divisor —←/→ en `Row`, ↑/↓ en `Column`—, `Shift` para el paso grande,
1293Home/End a los topes, Enter/Espacio para alternar) y la aplica con
1294`aplicar_ajuste`. El colapso lo resuelve `alternar_colapso(actual, recordado,
1295por_defecto) -> (nuevo, a_recordar)`: guarda **cuánto** medía, no que estaba
1296abierto, así restaurar devuelve el layout del usuario y no un ancho de fábrica.
1297
1298**scroll** — área de scroll vertical con barra arrastrable. `scroll_y(offset,
1299content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette)`.
1300Stateless (offset en el Model); rueda autocontenida. Helpers: `clamp_offset`,
1301`ensure_visible` (selección a la vista), `approach` (scroll suave). Ver §8.
1302Para darle scroll al cuerpo de un widget de filas está la pieza **compartida**
1303—la usan `list`, `tree` y `detail-table`, y por eso vive acá y no copiada tres
1304veces—: `cuerpo_scrolleable(contenido, contenido_len, DesplazamientoV{offset,
1305viewport_len, on_scroll}, medidor: Option<AltoVisible>, &palette)`.
1306`AltoVisible` publica el alto **medido** desde el pintado (el alto disponible no
1307existe hasta que taffy repartió) y `offset_efectivo(offset, viewport_len)` pinta
1308desde arriba mientras no haya medida — `clamp_offset` con viewport `0` da por
1309bueno cualquier offset, así que sin esto una lista re-montada con offset
1310guardado arranca desplazada y salta al cuadro siguiente.
1311
1312**lazy-list** — lista **perezosa** llave-en-mano (Bloque 22 de
1313`PLAN-NUCLEO-RETENIDO.md`). A diferencia de `list`/`grid` (el caller windowea a
1314mano) y de `scroll_y` (recibe el contenido ya construido), recibe un **builder**
1315`item: Fn(usize) -> View` + `total` y **sólo llama `item(i)` para la ventana
1316visible + overscan** — las filas fuera de pantalla no se construyen, no se
1317montan, no se layoutean (costo/frame ∝ lo visible, no ∝ el total). Sin estado
1318(offset en el Model); reusa la barra/física de `scroll`.
1319```rust
1320lazy_list_fixed(total, item_h, offset, viewport_h, overscan,
1321 item: Fn(usize)->View, on_scroll: Fn(f32)->Msg, &ScrollPalette) // extent uniforme, ventana O(1)
1322lazy_list_varied(offsets: &[f32] /*len total+1, tops acumulados*/, offset, viewport_h,
1323 overscan, item, on_scroll, &palette) // extent por fila, O(log n)
1324// extent CALCULABLE barato (el sabor a preferir para listas grandes de alto variado):
1325IndiceExtent::nuevo(total, extent: Fn(usize)->f32) // índice por bloques de BLOQUE_EXTENT (1024)
1326lazy_list_extent(&indice, extent, offset, viewport_h, overscan, item, on_scroll, &palette)
1327// guarda 1 float cada 1024 filas en vez de 1 por fila (1M filas: 978 floats ≈ 3,9 KB
1328// contra 4 MB) y NUNCA construye un View para medir. Consulta = binaria + ≤1 bloque.
1329// helpers puros: visible_window(total, item_h, offset, vp, overscan) -> Window{first,count,y0}
1330// visible_window_varied(offsets, offset, vp, overscan), content_height(total, item_h)
1331// visible_window_extent(&indice, &extent, offset, vp, overscan)
1332// IndiceExtent::{y, indice_en, alto_total, len_indice}
1333// altura MEDIDA (listas heterogéneas cuyo alto depende del contenido/reflow):
1334item_offsets(total, ancho, item: Fn(usize)->View) -> Vec<f32> // mide cada ítem → offsets acumulados
1335// el caller lo llama al cambiar contenido/ancho, cachea el Vec, y por frame usa
1336// lazy_list_varied(&offsets, ...). Usa llimphi_compositor::measured_height(view, ancho).
1337// SECCIONES con encabezado sticky llave-en-mano (agenda/contactos):
1338lazy_list_sections(rows_por_seccion: &[usize], item_h, header_h, offset, viewport_h,
1339 overscan, header: Fn(s)->View, item: Fn(s, fila_local)->View, on_scroll, &palette)
1340// el header de la sección activa queda PINEADO al tope y la siguiente lo empuja;
1341// darle fill opaco (pinta encima de las filas). Ventana O(#secciones + visibles).
1342// puros: section_tops, sections_window -> Vec<SectionUnit>, sticky_section
1343// re-exporta de scroll: clamp_offset, fling_step, rubber_band, sticky_y, DEFAULT_OVERSCAN, ScrollPalette
1344```
1345Demo: `cargo run -p llimphi-widget-lazy-list --example stress_lazy --release -- --nodes 1000000`.
1346
1347**tiled** — grilla auto cols×rows de tiles con title bar. `tiled_view(tiles, &palette)`,
1348`tiled_view_cols(tiles, cols, &palette)`, y variantes `*_reorderable*` con
1349`on_reorder: Fn(from, to)->Option<Msg>` (drag-to-swap por la title bar). `TileSpec { label, content }`.
1350
1351**panes** — árbol binario BSP tipo tmux. La app guarda un `Layout`:
1352```rust
1353Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second }
1354layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves()
1355panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec<Side>,DragPhase,delta)->Option<Msg>,
1356 on_focus: Fn(PaneId)->Msg, &palette)
1357```
1358
1359**dock-rail** — rail vertical de **dientes** para sidebars acoplables. Cada
1360diente es una pestaña: **barra de acento de 3px pegada al borde interno**
1361(encendida cuando está activo) + icono centrado. Apilados en una franja
1362flotante que **sobresale del panel** hacia el centro — el patrón canónico
1363de cosmos (`01_yachay/cosmos/cosmos-app-llimphi/src/chrome.rs`:
1364`dock_rail_overlay` / `dock_panel_for`). Cada item se identifica por un `u64`
1365opaco; el caller mantiene el orden, qué está activo, y el mapping
1366`u64 ↔ DominioPropio` (típicamente vía `enum::{to_u64,from_u64}`). Cada
1367diente es **arrastrable** (su id como payload) y el rail entero es **drop
1368target** → mover un diente de un sidebar a otro soltándolo sobre el rail
1369opuesto. Render-only, agnóstico del `Msg`.
1370```rust
1371DockRailItem { id: u64, active: bool }
1372dock_rail_view(&items, width: f32, &DockRailPalette,
1373 make_icon: Fn(id, size_px, color) -> View<Msg>,
1374 on_activate: Fn(id) -> Msg, // dispara en el press, no pelea con el drag
1375 on_drop: Fn(payload_id) -> Option<Msg>) // None = el rail no acepta drops
1376```
1377Layout canónico: el rail va como **overlay absoluto** pegado al borde interno
1378del centro (los dientes sobresalen sobre la rueda/canvas), y el panel del
1379item activo va al costado como un pane en el área resizable. NO es una lista
1380con rótulo: leer cosmos antes de inventar UX (lección 2026-06-05).
1381
1382**fugaces** — la **banda de controles fantasma**: más acciones y estados que
1383píxeles, resueltos por *voz* en vez de por espacio. No pinta íconos ni sabe qué
1384representan — es la **política**, y el caller pone la identidad (trait
1385`Fantasma`: una clave estable para persistir el uso y un `prior` de asiento) y
1386el dibujo de cada cara.
1387
1388Cada fantasma declara por cuadro su [`Nivel`]: `Nada` (sólo en el reveal —
1389acciones puras) · `Ambiente` (mirable, turno lento y al 55 % de alfa) · `Leve`
1390(estado que dura; se turna cada 4 s) · `Evento` (acuse de segundos: **no espera
1391turno**) · `Critica` (sale fija, y todas juntas). En reposo se ve una sola cosa;
1392al acercar el puntero salen todas.
1393
1394Las tres reglas que no son obvias, y que ya costaron reportes: el **orden se
1395congela** bajo el mouse (un click aprende, pero el asiento nuevo rige recién al
1396esfumarse el reveal); la **membresía** también (el que llega a mitad del hover
1397no se inserta, y el que se va deja un **hueco de su ancho**); y el asiento sale
1398de `prior + 2·√usos`, que **satura** — los primeros clicks mandan y después el
1399orden queda quieto, que es lo que permite memorizarlo.
1400
1401Dos consumidores, dos formas del mismo patrón: `pata` lo monta como overlay a la
1402derecha del cabezal de shuma (estados del sistema), y `nahual-shell` como banda
1403al borde derecho de la barra de herramientas (`nahual-shell-llimphi::fugaces`),
1404donde **la acción aparece cuando el contexto la vuelve posible** — marcás dos
1405archivos y sale *cotejar*.
1406```rust
1407trait Fantasma { fn clave(self) -> &'static str; fn prior(self) -> f32; }
1408elegir_visibles(&[(F, Nivel)], idx, pin) -> Vec<F> // qué se ve sin reveal
1409avanzar_idx(&mut idx, &mut reloj, ahora_us, congelar) -> bool
1410orden_asientos(&todos, usos: Fn(F) -> u32) -> Vec<F>
1411congelar(&todos, &cands, usos, idx, pin, ahora_us) -> Freeze<F>
1412slots(cands: Vec<(F, Nivel, View<Msg>)>, &Franja<F>, usos, ancho_slot) -> Vec<View<Msg>>
1413Uso::abrir(Option<PathBuf>) / .bump(f) / .usos(f) // feature `persistencia`
1414```
1415El contenedor es del caller: `slots` devuelve la fila lista, no una `View` — la
1416franja caliente, el ancla y el `hover_fill` los pone quien la hospeda.
1417
1418**toolbar** — barra de herramientas moderna: **grupos** de botones-ícono
1419planos (hover redondeado; activo = fondo de selección + ícono en acento;
1420deshabilitado atenuado y sin click) con separadores sutiles entre grupos.
1421Los íconos los dibuja el caller vía closure (mismo contrato que dock-rail);
1422los grupos son **datos** (`Vec<ToolbarGroup>`) → la barra es componible y
1423configurable por construcción. Uso canónico:
1424`nahual-shell-llimphi::shell_toolbar` (navegación + modos de vista + acciones).
1425```rust
1426ToolbarItem::new(icon: Fn(size_px, color) -> View<Msg>, on_click: Msg)
1427 .with_label("subir").active(bool).enabled(bool)
1428ToolbarGroup::new(vec![items...])
1429toolbar_view(groups: Vec<ToolbarGroup<Msg>>, height: f32, &ToolbarPalette) -> View<Msg>
1430// La MISMA barra de pie: columna de botones cuadrados, separador horizontal
1431// entre grupos, sin overflow ni rótulos (el label queda de nombre accesible).
1432// Es la caja de herramientas al canto de un lienzo — uso: `tullpu`.
1433toolbar_view_vertical(groups, width: f32, &ToolbarPalette) -> View<Msg>
1434// Overflow + tooltips (aditivo; Default = la barra de siempre):
1435let medidor = AnchoBarra::new(); // se lee el cuadro siguiente
1436let anchos = item_widths(&groups); // misma cuenta que el render
1437let visibles = overflow_count(&anchos, medidor.px(), MORE_W, ITEM_GAP);
1438let ocultos = items_ocultos(&groups, visibles); // para armar el popup
1439toolbar_view_ext(groups, 36.0, &pal, ToolbarOpciones {
1440 medidor: Some(medidor), visibles: Some(visibles),
1441 on_overflow: Some(Msg::AbrirMas), // dibuja «⋯» sólo si sobra algo
1442 on_hover: Some(Arc::new(|i| Msg::Tooltip(i))), // Some(i) al entrar, None al salir
1443})
1444```
1445El **ancho disponible** no se sabe hasta que taffy repartió, así que `AnchoBarra`
1446lo publica desde el pintado y el host lo usa en el cuadro siguiente (un frame de
1447latencia en el primero y en el resize — lo mismo que hace cualquier toolbar con
1448overflow). `item_offsets(&groups)` da `(left, width)` de cada ítem contando
1449padding, gaps **y separadores**: es el ancla del `tooltip` (que es render puro y
1450lo monta el host en su `view_overlay`). El menú de overflow también lo monta el
1451host, con `context_menu_view`; `items_ocultos` le da label/estado/`Msg` en orden
1452para que el índice del `on_pick` no se desincronice.
1453
1454**grid** — grilla 2D virtualizada. `ventana_visible(total, vp_w, vp_h, scroll_fila,
1455&metrics) -> VisibleWindow` para virtualizar, luego `grid_view(GridSpec { cells:
1456Vec<GridCell { content, label, selected, on_click }>, cols, metrics, caption, ... })`.
1457
1458**list** — lista vertical virtualizada. `list_view(ListSpec { rows: Vec<ListRow {
1459label, selected, on_click }>, total, caption, truncated_hint, row_height, palette })`.
1460La app prefiltra las filas visibles. `list_view_ext(spec, ListOpciones{scroll,
1461medidor})` le da **scroll real** (no sólo `clip`): las filas se desplazan y
1462tienen barra arrastrable, mientras el `caption` y el aviso de truncado quedan
1463fijos — son rótulos de la lista, no contenido de ella.
1464
1465**tree** — árbol expand/collapse. `tree_view(TreeSpec { rows: Vec<TreeRow { label,
1466depth, has_children, expanded, selected, on_toggle, on_select }>, row_height,
1467indent_px, palette })`. La app aplana el árbol según nodos expandidos.
1468`tree_view_ext(spec, TreeOpciones{scroll, medidor})` le da el mismo scroll real
1469que la `list`. Cada fila acepta además `icon` (un `View`), `color_label`
1470(teñir por tipo/estado), `trailing`, `on_context` y `on_double`.
1471`tree_view_2d(spec, Vista2D{offset, viewport, ancho_contenido, on_scroll,
1472medidor, fijadas})` agrega lo que un árbol de proyecto necesita y una lista
1473indentada no: **rueda en los dos ejes** (un nombre anidado no entra en un panel
1474angosto y el `…` no lo hace legible) y **filas fijadas** — `FilaFijada{fila,
1475y_natural, huerfana}` se apila contra el borde por el que salió del viewport
1476(`reparto_fijadas`, `cupo_de_pila` = un tercio del alto), y una `huerfana` (su
1477carpeta está plegada, no tiene fila en el cuerpo) se muestra igual. El ancho del
1478contenido se estima con `ancho_de_filas`. El uso canónico es `nada::arbol`, que
1479es el mismo árbol para el editor suelto y para el panel del anfitrión.
1480
1481**navigator** — navegador data-agnóstico de nodos en dos modos conmutables
1482(**árbol** ↔ **grafo**, reusa tree + nodegraph). Render-only: la app guarda
1483`expanded`/`selected`/`mode`. Pasa un bosque de `NavNode { id: u64, label,
1484kind: NavKind (Monad|Group|Dir|File|Other), children }` y callbacks por id.
1485```rust
1486navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides },
1487 is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg,
1488 on_select: Fn(u64)->Msg, on_context: Option<Fn(u64)->Msg>)
1489// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
1490// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
1491// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.
1492```
1493
1494**app-header** — `app_header(label, actions: Vec<View<Msg>>, &palette)`.
1495
1496**status-bar** — `status_bar_view(left, center, right, &palette)` con
1497`StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized()`.
1498
1499**breadcrumb** — `breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)`
1500(el último segmento no es clickable).
1501
1502**modal** — diálogo centrado con scrim. `modal_view(ModalSpec { title, body:
1503View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette })`.
1504`ModalButton::{primary, cancel, destructive}(label, msg)`. Se monta en `view_overlay`.
1505
1506**toast** — notificaciones efímeras bottom-right. La app guarda `Vec<Toast>`
1507(`Toast::{info,success,warning,error}(id, text, duration)`), filtra
1508`is_alive(now)`, y `toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg)`.
1509
1510**splash** — splash de arranque (cuatro cuadrantes andinos). `splash_view(started_at:
1511Instant, bg, fg_text)`; basado en tiempo, requiere redraws.
1512
1513### Ricos / interactivos
1514
1515**nodegraph** — lienzo de nodos + cables Bezier. Sin estado (la app guarda
1516posiciones y `Wire`s).
1517```rust
1518NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
1519Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
1520nodegraph_view(&nodes, &wires, &palette, &metrics,
1521 on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
1522 on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
1523// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)
1524// + wire_dash / edge_dash en NodegraphSpec: predicado por cable/arista → trazo
1525// PUNTEADO. Para la distinción que el color no lleva solo (lo sabido vs lo
1526// supuesto): soltar pinta punteada la clave foránea que sólo se ve en el dato
1527// y sólida la que una persona declaró.
1528// + nodegraph_view_spec(NodegraphSpec{..}) — el editor real: cámara (pan/zoom),
1529// selección, aristas sin pins, y CUERPO EMBEBIDO por nodo.
1530```
1531
1532**Un nodo puede contener algo** (2026-08-17). `NodegraphSpec.cuerpo` es
1533`Option<&dyn Fn(NodeId, zoom) -> Option<CuerpoNodo<Msg>>>`, y `CuerpoNodo` es
1534`{ alto: f32, vista: View<Msg> }` — el alto en coordenadas de canvas, que el
1535widget escala por el zoom. El cuerpo se dibuja **debajo de la fila de pins**, así
1536que las posiciones de los pins no se mueven y los cables siguen enganchando donde
1537`precompute_wires` los calcula. `None` = el nodo clásico.
1538
1539Para qué: `krama` pinta adentro del nodo el valor con su unidad, la perilla que
1540se arrastra y la curva de `pineal-cartesian`; `tullpu` podría poner el preview
1541del ajuste, `takiy` la onda de su eslabón, `pluma-notebook` el output de la
1542celda. **No va en `NodeSpec`** (que es `Debug + Clone` y no genérico en `Msg`):
1543una `View` adentro lo volvería genérico y rompería sus 12 sitios de construcción.
1544
1545Dos cosas medidas que conviene saber: `fit_to_view` **no** conoce los cuerpos
1546(encuadra sobre la altura de pins, así que queda corto por abajo con nodos
1547altos), y `node_height` suma 6 px fijos que `scaled()` no escala — el nodo entero
1548no se duplica exactamente al duplicar el zoom.
1549
1550**timeline** — scrub clickeable. `timeline_view(progress: f32, &palette,
1551on_seek: Fn(f32 [0..1])->Option<Msg>)`.
1552
1553**transport** — botones de **transporte de reproductor** (play/pause/prev/
1554next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq). Stateless:
1555el caller arma un `TransportButton` por cada botón con el flag que define su
1556estado activo (p.ej. `PlayPause { playing }`, `Mute { muted }`,
1557`Record { recording }`), lo pasa al widget, y recibe el `TransportAction`
1558semántico en un closure cuando el usuario clickea. El widget elige el icono
1559(de `llimphi-icons`), el color activo, el rojo de REC y arma la acción
1560(p.ej. `SeekBack { secs }` → `TransportAction::SeekBy(-secs)`). Quien
1561traduce `TransportAction` al comando del dominio (`MediaCommand`, etc.) es
1562la app.
1563```rust
1564TransportPalette { bg, bg_active, bg_hover, fg, fg_active, fg_record,
1565 btn_w, btn_h, radius, icon_stroke, gap }
1566TransportPalette::from_theme(&Theme)
1567TransportAction::{TogglePlay, Stop, Prev, Next, SeekBy(i64), VolumeBy(f32),
1568 ToggleMute, CycleRepeat, ToggleShuffle, SpeedStep(i32),
1569 SpeedReset, Snapshot, ToggleRecord, ToggleEqualizer}
1570TransportButton::{PlayPause{playing}, Stop, Prev, Next,
1571 SeekBack{secs}, SeekForward{secs},
1572 VolumeDown{step}, VolumeUp{step},
1573 Mute{muted}, Repeat{active}, Shuffle{active},
1574 SpeedDown, SpeedUp, SpeedReset{is_default},
1575 Snapshot, Record{recording}, Equalizer{enabled}}
1576transport_button_view(button, &palette, on_action: Fn(TransportAction)->Msg) -> View<Msg>
1577```
1578Para una fila, el caller mapea su `Vec<TransportButton>` con
1579`.iter().map(|b| transport_button_view(*b, &pal, on_action.clone()))` y los
1580pone como children de una `View` con `flex_direction: Row` y `gap:
1581palette.gap`. Consumidor de referencia: `02_ruway/media/media-app::bar_item_view`.
1582
1583**waveform** — visor de **forma de onda en vivo**. Stateless y agnóstico de
1584cpal/AudioProbe: el caller pasa un closure `Fn(&mut Vec<f32>) -> u16` que
1585rellena un buffer con los últimos samples intercalados y devuelve cuántos
1586**canales** trae; el widget hace el fold a mono y dibuja un **envelope
1587min/max por columna** (polígono cerrado con relleno tenue + stroke top/bot)
1588sobre una línea central que siempre está como "ground" — paint-only, sin
1589mouse. Si el closure devuelve `0` canales, el widget pinta sólo la línea
1590central ("visor vivo, sin señal").
1591```rust
1592WaveformPalette { bg, center, stroke, fill, radius, pad_x, pad_y, stroke_w }
1593WaveformPalette::from_theme(&Theme) // bg=bg_panel_alt, stroke=accent, fill=accent@α70
1594waveform_view(source: Fn(&mut Vec<f32>) -> u16 + Send + Sync + 'static,
1595 palette: &WaveformPalette) -> View<Msg>
1596```
1597Consumidor de referencia: `02_ruway/media/media-app::waveform_panel` (cablea
1598un `AudioProbe::snapshot`).
1599
1600**table** — tabla **editable** de celdas-texto: filas/columnas + agregar/quitar
1601fila + foco por celda. Stateless — la app posee el foco y el buffer de la
1602celda en edición; el resto se pinta desde `&rows`. Pensado para datos planos
1603editables (paramétricos, scratchpad, listas curables); no es la `list`/`tree`
1604virtualizada read-only ni un grid de hoja de cálculo.
1605```rust
1606table_view(&[String], headers, // [] = sin header
1607 &[Vec<String>] rows,
1608 focused: Option<(row, col)>,
1609 focused_state: Option<&TextInputState>, // buffer prestado de la celda en edición
1610 add_label: &str, &TablePalette,
1611 on_focus_cell: Fn(row, col) -> Msg, // clic en celda
1612 on_remove_row: Fn(row) -> Msg,
1613 on_add_row: Fn() -> Msg)
1614list_view(&[String], items, focused_row, focused_state, // sola columna sin header
1615 add_label, &palette, on_focus_cell: Fn(row)->Msg, on_remove_row, on_add_row)
1616table_height(n_rows, has_header) -> f32 // pre-calcular alto del bloque
1617```
1618La variante `list_view` no se confunde con la `list` virtualizada de §13:
1619ésta es **una sola columna editable** que delega en `table_view([], ..)`.
1620
1621**detail-table** — la vista *detalle* de un file manager: grilla read-only con
1622columnas `ColWidth::{Flex(peso), Fixed(px)}` y encabezados que ordenan (▲/▼).
1623`detail_table_view(DetailSpec{columns, rows, sort, row_height, caption,
1624palette}, on_sort)`; `detail_table_view_dnd(..)` agrega arrastre por fila.
1625`detail_table_view_ext(spec, on_sort, DetailOpciones{scroll, medidor,
1626on_resize})` suma **encabezado fijo con cuerpo scrolleable** y los **handles de
1627resize** entre columnas:
1628```rust
1629DetailScroll { offset, viewport_h, on_scroll: Fn(f32)->Msg } // compone scroll_y
1630AltoCuerpo::new() // publica el alto medido del cuerpo (post-layout)
1631offset_efectivo(offset, viewport_h) // sin medir todavía ⇒ se pinta desde arriba
1632resize_col(ancho, delta, min) // el host aplica el delta del handle
1633```
1634El «sticky» no es un truco de posición: con `scroll` el encabezado queda
1635**afuera** del cuerpo desplazado, así que no viaja. El cuerpo se compone del
1636`scroll` canónico (rueda, barra, clamp y fling ya viven ahí). El handle lleva
1637`draggable` y **no** `on_click`, así agarrarlo no dispara el orden de la columna
1638que tiene debajo — que es el bug clásico de esta UI.
1639
1640**Es también la DataTable** read-only ordenable y paginable — no hay una tabla
1641aparte para eso. Lo que faltaba y ya está:
1642```rust
1643ciclar_orden(sort_actual, col) -> Option<(col, SortDir)> // asc → desc → SIN orden
1644orden_de_filas(&celdas, col, dir, &TipoCol) -> Vec<usize> // permutación estable
1645TipoCol::{Texto, Numero, Natural, Propio(fn)} // «archivo2» < «archivo10»
1646Pagina{indice, tamano}: rango(total) · total_paginas · acotada(total) · etiqueta
1647botones_pagina(actual, total, max) -> Vec<BotonPagina> // 1 … 4 5 6 … 20
1648pager_view(pagina, total_filas, Fn(usize)->Msg, &palette)
1649```
1650`orden_de_filas` devuelve **índices**, no filas movidas: así ordena sin conocer
1651el tipo del caller (una `DetailRow` lleva `Msg` y `View`) y sin perder el orden
1652original, que es al que vuelve el tercer click. `Numero` manda al **final** lo
1653que no parsea (un «—» encabezando no es información) y no hay variante `Fecha`:
1654en ISO ordena bien como `Texto`, y para otros formatos está `Propio` — un parser
1655de fechas acá sería un segundo parser distinto del del `datetime-picker`.
1656
1657**text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda
1658`EditorState`:
1659```rust
1660EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo();
1661.add_cursor_at(line,col); .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult;
1662.ensure_caret_visible(visible_lines)
1663// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option<Msg>
1664text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer)
1665text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer)
1666text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer)
1667syntax_palette_dark(&theme) -> SyntaxPalette // en lib.rs del widget
1668```
1669
1670⚠️ **Las cuatro firmas de arriba son las viejas y piden `visible_lines: usize`.**
1671Siguen andando y no se van a tocar, pero **en código nuevo va la de abajo**: pedirle
1672al host que cuente líneas fue el origen de que el editor de `nada` mostrara 40
1673renglones en cualquier ventana, del tope de render de 200/512 líneas, y de que no
1674hubiera scroll suave ni horizontal. El módulo `geometria` (2026-08-27) le da al
1675widget su propio mapa línea ↔ fila visual ↔ píxel:
1676
1677```rust
1678// El host guarda un `TamanoVisible` y el widget publica su rect al pintar.
1679model.editor_medida: llimphi_widget_scroll::TamanoVisible
1680let vp: Viewport = state.viewport(ancho_px, alto_px, metrics); // medido, no contado
1681text_editor_view_viewport(&state, &palette, &vp,
1682 OpcionesRender { language, match_ranges: &m, medida: Some(&model.editor_medida) },
1683 |ev| Some(Msg::EditorPointer(ev)))
1684
1685// Scroll y encuadre, en píxeles:
1686state.desplazar_px(&vp, delta_px); // la rueda; conserva las fracciones
1687state.desplazar_x_px(&vp, dx, ancho_contenido);
1688state.asegurar_caret_visible(&vp); // respeta `vp.margen_filas` (scrolloff)
1689state.pos_en(&vp, x, y) -> (linea, col) // el hit-test; `screen_to_pos` NO sabe
1690 // del resto sub-fila ni del scroll en X
1691// El mapa, si lo necesitás suelto:
1692Disposicion::para(lineas, metrics) .filas() .y_de(fila) .fila_en_y(y) .fila_de_linea(l)
1693Viewport::medido(w, h, metrics) .filas_completas(&d) .rango_visible(&d) .desplazar_y(&d, px)
1694```
1695
1696El render virtualiza por rango de píxeles: el árbol de `View` depende del **alto
1697del panel**, no del tamaño del archivo (probado: 1.000 y 100.000 líneas dan
1698exactamente el mismo árbol). Por eso el camino nuevo no necesita los topes.
1699Plegado, ajuste de línea, líneas pegajosas e inlay hints son otra implementación
1700de `Disposicion`, sin tocar llamadores. Plan completo:
1701`02_ruway/nada/PLAN-PARIDAD-JETBRAINS.md`. Consumidor de referencia: `nada`
1702(`src/view.rs::active_editor_content` + `src/lib.rs::Model::editor_viewport`).
1703
1704**text-editor-core** — núcleo **agnóstico** (sin GPU, sin Llimphi; sólo
1705`peniko::Color`). Reutilizable en TUI/web/headless. Tipos clave:
1706- `Buffer` (sobre `ropey`): `from_str`, `text`, `insert(offset,s)`, `delete(s,e)`,
1707 `offset_to_pos`, `pos_to_offset`, `slice`, `line(n)`.
1708- `Pos { line, col }`, `Selection { anchor, caret }`, `Cursor { caret, anchor:
1709 Option, desired_col }` con `move_left/right/up/down/word_left/...`,
1710 `selection_range(&buf)`, `collapse`.
1711- Ops: `replace_selection`, `delete_backward/forward`, `indent_or_insert_tab`,
1712 `insert_newline_auto_indent` → devuelven `EditDelta { start, removed, inserted,
1713 cursor_before, cursor_after }` con `.apply()/.undo()`.
1714- `UndoStack`: `push(delta)`, `undo/redo(&mut buf, &mut cursor) -> bool`, `can_undo/redo`.
1715- `FindState { query, case_sensitive }`: `all_matches`, `find_next`, `find_prev`.
1716- Matching de brackets: `find_bracket_pair(&buf, &cursor) -> Option<(Pos, Pos)>`, `Direction`.
1717- `Clipboard` (trait `get/set`), `MemClipboard`, `NullClipboard`.
1718- `Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity,
1719 message: String, source: Option<String> }` (+ ctors `error(..)`, `warning(..)`);
1720 `Severity::{Error, Warning, Information, Hint}`.
1721- Highlight: `Language::{Plain, Rust, Python, Wat}` por tree-sitter/tokenizer, y
1722 por tabla en Rust puro (módulo `sintaxis` del core, sin gramáticas C)
1723 `Language::{JavaScript, TypeScript, Go, C, Cpp, Java, Kotlin, Ruby, Php,
1724 Swift, Lua, Zig, Haskell, Shell, Sql, Rhai, Json, Ron, Toml, Ini, Yaml,
1725 Markdown, Html, Xml, Css, Diff}` (+ `Language::from_cell_language(s)`, que
1726 acepta también la **extensión** del archivo); `Highlighter::new(lang)` con
1727 `.highlight(&mut self, source: &str) -> Vec<Vec<Span>>` (un `Vec<Span>` **por
1728 línea**), `.set_language(lang)`, `.language()`; helpers de módulo
1729 `invalidate_tree_cache(lang)` y `apply_pending_edits(lang, &edits)` para el
1730 caché incremental. `TokenKind`, `Span`, `SyntaxPalette::color(kind)`.
1731
1732**text-editor-lsp** — cliente LSP por stdin/stdout. `trait LspClient` (fire-and-forget
1733`request_*` + lecturas de caché `latest_*`/`clear_*`): completions, hover,
1734definition, references, rename, formatting, signature help, document symbols.
1735`RustAnalyzerClient::start(workspace_root)`; `NoopLspClient` para tests.
1736
1737**terminal** — superficie de **scrollback infinita virtualizada** (modo línea /
1738bloques / grilla GPU). Reemplaza al `output_pane` clásico: el costo de render
1739es **constante** a scrollback ilimitado — sólo se materializa la ventana visible
1740— bajo un `scroll_y` **propio del widget** (no `transform` del padre, la
1741anti-feature del SDD). Núcleo agnóstico, el caller arma el modelo y el widget
1742pinta (Regla 2). Diseño completo: `02_ruway/shuma/SDD-TERMINAL.md`. Consumidor
1743de referencia: `shuma-module-shell::output_pane_surface`.
1744- **Store** (`Scrollback::new(limit_bytes)`): append-only por línea con índice
1745 de offsets O(1), cap por memoria con recorte de frente, `line_id` global
1746 **estable** que sobrevive al recorte (`index_of_id`), `push_line`,
1747 `slice_text`, `clear`. `dropped()` / `total_pushed()` para el `numbering`
1748 global.
1749- **Spill a disco** (`SpillStore::create(path)` + `Scrollback::enable_spill(spill)`):
1750 cada línea recortada del frente se appendea al archive; lookup O(1) con
1751 `read_spilled(global_id)` + helpers `has_spill`/`spilled_count`/`spill_path`
1752 para que el host exponga UX (chip de status, builtin `:scrollback open`,
1753 prepend del archive al view).
1754- **Modo línea** (`line_surface(store, scroll_y, viewport_h, metrics, &palette,
1755 line_style: Fn(usize, &str) -> LineStyle, on_scroll: Fn(f32) -> Msg, measure:
1756 Option<Arc<Mutex<f32>>>)`): materializa sólo la ventana visible; scroll
1757 sub-renglón (`partial_px`), numeración global 1-based, color base + runs +
1758 tinte de fondo inyectados por el caller en `LineStyle`. Helpers puros:
1759 `visible_window`, `content_height`, `scroll_to_bottom`. `measure` opcional
1760 publica el `viewport_h` real medido al host (regla del SDD: verificar con
1761 viewport real).
1762- **Modo bloques** (`block_surface(store, items: Vec<Item<Msg>>, ...)`): el
1763 stream es una secuencia de `Item::chrome(height, view)` (header/badge/etapa
1764 de alto fijo que el caller pinta) + `Item::lines(start, end)` (rango del
1765 store). Virtualiza sobre alturas mixtas con búsqueda binaria (O(log n) en
1766 bloques) — dentro de un `Lines` enorme sólo se materializan las sub-filas
1767 visibles. Colapsar = no emitir el `Lines`. Helpers: `blocks_height`,
1768 `blocks_scroll_to_bottom`, `gutter_width(&store, metrics)`,
1769 `line_top_in_content(&items_geo, row_h, target_line)`.
1770- **Selección + copy** (`block_surface_with_selection` + `SelectionConfig { range:
1771 Option<&SelectionRange>, on_drag: Fn(DragPhase, lx0, ly0, dx, dy) -> Option<Msg>,
1772 on_double_click: Fn(lx, ly, w, h) -> Option<Msg> }`): el widget pinta el
1773 overlay translúcido y dispara callbacks; el caller mantiene el rango
1774 (`SelectionRange { anchor: Point, head: Point }`, `Point { line, col }` con
1775 `col` en **bytes** UTF-8) en su Model y resuelve clicks con `point_at(items,
1776 scroll_y, viewport_h, metrics, gutter_w, store, lx, ly)` —o su variante
1777 `Copy`-stashable `point_at_geo(&[ItemGeo], ...)` para resolver el frame
1778 anterior. `selection_rects` para overlays custom.
1779- **Find** (`find_matches(&store, query, FindOpts { case_insensitive })`):
1780 búsqueda sobre el scrollback **completo** (no sólo el viewport), O(total_bytes);
1781 `FindMatch { line, start, end }` con `start`/`end` en bytes UTF-8 slice-safe.
1782 Nav: `next_match(&matches, current)` / `prev_match`. El caller resuelve el
1783 `line` a su `scroll_y` con `line_top_in_content`.
1784- **Grilla GPU** (modo TUI, Fase 4): `CellPipeline::new(device, format)` +
1785 `GlyphAtlas` rasterizan cada celda con **quads instanciados** y atlas de
1786 glifos (precedente: `atlas` de wawa, Fontdue) — una sola draw call por
1787 viewport, escala a 200×80 sin penalty. Tipos: `CellInstance { cell_x/y,
1788 uv_x/y/w/h, fg_rgba, bg_rgba }` (POD 32 B), `CellUniforms`, `pack_rgba`,
1789 `instances_to_bytes`, `CELL_WGSL`, `CellPipeline::create_atlas_texture`.
1790 Wireado al widget contenedor vía `gpu_paint_with`. Activo en shuma con
1791 `SHUMA_GPU_GRID=1`.
1792
1793Headless dumps:
1794```bash
1795cargo run -p llimphi-widget-terminal --example dump_terminal # modo línea, 1M renglones
1796cargo run -p llimphi-widget-terminal --example dump_blocks # bloques + flood 500k
1797```
1798
1799**clipboard** — portapapeles del sistema vía `arboard`. `SystemClipboard::new()`,
1800`is_available()`, impl `Clipboard`. No-op silencioso si no hay display (CI headless).
1801
1802**menubar** — barra de menú mac-style. `menubar_view(&MenuBarSpec { menu: &AppMenu,
1803open: Option<usize>, theme, viewport, height, on_open: Fn(Option<usize>)->Msg,
1804on_command: Fn(&str)->Msg })`; dropdown en `view_overlay` con `menubar_overlay(spec)`
1805o `menubar_overlay_animated(spec, active, appear)`. Navegación por teclado:
1806`menubar_nav`, `menubar_command_at`.
1807
1808**edit-menu** — menú estándar de edición sobre un editor.
1809`EditFlags::from_editor(&state, masked)`, `edit_context_menu(anchor, viewport,
1810&theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss)` →
1811`ContextMenuSpec`. `apply(&mut state, EditAction, &mut clipboard) -> ApplyResult`.
1812`EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}`.
1813
1814**context-menu** — menú contextual genérico (panel elevado con sombra + borde +
1815esquinas redondeadas). `ContextMenuItem::action(label).with_shortcut(..).icon(..)
1816.checked(bool).disabled().destructive().submenu(children)` o `::separator()`
1817(`checked` pinta ✓ en el gutter y alterna estado). `context_menu_view(ContextMenuSpec
1818{ anchor, viewport, header, items, active, on_pick: Fn(usize)->Msg, on_dismiss,
1819palette })`; `context_menu_view_ex` con submenús (flyout que **flipea** a la izq. si
1820no cabe) + animación de aparición. Se monta en `view_overlay` con scrim. **Teclado
1821(helpers puros que el host cablea):** `step_active(items, cur, ±1)` (↑/↓, saltea
1822sep/disabled), `edge_active(items, to_end)` (Inicio/Fin), `type_ahead(items, cur, ch)`
1823(mnemónico por 1ª letra). Submenú por teclado = `step_active` sobre `parent.children`.
1824Demo completo: `cargo run -p llimphi-widget-context-menu --example menu_demo`.
1825
1826**theme-switcher** — `theme_switcher_view(¤t: &Theme, on_change: Fn(Theme)->Msg)`
1827(+ `_styled`/`_flex`). Cicla `Theme::next_after`.
1828
1829**shortcuts-help** — overlay "?" con atajos agrupados. `shortcuts_help_view(
1830ShortcutsHelpSpec { title, groups: Vec<ShortcutGroup { title, entries:
1831Vec<ShortcutEntry { keys, description }> }>, viewport, on_dismiss, palette })`.
1832
1833**wawa-mark** — sello vectorial del SO wawa. `wawa_mark_view(&WawaMarkPalette)`;
1834`paint_mark(scene, rect, &palette)` para canvas custom. Usar en contenedor cuadrado.
1835
1836---
1837
1838## 14. Catálogo de módulos
1839
1840Los módulos encapsulan **estado + comportamiento** (a diferencia de los widgets).
1841Todos siguen el mismo contrato:
1842
1843```
1844State + Msg + Action + apply(state, msg, ...) -> Action
1845 + on_key(state, &KeyEvent) -> Option<Msg>
1846 + open_shortcut(&KeyEvent) -> bool
1847 + view(state, ..., to_host: F) -> View<HostMsg>
1848 + Palette
1849```
1850
1851La app guarda `Option<ModuleState>` (o el state directo, p. ej. bookmarks),
1852rutea el atajo de apertura con `open_shortcut`, rutea teclas con `on_key`, aplica
1853`Msg`s con `apply`, y monta el `view` pasando un mapeo `to_host: Fn(ModuleMsg) ->
1854HostMsg`. Cuando `apply` devuelve una `Action` (p. ej. `Invoke(id)`, `OpenAt{..}`,
1855`GoTo{..}`), la app ejecuta el efecto. Crates en `modules/<nombre>/`.
1856
1857| Módulo | Atajo | Acción que devuelve | Propósito |
1858|---|---|---|---|
1859| **command-palette** | `Ctrl+Shift+P` | `Invoke(String)` | paleta de comandos fuzzy. El host declara `&[Command]` |
1860| **file-picker** | `Ctrl+P` | `Open(PathBuf)` | fuzzy file picker; host pasa `&[PathBuf]` + `root` |
1861| **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 |
1862| **diff-viewer** | `Ctrl+Shift+D` | — | diff side-by-side. `DiffState::new(before_label, after_label, before, after)` computa con `similar` |
1863| **mini-map** | `Ctrl+Shift+M` | `JumpTo(line)` | minimapa del buffer; agnóstico del editor (recibe `Snapshot`) |
1864| **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) |
1865| **symbol-outline** | `Ctrl+Shift+O` | `GoTo{line,col}` | outline de símbolos; host arma `Vec<SymbolItem>` (LSP/tree-sitter/custom) |
1866| **selector** | — | — | abstracción portátil abrir/guardar: `trait Selector` (`HostSelector` con PathBuf, `WawaSelector` placeholder content-addressed) |
1867| **plugin-host** | — | `OpenAt{..}`, `SetStatus(..)` | runtime WASM (wasmi) con permisos por bitfield; `PluginHost::load_from_dir`/`invoke(id, cap, args)` |
1868| **shuma-term** | `` Ctrl+` `` | `SetStatus(..)` | terminal integrada. `spawn(cwd)` lanza PTY (`shuma_exec`), `vt100::Parser` renderiza; `Tick` drena el PTY |
1869
1870Patrón típico de integración (command-palette):
1871```rust
1872struct Model { palette: Option<PaletteState>, commands: Vec<Command>, /* … */ }
1873enum Msg { Palette(PaletteMsg), /* … */ }
1874
1875// on_key:
1876if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); }
1877if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); }
1878
1879// update:
1880Msg::Palette(m) => {
1881 if let Some(state) = model.palette.as_mut() {
1882 match command_palette::apply(state, m, &model.commands) {
1883 PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; }
1884 PaletteAction::Close => model.palette = None,
1885 PaletteAction::None => {}
1886 }
1887 }
1888}
1889
1890// view_overlay:
1891model.palette.as_ref().map(|s|
1892 command_palette::view(s, &model.commands, &palette, Msg::Palette))
1893```
1894
1895---
1896
1897## 15. `llimphi-workspace` — chasis tipo tmux
1898
1899Monta cualquier componente en un layout intercambiable con splits resizables
1900(máquina de estados de foco/split/cierre + chrome estándar). Construido sobre
1901`llimphi-widget-panes`.
1902
1903```rust
1904Workspace::new()
1905 .focused() -> PaneId .count() .leaves() -> Vec<PaneId> .layout() -> &Layout
1906 .focus(id) .split(Axis) -> PaneId .close() -> Option<PaneId> .resize(&path, delta)
1907 .apply(WsMsg) -> WsEffect
1908
1909enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec<Side>, f32) }
1910enum WsEffect { None, Created(PaneId), Closed(PaneId) }
1911
1912workspace_view(&ws, &WorkspacePalette,
1913 leaf: FnMut(PaneId)->View<Host>, // materializa el contenido de cada panel
1914 lift: Fn(WsMsg)->Host) // sube los Msg del chasis a tu Msg
1915```
1916
1917Patrón: `enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }`. En `update`,
1918`ws.apply(msg)` te avisa con `WsEffect::{Created,Closed}(id)` para que crees o
1919destruyas el estado del panel correspondiente.
1920
1921---
1922
1923## 16. Reglas duras y gotchas
1924
1925### 🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE)
1926Ningún `update`/`init`/handler puede ejecutar trabajo **síncrono** pesado
1927(efemérides, simulación, IO, parse, embeddings, layout de árboles grandes).
1928Bloquea el hilo → "Not Responding". **`init` corre dentro de `resumed`, después
1929de crear la ventana**, así que un cómputo pesado ahí ya congela una ventana
1930visible.
1931
1932Patrón (referencia: `cosmos-app-llimphi`):
1933```rust
1934// Model: Option<Resultado> (None = "calculando…") + flag dirty + contador de generación.
1935struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64 }
1936enum Msg { XComputed(u64, Arc<Resultado>) }
1937
1938// al FINAL de update() (que tiene el Handle):
1939if m.x_dirty {
1940 m.x_dirty = false;
1941 m.x_gen = m.x_gen.wrapping_add(1);
1942 let (gen, input) = (m.x_gen, m.input.clone()); // sólo lo que el worker necesita (Send)
1943 handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input))));
1944}
1945// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un
1946// resultado tardío pise a uno más nuevo en drags/toggles rápidos).
1947Msg::XComputed(gen, x) => if gen == m.x_gen {
1948 m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
1949}
1950```
1951La **generación** es imprescindible si el recálculo se dispara seguido. Ver
1952[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) y su checklist por app.
1953
1954### Otras
1955- **Solvers iterativos** (Newton/bisección): cota dura `for _ in 0..N`, nunca
1956 `loop {}` con corte pegado al epsilon de f64 — en debug no converge → loop
1957 infinito.
1958- **Backend GPU**: preferir Vulkan (`Backends::PRIMARY`); el GL de Mesa sobre
1959 Wayland segfaultea en el teardown. Ya está hecho en `Hal::new`, no revertir.
1960- **Un nodo es draggable o clickable**, no ambos.
1961- **`alpha` y `clip`** crean capas intermedias: tienen costo, usar sólo cuando
1962 hace falta.
1963- **`paint_with`** no debe dejar `push_layer` sin `pop_layer` ni resetear la
1964 Scene.
1965- **Hit-test respeta `.transform()`**: un nodo rotado/escalado/trasladado recibe
1966 los clicks donde se ve pintado (el runtime invierte el afín acumulado). Lo que
1967 **no** se ajusta todavía: la posición local que reciben los handlers `*_at` se
1968 reporta en coords de pantalla, no en el espacio local del nodo transformado.
1969- **GPUI está extinto**: no agregar dependencias ni código GPUI (regla §3 de
1970 `CLAUDE.md`).
1971- **Texto en regla pesada**: crear un `Typesetter` por frame es caro
1972 (`FontContext::new` enumera fuentes del sistema). El runtime ya cachea uno y lo
1973 pasa a `paint_with`.
1974
1975---
1976
1977## 16.bis Tests de regresión visual — `llimphi-test` (golden-image)
1978
1979Para certificar que un widget/pantalla **no cambió de aspecto** sin renderizar-y-
1980mirar a mano (CLAUDE.md §8), usar el arnés `llimphi-test`. Renderiza un `View`
1981headless por el adapter de **software** (llvmpipe/lavapipe → mismo píxel host-a-
1982host y en CI) y lo diffea contra un golden con tolerancia perceptual.
1983
1984```rust
1985// en tests/<algo>.rs de tu crate
1986use llimphi_test::assert_golden;
1987
1988#[test]
1989fn mi_card() {
1990 let v = mi_widget::card(/* … */);
1991 assert_golden!("mi_card", v, 400, 240); // vs tests/goldens/mi_card.png
1992}
1993```
1994
1995- **Generar/actualizar goldens:** `LLIMPHI_GOLDEN_UPDATE=1 cargo test -p <crate>`
1996 (escribe los PNG en `tests/goldens/`; commitéalos).
1997- **En fallo:** escribe `mi_card.actual.png` + `mi_card.diff.png` (heatmap) al lado
1998 del golden y paniquea con `DiffStats { differing_pixels, max_delta, fraction }`
1999 — la stat numérica **es** la evidencia, no hace falta abrir el PNG.
2000- **Tolerancia:** `GoldenConfig { max_channel_delta, max_diff_fraction }` (default
2001 casi bit-exacto). Para control fino: `Harness::render(...)` + `diff(...)` +
2002 `compare_golden(path, &rendered, &cfg)` sueltos.
2003- **Determinismo:** el adapter de software es bit-exacto (el self-test
2004 `readback_es_determinista` verifica 0px entre dos renders). Si la máquina no
2005 tiene lavapipe, cae al adapter normal y la tolerancia absorbe la diferencia.
2006- **Path GPU-directo** (`GpuBatch`/`gpu_paint_with`, que no pasa por vello):
2007 `Harness::render_gpu(w, h, bg, |device, queue, encoder, view, vp| { … })` —
2008 misma firma que `View::gpu_paint_with`; limpia a `bg` y te deja pintar directo,
2009 después lee de vuelta para el golden (ver `llimphi-test/tests/gpu_golden.rs`).
2010
2011### El golden es el ÚLTIMO recurso, no el primero
2012
2013Un golden es una imagen bendecida: pasa a ser correcta porque alguien dijo que
2014lo era. Eso funciona para «esto no cambió», pero no contesta «¿esto está bien?»,
2015y para bendecirla hay que mirarla — justo lo que §8 pide evitar.
2016
2017Casi siempre hay algo mejor: **el oráculo es la otra manera de calcular lo
2018mismo**. En vez de comparar contra una imagen, se pinta el mismo árbol por dos
2019caminos que tienen que coincidir, y la afirmación pasa a ser objetiva. Los que
2020ya existen en el repo, todos escritos así:
2021
2022| test | los dos caminos | resultado |
2023|---|---|---|
2024| `boundary_cache.rs` | `paint` contra `paint_cached` | 0 px |
2025| `gpu_lotes.rs` | un buffer entero contra troceado | idénticos |
2026| `gpu_grosores.rs` | un lote con dos plumas contra dos lotes de una | idénticos |
2027| `gpu_scissor.rs` | pasada completa contra recorte | mismo píxel exacto |
2028| `cpu_vs_gpu.rs` | `vello_cpu` (wawa) contra `vello` (Linux) | tabla por patrón |
2029| `wire_vs_directo.rs` | por el cable contra en proceso | bit a bit |
2030| `hybrid_vs_cpu.rs` | `vello_hybrid` (Mali) contra CPU | Δ1:0 |
2031| `texto_gpu_directo.rs` | atlas de glifos contra vello | Δ64:0 |
2032
2033### Cuatro reglas que salieron de escribirlos
2034
20351. **Poné el umbral DESPUÉS de medir, no antes.** Los umbrales «prudentes» que
2036 se eligen a ojo casi nunca se rozan, y un umbral que nada roza no es un gate.
2037 En `hybrid_vs_cpu` el 5 % sobre Δ16 que parecía razonable no habría agarrado
2038 el sabotaje; lo medido (0 px sobre Δ1) sí. Dejá el número medido escrito al
2039 lado del `assert`, con fecha: es lo que permite decidir el umbral siguiente.
20402. **Saboteá el test antes de creerle.** Rompé a mano lo que dice cubrir y mirá
2041 que falle *ese* test y no otro. Dos veces en esta tanda el test pasaba con la
2042 pieza rota: `boundary_gradiente_imagen` reconstruía el objeto con `default()`
2043 en vez de clonarlo, y cada `default()` hacía un `Blob` nuevo — o sea que
2044 invalidaba por identidad y nunca llegaba a probar el hash. Cuatro de cinco
2045 sabotajes pasaban.
20463. **Todo test de igualdad necesita una guarda de arnés.** Dos lienzos vacíos
2047 coinciden perfecto. Afirmá además que se pintó algo (`pintados > total/8`),
2048 que el caché acertó (`hits > 0`), que se ejecutaron las órdenes
2049 (`ejecutadas > 0, salteadas == 0`), que se compararon todos los casos
2050 (`probados == patrones().len()`). Sin eso, el test firma cualquier cosa.
20514. **Recorré el camino difícil, no el fácil.** `texto_gpu_directo` sube sólo el
2052 rect sucio y no el atlas entero, porque es lo que hace una app; y tiene un
2053 caso incremental aparte justamente porque en un atlas recién hecho lo sucio
2054 arranca en (0,0), donde un error de coordenada pasa desapercibido.
2055
2056Cuándo sí un golden: cuando **no hay** segundo camino. `gpu_golden.rs` (starfield
2057por GPU directo) es el caso — no existe otra forma de producir esa imagen, así
2058que la referencia tiene que ser ella misma.
2059
2060### Correr la suite contra la GPU de verdad
2061
2062```bash
2063LLIMPHI_TEST_GPU=1 cargo test --no-fail-fast -p llimphi-test -p llimphi-raster -p llimphi-hybrid
2064```
2065
2066El arnés fuerza el adapter de **software** para que un golden valga lo mismo en
2067cualquier máquina, y eso está bien — pero deja una pregunta sin contestar: *¿el
2068hardware hace lo mismo?* Con esa env se ignora la preferencia y se usa la GPU
2069real. Es para preguntar a mano, no para cambiar la referencia.
2070
2071**Medido el 2026-08-05 (Iris Xe/Vulkan contra llvmpipe), y el resultado dice más
2072que cualquier argumento sobre goldens:**
2073
2074| | software | GPU real |
2075|---|---:|---:|
2076| tests en verde | 113 | **112** |
2077
2078El único que se cae es `gpu_golden` — el único que compara contra una imagen
2079bendecida (2,74 % de píxeles, Δ máx 44). **Los ocho oráculos pasan en las dos**,
2080y con casi el mismo número:
2081
2082| test | software | GPU real |
2083|---|---|---|
2084| `texto_gpu_directo` | Δ16:417 Δ64:0 · tinta +0,31 % | Δ16:423 Δ64:0 · tinta +0,29 % |
2085| `hybrid_vs_cpu` | Δ0:236 Δ1:0 | Δ0:232 Δ1:0 |
2086| `boundary_cache` / `gpu_lotes` / `gpu_scissor` | 0 px | 0 px |
2087
2088Un oráculo compara dos cosas que el mismo rasterizador produce, así que la
2089diferencia de rasterizador **se cancela**. Una foto no puede cancelar nada. Por
2090eso `gpu_golden` se saltea solo cuando se pide GPU: así el comando de arriba es
2091una pregunta limpia y no una corrida con un fallo conocido de ruido.
2092
2093---
2094
2095## 16.ter Semántica accesible + tests por consulta
2096
2097Un `View` describe **cajas**: posición, color, texto. No dice qué *significan*.
2098Esa segunda capa se declara con `View::role` + `View::aria_*`, y paga dos veces:
2099
21001. **Lectores de pantalla.** El runtime traduce el árbol montado a AccessKit en
2101 cada frame (`llimphi-ui::a11y`), y el adapter de Linux habla **AT-SPI2 por
2102 DBus** — el mismo bus que usa Orca. `accesskit_winit` está cableado en el
2103 event loop sin feature gate.
2104
2105 ⚠️ **«Gratis» y «ya anda» no son lo mismo, y hasta el 2026-09-03 esto no
2106 andaba: la app se CAÍA.** El adapter no publica nada mientras
2107 `org.a11y.Status.ScreenReaderEnabled` sea `false` (accesskit_unix se activa
2108 por esa propiedad, no al arrancar), así que en una sesión normal el camino no
2109 se recorre nunca. Al encenderla, el primer árbol mataba el proceso:
2110 `accesskit_consumer::Tree::new` paniquea con *«Cannot initialize with a
2111 subtree»* si el `TreeUpdate` inicial no trae `TreeId::ROOT`, y el runtime
2112 generaba un uuid v4 por proceso. Los tests no lo veían porque ahí el id se
2113 escribía a mano y a mano salía `nil` — que **es** ROOT. Arreglado, con el
2114 consumidor de verdad puesto de oráculo en `a11y.rs`
2115 (`el_arbol_publicado_lo_acepta_el_consumidor_de_verdad` y su gemelo
2116 `should_panic`). **Cualquier binario anterior al arreglo se cae si alguien
2117 enciende un lector de pantalla.**
21182. **Tests que preguntan por significado**, no por píxeles (ver más abajo).
21193. **Un agente puede leer la pantalla en texto**: el verbo MCP `ui_tree` de
2120 `sandokan-mcp` sale por ese mismo bus y devuelve este árbol —rol, nombre,
2121 caja, qué se activa— sin pasar por un PNG. Es la contraparte barata de
2122 `screenshot` (Regla 8) y de yapa lee apps ajenas (Qt/GTK). Lo que NO ve: las
2123 superficies de `llimphi-layer` (las barras y el cajón de `pata`) porque no
2124 llevan accesskit, y una app enjaulada (`tablado`), que es un rectángulo mudo.
2125
2126### Declarar
2127
2128```rust
2129View::new(style)
2130 .role(Role::Button) // qué ES el nodo
2131 .aria_label("Guardar") // cómo se llama (si no hay texto visible)
2132 .aria_value("70%") // dato dinámico: valor de un slider, texto de un input
2133 .aria_description("…") // contexto extra; el lector lo dice aparte
2134 .aria_pressed(activo) // …y los flags: checked/pressed/expanded/
2135 .aria_disabled(!habilitado) // disabled/readonly/required
2136```
2137
2138**Dónde va el rol: en el nodo que lleva el `on_click`**, no en el contenedor.
2139AccessKit sólo ofrece `Action::Click` donde hay handler; un rol `Button` sobre
2140una caja sin handler produce un botón que el lector no puede activar.
2141
2142⚠️ Y al revés también se paga: `tabs::cajon` tenía el `Role::Tab` en el nodo del
2143**rótulo** y el `on_click` en el chip que lo contiene. Resultado doble: el chip
2144salía **anónimo** en el auditor —nada podía apuntarle— y AccessKit ofrecía
2145`Action::Click` en un nodo sin handler. Corregido el 2026-08-29.
2146
2147**El nombre accesible** es `aria_label` si está, si no el `text` visible. Por eso
2148un nodo que ya pinta su texto no necesita label — pero si el texto vive en un
2149**hijo** (ícono + etiqueta en una fila), el nodo con el rol queda sin nombre y hay
2150que dárselo explícito. Es el error más común.
2151
2152Roles disponibles (`llimphi_ui::Role`): `Button` `TextInput` `MultilineTextInput`
2153`Heading` `Checkbox` `Switch` `Label` `Link` `Image` `Slider` `ProgressBar`
2154`MenuItem` `Menu` `MenuBar` `Tab` `TabList` `Toolbar` `List` `ListItem` `Tree`
2155`TreeItem` `Table` `Row` `Cell` `ColumnHeader` `Dialog` `Alert` `Status`
2156`Tooltip` `Terminal` `Splitter` `Group`. Si falta uno, agregalo en
2157`llimphi-compositor/src/semantics.rs` **y** mapealo en `llimphi-ui/src/a11y.rs` —
2158cuando aparezca un caller real, no antes.
2159
2160**Cuándo NO declarar:** decorativo puro (divider, gradiente, sombra, `View`
2161envoltorio de layout). Un `Role::Group` en cada caja ensucia la navegación más de
2162lo que ayuda.
2163
2164### Consultar — `llimphi_test::query`
2165
2166La misma declaración habilita tests que **no miran píxeles**:
2167
2168```rust
2169use llimphi_test::query::{Sel, Ui};
2170use llimphi_ui::Role;
2171
2172let ui = Ui::new(app.view(), 800.0, 600.0);
2173
2174assert_eq!(ui.find_all(Role::Tab).len(), 3);
2175let guardar = ui.get(Sel::new().role(Role::Button).label("Guardar"));
2176assert!(guardar.is_enabled());
2177
2178// Clickear = obtener el Msg y metérselo al update.
2179app.update(guardar.click().unwrap());
2180```
2181
2182- **No necesita GPU** — sólo `mount` + layout. Corre en cualquier CI, a
2183 diferencia de `Harness` (que pide lavapipe).
2184- **`Ui::get`** paniquea con el árbol semántico entero en el mensaje si no
2185 encuentra (o si hay más de uno). El fallo se lee sin abrir un PNG.
2186- **`Ui::dump()`** vuelca el árbol como texto indentado — la evidencia barata que
2187 pide CLAUDE.md §8:
2188
2189 ```
2190 · @0,0 510×100
2191 Heading txt:"Datos" @0,0 50×100
2192 TextInput "Nombre" = "sergio" required=true @50,0 200×32
2193 Button "Guardar" [click] @250,0 80×32
2194 Button "Cancelar" disabled [click] @330,0 80×32
2195 · [click] @410,0 100×100
2196 ```
2197
2198 El `·` es un nodo sin rol declarado. El último de ese volcado —`· [click]`— es
2199 un canvas con handler y sin semántica: un lector de pantalla no puede
2200 anunciarlo. Verlo en el dump **es** el hallazgo.
2201
2202- **Selectores:** `Sel::new()` + `.role() .label() .label_contains() .value()
2203 .checked() .enabled() .clickable() .focusable() .role_none()`. Atajos por
2204 conversión: `ui.find(Role::Button)` y `ui.find("Guardar")` (por nombre).
2205- **Auditar lo mudo:** `ui.find_all(Sel::new().clickable(true).role_none())`
2206 devuelve los nodos interactivos sin semántica propia.
2207
2208 ⚠️ **Ese conjunto NO es la deuda**: mezcla dos poblaciones. Los widgets ponen
2209 el rol en la fila y el `on_click` en un hijo, así que la mitad de lo que
2210 devuelve son hijos que su ancestro con rol **ya alcanza por delegación** — se
2211 los apunta por el padre, que tiene rol y nombre. La deuda real es el resto:
2212 clickeables sin rol y sin quien delegue, que nada puede señalar. El auditor
2213 del repo (`02_ruway/llimphi/auditor_mudos.rs`, `include!`ado por
2214 `cargo run -p <app> --example auditar_mudos [-- --detail]`) separa las tres
2215 columnas —**delegados · mudos · anónimos**— replicando `a11y::click_target`.
2216 Contarlas juntas daba «`nada` 98 % mudo» sobre un árbol donde cada fila se
2217 anuncia bien; separadas da 6 %.
2218
2219 ⚠️ **Y un cero del árbol del ARRANQUE no dice nada de los menús.** `pata` tiene
2220 29 nodos interactivos al abrirse y **1.282 sumando sus 25 estados** — casi todo
2221 lo que se clickea vive detrás de un panel que hay que abrir. El auditor toma
2222 escenarios: `escenario::<A>("menú de inicio", &h, vec![Msg::StartToggle], w, h)`
2223 reinicializa el modelo, le aplica esos `Msg` y audita **ese** árbol; cuando lo
2224 que abre el panel es el `Msg` de un módulo cuyo tipo no es público (la paleta de
2225 `nada`), la puerta es `escenario_teclas` con el atajo real, que sí es público
2226 porque lo tipea el usuario. Medir sólo el arranque daba «pata en cero» con
2227 treinta y cinco mudos a un clic de distancia.
2228
2229 Al 2026-09-02 las **siete** apps auditables dan **0 mudos y 0 anónimos**
2230 sumando sus 75 escenarios (4.054 nodos interactivos): `nada` 989 · `shuma`
2231 1.367 · `pata` 1.318 · `puriy` 226 · `pizarra` 79 · `launcher` 72 · `nakui` 3.
2232 Y lo último que quedaba casi no era de las apps: eran **tres nodos de widget
2233 compartido** —el scrim de `modal`, el de `context-menu` y el canal de
2234 `scroll`—, cada uno multiplicado por cuantos diálogos hubiera abiertos. Un
2235 mudo que aparece en todas las apps a la vez se arregla en el widget, no en
2236 cada llamador.
2237
2238 ⚠️ **«Las cinco en cero» del 31-ago contaba cinco de siete.** `nakui` tenía su
2239 auditor desde el 29-ago y nunca entró en la tabla —seguía con un mudo, y era
2240 el botón «Entrar» del login— y `launcher-llimphi` figuraba como no auditable
2241 «porque tiene la struct de `App` privada». Lo segundo era un diagnóstico
2242 correcto con la conclusión al revés: ese crate **no es una app, es una
2243 vista** (`launcher_view`/`launcher_overlay`), y se audita llamando `auditar`
2244 sobre el árbol que devuelve, con el estado puesto en los campos del
2245 `LauncherSpec` en vez de en una lista de `Msg`. Una app que no aparece en la
2246 tabla no está en cero: está sin medir.
2247
2248 El **nombre** de un nodo con rol sale, en orden: de su `aria_label`, de su
2249 texto propio, y —desde el 2026-08-31— de su `tooltip`. Lo último es la regla
2250 de `accname` para el `title` de HTML: el tooltip de un botón de sólo ícono
2251 **es** su nombre, ya está escrito y traducido, y obligar a repetirlo en un
2252 `aria_label` al lado sólo garantiza que las dos copias se desincronicen. Ojo
2253 con el caso que NO cubre: un botón cuyo único texto es un glifo («✕», «▦»)
2254 tiene texto, así que gana el glifo — ahí el rótulo hay que pasarlo a mano.
2255
2256### Estado de la cobertura
2257
2258**52 de 78 widgets anotados** (medido el 2026-08-29: `grep -rlE '\.role\(|Role::'
2259widgets/*/src`). De los 24 restantes, 23 lo están **a propósito** — ver abajo — y
2260uno, `tablado`, es un hueco real con nombre.
2261
2262⚠️ **Dos de esta lista estaban del lado equivocado, y los dos por el mismo
2263motivo:** la clasificación se hizo leyendo qué compone cada widget, no contando
2264sus propios clickeables. `rag-sidebar` («compone widgets ya anotados») ponía
2265seis, y `panes` («layout puro») pone uno —la hoja, que se clickea para
2266enfocarla—, que era el nodo mudo más grande de `shuma`: 1832×957 px. **Un widget
2267con `on_click` propio no es layout puro por mucho que lo parezca; correr el
2268auditor es la única forma de saberlo.**
2269
2270Anotados: `accordion` `app-header` `avatar` `badge` `banner` `breadcrumb`
2271`button` `calendar` `chip` `color-picker` `context-menu` `datetime-picker`
2272`detail-table` `dock-rail` `empty` `fab` `field` `form` `gauge` `list` `menubar`
2273`modal` `nodegraph` `progress` `range-slider` `rating` `rive-button` `segmented`
2274`select` `shortcuts-help` `slider` `spinner` `splitter` `stat-card` `status-bar`
2275`stepper` `switch` `table` `tabs` `terminal` `text-editor` `text-input`
2276`panes` `rag-sidebar` `theme-switcher` `timeline` `toast` `toolbar` `tooltip`
2277`transport` `tree` `waveform`.
2278
2279**Sin anotar por diseño** (el módulo `semantics` lo pide explícitamente: un
2280`Role::Group` en cada caja ensucia la navegación más de lo que ayuda):
2281
2282- *Contenedores de layout puro* — `card` `panel` `grid` `wrap` `hero`
2283 `fitted-box` `tiled` `scroll` `router` `scaffold` `lazy-list`.
2284 La semántica la traen sus hijos.
2285- *Decorativos* — `skeleton` `splash` `wawa-mark`.
2286- *Sin `View` propio* — `app-icon` (resuelve íconos XDG a bytes, no pinta),
2287 `clipboard` (que además no es un widget: es `llimphi-clipboard` viviendo en
2288 `widgets/`), `text-area` `text-editor-core` `text-editor-lsp` `carousel`
2289 (sólo helpers), `edit-menu` (produce `ContextMenuItem`, que ya viene
2290 anotado), `gallery` (binario de ejemplo).
2291- *Componen widgets ya anotados* — `navigator` (sobre `tree` + `nodegraph`).
2292
2293 ⚠️ **`rag-sidebar` estaba en esta línea y era falso** (corregido 2026-08-29).
2294 Compone `segmented` + `dock-rail`, sí, pero además pone **seis clickeables
2295 propios** que no vienen de nadie: la caja del buscador, el control mutable
2296 (lupa/gear), las dos encías (`+` de rama y `+` de grupo), cada subdiente y el
2297 botón de línea punteada del rail guardado. Los seis salieron mudos en el
2298 auditor, y trece de los diecisiete mudos de `pata` eran ésos. «Compone
2299 widgets anotados» describe de dónde vienen sus hijos, no si él mismo declara
2300 algo — la única forma de saberlo es contar.
2301- *Devuelve slots, no una `View`* — `fugaces`: la caja hospedadora y el
2302 anuncio los pone el caller, que es el único que sabe qué es cada fantasma.
2303
2304⚠️ **El que no está por decisión sino por hueco:** `tablado`, el nodo donde el
2305anfitrión pinta la `EscenaWire` de una app enjaulada. Por el cable viaja el IR de
2306**escena** y no el de vista (`SDD-JAULA-LINUX.md`), así que del lado del
2307anfitrión no hay roles que anunciar: para un lector de pantalla, una app
2308enjaulada es un rectángulo mudo. Cerrarlo pide que la semántica del huésped
2309cruce el cable, que es diseño pendiente y no una anotación que falte.
2310
2311### Activación: el rol y el handler no tienen por qué coincidir
2312
2313Los widgets ponen el rol en la **fila** (`TreeItem`, `Row`, `ListItem`) porque es
2314la unidad que el lector navega, pero el `on_click` suele vivir en un **hijo** (el
2315nodo del label). `a11y::click_target` resuelve ese desajuste:
2316
23171. Si el nodo mismo tiene handler, es él.
23182. Si no, y **declara un rol**, se delega en su subárbol — siempre que haya
2319 **exactamente un** descendiente clickeable **sin rol propio**.
23203. Si hay varios, no se delega: la elección sería arbitraria, y esos hijos ya
2321 están en el árbol como nodos propios para activarlos directo.
2322
2323**Un descendiente CON rol no compite** (2026-08-29). Una fila de árbol con hijos
2324lleva dos clickeables —el chevron y el label—, así que con la regla vieja quedaba
2325ambigua: se anunciaba y **no se podía activar**. Pero el chevron declara lo suyo
2326(`Button` + «Desplegar ‹fila›»), o sea que ya es un nodo aparte que el lector
2327alcanza solo; el único candidato a heredar la acción de la fila es el que no
2328tiene identidad propia. Lo cazó el auditor: con la regla vieja, las **16 carpetas**
2329del árbol de `nada` salían inalcanzables.
2330
2331El requisito de rol en (2) es lo que evita que toda caja de layout se vuelva
2332activable. El runtime además sintetiza la posición para los handlers
2333`on_click_at` (centro del rect del nodo en el layout del frame) — misma
2334convención que `query::NodeRef::click`.
2335
2336### Dientes y botones animados: el `label` es obligatorio
2337
2338`DockRailItem` y `RiveButton` no tienen texto en el árbol (el ícono lo pinta un
2339closure opaco), así que el nombre accesible viaja explícito:
2340
2341```rust
2342DockRailItem::nombrado(id, activo, "Configuración") // recomendado
2343DockRailItem::new(id, activo) // queda mudo
2344RiveButton::builtin().con_label("Ejecutar")
2345```
2346
2347Y un patrón que apareció en casi todos: **el ítem sólo-ícono**. Un botón sin
2348texto visible (`toolbar` sin `with_label`, transport, chevrones de calendario,
2349el `×` de una pestaña) es mudo salvo que se le dé `aria_label` a mano. Los que
2350tienen un nombre derivable ya lo llevan; los que dependen del caller están
2351señalados en el código.
2352
2353---
2354
2355## 17. Comandos y demos
2356
2357```bash
2358cargo check --workspace # smoke test mínimo (debe pasar siempre)
2359cargo run -p <crate> --release # correr una app
2360cargo run -p <crate> --example <demo> --release # correr un demo
2361
2362# demos del propio framework:
2363cargo run -p llimphi-ui --example counter --release # bucle Elm completo
2364cargo run -p llimphi-ui --example editor --release # text field + teclado
2365cargo run -p llimphi-ui --example gpu_paint_demo --release
2366cargo run -p llimphi-gallery --release # showcase de TODO el kit
2367cargo test -p llimphi-gallery # la vitrina como CALLER: los widgets responden
2368cargo run -p nada --release # editor real para ejercitar widgets
2369
2370# benchmark GPU directo vs vello (primitivas Y texto — pedile GPU real, en
2371# llvmpipe la mitad de rasterizado no decide nada):
2372cargo run -p llimphi-gpu-bench --release
2373
2374# perfilador por frame (a stderr cada 60 frames: media/p95 por etapa):
2375LLIMPHI_PERF=1 cargo run -p <crate> --release
2376# ...con conteo de asignaciones de heap por frame y su desglose por etapa:
2377LLIMPHI_PERF=1 cargo run -p <crate> --release --features llimphi-ui/perf-alloc
2378
2379# dónde poner un .repaint_boundary() en TU app (ver más abajo):
2380LLIMPHI_BOUNDARY_HINT=1 cargo run -p <crate> --release
2381# un update que tarda de más y congela la UI:
2382LLIMPHI_UI_SLOW_MS=50 cargo run -p <crate> --release
2383```
2384
2385`LLIMPHI_PERF=N` reporta cada N frames (default 60) el desglose
2386`view/mount/layout/paint/raster/gpu/present` en µs + `layout-reuso`, el caché de
2387`repaint_boundary` y —si está la feature `perf-alloc`— `alloc <media>/<p95>
2388(<KB>) [view N·KB mount N·KB layout N·KB paint N·KB resto N·KB]`, donde `resto`
2389es el tramo posterior a paint (raster vello + GPU + present) y el desglose suma
2390el total. Sin esa feature el tramo de allocs dice `n/a` en vez de ceros falsos.
2391Ojo: los contadores son del proceso, no del hilo — lo que asigne otro hilo
2392durante una etapa cae en esa etapa. El contador es
2393`llimphi_ui::alloc::ContadorAlloc`: si tu app ya elige su allocator, instalalo a
2394mano (`#[global_allocator]`) en vez de usar la feature. El camino `cache_hit`
2395(frame retenido entero) **no** se muestrea a propósito.
2396
2397### `LLIMPHI_BOUNDARY_HINT` — dónde poner un `repaint_boundary`
2398
2399`.repaint_boundary(clave)` cachea la rasterización de un subárbol quieto y la
2400reusa mientras su contenido no cambie (Bloque 23). El problema práctico nunca fue
2401la API sino **dónde**: marcar un subárbol que en realidad cambia todos los
2402cuadros no es neutro —se paga el hash entero cada frame y no se ahorra nada—, y
2403desde afuera un panel quieto y uno que se reconstruye idéntico a sí mismo se ven
2404igual. Por eso ninguna app lo usaba.
2405
2406```bash
2407LLIMPHI_BOUNDARY_HINT=1 cargo run -p <tu-app> --release # reporta cada 120 cuadros
2408LLIMPHI_BOUNDARY_HINT=300 … # cada 300
2409```
2410
2411Usá la app un rato con el puntero **fuera** de lo que sospechás. La salida:
2412
2413```
2414[llimphi] 2 subárbol(es) quieto(s), 431 nodos que se repintan de gusto
2415 — poneles .repaint_boundary(clave):
2416 nodo 12 · 287 nodos · (0,48 260×672) · 118 cuadros quieto «Archivos»
2417 nodo 640 · 144 nodos · (0,0 1920×48) · 118 cuadros quieto «Inicio»
2418```
2419
2420Cada línea es el subárbol **máximo** que vino idéntico: si un panel entero está
2421quieto no se listan también sus hijos. El texto es el primero que aparece
2422adentro, para que lo reconozcas sin contar nodos.
2423
2424Es una **predicción, no una opinión**: el detector consulta las mismas tres
2425funciones que el caché de verdad (posición limpia, paint-puro, y el mismo hash
2426que decide hit o miss), así que lo que señala el caché lo va a acertar. Dos
2427cosas que no dice:
2428
2429- **Si conviene.** Sugiere «esto habría sido cache-hit»; el hash se paga siempre.
2430 Por eso ignora subárboles de menos de 16 nodos.
2431- **Que esté quieto para siempre.** Un panel quieto 120 cuadros puede cambiar al
2432 siguiente click, y ahí el boundary falla y repinta — correcto, pero sin ahorro.
2433
2434Con el puntero **adentro** de un subárbol, ni el caché lo sirve ni el detector lo
2435sugiere: el hover cambia lo pintado. Y es diagnóstico, no producción — hashea
2436cada candidato por cuadro. Encendelo, anotá, apagalo.
2437
2438**Qué descalifica a un subárbol** (el detector se calla y el boundary, si lo
2439ponés igual, se pinta fresco): cualquier `paint_with`/`gpu_paint_with`, una
2440animación viva, un ripple, un hero, una `mask_image`, o estar bajo un
2441clip/alpha/transform de un ancestro. **Degradé e imagen sí entran** desde
24422026-08-05 — antes no, y como un sidebar real casi siempre tiene un ícono o un
2443fondo con degradé, un solo nodo así descalificaba el panel entero y el caché no
2444servía para lo que existía.
2445
2446`llimphi-gallery` (`src/main.rs`) es la **referencia viva** del
2447patrón completo: `Model`/`Msg`/`init`/`update`/`view`/`view_overlay` con overlays
2448mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown).
2449Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; `?` abre
2450atajos; `Esc` cierra el overlay activo.
2451
2452---
2453
2454## 18. Cheat-sheet
2455
2456```rust
2457// ── App mínima ──────────────────────────────────────────────
2458impl App for X { type Model; type Msg; init; update; view; }
2459llimphi_ui::run::<X>();
2460
2461// ── Nodo ────────────────────────────────────────────────────
2462View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
2463 .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)
2464 .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)
2465 .image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{})
2466 .on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m)
2467 .on_pointer_enter(m) | .on_pointer_leave(m)
2468 .draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|)
2469 .drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c)
2470 .children(vec![..])
2471
2472// ── Efectos ─────────────────────────────────────────────────
2473handle.spawn(|| Msg::Done(compute())); // worker → reentra al update
2474handle.spawn_periodic(dur, || Msg::Tick); // feed periódico
2475handle.dispatch(Msg::X); handle.quit();
2476
2477// ── Estilo de layout (taffy prelude) ────────────────────────
2478length(px) percent(0..1) Dimension::auto()
2479FlexDirection::{Row,Column} AlignItems::{Start,Center,End,Stretch}
2480JustifyContent::{Start,Center,End,SpaceBetween}
2481
2482// ── Theme ───────────────────────────────────────────────────
2483Theme::dark()/light()/aurora()/sunset(); Theme::next_after(name); XxxPalette::from_theme(&t)
2484
2485// ── Overlay (menús/modales) ─────────────────────────────────
2486fn view_overlay(m) -> Option<View<Msg>> { if m.open { Some(menu) } else { None } }
2487```
2488
2489---
2490
2491## 19. Índice de crates
2492
2493> Recontado contra el disco el **2026-08-27**. Son 139 crates en el workspace
2494> raíz más dos que viven en workspaces aparte (`llimphi-jaula`, `llimphi-wawa`:
2495> los backends de `llimphi-text` son excluyentes y no conviven en un proceso).
2496
2497**Núcleo del camino a píxeles** (`02_ruway/llimphi/`): `llimphi-hal` ·
2498`llimphi-raster` · `llimphi-text` · `llimphi-glifos` · `llimphi-layout` ·
2499`llimphi-compositor` · `llimphi-ui`.
2500
2501**Auxiliares del runtime**: `llimphi-theme` · `llimphi-motion` · `llimphi-icons` ·
2502`llimphi-image` · `llimphi-svg` · `llimphi-surface` · `llimphi-entrada` ·
2503`llimphi-despacho` · `llimphi-layer` · `llimphi-workspace` · `llimphi-test`
2504(golden-image) · `llimphi-gallery` · `llimphi-gpu-bench` · `demo`.
2505
2506**Los otros caminos a píxeles**: `llimphi-cpu` (`vello_cpu`, el de wawa y el
2507Android sin compute) · `llimphi-hybrid` (`vello_hybrid`) · `llimphi-reproductor`
2508(el pintor único de los dos anfitriones) · `llimphi-cable` ·
2509`llimphi-wire-escena` · `llimphi-wire-view` · `llimphi-jaula` y `llimphi-wawa`
2510(*workspaces aparte*). Se comparan por número con
2511`scripts/check-llimphi-cpu-vs-gpu.sh`.
2512
2513**3D, voxel y animación**: `llimphi-3d` · `llimphi-mesh` · `llimphi-voxel` ·
2514`llimphi-voxel-render` · `llimphi-voxel-app` · `llimphi-voxel-studio` ·
2515`llimphi-anim` · `llimphi-anim-studio` · `llimphi-lottie` ·
2516`llimphi-video-plane` · `llimphi-term-graphics`.
2517
2518**Camino web / WASM** (guardián: `scripts/check-llimphi-web.sh`):
2519`llimphi-wasm-core` · `llimphi-wasm-app-sdk` · `llimphi-wasm-runner` ·
2520`llimphi-wasm-wasi` · `llimphi-wasm-net` · `llimphi-wasm-registry` ·
2521`llimphi-wasm-dist` · `llimphi-wasm-open` · `llimphi-wasm-demo-counter` ·
2522`llimphi-wasm-demo-form`.
2523
2524**Widgets** (`widgets/`, dep `llimphi-widget-<n>`) — **78**, más
2525`llimphi-clipboard` que vive ahí sin serlo:
2526accordion · app-header · app-icon · avatar · badge · banner · breadcrumb ·
2527button · calendar · card · carousel · chip · color-picker · context-menu ·
2528datetime-picker · detail-table · dock-rail · edit-menu · empty · fab · field ·
2529fitted-box · form · fugaces · gallery · gauge · grid · hero · lazy-list · list
2530· menubar · modal · navigator · nodegraph · panel · panes · progress · qr ·
2531rag-sidebar · range-slider · rating · rive-button · router · scaffold · scroll
2532· segmented · select · seven-segment · shortcuts-help · skeleton · slider ·
2533spinner · splash · splitter · stat-card · status-bar · stepper · switch ·
2534tablado · table · tabs · terminal · text-area · text-editor · text-editor-core
2535· text-editor-lsp · text-input · theme-switcher · tiled · timeline · toast ·
2536toolbar · tooltip · transport · tree · waveform · wawa-mark · wrap.
2537
2538**Módulos** (`modules/`) — **11**: allichay · bookmarks · command-palette ·
2539diff-viewer · fif · file-picker · mini-map · plugin-host · selector ·
2540shuma-term · symbol-outline.
2541
2542**Android** (`android/`): clear-screen-android · vello-hello-android ·
2543vello-text-android · hybrid-probe-android · llimphi-hybrid-android.
2544
2545---
2546
2547> Documentos hermanos: [`SDD.md`](SDD.md) (diseño y roadmap),
2548> [`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) (regla de
2549> concurrencia), [`README.md`](README.md) / [`LEEME.md`](LEEME.md) (overview).
2550> Las firmas de este manual reflejan el código al 2026-06-01; el índice de
2551> crates y los conteos de widgets/módulos se recontaron contra el disco el
2552> 2026-08-27. Ante divergencia, la fuente autoritativa es el `lib.rs` de cada
2553> crate — y para cualquier conteo, el comando.
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
16.bis. Tests de regresión visual (golden-image)
16.ter. Semántica accesible y tests por consulta
- 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
└ llimphi-glifos ..... atlas de cobertura: el texto del camino GPU-directo
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: 79 widgets (visuales, crates llimphi-widget-*) + 11 módulos
(features con estado). ls -d widgets/*/ da 79 carpetas: la que sobra es
llimphi-clipboard, que vive ahí sin ser un widget. Recontado el 2026-08-27;
el número sale del comando, no de esta línea.
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.
El sentido (natural / clásico) va por CLASE de dispositivo y no lo decide la
app. llimphi_entrada::PoliticaRueda tiene dos sentidos —uno para la rueda del
ratón, otro para el touchpad—, y lo aplican las tres fronteras donde nace un
WheelDelta: el runner de ventana, el de subventana y el de capa. Una App
recibe el delta ya en el sentido que el humano eligió; no lo inviertas de
nuevo (invertir dos veces cancela el ajuste). Invertir para zoom —rueda
arriba = acercar— sí es tuyo: eso es semántica de la app, no sentido de scroll.
La política la siembra el runtime al arrancar (llimphi_ui::sembrar_politica_rueda).
En tawasuyu sale del bus wawa-config (entrada.rueda_raton / entrada.rueda_touchpad,
que escribe wawa-panel) gracias a la feature config-wawa, encendida en el
[workspace.dependencies] de la raíz; fuera del monorepo se fija a mano con
llimphi_entrada::fijar_politica_rueda. Bajo mirada el runtime se queda neutro
a propósito: ahí lo aplica el compositor en libinput, por dispositivo, y así lo
obedece toda la sesión.
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
// --- ventana (sólo la primaria; en un handle `lift`ado son no-op) ---
pub fn set_fullscreen(&self, on: bool); // pantalla completa sin bordes, en su monitor
pub fn set_minimized(&self, on: bool); // minimizar / restaurar
pub fn set_content_type(&self, k: ContentType); // qué mostrás: None/Photo/Video/Game
pub fn open_window(&self, key: u64, title: impl Into<String>, w: u32, h: u32); // ventana OS secundaria
pub fn close_window(&self, key: u64);
}
-
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.
-
set_fullscreen — Fullscreen::Borderless sobre el monitor donde ya
está la ventana (no cambia el modo de video). Es una petición al
compositor: el estado vive en el modelo de la app, que decide cuándo entra y
cuándo sale. El resize llega por el camino normal (Resized), no hay que
tocar la surface.
Y el compositor también lo cambia por su cuenta — F11 es suyo en mirada
desde el 2026-09-05, para TODAS las apps: la ventana se va a pantalla completa
sin que la app pida nada. Para eso está App::on_fullscreen(model, bool),
que el runtime dispara cuando el estado del último configure difiere del que
la app conocía. Es el enganche para resaltar distinto —esconder barras,
estirar el contenido, sacar márgenes y esquinas redondeadas— venga la orden de
donde venga; el ejemplo canónico a copiar es media-app
(modelo.rs::on_fullscreen → Msg::FullscreenChanged). Dos reglas:
idempotente (también se dispara para tu propio cambio: si el modelo ya
está así, devolvé None) y sin set_fullscreen adentro (el compositor ya
lo hizo; volver a pedírselo es contestarle a un eco). Sin implementarlo no se
rompe nada: la ventana se pone a pantalla completa igual, con el aspecto de
siempre.
-
set_content_type — declara qué muestra la ventana
(wp_content_type_v1). No cambia nada de cómo pintás: es una pista para que
el compositor ajuste su política. mirada le cede la pantalla al contenido
con ritmo propio —deja de animar su fondo de marca, que le cuesta una
recomposición completa por latido— y con lo mismo decide VRR y tearing. Un
reproductor declara Video al arrancar y vuelve a Photo al pausar (un video
en pausa es una imagen fija) o a None al cerrar el medio; repetir el
mismo valor es gratis. Fuera de Wayland, o con un compositor que no exponga el
protocolo, es un no-op silencioso (avisa una vez por stderr).
Winit no habla ese protocolo: por debajo se monta una segunda conexión
wayland-client sobre el mismo wl_display y se envuelve la wl_surface
de winit — el mismo cruce que validó el spike llimphi-video-plane. La pista
es estado doble-buffereado, así que viaja con el próximo cuadro que
pintes. Ejemplo vivo: cargo run -p llimphi-ui --example content_type.
-
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
.double_tap_exclusive() // …y que ARBITRE contra el click del nodo
.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).
double_tap_exclusive() rompe esa aditividad para el doble-tap, y es
la salida para el nodo que necesita las dos cosas en el mismo botón: el
runtime retiene el click del primer tap hasta que vence la ventana del
doble (400 ms) y lo descarta si el segundo llega. Cuesta latencia —el
click de un toque suelto sale al vencer, no al soltar—, por eso es opt-in: sin
esto una fila que elige con un click y abre con dos sigue disparando los dos, y
esa es la semántica normal. Lo estrena el diente del rag-sidebar (un
click despliega su panel, dos alternan el eje Flota/Fijo): sin arbitrar, el
doble desplegaba y replegaba el panel de paso, y en FIJO eso suelta y vuelve a
tomar la franja reservada del escritorio. Vale en los tres bucles (ventana,
llimphi-layer y pata).
- 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| ...); + .double_tap_exclusive() si el mismo nodo tiene click |
| 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).
⚠️ Pasado el techo, vello devuelve Ok y no pinta un solo píxel. No tira
Err, no loguea, no panickea: render_to_texture vuelve bien y la textura
queda como estaba. Medido el 2026-08-28 con pukllay-mayu-spike (hebras
Bézier con stroke, llvmpipe): el corte cae entre 324 K y 336 K cubics por
cuadro con un trazo por camino, y entre 120 K y 180 K trazos cuando cada
cubic es su propio trazo — o sea que el techo depende de la forma de la
escena, no de un solo número. Consecuencia práctica: un canvas que crece sin
tope no falla ruidosamente, se apaga, y encima parece rápido (la fila del
techo mide el cuadro que no dibujó). Si tu paint_with escala con los datos,
contá píxeles pintados o hasheá el cuadro además de mirar los ms — un contador
que no distingue «no dibujó» de «dibujó poco» miente. El arnés de referencia
está en 02_ruway/pukllay/pukllay-mayu-spike/src/main.rs::contar_pintados.
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. Sí soporta texto desde
2026-08-05 (§12, llimphi-glifos + add_glyph) y múltiples grosores de stroke
por flush desde el mismo día. Lo que sigue sin soportar es el AA analítico de
curvas: los bordes de rects/líneas/tris salen del MSAA 4×, no de cobertura
exacta. Para texto con brush (gradiente, imagen) o decoraciones sigue
haciendo falta la Scene vello de view_overlay.
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í, con brush | sí, color sólido (atlas de cobertura) |
| AA fino | sí (analítico) | bordes por MSAA 4×; el texto trae el suyo |
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) // pluma: vale para lo que se agregue DESPUÉS
.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)
.add_disc(cx,cy,r,color) .add_ring(cx,cy,r,stroke,color)
.atlas(&AtlasTextura) // sin esto los quads NO se dibujan
.add_glyph(x,y,w,h,(u0,v0,u1,v1),color) // quad texturado: el TEXTO
.scissor(x,y,w,h) // sólo se toca ese rect (damage)
.max_buffer_bytes(bytes) // baja el techo por buffer; normalmente no se toca
.primitive_count() -> u32
.flush(device, queue, encoder, view, viewport, load_op)
units_per_chunk(stride, max_bytes, multiplo) -> u32
RECT_INSTANCE_STRIDE / LINE_INSTANCE_STRIDE / DISC_INSTANCE_STRIDE / TRI_VERTEX_STRIDE
QUAD_INSTANCE_STRIDE
// la textura de cobertura contra la que se muestrean los quads:
AtlasTextura::new(device, &pipelines, lado) -> Self
.subir(queue, pixeles, (x,y,w,h)) // sube SÓLO ese rect, no el atlas entero
Re-exporta vello y peniko (Color, Image, Fill, etc.).
Cuatro cosas del flush que conviene saber y no se ven en la firma:
-
line_width es una pluma, no un modo del lote (cambió 2026-08-05). Cada
línea se queda con el grosor que estaba puesto cuando se la agregó, así que
una grilla fina y una curva gruesa entran en el mismo flush. Antes el grosor
era un uniforme del lote entero y dibujar dos grosores obligaba a cortar —
y cortar no cuesta una draw call más: cuesta el ciclo entero otra vez,
porque cada flush arma su propia textura MSAA 4×, la resuelve y la compone
(2 texturas + 2 render passes por flush). El precio es 4 B más por línea
(36 en vez de 32), que baja el techo de un solo buffer de 8,4 M de líneas a
7,4 M; el troceado ya lo maneja.
-
No hay techo de primitivas. max_buffer_size del dispositivo (256 MB en
una Iris Xe) topa a ~8,4 M de instancias en una reserva, y pasado eso wgpu
rechaza: la app no se pone lenta, se cae. flush parte cada tipo en lotes y
dibuja uno por lote en orden, así que el alpha-over sale idéntico. En el caso
normal es un lote y las mismas cuatro draw calls. max_buffer_bytes existe
sobre todo para testear el troceado sin reservar cientos de MB.
-
scissor recorta el costo, no sólo el dibujo. Las texturas intermedias
(MSAA 4× + resolve) se crean del tamaño del rect sucio, porque el
LoadOp::Clear del attachment y el resolve son de toda la superficie y el
scissor no los toca. Sin eso, pintar un cursor de 32 px costaba limpiar y
resolver la pantalla entera. El rect se pide siempre en coordenadas de
viewport; flush baja el origen a par (las derivadas de fwidth, de las
que sale el AA del disco, se calculan por bloques de 2×2 alineados al target:
un corrimiento impar cambiaría la orla), así que puede tocar una fila y una
columna de más.
-
Los quads sin atlas no se dibujan, y no revientan. add_glyph acumula
igual, pero si nadie llamó a atlas() el flush los saltea en silencio. La
alternativa —un panic adentro del flush— sería reventar el frame de una app
porque alguien pidió texto antes de tener el atlas listo. Que falte el texto es
un síntoma leíble; que se caiga la ventana, no.
llimphi-glifos — el texto del camino GPU-directo
AtlasGlifos::new(lado) -> Self // atlas cuadrado de cobertura, 1 B/px
.pixeles() -> &[u8] .lado() -> u32 .version() -> u64 .entradas() -> usize
.tomar_sucio() -> Option<Sucio> // qué cambió desde la última vez, y limpia
.limpiar() // única salida cuando `fallos` sube
.fallos / .aciertos / .rasterizados // contadores públicos
quads_de_layout(&mut atlas, &layout, (x,y), &mut Vec<QuadGlifo>) -> usize
quads_de_run(&mut atlas, &glyph_run, (x,y), &mut Vec<QuadGlifo>) // multicolor
Rasteriza cada glifo una sola vez a un mapa de 8 bits y lo empaqueta por
estantes; de ahí en más pintar texto es pintar quads. Un renglón de 80
caracteres pasa de 80 contornos rellenos a 80 instancias de 48 B.
Lo que hay que saber antes de usarlo:
- No shapea. Recibe un
parley::Layout ya resuelto —el que arma
llimphi_text::Typesetter—, así que el texto por GPU-directo y el texto por
vello parten del mismo layout. Certificado:
llimphi-test/tests/texto_gpu_directo.rs pone los dos caminos sobre el mismo
layout y mide Δ64:0 — ningún píxel difiere más de 64/255 — con la caja de lo
pintado coincidente y 0,31 % de tinta de diferencia.
- Un color sólido por run. El atlas guarda cobertura, no color, así que el
mismo glifo cacheado sirve para cualquier color sin re-rasterizar — pero un
brush (gradiente, imagen recortada al texto) no entra. Eso sigue siendo vello.
- Ni emoji ni glifos de color: el atlas es de un canal. Pedirían un atlas
RGBA y un pipeline propio.
- No hintea, a propósito: vello rasteriza el contorno crudo, y hintear haría
que el mismo texto saliera con métricas distintas por cada camino.
- No crece ni desaloja. Lleno, devuelve
None y suma a fallos. La salida
es limpiar() y volver a llenar. Una política de desalojo sin una app que la
presione se diseña a ciegas.
- Subí sólo el rect sucio.
tomar_sucio() + AtlasTextura::subir es el
camino esperado; subir el atlas entero por frame anula el punto de cachear (un
atlas de 1024² son 1 MiB por cuadro).
Hay DOS atlas de glifos en llimphi, y hay que saber cuál usar. El otro es
llimphi_widget_terminal::GlyphAtlas, anterior (Fase 4 del SDD-TERMINAL) y vivo:
lo consume el modo TUI de shuma. llimphi-glifos se escribió sin advertirlo.
Desde el 2026-08-05 comparten rasterizador (llimphi_glifos::Rasterizador,
swash/zeno); antes el terminal tenía fontdue propio. Lo que sigue habiendo dos
es el empaquetado, y eso es de diseño:
| GlyphAtlas (widget terminal) | AtlasGlifos (llimphi-glifos) |
| clave | char (charmap, sin shaping) | (fuente, id, tamaño, subpíxel, síntesis) |
| empaquetado | grilla de celdas iguales | estantes de tamaño variable |
| subpíxel | no | 4×4 |
| fuentes | una, monoespaciada | cualquiera, varias por renglón |
Cuál usar: una grilla de terminal, GlyphAtlas — puede asumir monoespaciado,
celdas enteras y cero shaping, y esas suposiciones son parte de por qué es
rápido. Cualquier otro texto, llimphi-glifos.
Si empaquetás a tu manera, no escribas un tercer rasterizador:
Rasterizador::glifo(datos, ClaveGlifo::de_glifo(id, tam)) devuelve el
MapaGlifo (cobertura + colocación) y vos lo ponés donde quieras. Y para lo que
no necesita shaping hay metricas, id_de_caracter y avance.
La migración se hizo contra dos oráculos, no a ojo:
tests/metricas_fontdue.rs — fontdue sobrevive como dev-dependency para
esto: la celda sale idéntica en 11/12/13/14/16/18 px, y de 42 glifos
comparados sólo 1 difiere (1 px de caja, el 0 a 13 px).
tests/dos_atlas.rs — los dos empaquetados entregan el mismo glifo.
Y un tropiezo que dejó regla: la primera corrida de dos_atlas daba 44 % de
diferencia. No era el rasterizador — TextBlock::simple deja
font_family: None y parley cae a sans-serif, así que comparaba dos
tipografías. Si comparás glifos, fijá la familia primero.
¿Cuándo conviene? Medido en metal (Iris Xe/Vulkan, llimphi-gpu-bench
sección «Texto», 12.096 glifos): el total baja 3,6-5,0× contra vello, y lo
que importa es la forma — el costo del atlas es casi plano con la cantidad de
texto (5,0 → 5,5 ms entre 7.200 y 12.096 glifos) mientras vello crece lineal
(13,6 → 26,4 ms). Pero la mitad de CPU del atlas es peor que la de vello
(~0,7×): vello apila un id y una posición por glifo, el atlas hace una búsqueda
en tabla y arma un quad. Toda la ganancia viene del lado GPU.
O sea: el atlas es para mucho texto que se repinta seguido — grilla de
terminal, editor, etiquetas sobre un canvas denso. Para un rótulo suelto no
compra nada y encima paga el ciclo fijo del flush (MSAA + resolve +
composite). El default sigue siendo vello.
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
// mismo decode, pero además dice de qué espacio venía (para editores):
decode_bytes_con_origen(&[u8]) -> Result<Decodificada, DecodeError>
load_path_con_origen(&Path, max_bytes: u64) -> Result<Decodificada, DecodeError>
Decodificada { imagen: Image, origen: Option<Perfil>, convertida: bool }
Decodificada::devolver_al_origen(&self, rgba: &mut [u8]) -> bool
// perfiles de color (mod perfil):
perfil::leer_icc(&[u8]) -> Option<Perfil> // matrix-shaper; None si no lo es
perfil::convertir_a_srgb(&mut [u8], &Perfil) -> bool // entrada: del perfil a sRGB
perfil::convertir_desde_srgb(&mut [u8], &Perfil) -> bool // salida: de sRGB al perfil
perfil::matriz_a_srgb(&[f32;9]) -> [f32;9] / matriz_desde_srgb(&[f32;9]) -> Option<[f32;9]>
perfil::es_identidad(&[f32;9], tol) -> bool
Curva::{Lineal, Gamma(f32), Tabla(Vec<u16>), Parametrica{tipo, p}}
Curva::a_lineal(v) -> f32 / Curva::desde_lineal(lin) -> f32 // una es inversa de la otra
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).
Gestión de color (2026-08-05). decode_bytes / load_path leen el perfil
ICC embebido y llevan la imagen a sRGB. Antes se tiraba: una foto Display P3
—lo que saca cualquier teléfono desde 2016— se pintaba sobresaturada, porque
sus rojos, que son más rojos que los de sRGB, se mostraban como si fueran los de
sRGB. Nadie tiene que hacer nada: el caller sigue recibiendo RGBA8 sRGB.
- Cubre perfiles matrix-shaper (matriz 3×3 + curva), que es lo que son sRGB,
Display P3, Adobe RGB, Rec.2020 y casi todos los de pantalla y cámara.
- No cubre los basados en LUT (
A2B0), como los CMYK de imprenta: ahí no
convierte y deja la imagen como estaba, que es mejor que inventar.
- Sin perfil se asume sRGB (la convención de la web). Con perfil sRGB, la matriz
da la identidad y no se recorre un solo píxel.
- El recorte a gamut es por saturación de canal: un rojo P3 puro no existe en
sRGB y termina en el rojo sRGB más cercano.
Volver al espacio de origen (2026-08-05). Un editor no puede conformarse con
recibir sRGB: si
tullpu abre una foto Display P3 y la guarda, tiene que
guardarla en P3, no degradar el archivo del usuario por el solo hecho de
haberlo abierto. Para eso están decode_bytes_con_origen / load_path_con_origen,
que devuelven una Decodificada con el perfil del archivo al lado de los píxeles.
let d = llimphi_image::load_path_con_origen(ruta, 64 << 20)?;
let mut px: Vec<u8> = d.imagen.image.data.as_ref().to_vec(); // sRGB, para editar
// …edición…
d.devolver_al_origen(&mut px); // false si no hay a dónde volver: se guarda sRGB
origen: None no significa «era sRGB»: significa que no había perfil o que
era de los que este crate no entiende (CMYK/LUT). En los dos casos los píxeles
quedaron como venían y no hay a dónde volver.
convertida: false con origen: Some(…) es el caso corriente: el archivo ya
venía en sRGB y no se recorrió un píxel.
- La ida y vuelta no es sin pérdida, por dos razones distintas. La segunda
cuantización a 8 bits mete ±1. Y lo que se recortó al entrar (un rojo P3 que
en sRGB no existe) no vuelve: se perdió en el recorte, no al salir. Editar un P3
sin pérdida pide trabajar en P3; el
Perfil expuesto es lo que lo habilita.
- Quien sólo va a pintar sigue usando
decode_bytes / load_path y no se entera:
son las mismas funciones, que ahora delegan y tiran el perfil.
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.
Y llimphi_motion::morfo — transformar una forma en otra (el escalón sobre
el tween: interpola la figura, no un número):
Silueta::nueva([contorno, hueco]) // contornos en CAJA UNIDAD (0..1)
morfo(&a, &b, t) -> Vec<Vec<(f64,f64)>> // escalás al PaintRect y rellenás con EvenOdd
banda(&polilinea, grosor) / rect_redondo(..) // un trazo o un chip, como contorno
Transicion::nueva(destino, dur_ms) // .ir_a(destino, ahora_ms) / .valor(ahora) / .activa(ahora)
Los contornos que sobran se cierran sobre el centroide del otro (el hueco de
la chakana desapareciendo hacia el chevron): eso es el cambio de topología, y es
lo que separa esto de un crossfade. Consumidores: el PS1 de pata
(render::task_manager::silueta_chakana/silueta_chevron) y el botón del canto
del input (shuma_voz_ui::boton_voz_o_enviar, micrófono ↔ avioncito).
Transicion mide contra el reloj de pared porque .animated_* es inerte
bajo pata (capa layer-shell); mientras .activa() diga que sí, el host tiene que
seguir pidiendo cuadros o el morfo se congela a mitad.
llimphi-icons — iconos vectoriales (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, ...}
// Herramientas de pintura (2026-08-28), para la caja de un editor de imágenes:
Icon::{Move, Eyedropper, Marquee, Bucket, Brush, Eraser, Gradient, Wand, Lasso,
Polygon, Type, Stamp, Bandage, Pen, Layers}
// Y sus CONTROLES (2026-08-29), para que un panel de acciones se lea de un
// vistazo en vez de renglón por renglón:
Icon::{SelectAll, Expand, Contract, Scissors, Paste, Duplicate, Crop, Trim, Mask,
Invert, Mirror, MirrorV, Square, Circle, RoundedSquare, Star, Hexagon,
Line, Flatten, RotateCw, RotateCcw, Shadow, Brightness, Contrast,
ColorWheel, Blur, Levels, Curve, Segment, Upscale}
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). Si a una app le
falta uno, se agrega acá (lo dice el propio crate): la consistencia visual
pesa más que el aislamiento, y un ícono en el crate de la app no lo encuentra
nadie más.
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>.
⚠️ select_menu_view es sólo para overlay: devuelve un scrim de 100 %×100 %
con el panel Position::Absolute adentro. Sin overlay donde colgarlo —una
surface de layer-shell con su propio tamaño y el clip de un scroll— va
select_menu_inline_view(spec), que entrega el mismo panel en el flujo, con
alto declarado, y empuja lo de abajo (sin scrim: cerrar es volver a apretar el
disparador; ignora anchor/viewport). Meter el flotante en el flujo pinta el
panel fuera de los límites de su padre y el contenido siguiente lo tapa:
medido con el dedo, 2 de 4 filas inalcanzables (pata, panel de video, 5-oct).
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: una banda de gradiente [low, high, low] que cruza el rect. skeleton_view, skeleton_box_view(w,h,..),
skeleton_line_view(w,..). Requiere redraws periódicos (spawn_periodic(50ms)
mientras haya skeletons visibles). Esas tres arrancan reloj propio y están
gateadas a std; skeleton_view_en(inicio, ahora, &pal) recibe el instante por
argumento y es la que sirve sin std (jaula/kernel), misma doctrina que el
today del calendar. La aritmética es pura y testeable: banda_shimmer(ancho, progreso) -> (izq, der) —la banda entra y sale entera del rect, con piso de
ancho para que en un avatar chico el destello no sea un píxel— y
progreso_shimmer(segundos) -> [0,1).
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.
calendar_view_ext(spec, CalendarOpciones { limites, rango, navegacion_anual })
suma lo que un <input type=date> sí tiene, sin tocar a quien ya usa
calendar_view (el Default de las opciones es el calendario de siempre):
Limites{min,max} inclusive apaga los días fuera de rango (aria_disabled, sin
click) y las flechas cuyo mes destino esté entero afuera (mes_habil);
RangoSeleccion pinta la banda entre dos extremos y avanza sola con
.click(fecha) (1º fija inicio, 2º cierra ordenando, 3º empieza de nuevo;
dos clicks al mismo día = rango de un día), con contiene/es_extremo/dias/
recortado(&limites) —que interseca, no clampea cada punta— para el host;
navegacion_anual agrega una fila « año » arriba de la de mes (fila aparte y
no cuatro flechas en una: el ancho lo fija la grilla y «septiembre» no entraría).
Teclado: cal_move_desde_tecla(&KeyEvent) -> Option<CalMove> es el mapa del
picker nativo (flechas = día/semana, PageUp/Down = mes, Ctrl+PageUp/Down =
año, Home/End = bordes del mes) y nav_date_limitada(fecha, mov, &limites)
aterriza en el borde en vez de salirse.
accordion — secciones plegables (expansion panels).
accordion_view(AccordionSpec{secciones, abiertas: &[usize], modo, on_toggle, palette}) con Modo::{Una, Varias}; la máquina de qué queda abierto es
alternar(&abiertas, i, modo) -> Vec<usize> (pura) y todas(n, modo) da el
«expandir/plegar todo» — en Modo::Una devuelve una, porque devolver todas
dejaría el Model en un estado que el propio modo prohíbe. El cuerpo llega como
Fn() -> View y no se construye si la sección está cerrada: en un acordeón
de veinte secciones con formularios adentro, armar los diecinueve invisibles es
el costo real. Seccion::con_resumen("Visa ••4242") muestra el dato a la
derecha sólo cuando está cerrada.
stepper — asistente por pasos. stepper_view(StepperSpec{pasos: &[Paso], actual, cuerpo, on_ir, on_finalizar, palette}); Paso::{pendiente, actual, hecho, con_error}. La regla de navegación es pura: puede_ir_a(pasos, actual, destino) —atrás siempre, adelante sólo si el actual está Hecho, de un salto
sólo si todos los intermedios lo están—, más siguiente/anterior/
es_ultimo/hechos. En el último paso el botón dice Finalizar y emite otro
Msg. Los botones del pie y los pasos inalcanzables se apagan con
aria_disabled, no desaparecen ni quedan mudos.
form — validación de formularios sobre el field. Tres piezas, y la
tercera es la que se hace mal en todos lados:
Formulario::new(vec![ // qué se pide
CampoDef::new(Id::Correo, "Correo").reglas(vec![Regla::Requerido, Regla::Correo]),
CampoDef::new(Id::Edad, "Edad").regla(Regla::Rango(18.0, 120.0)).ayuda("…"),
])
form.validar(&[(Id::Correo, &m.correo), …]) -> Errores<Id> // qué está mal: TODOS
estado.tocar(id) · estado.enviar() · estado.visibles(&errores) // cuándo se dice
campo_view(&form, id, control, &visibles, &pal) // etiqueta + asterisco + error
resumen_errores_view(&visibles, Msg::IrA, &pal) -> Option<View>
Regla::{Requerido, LargoMin, LargoMax, Numero, Rango, Correo, Propia(fn)}. Un
valor vacío pasa todas menos Requerido (si no, un campo opcional en blanco
se queja de formato). El error se muestra al salir del campo o al intentar
enviar, nunca mientras se tipea por primera vez; tras el submit se muestran
todos, porque esconder alguno sería mentir sobre por qué no se envió.
campo_view saca del formulario la etiqueta, el asterisco de obligatorio
(derivado de las reglas, no de un flag aparte) y el error: los tres dejan de
escribirse dos veces. resumen_errores_view devuelve None si no hay nada que
decir, y cada ítem es clickeable para ir al campo — en un formulario largo el
error puede quedar fuera de pantalla y sólo se ve que el botón no hizo nada.
datetime-picker — pickers de fecha y hora, los dos con la misma
forma: un *_trigger en el formulario (muestra el valor formateado o el
placeholder, emite Msg al click) y un *_popup que el host monta en
App::view_overlay con scrim que cierra al click-fuera. Sin estado propio: el
valor, si está abierto y el mes en foco viven en el Model.
date_picker_trigger(DateTrigger{valor, formato, placeholder, enabled, on_open, palette})
date_picker_popup(DatePopup{anchor, viewport, valor, view:(año,mes), today,
week_start, opciones: CalendarOpciones, on_pick, on_view_change, on_dismiss,
on_clear, acciones: Acciones::hoy_y_limpiar(), palette, theme})
time_picker_trigger(TimeTrigger{..}) · time_picker_popup(TimePopup{.., paso_minutos})
formatear_fecha(d, FormatoFecha::{Iso,DiaMesAnio,Largo}) · parsear_fecha(&str)
Hora{h,m}: formatear/parsear (FormatoHora::{H24,H12}) · mover(campo,±1,paso) · al_paso
El de fecha compone el calendar, así que hereda límites min/max, rango,
salto de año y mapa de teclas — no reimplementa la grilla. El de hora es grilla
de horas + minutos por paso: elegir la hora conserva el minuto y viceversa.
parsear_fecha acepta ISO y día-primero con -, / o ., y rechaza el año
de dos dígitos (adivinar en una fecha es peor que devolver None). Hora
resuelve el caso que todos yerran: medianoche es 12 AM y mediodía 12 PM, no 0.
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.
tabs_view_sliding(spec, indicator_t) cambia el acento por-tab (que se prende de
golpe) por un realce único que se desliza hasta indicator_t — el índice
animado que el host tween-ea con llimphi-motion hacia el activo. Como cada tab
mide lo que dice su label, interpola posición y ancho a la vez
(indicator_bounds_px(&anchos, t); los anchos con tab_widths(&labels, tab_width, closable), la misma cuenta que el render y que strip_width), así se
estira al ir hacia un tab más largo. Con overflow viaja dentro de la tira, o sea
que el strip_offset lo arrastra solo. indicator_t = active as f32 da la
posición estática, para un host que no quiera animar.
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 (con
apply_resize(actual, delta, min, max), que clampea por paso).
splitter_two_ext(…, SplitterOpciones { on_toggle, focus_id }) agrega el
doble-click sobre el divisor y lo hace enfocable, que es la puerta del
resize por teclado: con el foco puesto, el host traduce la tecla con
ajuste_desde_tecla(&ev, direction) -> Option<AjusteSplitter> (las flechas del
eje del divisor —←/→ en Row, ↑/↓ en Column—, Shift para el paso grande,
Home/End a los topes, Enter/Espacio para alternar) y la aplica con
aplicar_ajuste. El colapso lo resuelve alternar_colapso(actual, recordado, por_defecto) -> (nuevo, a_recordar): guarda cuánto medía, no que estaba
abierto, así restaurar devuelve el layout del usuario y no un ancho de fábrica.
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.
Para darle scroll al cuerpo de un widget de filas está la pieza compartida
—la usan list, tree y detail-table, y por eso vive acá y no copiada tres
veces—: cuerpo_scrolleable(contenido, contenido_len, DesplazamientoV{offset, viewport_len, on_scroll}, medidor: Option<AltoVisible>, &palette).
AltoVisible publica el alto medido desde el pintado (el alto disponible no
existe hasta que taffy repartió) y offset_efectivo(offset, viewport_len) pinta
desde arriba mientras no haya medida — clamp_offset con viewport 0 da por
bueno cualquier offset, así que sin esto una lista re-montada con offset
guardado arranca desplazada y salta al cuadro siguiente.
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)
// extent CALCULABLE barato (el sabor a preferir para listas grandes de alto variado):
IndiceExtent::nuevo(total, extent: Fn(usize)->f32) // índice por bloques de BLOQUE_EXTENT (1024)
lazy_list_extent(&indice, extent, offset, viewport_h, overscan, item, on_scroll, &palette)
// guarda 1 float cada 1024 filas en vez de 1 por fila (1M filas: 978 floats ≈ 3,9 KB
// contra 4 MB) y NUNCA construye un View para medir. Consulta = binaria + ≤1 bloque.
// 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)
// visible_window_extent(&indice, &extent, offset, vp, overscan)
// IndiceExtent::{y, indice_en, alto_total, len_indice}
// 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).
fugaces — la banda de controles fantasma: más acciones y estados que
píxeles, resueltos por voz en vez de por espacio. No pinta íconos ni sabe qué
representan — es la política, y el caller pone la identidad (trait
Fantasma: una clave estable para persistir el uso y un prior de asiento) y
el dibujo de cada cara.
Cada fantasma declara por cuadro su [Nivel]: Nada (sólo en el reveal —
acciones puras) · Ambiente (mirable, turno lento y al 55 % de alfa) · Leve
(estado que dura; se turna cada 4 s) · Evento (acuse de segundos: no espera
turno) · Critica (sale fija, y todas juntas). En reposo se ve una sola cosa;
al acercar el puntero salen todas.
Las tres reglas que no son obvias, y que ya costaron reportes: el orden se
congela bajo el mouse (un click aprende, pero el asiento nuevo rige recién al
esfumarse el reveal); la membresía también (el que llega a mitad del hover
no se inserta, y el que se va deja un hueco de su ancho); y el asiento sale
de prior + 2·√usos, que satura — los primeros clicks mandan y después el
orden queda quieto, que es lo que permite memorizarlo.
Dos consumidores, dos formas del mismo patrón: pata lo monta como overlay a la
derecha del cabezal de shuma (estados del sistema), y nahual-shell como banda
al borde derecho de la barra de herramientas (nahual-shell-llimphi::fugaces),
donde la acción aparece cuando el contexto la vuelve posible — marcás dos
archivos y sale cotejar.
trait Fantasma { fn clave(self) -> &'static str; fn prior(self) -> f32; }
elegir_visibles(&[(F, Nivel)], idx, pin) -> Vec<F> // qué se ve sin reveal
avanzar_idx(&mut idx, &mut reloj, ahora_us, congelar) -> bool
orden_asientos(&todos, usos: Fn(F) -> u32) -> Vec<F>
congelar(&todos, &cands, usos, idx, pin, ahora_us) -> Freeze<F>
slots(cands: Vec<(F, Nivel, View<Msg>)>, &Franja<F>, usos, ancho_slot) -> Vec<View<Msg>>
Uso::abrir(Option<PathBuf>) / .bump(f) / .usos(f) // feature `persistencia`
El contenedor es del caller: slots devuelve la fila lista, no una View — la
franja caliente, el ancla y el hover_fill los pone quien la hospeda.
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>
// La MISMA barra de pie: columna de botones cuadrados, separador horizontal
// entre grupos, sin overflow ni rótulos (el label queda de nombre accesible).
// Es la caja de herramientas al canto de un lienzo — uso: `tullpu`.
toolbar_view_vertical(groups, width: f32, &ToolbarPalette) -> View<Msg>
// Overflow + tooltips (aditivo; Default = la barra de siempre):
let medidor = AnchoBarra::new(); // se lee el cuadro siguiente
let anchos = item_widths(&groups); // misma cuenta que el render
let visibles = overflow_count(&anchos, medidor.px(), MORE_W, ITEM_GAP);
let ocultos = items_ocultos(&groups, visibles); // para armar el popup
toolbar_view_ext(groups, 36.0, &pal, ToolbarOpciones {
medidor: Some(medidor), visibles: Some(visibles),
on_overflow: Some(Msg::AbrirMas), // dibuja «⋯» sólo si sobra algo
on_hover: Some(Arc::new(|i| Msg::Tooltip(i))), // Some(i) al entrar, None al salir
})
El ancho disponible no se sabe hasta que taffy repartió, así que AnchoBarra
lo publica desde el pintado y el host lo usa en el cuadro siguiente (un frame de
latencia en el primero y en el resize — lo mismo que hace cualquier toolbar con
overflow). item_offsets(&groups) da (left, width) de cada ítem contando
padding, gaps y separadores: es el ancla del tooltip (que es render puro y
lo monta el host en su view_overlay). El menú de overflow también lo monta el
host, con context_menu_view; items_ocultos le da label/estado/Msg en orden
para que el índice del on_pick no se desincronice.
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. list_view_ext(spec, ListOpciones{scroll, medidor}) le da scroll real (no sólo clip): las filas se desplazan y
tienen barra arrastrable, mientras el caption y el aviso de truncado quedan
fijos — son rótulos de la lista, no contenido de ella.
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.
tree_view_ext(spec, TreeOpciones{scroll, medidor}) le da el mismo scroll real
que la list. Cada fila acepta además icon (un View), color_label
(teñir por tipo/estado), trailing, on_context y on_double.
tree_view_2d(spec, Vista2D{offset, viewport, ancho_contenido, on_scroll, medidor, fijadas}) agrega lo que un árbol de proyecto necesita y una lista
indentada no: rueda en los dos ejes (un nombre anidado no entra en un panel
angosto y el … no lo hace legible) y filas fijadas — FilaFijada{fila, y_natural, huerfana} se apila contra el borde por el que salió del viewport
(reparto_fijadas, cupo_de_pila = un tercio del alto), y una huerfana (su
carpeta está plegada, no tiene fila en el cuerpo) se muestra igual. El ancho del
contenido se estima con ancho_de_filas. El uso canónico es nada::arbol, que
es el mismo árbol para el editor suelto y para el panel del anfitrión.
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)
// + wire_dash / edge_dash en NodegraphSpec: predicado por cable/arista → trazo
// PUNTEADO. Para la distinción que el color no lleva solo (lo sabido vs lo
// supuesto): soltar pinta punteada la clave foránea que sólo se ve en el dato
// y sólida la que una persona declaró.
// + nodegraph_view_spec(NodegraphSpec{..}) — el editor real: cámara (pan/zoom),
// selección, aristas sin pins, y CUERPO EMBEBIDO por nodo.
Un nodo puede contener algo (2026-08-17). NodegraphSpec.cuerpo es
Option<&dyn Fn(NodeId, zoom) -> Option<CuerpoNodo<Msg>>>, y CuerpoNodo es
{ alto: f32, vista: View<Msg> } — el alto en coordenadas de canvas, que el
widget escala por el zoom. El cuerpo se dibuja debajo de la fila de pins, así
que las posiciones de los pins no se mueven y los cables siguen enganchando donde
precompute_wires los calcula. None = el nodo clásico.
Para qué: krama pinta adentro del nodo el valor con su unidad, la perilla que
se arrastra y la curva de pineal-cartesian; tullpu podría poner el preview
del ajuste, takiy la onda de su eslabón, pluma-notebook el output de la
celda. No va en NodeSpec (que es Debug + Clone y no genérico en Msg):
una View adentro lo volvería genérico y rompería sus 12 sitios de construcción.
Dos cosas medidas que conviene saber: fit_to_view no conoce los cuerpos
(encuadra sobre la altura de pins, así que queda corto por abajo con nodos
altos), y node_height suma 6 px fijos que scaled() no escala — el nodo entero
no se duplica exactamente al duplicar el zoom.
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([], ..).
detail-table — la vista detalle de un file manager: grilla read-only con
columnas ColWidth::{Flex(peso), Fixed(px)} y encabezados que ordenan (▲/▼).
detail_table_view(DetailSpec{columns, rows, sort, row_height, caption, palette}, on_sort); detail_table_view_dnd(..) agrega arrastre por fila.
detail_table_view_ext(spec, on_sort, DetailOpciones{scroll, medidor, on_resize}) suma encabezado fijo con cuerpo scrolleable y los handles de
resize entre columnas:
DetailScroll { offset, viewport_h, on_scroll: Fn(f32)->Msg } // compone scroll_y
AltoCuerpo::new() // publica el alto medido del cuerpo (post-layout)
offset_efectivo(offset, viewport_h) // sin medir todavía ⇒ se pinta desde arriba
resize_col(ancho, delta, min) // el host aplica el delta del handle
El «sticky» no es un truco de posición: con scroll el encabezado queda
afuera del cuerpo desplazado, así que no viaja. El cuerpo se compone del
scroll canónico (rueda, barra, clamp y fling ya viven ahí). El handle lleva
draggable y no on_click, así agarrarlo no dispara el orden de la columna
que tiene debajo — que es el bug clásico de esta UI.
Es también la DataTable read-only ordenable y paginable — no hay una tabla
aparte para eso. Lo que faltaba y ya está:
ciclar_orden(sort_actual, col) -> Option<(col, SortDir)> // asc → desc → SIN orden
orden_de_filas(&celdas, col, dir, &TipoCol) -> Vec<usize> // permutación estable
TipoCol::{Texto, Numero, Natural, Propio(fn)} // «archivo2» < «archivo10»
Pagina{indice, tamano}: rango(total) · total_paginas · acotada(total) · etiqueta
botones_pagina(actual, total, max) -> Vec<BotonPagina> // 1 … 4 5 6 … 20
pager_view(pagina, total_filas, Fn(usize)->Msg, &palette)
orden_de_filas devuelve índices, no filas movidas: así ordena sin conocer
el tipo del caller (una DetailRow lleva Msg y View) y sin perder el orden
original, que es al que vuelve el tercer click. Numero manda al final lo
que no parsea (un «—» encabezando no es información) y no hay variante Fecha:
en ISO ordena bien como Texto, y para otros formatos está Propio — un parser
de fechas acá sería un segundo parser distinto del del datetime-picker.
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
⚠️ Las cuatro firmas de arriba son las viejas y piden visible_lines: usize.
Siguen andando y no se van a tocar, pero en código nuevo va la de abajo: pedirle
al host que cuente líneas fue el origen de que el editor de nada mostrara 40
renglones en cualquier ventana, del tope de render de 200/512 líneas, y de que no
hubiera scroll suave ni horizontal. El módulo geometria (2026-08-27) le da al
widget su propio mapa línea ↔ fila visual ↔ píxel:
// El host guarda un `TamanoVisible` y el widget publica su rect al pintar.
model.editor_medida: llimphi_widget_scroll::TamanoVisible
let vp: Viewport = state.viewport(ancho_px, alto_px, metrics); // medido, no contado
text_editor_view_viewport(&state, &palette, &vp,
OpcionesRender { language, match_ranges: &m, medida: Some(&model.editor_medida) },
|ev| Some(Msg::EditorPointer(ev)))
// Scroll y encuadre, en píxeles:
state.desplazar_px(&vp, delta_px); // la rueda; conserva las fracciones
state.desplazar_x_px(&vp, dx, ancho_contenido);
state.asegurar_caret_visible(&vp); // respeta `vp.margen_filas` (scrolloff)
state.pos_en(&vp, x, y) -> (linea, col) // el hit-test; `screen_to_pos` NO sabe
// del resto sub-fila ni del scroll en X
// El mapa, si lo necesitás suelto:
Disposicion::para(lineas, metrics) .filas() .y_de(fila) .fila_en_y(y) .fila_de_linea(l)
Viewport::medido(w, h, metrics) .filas_completas(&d) .rango_visible(&d) .desplazar_y(&d, px)
El render virtualiza por rango de píxeles: el árbol de View depende del alto
del panel, no del tamaño del archivo (probado: 1.000 y 100.000 líneas dan
exactamente el mismo árbol). Por eso el camino nuevo no necesita los topes.
Plegado, ajuste de línea, líneas pegajosas e inlay hints son otra implementación
de Disposicion, sin tocar llamadores. Plan completo:
02_ruway/nada/PLAN-PARIDAD-JETBRAINS.md. Consumidor de referencia: nada
(src/view.rs::active_editor_content + src/lib.rs::Model::editor_viewport).
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:
Language::{Plain, Rust, Python, Wat} por tree-sitter/tokenizer, y
por tabla en Rust puro (módulo sintaxis del core, sin gramáticas C)
Language::{JavaScript, TypeScript, Go, C, Cpp, Java, Kotlin, Ruby, Php, Swift, Lua, Zig, Haskell, Shell, Sql, Rhai, Json, Ron, Toml, Ini, Yaml, Markdown, Html, Xml, Css, Diff} (+ Language::from_cell_language(s), que
acepta también la extensión del archivo); 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).
El golden es el ÚLTIMO recurso, no el primero
Un golden es una imagen bendecida: pasa a ser correcta porque alguien dijo que
lo era. Eso funciona para «esto no cambió», pero no contesta «¿esto está bien?»,
y para bendecirla hay que mirarla — justo lo que §8 pide evitar.
Casi siempre hay algo mejor: el oráculo es la otra manera de calcular lo
mismo. En vez de comparar contra una imagen, se pinta el mismo árbol por dos
caminos que tienen que coincidir, y la afirmación pasa a ser objetiva. Los que
ya existen en el repo, todos escritos así:
| test | los dos caminos | resultado |
boundary_cache.rs | paint contra paint_cached | 0 px |
gpu_lotes.rs | un buffer entero contra troceado | idénticos |
gpu_grosores.rs | un lote con dos plumas contra dos lotes de una | idénticos |
gpu_scissor.rs | pasada completa contra recorte | mismo píxel exacto |
cpu_vs_gpu.rs | vello_cpu (wawa) contra vello (Linux) | tabla por patrón |
wire_vs_directo.rs | por el cable contra en proceso | bit a bit |
hybrid_vs_cpu.rs | vello_hybrid (Mali) contra CPU | Δ1:0 |
texto_gpu_directo.rs | atlas de glifos contra vello | Δ64:0 |
Cuatro reglas que salieron de escribirlos
- Poné el umbral DESPUÉS de medir, no antes. Los umbrales «prudentes» que
se eligen a ojo casi nunca se rozan, y un umbral que nada roza no es un gate.
En
hybrid_vs_cpu el 5 % sobre Δ16 que parecía razonable no habría agarrado
el sabotaje; lo medido (0 px sobre Δ1) sí. Dejá el número medido escrito al
lado del assert, con fecha: es lo que permite decidir el umbral siguiente.
- Saboteá el test antes de creerle. Rompé a mano lo que dice cubrir y mirá
que falle ese test y no otro. Dos veces en esta tanda el test pasaba con la
pieza rota:
boundary_gradiente_imagen reconstruía el objeto con default()
en vez de clonarlo, y cada default() hacía un Blob nuevo — o sea que
invalidaba por identidad y nunca llegaba a probar el hash. Cuatro de cinco
sabotajes pasaban.
- Todo test de igualdad necesita una guarda de arnés. Dos lienzos vacíos
coinciden perfecto. Afirmá además que se pintó algo (
pintados > total/8),
que el caché acertó (hits > 0), que se ejecutaron las órdenes
(ejecutadas > 0, salteadas == 0), que se compararon todos los casos
(probados == patrones().len()). Sin eso, el test firma cualquier cosa.
- Recorré el camino difícil, no el fácil.
texto_gpu_directo sube sólo el
rect sucio y no el atlas entero, porque es lo que hace una app; y tiene un
caso incremental aparte justamente porque en un atlas recién hecho lo sucio
arranca en (0,0), donde un error de coordenada pasa desapercibido.
Cuándo sí un golden: cuando no hay segundo camino. gpu_golden.rs (starfield
por GPU directo) es el caso — no existe otra forma de producir esa imagen, así
que la referencia tiene que ser ella misma.
Correr la suite contra la GPU de verdad
LLIMPHI_TEST_GPU=1 cargo test --no-fail-fast -p llimphi-test -p llimphi-raster -p llimphi-hybrid
El arnés fuerza el adapter de software para que un golden valga lo mismo en
cualquier máquina, y eso está bien — pero deja una pregunta sin contestar: ¿el
hardware hace lo mismo? Con esa env se ignora la preferencia y se usa la GPU
real. Es para preguntar a mano, no para cambiar la referencia.
Medido el 2026-08-05 (Iris Xe/Vulkan contra llvmpipe), y el resultado dice más
que cualquier argumento sobre goldens:
| software | GPU real |
| tests en verde | 113 | 112 |
El único que se cae es gpu_golden — el único que compara contra una imagen
bendecida (2,74 % de píxeles, Δ máx 44). Los ocho oráculos pasan en las dos,
y con casi el mismo número:
| test | software | GPU real |
texto_gpu_directo | Δ16:417 Δ64:0 · tinta +0,31 % | Δ16:423 Δ64:0 · tinta +0,29 % |
hybrid_vs_cpu | Δ0:236 Δ1:0 | Δ0:232 Δ1:0 |
boundary_cache / gpu_lotes / gpu_scissor | 0 px | 0 px |
Un oráculo compara dos cosas que el mismo rasterizador produce, así que la
diferencia de rasterizador se cancela. Una foto no puede cancelar nada. Por
eso gpu_golden se saltea solo cuando se pide GPU: así el comando de arriba es
una pregunta limpia y no una corrida con un fallo conocido de ruido.
16.ter Semántica accesible + tests por consulta
Un View describe cajas: posición, color, texto. No dice qué significan.
Esa segunda capa se declara con View::role + View::aria_*, y paga dos veces:
-
Lectores de pantalla. El runtime traduce el árbol montado a AccessKit en
cada frame (llimphi-ui::a11y), y el adapter de Linux habla AT-SPI2 por
DBus — el mismo bus que usa Orca. accesskit_winit está cableado en el
event loop sin feature gate.
⚠️ «Gratis» y «ya anda» no son lo mismo, y hasta el 2026-09-03 esto no
andaba: la app se CAÍA. El adapter no publica nada mientras
org.a11y.Status.ScreenReaderEnabled sea false (accesskit_unix se activa
por esa propiedad, no al arrancar), así que en una sesión normal el camino no
se recorre nunca. Al encenderla, el primer árbol mataba el proceso:
accesskit_consumer::Tree::new paniquea con «Cannot initialize with a
subtree» si el TreeUpdate inicial no trae TreeId::ROOT, y el runtime
generaba un uuid v4 por proceso. Los tests no lo veían porque ahí el id se
escribía a mano y a mano salía nil — que es ROOT. Arreglado, con el
consumidor de verdad puesto de oráculo en a11y.rs
(el_arbol_publicado_lo_acepta_el_consumidor_de_verdad y su gemelo
should_panic). Cualquier binario anterior al arreglo se cae si alguien
enciende un lector de pantalla.
-
Tests que preguntan por significado, no por píxeles (ver más abajo).
-
Un agente puede leer la pantalla en texto: el verbo MCP ui_tree de
sandokan-mcp sale por ese mismo bus y devuelve este árbol —rol, nombre,
caja, qué se activa— sin pasar por un PNG. Es la contraparte barata de
screenshot (Regla 8) y de yapa lee apps ajenas (Qt/GTK). Lo que NO ve: las
superficies de llimphi-layer (las barras y el cajón de pata) porque no
llevan accesskit, y una app enjaulada (tablado), que es un rectángulo mudo.
Declarar
View::new(style)
.role(Role::Button) // qué ES el nodo
.aria_label("Guardar") // cómo se llama (si no hay texto visible)
.aria_value("70%") // dato dinámico: valor de un slider, texto de un input
.aria_description("…") // contexto extra; el lector lo dice aparte
.aria_pressed(activo) // …y los flags: checked/pressed/expanded/
.aria_disabled(!habilitado) // disabled/readonly/required
Dónde va el rol: en el nodo que lleva el on_click, no en el contenedor.
AccessKit sólo ofrece Action::Click donde hay handler; un rol Button sobre
una caja sin handler produce un botón que el lector no puede activar.
⚠️ Y al revés también se paga: tabs::cajon tenía el Role::Tab en el nodo del
rótulo y el on_click en el chip que lo contiene. Resultado doble: el chip
salía anónimo en el auditor —nada podía apuntarle— y AccessKit ofrecía
Action::Click en un nodo sin handler. Corregido el 2026-08-29.
El nombre accesible es aria_label si está, si no el text visible. Por eso
un nodo que ya pinta su texto no necesita label — pero si el texto vive en un
hijo (ícono + etiqueta en una fila), el nodo con el rol queda sin nombre y hay
que dárselo explícito. Es el error más común.
Roles disponibles (llimphi_ui::Role): Button TextInput MultilineTextInput
Heading Checkbox Switch Label Link Image Slider ProgressBar
MenuItem Menu MenuBar Tab TabList Toolbar List ListItem Tree
TreeItem Table Row Cell ColumnHeader Dialog Alert Status
Tooltip Terminal Splitter Group. Si falta uno, agregalo en
llimphi-compositor/src/semantics.rs y mapealo en llimphi-ui/src/a11y.rs —
cuando aparezca un caller real, no antes.
Cuándo NO declarar: decorativo puro (divider, gradiente, sombra, View
envoltorio de layout). Un Role::Group en cada caja ensucia la navegación más de
lo que ayuda.
Consultar — llimphi_test::query
La misma declaración habilita tests que no miran píxeles:
use llimphi_test::query::{Sel, Ui};
use llimphi_ui::Role;
let ui = Ui::new(app.view(), 800.0, 600.0);
assert_eq!(ui.find_all(Role::Tab).len(), 3);
let guardar = ui.get(Sel::new().role(Role::Button).label("Guardar"));
assert!(guardar.is_enabled());
// Clickear = obtener el Msg y metérselo al update.
app.update(guardar.click().unwrap());
-
No necesita GPU — sólo mount + layout. Corre en cualquier CI, a
diferencia de Harness (que pide lavapipe).
-
Ui::get paniquea con el árbol semántico entero en el mensaje si no
encuentra (o si hay más de uno). El fallo se lee sin abrir un PNG.
-
Ui::dump() vuelca el árbol como texto indentado — la evidencia barata que
pide CLAUDE.md §8:
· @0,0 510×100
Heading txt:"Datos" @0,0 50×100
TextInput "Nombre" = "sergio" required=true @50,0 200×32
Button "Guardar" [click] @250,0 80×32
Button "Cancelar" disabled [click] @330,0 80×32
· [click] @410,0 100×100
El · es un nodo sin rol declarado. El último de ese volcado —· [click]— es
un canvas con handler y sin semántica: un lector de pantalla no puede
anunciarlo. Verlo en el dump es el hallazgo.
-
Selectores: Sel::new() + .role() .label() .label_contains() .value() .checked() .enabled() .clickable() .focusable() .role_none(). Atajos por
conversión: ui.find(Role::Button) y ui.find("Guardar") (por nombre).
-
Auditar lo mudo: ui.find_all(Sel::new().clickable(true).role_none())
devuelve los nodos interactivos sin semántica propia.
⚠️ Ese conjunto NO es la deuda: mezcla dos poblaciones. Los widgets ponen
el rol en la fila y el on_click en un hijo, así que la mitad de lo que
devuelve son hijos que su ancestro con rol ya alcanza por delegación — se
los apunta por el padre, que tiene rol y nombre. La deuda real es el resto:
clickeables sin rol y sin quien delegue, que nada puede señalar. El auditor
del repo (02_ruway/llimphi/auditor_mudos.rs, include!ado por
cargo run -p <app> --example auditar_mudos [-- --detail]) separa las tres
columnas —delegados · mudos · anónimos— replicando a11y::click_target.
Contarlas juntas daba «nada 98 % mudo» sobre un árbol donde cada fila se
anuncia bien; separadas da 6 %.
⚠️ Y un cero del árbol del ARRANQUE no dice nada de los menús. pata tiene
29 nodos interactivos al abrirse y 1.282 sumando sus 25 estados — casi todo
lo que se clickea vive detrás de un panel que hay que abrir. El auditor toma
escenarios: escenario::<A>("menú de inicio", &h, vec![Msg::StartToggle], w, h)
reinicializa el modelo, le aplica esos Msg y audita ese árbol; cuando lo
que abre el panel es el Msg de un módulo cuyo tipo no es público (la paleta de
nada), la puerta es escenario_teclas con el atajo real, que sí es público
porque lo tipea el usuario. Medir sólo el arranque daba «pata en cero» con
treinta y cinco mudos a un clic de distancia.
Al 2026-09-02 las siete apps auditables dan 0 mudos y 0 anónimos
sumando sus 75 escenarios (4.054 nodos interactivos): nada 989 · shuma
1.367 · pata 1.318 · puriy 226 · pizarra 79 · launcher 72 · nakui 3.
Y lo último que quedaba casi no era de las apps: eran tres nodos de widget
compartido —el scrim de modal, el de context-menu y el canal de
scroll—, cada uno multiplicado por cuantos diálogos hubiera abiertos. Un
mudo que aparece en todas las apps a la vez se arregla en el widget, no en
cada llamador.
⚠️ «Las cinco en cero» del 31-ago contaba cinco de siete. nakui tenía su
auditor desde el 29-ago y nunca entró en la tabla —seguía con un mudo, y era
el botón «Entrar» del login— y launcher-llimphi figuraba como no auditable
«porque tiene la struct de App privada». Lo segundo era un diagnóstico
correcto con la conclusión al revés: ese crate no es una app, es una
vista (launcher_view/launcher_overlay), y se audita llamando auditar
sobre el árbol que devuelve, con el estado puesto en los campos del
LauncherSpec en vez de en una lista de Msg. Una app que no aparece en la
tabla no está en cero: está sin medir.
El nombre de un nodo con rol sale, en orden: de su aria_label, de su
texto propio, y —desde el 2026-08-31— de su tooltip. Lo último es la regla
de accname para el title de HTML: el tooltip de un botón de sólo ícono
es su nombre, ya está escrito y traducido, y obligar a repetirlo en un
aria_label al lado sólo garantiza que las dos copias se desincronicen. Ojo
con el caso que NO cubre: un botón cuyo único texto es un glifo («✕», «▦»)
tiene texto, así que gana el glifo — ahí el rótulo hay que pasarlo a mano.
Estado de la cobertura
52 de 78 widgets anotados (medido el 2026-08-29: grep -rlE '\.role\(|Role::' widgets/*/src). De los 24 restantes, 23 lo están a propósito — ver abajo — y
uno, tablado, es un hueco real con nombre.
⚠️ Dos de esta lista estaban del lado equivocado, y los dos por el mismo
motivo: la clasificación se hizo leyendo qué compone cada widget, no contando
sus propios clickeables. rag-sidebar («compone widgets ya anotados») ponía
seis, y panes («layout puro») pone uno —la hoja, que se clickea para
enfocarla—, que era el nodo mudo más grande de shuma: 1832×957 px. Un widget
con on_click propio no es layout puro por mucho que lo parezca; correr el
auditor es la única forma de saberlo.
Anotados: accordion app-header avatar badge banner breadcrumb
button calendar chip color-picker context-menu datetime-picker
detail-table dock-rail empty fab field form gauge list menubar
modal nodegraph progress range-slider rating rive-button segmented
select shortcuts-help slider spinner splitter stat-card status-bar
stepper switch table tabs terminal text-editor text-input
panes rag-sidebar theme-switcher timeline toast toolbar tooltip
transport tree waveform.
Sin anotar por diseño (el módulo semantics lo pide explícitamente: un
Role::Group en cada caja ensucia la navegación más de lo que ayuda):
-
Contenedores de layout puro — card panel grid wrap hero
fitted-box tiled scroll router scaffold lazy-list.
La semántica la traen sus hijos.
-
Decorativos — skeleton splash wawa-mark.
-
Sin View propio — app-icon (resuelve íconos XDG a bytes, no pinta),
clipboard (que además no es un widget: es llimphi-clipboard viviendo en
widgets/), text-area text-editor-core text-editor-lsp carousel
(sólo helpers), edit-menu (produce ContextMenuItem, que ya viene
anotado), gallery (binario de ejemplo).
-
Componen widgets ya anotados — navigator (sobre tree + nodegraph).
⚠️ rag-sidebar estaba en esta línea y era falso (corregido 2026-08-29).
Compone segmented + dock-rail, sí, pero además pone seis clickeables
propios que no vienen de nadie: la caja del buscador, el control mutable
(lupa/gear), las dos encías (+ de rama y + de grupo), cada subdiente y el
botón de línea punteada del rail guardado. Los seis salieron mudos en el
auditor, y trece de los diecisiete mudos de pata eran ésos. «Compone
widgets anotados» describe de dónde vienen sus hijos, no si él mismo declara
algo — la única forma de saberlo es contar.
-
Devuelve slots, no una View — fugaces: la caja hospedadora y el
anuncio los pone el caller, que es el único que sabe qué es cada fantasma.
⚠️ El que no está por decisión sino por hueco: tablado, el nodo donde el
anfitrión pinta la EscenaWire de una app enjaulada. Por el cable viaja el IR de
escena y no el de vista (SDD-JAULA-LINUX.md), así que del lado del
anfitrión no hay roles que anunciar: para un lector de pantalla, una app
enjaulada es un rectángulo mudo. Cerrarlo pide que la semántica del huésped
cruce el cable, que es diseño pendiente y no una anotación que falte.
Activación: el rol y el handler no tienen por qué coincidir
Los widgets ponen el rol en la fila (TreeItem, Row, ListItem) porque es
la unidad que el lector navega, pero el on_click suele vivir en un hijo (el
nodo del label). a11y::click_target resuelve ese desajuste:
- Si el nodo mismo tiene handler, es él.
- Si no, y declara un rol, se delega en su subárbol — siempre que haya
exactamente un descendiente clickeable sin rol propio.
- Si hay varios, no se delega: la elección sería arbitraria, y esos hijos ya
están en el árbol como nodos propios para activarlos directo.
Un descendiente CON rol no compite (2026-08-29). Una fila de árbol con hijos
lleva dos clickeables —el chevron y el label—, así que con la regla vieja quedaba
ambigua: se anunciaba y no se podía activar. Pero el chevron declara lo suyo
(Button + «Desplegar ‹fila›»), o sea que ya es un nodo aparte que el lector
alcanza solo; el único candidato a heredar la acción de la fila es el que no
tiene identidad propia. Lo cazó el auditor: con la regla vieja, las 16 carpetas
del árbol de nada salían inalcanzables.
El requisito de rol en (2) es lo que evita que toda caja de layout se vuelva
activable. El runtime además sintetiza la posición para los handlers
on_click_at (centro del rect del nodo en el layout del frame) — misma
convención que query::NodeRef::click.
Dientes y botones animados: el label es obligatorio
DockRailItem y RiveButton no tienen texto en el árbol (el ícono lo pinta un
closure opaco), así que el nombre accesible viaja explícito:
DockRailItem::nombrado(id, activo, "Configuración") // recomendado
DockRailItem::new(id, activo) // queda mudo
RiveButton::builtin().con_label("Ejecutar")
Y un patrón que apareció en casi todos: el ítem sólo-ícono. Un botón sin
texto visible (toolbar sin with_label, transport, chevrones de calendario,
el × de una pestaña) es mudo salvo que se le dé aria_label a mano. Los que
tienen un nombre derivable ya lo llevan; los que dependen del caller están
señalados en el código.
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 test -p llimphi-gallery # la vitrina como CALLER: los widgets responden
cargo run -p nada --release # editor real para ejercitar widgets
# benchmark GPU directo vs vello (primitivas Y texto — pedile GPU real, en
# llvmpipe la mitad de rasterizado no decide nada):
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
# dónde poner un .repaint_boundary() en TU app (ver más abajo):
LLIMPHI_BOUNDARY_HINT=1 cargo run -p <crate> --release
# un update que tarda de más y congela la UI:
LLIMPHI_UI_SLOW_MS=50 cargo run -p <crate> --release
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 N·KB mount N·KB layout N·KB paint N·KB resto N·KB], donde resto
es el tramo posterior a paint (raster vello + GPU + present) y el desglose suma
el total. Sin esa feature el tramo de allocs dice n/a en vez de ceros falsos.
Ojo: los contadores son del proceso, no del hilo — lo que asigne otro hilo
durante una etapa cae en esa etapa. 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_BOUNDARY_HINT — dónde poner un repaint_boundary
.repaint_boundary(clave) cachea la rasterización de un subárbol quieto y la
reusa mientras su contenido no cambie (Bloque 23). El problema práctico nunca fue
la API sino dónde: marcar un subárbol que en realidad cambia todos los
cuadros no es neutro —se paga el hash entero cada frame y no se ahorra nada—, y
desde afuera un panel quieto y uno que se reconstruye idéntico a sí mismo se ven
igual. Por eso ninguna app lo usaba.
LLIMPHI_BOUNDARY_HINT=1 cargo run -p <tu-app> --release # reporta cada 120 cuadros
LLIMPHI_BOUNDARY_HINT=300 … # cada 300
Usá la app un rato con el puntero fuera de lo que sospechás. La salida:
[llimphi] 2 subárbol(es) quieto(s), 431 nodos que se repintan de gusto
— poneles .repaint_boundary(clave):
nodo 12 · 287 nodos · (0,48 260×672) · 118 cuadros quieto «Archivos»
nodo 640 · 144 nodos · (0,0 1920×48) · 118 cuadros quieto «Inicio»
Cada línea es el subárbol máximo que vino idéntico: si un panel entero está
quieto no se listan también sus hijos. El texto es el primero que aparece
adentro, para que lo reconozcas sin contar nodos.
Es una predicción, no una opinión: el detector consulta las mismas tres
funciones que el caché de verdad (posición limpia, paint-puro, y el mismo hash
que decide hit o miss), así que lo que señala el caché lo va a acertar. Dos
cosas que no dice:
- Si conviene. Sugiere «esto habría sido cache-hit»; el hash se paga siempre.
Por eso ignora subárboles de menos de 16 nodos.
- Que esté quieto para siempre. Un panel quieto 120 cuadros puede cambiar al
siguiente click, y ahí el boundary falla y repinta — correcto, pero sin ahorro.
Con el puntero adentro de un subárbol, ni el caché lo sirve ni el detector lo
sugiere: el hover cambia lo pintado. Y es diagnóstico, no producción — hashea
cada candidato por cuadro. Encendelo, anotá, apagalo.
Qué descalifica a un subárbol (el detector se calla y el boundary, si lo
ponés igual, se pinta fresco): cualquier paint_with/gpu_paint_with, una
animación viva, un ripple, un hero, una mask_image, o estar bajo un
clip/alpha/transform de un ancestro. Degradé e imagen sí entran desde
2026-08-05 — antes no, y como un sidebar real casi siempre tiene un ícono o un
fondo con degradé, un solo nodo así descalificaba el panel entero y el caché no
servía para lo que existía.
llimphi-gallery (src/main.rs) 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
Recontado contra el disco el 2026-08-27. Son 139 crates en el workspace
raíz más dos que viven en workspaces aparte (llimphi-jaula, llimphi-wawa:
los backends de llimphi-text son excluyentes y no conviven en un proceso).
Núcleo del camino a píxeles (02_ruway/llimphi/): llimphi-hal ·
llimphi-raster · llimphi-text · llimphi-glifos · llimphi-layout ·
llimphi-compositor · llimphi-ui.
Auxiliares del runtime: llimphi-theme · llimphi-motion · llimphi-icons ·
llimphi-image · llimphi-svg · llimphi-surface · llimphi-entrada ·
llimphi-despacho · llimphi-layer · llimphi-workspace · llimphi-test
(golden-image) · llimphi-gallery · llimphi-gpu-bench · demo.
Los otros caminos a píxeles: llimphi-cpu (vello_cpu, el de wawa y el
Android sin compute) · llimphi-hybrid (vello_hybrid) · llimphi-reproductor
(el pintor único de los dos anfitriones) · llimphi-cable ·
llimphi-wire-escena · llimphi-wire-view · llimphi-jaula y llimphi-wawa
(workspaces aparte). Se comparan por número con
scripts/check-llimphi-cpu-vs-gpu.sh.
3D, voxel y animación: llimphi-3d · llimphi-mesh · llimphi-voxel ·
llimphi-voxel-render · llimphi-voxel-app · llimphi-voxel-studio ·
llimphi-anim · llimphi-anim-studio · llimphi-lottie ·
llimphi-video-plane · llimphi-term-graphics.
Camino web / WASM (guardián: scripts/check-llimphi-web.sh):
llimphi-wasm-core · llimphi-wasm-app-sdk · llimphi-wasm-runner ·
llimphi-wasm-wasi · llimphi-wasm-net · llimphi-wasm-registry ·
llimphi-wasm-dist · llimphi-wasm-open · llimphi-wasm-demo-counter ·
llimphi-wasm-demo-form.
Widgets (widgets/, dep llimphi-widget-<n>) — 78, más
llimphi-clipboard que vive ahí sin serlo:
accordion · app-header · app-icon · avatar · badge · banner · breadcrumb ·
button · calendar · card · carousel · chip · color-picker · context-menu ·
datetime-picker · detail-table · dock-rail · edit-menu · empty · fab · field ·
fitted-box · form · fugaces · gallery · gauge · grid · hero · lazy-list · list
· menubar · modal · navigator · nodegraph · panel · panes · progress · qr ·
rag-sidebar · range-slider · rating · rive-button · router · scaffold · scroll
· segmented · select · seven-segment · shortcuts-help · skeleton · slider ·
spinner · splash · splitter · stat-card · status-bar · stepper · switch ·
tablado · 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 · wrap.
Módulos (modules/) — 11: allichay · 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 · hybrid-probe-android · llimphi-hybrid-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; el índice de
crates y los conteos de widgets/módulos se recontaron contra el disco el
2026-08-27. Ante divergencia, la fuente autoritativa es el lib.rs de cada
crate — y para cualquier conteo, el comando.