tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 MANUAL.md Markdown · 1516 siq'ikuna · 78.8 KB Ñawpa willay ch'uya

docs: sincroniza MANUAL.md y SDD.md con el monorepo (70 widgets / 11 módulos) c5a232cf2 · Sergio ·

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

MANUAL.md

Manual de Llimphi

Motor gráfico soberano de tawasuyu. wgpu + vello + taffy + parley, bucle Elm input → update → view → layout → raster → present. Reemplazo total de GPUI (extinto 2026-05-26): toda app gráfica de la suite corre sobre Llimphi.

Este documento es la referencia de uso orientada a humanos y a IA. Está organizado para salto directo: cada capa, widget y módulo trae su API real (firmas copiadas del código). Para el porqué arquitectónico ver SDD.md; para la regla de concurrencia ver COMPUTO-FUERA-DEL-HILO-UI.md.


Índice

  1. Modelo mental en 60 segundos
  2. Arquitectura — las capas
  3. Quickstart — la app mínima
  4. El trait App (bucle Elm)
  5. Handle — efectos y concurrencia
  6. View<Msg> — el DSL declarativo
  7. Layout (taffy / Style)
  8. Eventos e interacción
  9. Texto
  10. Canvas custom y GPU directo
  11. Theme y paletas
  12. Capas base (hal · raster · text · motion · icons · surface)
  13. Catálogo de widgets
  14. Catálogo de módulos
  15. llimphi-workspace — chasis tipo tmux
  16. Reglas duras y gotchas
  17. Comandos y demos
  18. Cheat-sheet
  19. Í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:

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:

  1. view es pura — no muta nada, sólo lee el modelo y arma el árbol.
  2. Cómputo pesado va a un worker vía Handle::spawn, nunca síncrono en update/init/handlers (congela la ventana → "Not Responding").
  3. Widgets son visuales y stateless; el estado vive en tu Model. Módulos sí encapsulan estado + comportamiento.

2. Arquitectura — las capas

4. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent)
   └ llimphi-compositor . árbol View<Msg>, mount sobre taffy, paint, hit-test (winit-free)
3. llimphi-layout ....... motor de layout (taffy: flexbox + grid)
2. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo
1. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji
0. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer)

El split compositor/runtime (2026-05-31) es importante: llimphi-compositor es winit-free (sólo View, mount, paint, hit-test). llimphi-ui lo corre sobre winit y re-exporta todo el compositor, así escribes llimphi_ui::View sin enterarte del split. Esto habilita un futuro runtime sobre el framebuffer del kernel wawa reusando el mismo compositor.

Auxiliares: llimphi-theme (paletas), llimphi-motion (tweens), llimphi-icons (iconos vectoriales), llimphi-surface (texturas externas), llimphi-workspace (chasis tmux), llimphi-gallery (showcase).

Catálogo: 70 widgets (visuales) + 11 módulos (features con estado).


3. Quickstart — la app mínima

use llimphi_ui::llimphi_layout::taffy::prelude::*;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{App, Handle, View};

#[derive(Clone)]
enum Msg { Increment, Reset }

struct Counter;

impl App for Counter {
    type Model = u32;
    type Msg = Msg;

    fn title() -> &'static str { "llimphi · counter" }

    fn init(_: &Handle<Self::Msg>) -> Self::Model { 0 }

    fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
        match msg {
            Msg::Increment => model.saturating_add(1),
            Msg::Reset => 0,
        }
    }

    fn view(model: &Self::Model) -> View<Self::Msg> {
        let boton = View::new(Style {
            size: Size { width: length(160.0), height: length(56.0) },
            align_items: Some(AlignItems::Center),
            justify_content: Some(JustifyContent::Center),
            ..Default::default()
        })
        .fill(Color::from_rgba8(60, 200, 130, 255))
        .radius(12.0)
        .text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255))
        .on_click(Msg::Increment);

        View::new(Style {
            flex_direction: FlexDirection::Column,
            size: Size { width: percent(1.0), height: percent(1.0) },
            align_items: Some(AlignItems::Center),
            justify_content: Some(JustifyContent::Center),
            gap: Size { width: length(0.0), height: length(24.0) },
            ..Default::default()
        })
        .fill(Color::from_rgba8(20, 24, 32, 255))
        .children(vec![
            View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE),
            boton,
        ])
    }
}

fn main() { llimphi_ui::run::<Counter>(); }

Cargo.toml:

[dependencies]
llimphi-ui    = { workspace = true }
llimphi-theme = { workspace = true }
# + los widgets/modules que uses:
# llimphi-widget-button = { workspace = true }

Corre con cargo run -p <tu-crate> --release. El ejemplo vivo está en llimphi-ui/examples/counter.rs.


4. El trait App (bucle Elm)

Definido en llimphi-ui/src/lib.rs. El estado es inmutable; cada evento produce un Model nuevo.

pub trait App: 'static {
    type Model: 'static;
    type Msg: Clone + Send + 'static;

    fn init(handle: &Handle<Self::Msg>) -> Self::Model;
    fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
    fn view(model: &Self::Model) -> View<Self::Msg>;

    // --- Todo lo de abajo tiene default; sobreescribí lo que necesites ---

    fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> { None }

    fn on_wheel(_model: &Self::Model, _delta: WheelDelta,
                _cursor: (f32, f32), _modifiers: Modifiers) -> Option<Self::Msg> { None }

    /// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta
    /// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo
    /// vidrio"). La transición la maneja tu Model.
    fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> { None }

    /// Drag&drop de archivos desde el file manager. Un evento por archivo.
    fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> { None }

    /// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El
    /// runtime administra el foco; guardas `id` en tu Model para pintar el ring
    /// y rutear el teclado. Ver §8 (Foco y teclado).
    fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> { None }

    /// IME (composición de texto: CJK, acentos muertos, emoji). Opt-in vía
    /// `ime_allowed()` para no robarle el texto a las apps que sólo leen
    /// `on_key`. Flujo: Enabled → Preedit* → Commit/Disabled. Ver §8 (IME).
    fn ime_allowed() -> bool { false }
    fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> { None }
    /// Área del caret en px físicos para ubicar la ventana de candidatos.
    fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { None }

    fn title() -> &'static str { "llimphi" }
    fn app_id() -> Option<&'static str> { None }   // app_id del xdg-toplevel en Wayland
    fn initial_size() -> (u32, u32) { (960, 540) }
}

Punto de entrada: pub fn run<A: App>() — corre hasta que el usuario cierre la ventana o la app llame Handle::quit.

Eventos de teclado (KeyEvent):

pub struct KeyEvent {
    pub key: Key,                 // re-export de winit; usar NamedKey para teclas especiales
    pub state: KeyState,          // Pressed | Released
    pub text: Option<String>,     // texto resultante con IME/modifiers; None para flechas etc.
    pub modifiers: Modifiers,     // { shift, ctrl, alt, meta }
    pub repeat: bool,
}

Key y NamedKey se re-exportan desde llimphi_ui.

Rueda (WheelDelta { x, y }): normalizado a "líneas". Convención CSS: y positivo = scroll hacia abajo.


5. Handle — efectos y concurrencia

Handle<Msg> es Send + Clone. Llega a init y update. Es el único modo legítimo de producir efectos sin romper la pureza de la transición.

impl<Msg: Send + 'static> Handle<Msg> {
    pub fn quit(&self);                 // cierra la ventana / termina el bucle
    pub fn dispatch(&self, msg: Msg);   // encola un Msg para el próximo turno
    pub fn spawn<F: FnOnce() -> Msg + Send + 'static>(&self, f: F);   // worker; su Msg reentra al update
    pub fn spawn_periodic<F: Fn() -> Msg + Send + 'static>(&self, period: Duration, f: F);  // tick periódico
    pub fn for_test() -> Self;          // handle "muerto" para tests sin event loop
    pub fn for_test_with<F: Fn(Msg) + Send + Sync + 'static>(sink: F) -> Self;  // tests que SÍ quieren los Msg
}

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étodoEfecto
.fill(Color)color de fondo
.fill_gradient(Gradient)relleno con gradiente (autoreado en el cuadrado unidad [0,1]², mapeado al rect). Gana sobre fill; hover_fill lo overridea en hover
.hover_fill(Color)color al pasar el cursor (habilita hit-test de hover)
.radius(f64)esquinas redondeadas (radio uniforme)
.radius_corners(tl,tr,br,bl)radio por esquina (CSS border-radius 4 valores); override de .radius. El borde sigue las 4 esquinas; la sombra usa el radio escalar
.shadow(Shadow)drop shadow (vello draw_blurred_rounded_rect). Shadow::soft(alpha,blur) + .offset(dx,dy)/.spread(s)
.border(width, Color)stroke sobre el contorno redondeado, inset hacia adentro (border-box)
.alpha(f32)opacidad de todo el subtree [0,1] (capa intermedia — no gratis)
.transform(Affine)afín 2D alrededor del centro del rect (estilo CSS transform-origin:50% 50%)
.animated(key, Duration)animación implícita estilo Flutter AnimatedContainer: si fill/radius cambian entre frames, el runtime interpola (ease-out cúbico) en vez de saltar. key estable entre rebuilds. .animated_curve(key,dur,fn) para otra curva
.clip(bool)recorta hijos al rect (paint + hit-test)
.image(Image)pinta peniko::Image centrada, preservando aspect ratio
.children(Vec<View<Msg>>)hijos

Texto (ver §9)

.text(content, size_px, color)                            // centrado
.text_aligned(content, size_px, color, Alignment)
.text_aligned_italic(content, size_px, color, Alignment, italic)
.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option<String>)
.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada
.line_height(mult)                                        // override interlínea (default 1.2)
.text_weight(f32)                                         // peso CSS: 400 normal, 600 semibold, 700 bold
.bold()                                                   // atajo de text_weight(700.0)
.ellipsis(n)                                              // clampa a n líneas terminando en … (n=1 = single-line)
.max_lines(n)                                             // clampa a n líneas sin glifo (corte seco)

Interacción (ver §8)

.on_click(Msg)
.on_click_at(|lx, ly, w, h| -> Option<Msg>)     // posición local + tamaño del rect
.on_right_click(Msg) / .on_right_click_at(...)
.on_middle_click(Msg)
.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
.draggable(|phase: DragPhase, dx, dy| -> Option<Msg>)
.draggable_at(|phase, dx, dy, lx0, ly0| -> Option<Msg>)   // + posición inicial del press
.drag_payload(u64)                                        // payload que viaja con el drag
.on_drop(|payload: u64| -> Option<Msg>)                   // este nodo es drop target
.drop_hover_fill(Color)                                   // resaltado mientras un drag lo sobrevuela
.on_scroll(|dx, dy| -> Option<Msg>)                       // rueda local (antes del on_wheel global)
.on_scroll_take(|dx, dy| -> (Option<Msg>, rx, ry))        // reparto PARCIAL: consume lo que puede y el
                                                          // sobrante sigue al ancestro (mismo evento);
                                                          // scroll_y/lazy_list ya lo usan (scroll_take)
.on_scale(|phase: GesturePhase, factor, fx, fy| -> Option<Msg>)  // pinch-to-zoom (Ctrl+rueda / trackpad)
.on_double_tap(Msg) / .on_double_tap_at(|lx, ly, w, h| ...)      // dos clicks rápidos y cercanos
.on_long_press(Msg) / .on_long_press_at(|lx, ly, w, h| ...)      // mantener ~500 ms quieto
.focusable(u64)                                           // nodo enfocable por Tab/click (id opaco)

Pintura custom (ver §10)

.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... })
.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... })

Notas clave:


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:

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 zonasorigen: .drag_payload(id); destino: .on_drop(|id| ...) + .drop_hover_fill
Scroll globalApp::on_wheel(model, delta, cursor, mods)
Área de scrollwidget scroll_y(...) (autocontenido) o .on_scroll(|dx,dy| ...) por nodo
Zoom de canvas (pinch).on_scale(|phase,factor,fx,fy| ...) → zoom *= factor, reajustar pan al focal
Doble-click.on_double_tap(Msg) / .on_double_tap_at(|lx,ly,w,h| ...)
Long-press (mantener).on_long_press(Msg) / .on_long_press_at(|lx,ly,w,h| ...)
TecladoApp::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 SOApp::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):

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
}

10. Canvas custom y GPU directo

Dos hooks para pintar primitivas no expresables como composición de Views. Conviven en el mismo árbol; el runtime pinta toda la pasada vello primero, luego los gpu_painter en orden DFS.

paint_with — vía vello (el default)

.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| {
    // dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`.
    // NO dejar push_layer sin pop_layer; NO resetear la scene.
})

Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal. Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada).

gpu_paint_with — sube vertex buffers directo a wgpu, salta vello

.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| {
    // abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello.
    // (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC.
})

Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de nakui, pineal denso. Rango 100 K – 10 M+ primitivos. No soporta texto ni AA fino ni múltiples grosores de stroke por flush. Para texto encima de un render GPU, usar view_overlay (segunda Scene vello).

UI encima de contenido GPU — View::over y paint_over

El orden del frame es [vello base] → [gpu_paint] → [vello over] → [overlay/ menús]: cualquier View normal que se solape con un gpu_paint_with queda tapado por él (el blit corre después de la pasada base). Dos salidas:

// Subárbol entero de Views pintado en la pasada over (fills, texto, widgets,
// hover — la maquinaria completa). El primitivo para un OSC de reproductor,
// un HUD, un rail que solapa un canvas GPU. El hit-test no cambia.
View::new(estilo_absoluto).over(true).children(vec![barra_de_transporte])

// Closure vello suelta en la pasada over (sprites/texto AA sobre celdas
// instanciadas — dominium, motor voxel).
.paint_over(|scene, ts, rect| { ... })

Limitación de ambas: la capa over se compone con alpha sobre la intermedia; clips/alpha/transform de ancestros fuera del subárbol no la afectan. Caso canónico de .over(true): el OSC estilo mpv de media-app (02_ruway/media/media-app/src/vista.rs::overlay_bars_view) y su fondo de marca (src/fondo.rs::fondo_overlay) flotando sobre el video.

¿Cuándo cada uno?

Preguntavello (paint_with)GPU directo (gpu_paint_with)
Primitivos/frame< ~500 K rebuild / < ~2 M Scene reusada100 K – 10 M+
¿Cambian cada frame?sí, rebuild baratomejor estático (buffer persistente)
Curvas Beziernativashay que teselar
Textosíno
AA finosí (analítico)no (sin MSAA)

Default: paint_with salvo que ya midas que el volumen lo justifica (factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU expone GpuPipelines/GpuBatch en llimphi-raster (§12).


11. Theme y paletas

llimphi-theme::Theme es un struct de slots semánticos de color. Cuatro presets const: Theme::dark() (default), light(), aurora(), sunset().

pub struct Theme {
    pub name: &'static str,
    // fondos
    pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus,
    pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color,
    // texto
    pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color,
    // bordes y acento
    pub border, border_focus, accent: Color,
}

Theme::all() -> Vec<Theme>                 // orden de rotación canónico
Theme::by_name(name) -> Option<Theme>
Theme::next_after(current_name) -> Theme   // para el theme-switcher

Tokens auxiliares en el mismo crate:

Patrón de widgets: cada widget define su XxxPalette con Palette::from_theme(&theme). Tu app guarda un Theme en el Model, deriva las paletas que necesita en view, y se las pasa a los widgets. Para cambiar de tema, el theme-switcher emite Msg(next_theme) y reconstruyes todo.


12. Capas base

llimphi-hal — superficie

Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result<Hal, HalError>   // async
trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result<Frame,_>; fn present(frame, hal); }
WinitSurface::new(hal, window: Arc<Window>) -> Result<Self, HalError>
Frame::view() -> &wgpu::TextureView;  Frame::size() -> (u32,u32)

Hal::new pide adapter Backends::PRIMARY (Vulkan) y cae a all() sólo si no hay — no volver a InstanceDescriptor::default(): el backend GL de Mesa sobre Wayland segfaultea en el teardown. El runtime de llimphi-ui ya maneja todo esto; sólo tocas HAL si escribes un runtime nuevo.

llimphi-raster — rasterización

Renderer::new(hal) -> Result<Renderer,_>
Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
// GPU directo:
GpuPipelines::new(device, color_format) -> Self   // campos: lines, tris, rects, bind_layout
GpuBatch::new(&pipelines)
  .line_width(w) .add_line(p0,p1,color) .add_polyline(&pts,color)
  .add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
  .primitive_count() -> u32
  .flush(device, queue, encoder, view, viewport, load_op)

Re-exporta vello y peniko (Color, Image, Fill, etc.).

llimphi-image — decode pipeline

decode_bytes(&[u8]) -> Result<peniko::Image, DecodeError>
load_path(&Path, max_bytes: u64) -> Result<peniko::Image, DecodeError>
from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> peniko::Image

Encapsula el patrón image::ImageReader + to_rgba8 + Blob + Image::new que cada app que decodifica imágenes tenía duplicado. max_bytes aplica al tamaño del archivo en disco (no a la imagen decodificada, que en RGBA8 puede ser mucho mayor — un PNG 4K ocupa ~64 MB descomprimido); 0 deshabilita el cap. Re-exporta peniko::{Blob, Image, ImageFormat}. Formatos según las features del crate image upstream del workspace (hoy: PNG, JPEG, WEBP).

llimphi-text — shaping

Typesetter::new()                          // una por proceso (FontContext es caro)
  .layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()>
  .layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout<RunBrush>
TextBlock::simple(text, size_px, color, origin)
layout_block(ts, &block) / measure(ts, &block) -> Measurement
draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin)
Alignment::{Start, Center, End, Justify}

llimphi-motion — tweens

trait Lerp { fn lerp(self, other, t: f32) -> Self; }   // impl para f32,f64,(f32,f32),(f64,f64),Color
Tween::new(from, to, duration, easing: fn(f32)->f32)   // o Tween::idle(value)
tween.value() / .progress() / .done()
animate(handle, duration, make_msg)                    // arranca los ticks del tween

Patrón: guardas Tween<T> en el Model, animate(...) en el update, la view lee tween.value() cada repaint. El tween se auto-termina.

llimphi-icons — iconos vectoriales (~23, grid 24×24)

Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right,
       Home, Search, Info, Warning, Error, Bell, Settings, More, ...}
icon_view(Icon, color, stroke_width) -> View<Msg>
paint_icon(scene, rect, icon, color, stroke_width)     // dentro de un paint_with

stroke_width en unidades del grid 24×24 (1.6 es armónico).

llimphi-surface — texturas externas

ExternalSurface::new(device, queue)        // barato de clonar (Arc<Mutex> interno)
  .upload(&rgba, w, h)                      // desde otro hilo/decoder/cámara
  .view(style) -> View<Msg>                 // blittea a su rect en el árbol Elm
  .blit(queue, encoder, dst_view, rect, viewport)   // o manual desde gpu_paint_with

13. Catálogo de widgets

Los widgets son funciones puras que devuelven View<Msg> (o specs que se convierten a View). Son stateless: el estado vive en tu Model. Convención: cada uno trae XxxPalette::from_theme(&Theme). Crates en widgets/<nombre>/, dep llimphi-widget-<nombre>.

Controles

button — button_view(label, &ButtonPalette, on_click: Msg) -> View; button_styled(label, style, alignment, &palette, on_click).

field — wrapper de formulario (label + helper/error + requerido). field_view(FieldSpec { label, control: View<Msg>, required, helper, error, palette }).

text-input — input single-line con estado TextInputState (new()/masked(), text(), set_text(), apply_key(&KeyEvent) -> bool, soporta undo/redo + selección con Shift). Render: text_input_view(&state, placeholder, focused, &palette, on_focus: Msg).

text-area — input multilínea con paridad real (mismo motor EditorState que el text-input; NO el viejo String plano). El estado es TextInputState::multiline() (o llimphi_widget_text_area::new_state()). text_area_view(&state, placeholder, focused, rows, &palette, |ev| Msg::Area(ev)) emite TextAreaEvent (Key/Ime/Press(x,y)/Drag/DragEnd/Context) ya envueltos; en update → state.handle_area(ev, &mut clipboard). Da: click 2D (renglón+columna por glifos reales), ↑/↓ entre renglones, selección a través de líneas (arrastre 2D, doble/triple-click, Shift+flechas), copiar/cortar/pegar, undo/redo, IME, Enter=\n, soft-wrap (la línea larga se envuelve al ancho de la caja) y scroll vertical (caret a la vista si hay más renglones que rows). Caret + selección pintados por paint_over en coords de renglón visual; fuente única del layout: visual_layout (render/hit-testing/caret/nav comparten). ↑/↓ navegan por renglón visual (no por línea lógica: en texto envuelto bajan un renglón, no la línea entera), con columna meta en px preservada (move_vertical_visual; Shift extiende selección). El crate llimphi-widget-text-area es una fachada que re-exporta esto. Demo: cargo run -p llimphi-widget-text-input --example area_demo.

text-area RICO — el mismo widget con los adornos que antes obligaban a escribirse un pintor propio. text_area_view_rico(&state, focused, rows, &palette, &Metricas, &Adornos, wrap). Si necesitás un input mono, coloreado, con fantasma o con caret animado, es ESTE, no un paint_with a mano.

slider — sin estado. slider_view(label, value, min, max, &palette, on_change: Fn(DragPhase, delta_value) -> Option<Msg>). El delta viene en unidades, no píxeles.

color-picker — selector RGBA: swatch actual + paleta de chips + barra de hue + sliders R/G/B/A (+ campo #RRGGBB opcional). Agnóstico, emite [u8; 4]. color_picker_view(rgba: [u8;4], swatches: &[[u8;3]] (p.ej. DEFAULT_SWATCHES), &ColorPickerPalette, hex: Option<HexField { focused, state: Option<&TextInputState>, on_focus }>, on_change: Fn([u8;4]) -> Msg). Helpers puros: rgba_to_hex, parse_hex(s, cur_alpha) (acepta 3/6/8 dígitos, # opcional), color_picker_height(with_hex) para pre-calcular el alto.

switch — switch_view(progress: f32 [0..1], on_toggle: Msg, &palette). La app guarda el bool y opcionalmente anima progress con un Tween.

segmented — N opciones exclusivas. segmented_view(&[&str], selected: usize, make_msg: Fn(usize)->Msg, &palette).

select — combobox/dropdown moderno (disparador cerrado + menú flotante en view_overlay). Soporta ítems ricos (SelectItem { label, sublabel, icon (glifo unicode), badge: SelectBadge, enabled }), selección múltiple (selected: &[usize] con check ✓), búsqueda in-menu y fases async (SelectPhase::{Loading, Error(&str), Ready(&[SelectItem])}) para listas que vienen de Handle::spawn. El cerrado se pinta con select_trigger_view(selected: Option<&SelectItem>, placeholder, open, width, &palette, on_toggle: Msg); el menú con select_menu_view(SelectMenuSpec { anchor, viewport, width, phase, visible: &[orig_idx], active, selected, query, searchable, empty_text, appear, on_pick: Fn(orig_idx)->Msg, on_hover, on_dismiss, on_retry, palette }). La app mantiene query/active/visible/selected en el Model. Helpers puros para el filtro: filter(items, query) -> Vec<orig_idx>, step_active(items, visible, current, ±1) para nav, resolve(visible, pos) -> Option<orig_idx>.

progress — linear_progress_view(progress, track, fill, height) y radial_progress_view(progress, track, fill, stroke_ratio). Sin eventos.

spinner — spinner_view(color, stroke_ratio, speed_rev_per_sec). Animado por reloj absoluto; requiere redraws periódicos (spawn_periodic).

rive-button — botón reactivo dirigido por máquina de estados (llimphi-anim, estilo Rive) — primer consumidor real de la StateMachine fuera del studio. Stateful (como text-editor): guardas RiveButton en tu Model. Tres estados idle/hover/press, cada uno con su clip Lottie; hover por bool (listeners Enter/Exit del motor), press por trigger (ClipDone lo devuelve solo). API: RiveButton::new(idle, hover, press, press_secs, blend_secs) o ::builtin() (clips embebidos); advance(dt) desde un tick, pointer(Option<(f64,f64)>), press() desde el on_click del host, current_state()/is_transitioning(). Render: view(on_pointer: Fn(Option<(f64,f64)>)->Msg, on_click: Msg). Demo: cargo run -p llimphi-widget-rive-button --example rive_button_demo --release.

badge — count_badge_view(count, BadgeKind) ("99+" si ≥100) y dot_badge_view(BadgeKind). BadgeKind::{Info,Success,Warning,Error,Neutral}.

avatar — avatar_view(name, size_px): círculo determinista (color por hash del nombre + inicial).

tooltip — render puro. tooltip_view(TooltipSpec { anchor, viewport, side: Side, text, palette }). Se monta en view_overlay; la app controla visibilidad con on_pointer_enter/leave.

empty — empty-state. empty_view(Icon, title, description: Option<&str>, &palette).

skeleton — placeholder con shimmer. skeleton_view, skeleton_box_view(w,h,..), skeleton_line_view(w,..). Requiere redraws periódicos.

carousel — pager paginado de N páginas. carousel_view(CarouselSpec { pages, current, wrap: CarouselWrap::{Wrap, Clamp}, show_arrows, palette, on_change: Fn(usize) -> Msg }). Dots indicadores abajo (clickeables para saltar a la página i) + flechas opcionales ‹ / › a los costados. v1 sin swipe; para v2 sumar swipe horizontal usando draggable_velocity + fling_step para snap-on-release.

fitted-box — escala un subárbol al slot del padre. fitted_box((iw, ih), BoxFit::{Contain, Cover, Fill, None, ScaleDown}, || inner_view()) aplica un transform afín al inner (medido por el seam LayoutBuilder) para que entre en el slot real, centrado, preservando aspect salvo Fill. Útil para imágenes, canvases paint_with y texto grande que deben caber en celdas chicas sin que el caller los re-mida. La función pura compute_fit(slot, inner, fit) -> (sx, sy, dx, dy) es testeable.

calendar — vista mensual 6×7 (calendar_view(CalendarSpec { view_year, view_month, selected, today, week_start, palette, on_select: Fn(NaiveDate)->Msg, on_view_change: Fn(year, month)->Msg })). Header con < Mes Año > para navegar entre meses, fila de iniciales (L M M J V S D ó D L M M J V S según WeekStart), grilla siempre de 6 filas (no reflowea al cambiar de mes). El caller le inyecta today — el widget no toca el reloj. Base del date-picker: combinar con view_overlay + un field/button disparador.

banner — tira de status. banner_view(BannerKind::{Info,Success,Warning,Error}, message).

Contenedores y layout

panel — chrome (gradiente + hairline accent). panel_view(children, PanelStyle); PanelStyle::{from_theme, from_theme_large, neutral}. panel_signature_painter(style) para reusar el look en un paint_with.

card — card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette).

stat-card — métrica de dashboard. stat_card_view(label, value, description, accent, &recent_items, &palette).

tabs — tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg, content: View<Msg>, tab_height, palette, tab_width }). Selección la maneja la app.

router — navegación por pila de rutas con transiciones de página (el Navigator de Flutter, a la Elm). La pila vive en el Model; el widget pinta sólo la ruta actual y anima el cambio con la infra de ghosts del compositor (animated_switch_styled). Push entra desde la derecha y lo viejo retrocede 30 % (shared-axis X); pop lo inverso; Zoom = fade-through; Cover = push opaco estilo UIKit (la nueva tapa sin fade; el pop desliza la vieja por encima revelando — scene-split de fantasmas: el saliente se pinta DEBAJO del entrante, en su posición z real); None = corte seco sin captura. Los hero cruzan rutas gratis (misma key en ambas páginas).

// Model: nav: RouteStack<Ruta>   (enum propio: Clone + PartialEq + Hash)
m.nav.push(Ruta::Detalle(7));  m.nav.pop();  m.nav.replace(r);  m.nav.pop_to_root();
router_view(key, &m.nav, PageTransition::{None,Fade,Slide,Zoom,Cover}, dur,
    page: Fn(&Ruta) -> View<Msg>)          // mide su slot vía LayoutBuilder
router_view_sized(key, &m.nav, tr, dur, (w, h), page)  // slot explícito — para
    // páginas que anidan otro layout_builder (límite v1 del seam: sin anidar)

Demo: cargo run -p llimphi-widget-router --example router_demo --release (hero volando entre Lista y Detalle; T cicla la transición, Esc vuelve).

hero — shared-element transition (el Hero de Flutter auténtico): un nodo con la misma key que aparece en otro rect en un frame posterior vuela del rect viejo al nuevo (el HeroRegistry del runtime interpola transform). hero_view(key, child) (480 ms) / hero_quick(key, child) (320 ms), o el primitive View::hero(key, dur) directo. Key estable y única entre los heroes de un mismo frame.

splitter — divisor draggable de 2 panes. splitter_two(Direction::{Row,Column}, a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette). PaneSize::{Fixed(px), Flex}. La app acumula el delta en su Model.

scroll — área de scroll vertical con barra arrastrable. scroll_y(offset, content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette). Stateless (offset en el Model); rueda autocontenida. Helpers: clamp_offset, ensure_visible (selección a la vista), approach (scroll suave). Ver §8.

lazy-list — lista perezosa llave-en-mano (Bloque 22 de PLAN-NUCLEO-RETENIDO.md). A diferencia de list/grid (el caller windowea a mano) y de scroll_y (recibe el contenido ya construido), recibe un builder item: Fn(usize) -> View + total y sólo llama item(i) para la ventana visible + overscan — las filas fuera de pantalla no se construyen, no se montan, no se layoutean (costo/frame ∝ lo visible, no ∝ el total). Sin estado (offset en el Model); reusa la barra/física de scroll.

lazy_list_fixed(total, item_h, offset, viewport_h, overscan,
    item: Fn(usize)->View, on_scroll: Fn(f32)->Msg, &ScrollPalette)   // extent uniforme, ventana O(1)
lazy_list_varied(offsets: &[f32] /*len total+1, tops acumulados*/, offset, viewport_h,
    overscan, item, on_scroll, &palette)                             // extent por fila, O(log n)
// helpers puros: visible_window(total, item_h, offset, vp, overscan) -> Window{first,count,y0}
//               visible_window_varied(offsets, offset, vp, overscan), content_height(total, item_h)
// altura MEDIDA (listas heterogéneas cuyo alto depende del contenido/reflow):
item_offsets(total, ancho, item: Fn(usize)->View) -> Vec<f32>   // mide cada ítem → offsets acumulados
// el caller lo llama al cambiar contenido/ancho, cachea el Vec, y por frame usa
// lazy_list_varied(&offsets, ...). Usa llimphi_compositor::measured_height(view, ancho).
// SECCIONES con encabezado sticky llave-en-mano (agenda/contactos):
lazy_list_sections(rows_por_seccion: &[usize], item_h, header_h, offset, viewport_h,
    overscan, header: Fn(s)->View, item: Fn(s, fila_local)->View, on_scroll, &palette)
// el header de la sección activa queda PINEADO al tope y la siguiente lo empuja;
// darle fill opaco (pinta encima de las filas). Ventana O(#secciones + visibles).
// puros: section_tops, sections_window -> Vec<SectionUnit>, sticky_section
// re-exporta de scroll: clamp_offset, fling_step, rubber_band, sticky_y, DEFAULT_OVERSCAN, ScrollPalette

Demo: cargo run -p llimphi-widget-lazy-list --example stress_lazy --release -- --nodes 1000000.

tiled — grilla auto cols×rows de tiles con title bar. tiled_view(tiles, &palette), tiled_view_cols(tiles, cols, &palette), y variantes *_reorderable* con on_reorder: Fn(from, to)->Option<Msg> (drag-to-swap por la title bar). TileSpec { label, content }.

panes — árbol binario BSP tipo tmux. La app guarda un Layout:

Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second }
layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves()
panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec<Side>,DragPhase,delta)->Option<Msg>,
           on_focus: Fn(PaneId)->Msg, &palette)

dock-rail — rail vertical de dientes para sidebars acoplables. Cada diente es una pestaña: barra de acento de 3px pegada al borde interno (encendida cuando está activo) + icono centrado. Apilados en una franja flotante que sobresale del panel hacia el centro — el patrón canónico de cosmos (01_yachay/cosmos/cosmos-app-llimphi/src/chrome.rs: dock_rail_overlay / dock_panel_for). Cada item se identifica por un u64 opaco; el caller mantiene el orden, qué está activo, y el mapping u64 ↔ DominioPropio (típicamente vía enum::{to_u64,from_u64}). Cada diente es arrastrable (su id como payload) y el rail entero es drop target → mover un diente de un sidebar a otro soltándolo sobre el rail opuesto. Render-only, agnóstico del Msg.

DockRailItem { id: u64, active: bool }
dock_rail_view(&items, width: f32, &DockRailPalette,
    make_icon: Fn(id, size_px, color) -> View<Msg>,
    on_activate: Fn(id) -> Msg,                  // dispara en el press, no pelea con el drag
    on_drop:     Fn(payload_id) -> Option<Msg>)  // None = el rail no acepta drops

Layout canónico: el rail va como overlay absoluto pegado al borde interno del centro (los dientes sobresalen sobre la rueda/canvas), y el panel del item activo va al costado como un pane en el área resizable. NO es una lista con rótulo: leer cosmos antes de inventar UX (lección 2026-06-05).

toolbar — barra de herramientas moderna: grupos de botones-ícono planos (hover redondeado; activo = fondo de selección + ícono en acento; deshabilitado atenuado y sin click) con separadores sutiles entre grupos. Los íconos los dibuja el caller vía closure (mismo contrato que dock-rail); los grupos son datos (Vec<ToolbarGroup>) → la barra es componible y configurable por construcción. Uso canónico: nahual-shell-llimphi::shell_toolbar (navegación + modos de vista + acciones).

ToolbarItem::new(icon: Fn(size_px, color) -> View<Msg>, on_click: Msg)
    .with_label("subir").active(bool).enabled(bool)
ToolbarGroup::new(vec![items...])
toolbar_view(groups: Vec<ToolbarGroup<Msg>>, height: f32, &ToolbarPalette) -> View<Msg>

grid — grilla 2D virtualizada. ventana_visible(total, vp_w, vp_h, scroll_fila, &metrics) -> VisibleWindow para virtualizar, luego grid_view(GridSpec { cells: Vec<GridCell { content, label, selected, on_click }>, cols, metrics, caption, ... }).

list — lista vertical virtualizada. list_view(ListSpec { rows: Vec<ListRow { label, selected, on_click }>, total, caption, truncated_hint, row_height, palette }). La app prefiltra las filas visibles.

tree — árbol expand/collapse. tree_view(TreeSpec { rows: Vec<TreeRow { label, depth, has_children, expanded, selected, on_toggle, on_select }>, row_height, indent_px, palette }). La app aplana el árbol según nodos expandidos.

navigator — navegador data-agnóstico de nodos en dos modos conmutables (árbol ↔ grafo, reusa tree + nodegraph). Render-only: la app guarda expanded/selected/mode. Pasa un bosque de NavNode { id: u64, label, kind: NavKind (Monad|Group|Dir|File|Other), children } y callbacks por id.

navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides },
    is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg,
    on_select: Fn(u64)->Msg, on_context: Option<Fn(u64)->Msg>)
// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.

app-header — app_header(label, actions: Vec<View<Msg>>, &palette).

status-bar — status_bar_view(left, center, right, &palette) con StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized().

breadcrumb — breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette) (el último segmento no es clickable).

modal — diálogo centrado con scrim. modal_view(ModalSpec { title, body: View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette }). ModalButton::{primary, cancel, destructive}(label, msg). Se monta en view_overlay.

toast — notificaciones efímeras bottom-right. La app guarda Vec<Toast> (Toast::{info,success,warning,error}(id, text, duration)), filtra is_alive(now), y toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg).

splash — splash de arranque (cuatro cuadrantes andinos). splash_view(started_at: Instant, bg, fg_text); basado en tiempo, requiere redraws.

Ricos / interactivos

nodegraph — lienzo de nodos + cables Bezier. Sin estado (la app guarda posiciones y Wires).

NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
nodegraph_view(&nodes, &wires, &palette, &metrics,
    on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
    on_connect:   Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)

timeline — scrub clickeable. timeline_view(progress: f32, &palette, on_seek: Fn(f32 [0..1])->Option<Msg>).

transport — botones de transporte de reproductor (play/pause/prev/ next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq). Stateless: el caller arma un TransportButton por cada botón con el flag que define su estado activo (p.ej. PlayPause { playing }, Mute { muted }, Record { recording }), lo pasa al widget, y recibe el TransportAction semántico en un closure cuando el usuario clickea. El widget elige el icono (de llimphi-icons), el color activo, el rojo de REC y arma la acción (p.ej. SeekBack { secs } → TransportAction::SeekBy(-secs)). Quien traduce TransportAction al comando del dominio (MediaCommand, etc.) es la app.

TransportPalette { bg, bg_active, bg_hover, fg, fg_active, fg_record,
                   btn_w, btn_h, radius, icon_stroke, gap }
TransportPalette::from_theme(&Theme)
TransportAction::{TogglePlay, Stop, Prev, Next, SeekBy(i64), VolumeBy(f32),
                  ToggleMute, CycleRepeat, ToggleShuffle, SpeedStep(i32),
                  SpeedReset, Snapshot, ToggleRecord, ToggleEqualizer}
TransportButton::{PlayPause{playing}, Stop, Prev, Next,
                  SeekBack{secs}, SeekForward{secs},
                  VolumeDown{step}, VolumeUp{step},
                  Mute{muted}, Repeat{active}, Shuffle{active},
                  SpeedDown, SpeedUp, SpeedReset{is_default},
                  Snapshot, Record{recording}, Equalizer{enabled}}
transport_button_view(button, &palette, on_action: Fn(TransportAction)->Msg) -> View<Msg>

Para una fila, el caller mapea su Vec<TransportButton> con .iter().map(|b| transport_button_view(*b, &pal, on_action.clone())) y los pone como children de una View con flex_direction: Row y gap: palette.gap. Consumidor de referencia: 02_ruway/media/media-app::bar_item_view.

waveform — visor de forma de onda en vivo. Stateless y agnóstico de cpal/AudioProbe: el caller pasa un closure Fn(&mut Vec<f32>) -> u16 que rellena un buffer con los últimos samples intercalados y devuelve cuántos canales trae; el widget hace el fold a mono y dibuja un envelope min/max por columna (polígono cerrado con relleno tenue + stroke top/bot) sobre una línea central que siempre está como "ground" — paint-only, sin mouse. Si el closure devuelve 0 canales, el widget pinta sólo la línea central ("visor vivo, sin señal").

WaveformPalette { bg, center, stroke, fill, radius, pad_x, pad_y, stroke_w }
WaveformPalette::from_theme(&Theme)  // bg=bg_panel_alt, stroke=accent, fill=accent@α70
waveform_view(source: Fn(&mut Vec<f32>) -> u16 + Send + Sync + 'static,
              palette: &WaveformPalette) -> View<Msg>

Consumidor de referencia: 02_ruway/media/media-app::waveform_panel (cablea un AudioProbe::snapshot).

table — tabla editable de celdas-texto: filas/columnas + agregar/quitar fila + foco por celda. Stateless — la app posee el foco y el buffer de la celda en edición; el resto se pinta desde &rows. Pensado para datos planos editables (paramétricos, scratchpad, listas curables); no es la list/tree virtualizada read-only ni un grid de hoja de cálculo.

table_view(&[String], headers,                          // [] = sin header
    &[Vec<String>] rows,
    focused: Option<(row, col)>,
    focused_state: Option<&TextInputState>,             // buffer prestado de la celda en edición
    add_label: &str, &TablePalette,
    on_focus_cell: Fn(row, col) -> Msg,                 // clic en celda
    on_remove_row: Fn(row) -> Msg,
    on_add_row:    Fn() -> Msg)
list_view(&[String], items, focused_row, focused_state, // sola columna sin header
    add_label, &palette, on_focus_cell: Fn(row)->Msg, on_remove_row, on_add_row)
table_height(n_rows, has_header) -> f32                 // pre-calcular alto del bloque

La variante list_view no se confunde con la list virtualizada de §13: ésta es una sola columna editable que delega en table_view([], ..).

text-editor — editor IDE (capa visual sobre el core agnóstico). La app guarda EditorState:

EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo();
.add_cursor_at(line,col);  .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult;
.ensure_caret_visible(visible_lines)
// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option<Msg>
text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer)
text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer)
text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer)
syntax_palette_dark(&theme) -> SyntaxPalette   // en lib.rs del widget

text-editor-core — núcleo agnóstico (sin GPU, sin Llimphi; sólo peniko::Color). Reutilizable en TUI/web/headless. Tipos clave:

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.

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(&current: &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óduloAtajoAcción que devuelvePropósito
command-paletteCtrl+Shift+PInvoke(String)paleta de comandos fuzzy. El host declara &[Command]
file-pickerCtrl+POpen(PathBuf)fuzzy file picker; host pasa &[PathBuf] + root
fif (find-in-files)Ctrl+Shift+FOpenAt{path,line,col}, Searched{..}, Replaced{..}buscar/reemplazar; dual-view (dialog + barra). search() / replace_all() hacen el I/O
diff-viewerCtrl+Shift+D—diff side-by-side. DiffState::new(before_label, after_label, before, after) computa con similar
mini-mapCtrl+Shift+MJumpTo(line)minimapa del buffer; agnóstico del editor (recibe Snapshot)
bookmarksCtrl+Alt+B toggle, Ctrl+Shift+B lista, Ctrl+Alt+N/P navJumpTo{path,line}marcadores per-file persistentes (state directo, no Option)
symbol-outlineCtrl+Shift+OGoTo{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-termCtrl+`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


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
}

17. Comandos y demos

cargo check --workspace                              # smoke test mínimo (debe pasar siempre)
cargo run -p <crate> --release                       # correr una app
cargo run -p <crate> --example <demo> --release      # correr un demo

# demos del propio framework:
cargo run -p llimphi-ui      --example counter --release   # bucle Elm completo
cargo run -p llimphi-ui      --example editor  --release   # text field + teclado
cargo run -p llimphi-ui      --example gpu_paint_demo --release
cargo run -p llimphi-gallery --release                     # showcase de TODO el kit
cargo run -p nada            --release                     # editor real para ejercitar widgets

# benchmark GPU directo vs vello:
cargo run -p llimphi-gpu-bench --release

# perfilador por frame (a stderr cada 60 frames: media/p95 por etapa):
LLIMPHI_PERF=1 cargo run -p <crate> --release
# ...con conteo de asignaciones de heap por frame y su desglose por etapa:
LLIMPHI_PERF=1 cargo run -p <crate> --release --features llimphi-ui/perf-alloc

LLIMPHI_PERF=N reporta cada N frames (default 60) el desglose view/mount/layout/paint/raster/gpu/present en µs + layout-reuso, el caché de repaint_boundary y —si está la feature perf-alloc— alloc <media>/<p95> (<KB>) [view … mount … layout … paint …]. Sin esa feature el tramo de allocs dice n/a en vez de ceros falsos. El contador es llimphi_ui::alloc::ContadorAlloc: si tu app ya elige su allocator, instalalo a mano (#[global_allocator]) en vez de usar la feature. El camino cache_hit (frame retenido entero) no se muestrea a propósito.

llimphi-gallery (src/main.rs, ~967 líneas) es la referencia viva del patrón completo: Model/Msg/init/update/view/view_overlay con overlays mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown). Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; ? abre atajos; Esc cierra el overlay activo.


18. Cheat-sheet

// ── App mínima ──────────────────────────────────────────────
impl App for X { type Model; type Msg; init; update; view; }
llimphi_ui::run::<X>();

// ── Nodo ────────────────────────────────────────────────────
View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
    .fill(c).fill_gradient(g).hover_fill(c).radius(r).radius_corners(tl,tr,br,bl).shadow(sh).border(w,c).clip(b).alpha(a).transform(xf).animated(key,dur)
    .text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al) | .text_weight(w) | .bold() | .ellipsis(n) | .max_lines(n)
    .image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{})
    .on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m)
    .on_pointer_enter(m) | .on_pointer_leave(m)
    .draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|)
    .drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c)
    .children(vec![..])

// ── Efectos ─────────────────────────────────────────────────
handle.spawn(|| Msg::Done(compute()));          // worker → reentra al update
handle.spawn_periodic(dur, || Msg::Tick);       // feed periódico
handle.dispatch(Msg::X);  handle.quit();

// ── Estilo de layout (taffy prelude) ────────────────────────
length(px)  percent(0..1)  Dimension::auto()
FlexDirection::{Row,Column}  AlignItems::{Start,Center,End,Stretch}
JustifyContent::{Start,Center,End,SpaceBetween}

// ── Theme ───────────────────────────────────────────────────
Theme::dark()/light()/aurora()/sunset();  Theme::next_after(name);  XxxPalette::from_theme(&t)

// ── Overlay (menús/modales) ─────────────────────────────────
fn view_overlay(m) -> Option<View<Msg>> { if m.open { Some(menu) } else { None } }

19. Índice de crates

Framework (02_ruway/llimphi/): llimphi-hal · llimphi-raster · llimphi-text · llimphi-layout · llimphi-compositor · llimphi-ui · llimphi-theme · llimphi-motion · llimphi-icons · llimphi-surface · llimphi-workspace · llimphi-gallery · llimphi-gpu-bench.

Widgets (widgets/, dep llimphi-widget-<n>): app-header · avatar · badge · banner · breadcrumb · button · card · clipboard · color-picker · context-menu · dock-rail · edit-menu · empty · field · gallery · grid · lazy-list · list · menubar · modal · navigator · nodegraph · panel · panes · progress · segmented · select · shortcuts-help · skeleton · slider · splash · splitter · stat-card · status-bar · switch · table · tabs · terminal · text-area · text-editor · text-editor-core · text-editor-lsp · text-input · theme-switcher · tiled · timeline · toast · toolbar · tooltip · transport · tree · waveform · wawa-mark.

Módulos (modules/): bookmarks · command-palette · diff-viewer · fif · file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline.

Android (android/): clear-screen-android · vello-hello-android · vello-text-android.


Documentos hermanos: SDD.md (diseño y roadmap), COMPUTO-FUERA-DEL-HILO-UI.md (regla de concurrencia), README.md / LEEME.md (overview). Las firmas de este manual reflejan el código al 2026-06-01; ante divergencia, la fuente autoritativa es el lib.rs de cada crate.