# 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`](SDD.md); para la regla de concurrencia ver [`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md). --- ## Índice 1. [Modelo mental en 60 segundos](#1-modelo-mental-en-60-segundos) 2. [Arquitectura — las capas](#2-arquitectura--las-capas) 3. [Quickstart — la app mínima](#3-quickstart--la-app-mínima) 4. [El trait `App` (bucle Elm)](#4-el-trait-app-bucle-elm) 5. [`Handle` — efectos y concurrencia](#5-handle--efectos-y-concurrencia) 6. [`View` — el DSL declarativo](#6-viewmsg--el-dsl-declarativo) 7. [Layout (`taffy` / `Style`)](#7-layout-taffy--style) 8. [Eventos e interacción](#8-eventos-e-interacción) 9. [Texto](#9-texto) 10. [Canvas custom y GPU directo](#10-canvas-custom-y-gpu-directo) 11. [Theme y paletas](#11-theme-y-paletas) 12. [Capas base (hal · raster · text · motion · icons · surface)](#12-capas-base) 13. [Catálogo de widgets](#13-catálogo-de-widgets) 14. [Catálogo de módulos](#14-catálogo-de-módulos) 15. [`llimphi-workspace` — chasis tipo tmux](#15-llimphi-workspace--chasis-tipo-tmux) 16. [Reglas duras y gotchas](#16-reglas-duras-y-gotchas) 16.bis. [Tests de regresión visual (golden-image)](#16bis-tests-de-regresión-visual--llimphi-test-golden-image) 16.ter. [Semántica accesible y tests por consulta](#16ter-semántica-accesible--tests-por-consulta) 17. [Comandos y demos](#17-comandos-y-demos) 18. [Cheat-sheet](#18-cheat-sheet) 19. [Índice de crates](#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: - `Model` — estado **inmutable** de la app. - `Msg` — todo lo que puede pasar (`Clone + Send`). - `update(model, msg, handle) -> model` — transición **pura** que devuelve un modelo nuevo. - `view(&model) -> View` — función **pura** que describe la pantalla como un árbol de `View`. El runtime hace el bucle: un evento (click/tecla/rueda) produce un `Msg`, `update` deriva el nuevo `Model`, `view` reconstruye el árbol, `taffy` calcula las cajas, `vello` rasteriza, y se hace swap del frame. **No hay mutabilidad compartida, no hay vDOM ajeno, no hay callbacks imperativos**: declaras qué se ve y qué `Msg` emite cada nodo. ``` evento ─▶ Msg ─▶ update(model,msg) ─▶ model' ─▶ view(model') ─▶ View │ 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, mount sobre taffy, paint, hit-test (winit-free) 3. llimphi-layout ....... motor de layout (taffy: flexbox + grid) 2. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo 1. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji └ llimphi-glifos ..... atlas de cobertura: el texto del camino GPU-directo 0. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer) ``` El **split compositor/runtime** (2026-05-31) es importante: `llimphi-compositor` es *winit-free* (sólo `View`, `mount`, `paint`, hit-test). `llimphi-ui` lo corre sobre winit y **re-exporta todo el compositor**, así escribes `llimphi_ui::View` sin enterarte del split. Esto habilita un futuro runtime sobre el framebuffer del kernel `wawa` reusando el mismo compositor. Auxiliares: `llimphi-theme` (paletas), `llimphi-motion` (tweens), `llimphi-icons` (iconos vectoriales), `llimphi-surface` (texturas externas), `llimphi-workspace` (chasis tmux), `llimphi-gallery` (showcase). Catálogo: **76 widgets** (visuales, crates `llimphi-widget-*`) + **11 módulos** (features con estado). `ls -d widgets/*/` da **77** carpetas: la que sobra es `llimphi-clipboard`, que vive ahí sin ser un widget. Recontado el 2026-08-27; el número sale del comando, no de esta línea. --- ## 3. Quickstart — la app mínima ```rust 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::Model { 0 } fn update(model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { match msg { Msg::Increment => model.saturating_add(1), Msg::Reset => 0, } } fn view(model: &Self::Model) -> View { 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::(); } ``` `Cargo.toml`: ```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 --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. ```rust pub trait App: 'static { type Model: 'static; type Msg: Clone + Send + 'static; fn init(handle: &Handle) -> Self::Model; fn update(model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model; fn view(model: &Self::Model) -> View; // --- Todo lo de abajo tiene default; sobreescribí lo que necesites --- fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option { None } fn on_wheel(_model: &Self::Model, _delta: WheelDelta, _cursor: (f32, f32), _modifiers: Modifiers) -> Option { 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> { 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 { 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) -> Option { 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 { 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()` — corre hasta que el usuario cierre la ventana o la app llame `Handle::quit`. **Eventos de teclado** (`KeyEvent`): ```rust pub struct KeyEvent { pub key: Key, // re-export de winit; usar NamedKey para teclas especiales pub state: KeyState, // Pressed | Released pub text: Option, // texto resultante con IME/modifiers; None para flechas etc. pub modifiers: Modifiers, // { shift, ctrl, alt, meta } pub repeat: bool, } ``` `Key` y `NamedKey` se re-exportan desde `llimphi_ui`. **Rueda** (`WheelDelta { x, y }`): normalizado a "líneas". Convención CSS: `y` positivo = scroll hacia abajo. **El sentido (natural / clásico) va por CLASE de dispositivo y no lo decide la app.** `llimphi_entrada::PoliticaRueda` tiene dos sentidos —uno para la rueda del ratón, otro para el touchpad—, y lo aplican las tres fronteras donde nace un `WheelDelta`: el runner de ventana, el de subventana y el de capa. Una `App` recibe el delta ya en el sentido que el humano eligió; **no lo inviertas de nuevo** (invertir dos veces cancela el ajuste). Invertir para *zoom* —rueda arriba = acercar— sí es tuyo: eso es semántica de la app, no sentido de scroll. La política la siembra el runtime al arrancar (`llimphi_ui::sembrar_politica_rueda`). En tawasuyu sale del bus `wawa-config` (`entrada.rueda_raton` / `entrada.rueda_touchpad`, que escribe wawa-panel) gracias a la feature `config-wawa`, encendida en el `[workspace.dependencies]` de la raíz; fuera del monorepo se fija a mano con `llimphi_entrada::fijar_politica_rueda`. Bajo `mirada` el runtime se queda neutro a propósito: ahí lo aplica el compositor en libinput, por dispositivo, y así lo obedece toda la sesión. --- ## 5. `Handle` — efectos y concurrencia `Handle` 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. ```rust impl Handle { 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 Msg + Send + 'static>(&self, f: F); // worker; su Msg reentra al update pub fn spawn_periodic 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(sink: F) -> Self; // tests que SÍ quieren los Msg // --- ventana (sólo la primaria; en un handle `lift`ado son no-op) --- pub fn set_fullscreen(&self, on: bool); // pantalla completa sin bordes, en su monitor pub fn set_minimized(&self, on: bool); // minimizar / restaurar pub fn set_content_type(&self, k: ContentType); // qué mostrás: None/Photo/Video/Game pub fn open_window(&self, key: u64, title: impl Into, w: u32, h: u32); // ventana OS secundaria pub fn close_window(&self, key: u64); } ``` - **`on_modifiers`** (hook del trait `App`) — el `WindowEvent::ModifiersChanged`. Existe porque **los handlers de click llevan un `Msg` plano y no reciben los modificadores**: para hacer Shift+click (rango) o Ctrl+click (aditivo) en una lista, la app guarda el estado en su modelo desde este hook y lo consulta en el `update` del click. Devolvé `None` cuando el cambio no te importa (si no, cada roce de Shift dispara un update y un repintado). - **`for_test` vs `for_test_with`** — `for_test` **descarta** los `Msg`; ojo con eso: la closure de un `spawn` **sí se ejecuta** (para no perder sus efectos), sólo se tira el mensaje. Un test que necesitaba el resultado terminaba volviendo a hacer el trabajo a mano → el mismo job dos veces, en paralelo, y carreras intermitentes sobre el disco. `for_test_with` entrega cada `Msg` a un sink (típicamente el `Sender` de un canal, detrás de un `Mutex` porque el sink pide `Sync`); el test lo drena y lo realimenta a `update`, que es lo que hace el runtime real. Con eso el trabajo corre **una sola vez** y el test es determinista. - **`set_fullscreen`** — `Fullscreen::Borderless` sobre el monitor donde ya está la ventana (no cambia el modo de video). Es una **petición** al compositor y el runtime **no lleva la cuenta**: el estado vive en el modelo de la app, que decide cuándo entra y cuándo sale. Si el usuario sale por el WM, tu `bool` queda desfasado — repetir el pedido es inocuo. El resize llega por el camino normal (`Resized`), no hay que tocar la surface. - **`set_content_type`** — declara **qué** muestra la ventana (`wp_content_type_v1`). No cambia nada de cómo pintás: es una pista para que el compositor ajuste su política. mirada le **cede la pantalla** al contenido con ritmo propio —deja de animar su fondo de marca, que le cuesta una recomposición completa por latido— y con lo mismo decide VRR y tearing. Un reproductor declara `Video` al arrancar y vuelve a `Photo` al pausar (un video en pausa **es** una imagen fija) o a `None` al cerrar el medio; repetir el mismo valor es gratis. Fuera de Wayland, o con un compositor que no exponga el protocolo, es un no-op silencioso (avisa una vez por stderr). Winit no habla ese protocolo: por debajo se monta una segunda conexión wayland-client sobre el **mismo** `wl_display` y se envuelve la `wl_surface` de winit — el mismo cruce que validó el spike `llimphi-video-plane`. La pista es estado doble-buffereado, así que viaja con el **próximo cuadro** que pintes. Ejemplo vivo: `cargo run -p llimphi-ui --example content_type`. - **`spawn`** — trabajo bloqueante (IO, PAM, parse, efemérides). El `Msg` que devuelve la closure se entrega al `update` en el hilo de UI. **Este es el patrón obligatorio para todo cómputo pesado** (§16). - **`spawn_periodic`** — feeds a intervalos: ticks de simulación (~11 Hz en dominium), polling, animaciones por reloj. El thread muere cuando se cierra el event loop. --- ## 6. `View` — 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`. ```rust View::new(style: Style) -> View ``` ### Apariencia | Método | Efecto | |---|---| | `.fill(Color)` | color de fondo | | `.fill_gradient(Gradient)` | relleno con gradiente (autoreado en el cuadrado unidad `[0,1]²`, mapeado al rect). Gana sobre `fill`; `hover_fill` lo overridea en hover | | `.hover_fill(Color)` | color al pasar el cursor (habilita hit-test de hover) | | `.radius(f64)` | esquinas redondeadas (radio uniforme) | | `.radius_corners(tl,tr,br,bl)` | radio **por esquina** (CSS `border-radius` 4 valores); override de `.radius`. El borde sigue las 4 esquinas; la sombra usa el radio escalar | | `.shadow(Shadow)` | drop shadow (vello `draw_blurred_rounded_rect`). `Shadow::soft(alpha,blur)` + `.offset(dx,dy)`/`.spread(s)` | | `.border(width, Color)` | stroke sobre el contorno redondeado, inset hacia adentro (border-box) | | `.alpha(f32)` | opacidad de todo el subtree `[0,1]` (capa intermedia — no gratis) | | `.transform(Affine)` | afín 2D alrededor del centro del rect (estilo CSS `transform-origin:50% 50%`) | | `.animated(key, Duration)` | animación **implícita** estilo Flutter `AnimatedContainer`: si `fill`/`radius` cambian entre frames, el runtime interpola (ease-out cúbico) en vez de saltar. `key` estable entre rebuilds. `.animated_curve(key,dur,fn)` para otra curva | | `.clip(bool)` | recorta hijos al rect (paint + hit-test) | | `.image(Image)` | pinta `peniko::Image` centrada, preservando aspect ratio | | `.children(Vec>)` | hijos | ### Texto (ver §9) ```rust .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) .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) ```rust .on_click(Msg) .on_click_at(|lx, ly, w, h| -> Option) // 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) .draggable_at(|phase, dx, dy, lx0, ly0| -> Option) // + posición inicial del press .drag_payload(u64) // payload que viaja con el drag .on_drop(|payload: u64| -> Option) // este nodo es drop target .drop_hover_fill(Color) // resaltado mientras un drag lo sobrevuela .on_scroll(|dx, dy| -> Option) // rueda local (antes del on_wheel global) .on_scroll_take(|dx, dy| -> (Option, 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) // pinch-to-zoom (Ctrl+rueda / trackpad) .on_double_tap(Msg) / .on_double_tap_at(|lx, ly, w, h| ...) // dos clicks rápidos y cercanos .double_tap_exclusive() // …y que ARBITRE contra el click del nodo .on_long_press(Msg) / .on_long_press_at(|lx, ly, w, h| ...) // mantener ~500 ms quieto .focusable(u64) // nodo enfocable por Tab/click (id opaco) ``` ### Pintura custom (ver §10) ```rust .paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... }) .gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... }) ``` Notas clave: - **Un nodo es draggable *o* clickable**, no ambos: `draggable` sobreescribe `on_click`. - Las variantes `*_at` ganan sobre las simples si ambas están. - `PaintRect { x, y, w, h }` es el rect **absoluto** del nodo en píxeles físicos. - `DragPhase` = `Move` (un evento por `CursorMoved`, `dx/dy` = delta **desde el evento anterior**, no acumulado) | `End` (al soltar). - **`double_tap_exclusive()`** rompe esa aditividad **para el doble-tap**, y es la salida para el nodo que necesita las dos cosas en el mismo botón: el runtime **retiene** el click del primer tap hasta que vence la ventana del doble (400 ms) y lo **descarta** si el segundo llega. Cuesta latencia —el click de un toque suelto sale al vencer, no al soltar—, por eso es opt-in: sin esto una fila que elige con un click y abre con dos sigue disparando los dos, y esa es la semántica normal. Lo estrena el **diente** del `rag-sidebar` (un click despliega su panel, dos alternan el eje Flota/Fijo): sin arbitrar, el doble desplegaba y replegaba el panel de paso, y en FIJO eso suelta y vuelve a tomar la franja reservada del escritorio. Vale en los tres bucles (ventana, `llimphi-layer` y `pata`). - **Gestos (`on_scale`/`on_double_tap`/`on_long_press`)** son **aditivos**: se resuelven con su propio hit-test y no interfieren con `on_click`/`draggable` del mismo nodo. El caso limpio (sin disparos cruzados) es ponerlos en un nodo que **no** tenga `on_click` — p. ej. un canvas con `draggable` (pan) + `on_scale` (zoom) + `on_long_press` (marca). `GesturePhase` = `Begin`/`Update`/ `End`; en `on_scale`, `factor` es **multiplicativo incremental** (`>1` agranda) y `(fx, fy)` el focal local — `Ctrl+rueda` lo sintetiza en cualquier desktop (Wayland/Windows no emiten el pinch del trackpad; macOS sí, vía `PinchGesture`). El long-press lo arbitra el tiempo (~500 ms quieto); moverse (>8px) o soltar lo cancela. Demo completo: `cargo run -p llimphi-ui --example gestos --release`. --- ## 7. Layout (`taffy` / `Style`) `Style` es el `taffy::Style` directo, re-exportado vía `llimphi_ui::llimphi_layout::taffy::prelude::*`. Es Flexbox + CSS Grid puro. Campos más usados: ```rust Style { flex_direction: FlexDirection::Row | Column, size: Size { width, height }, // length(px) | percent(0..1) | Dimension::auto() min_size, max_size, flex_grow: f32, flex_shrink: f32, align_items: Some(AlignItems::{Start,Center,End,Stretch}), justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}), gap: Size { width, height }, padding: Rect { left, right, top, bottom }, // con length(px) margin: Rect { ... }, ..Default::default() } ``` Helpers de `prelude`: `length(px)`, `percent(frac)`, `auto()`, `Dimension`, `Size`, `Rect`, `FlexDirection`, `AlignItems`, `JustifyContent`. `llimphi-layout` además expone: - `LayoutTree::new()` / `.clear()` (reuso entre frames), `.leaf(style)`, `.node(style, &children)`, `.compute(...)`, `.compute_with_measure(F)`. - `Rect { x, y, w, h }` y `ComputedLayout { rects: HashMap }`. En el 99% de los casos no tocas `LayoutTree` a mano: lo maneja el runtime al montar tu `View`. Sólo armás `Style`s. --- ## 8. Eventos e interacción | Quiero… | Cómo | |---|---| | Botón / fila clickable | `.on_click(Msg)` (+ `.hover_fill` para feedback) | | Saber dónde se clickeó (canvas) | `.on_click_at(\|lx,ly,w,h\| ...)` → convertir a coords de mundo | | Menú contextual | `.on_right_click(Msg::OpenMenu{..})`, guardar pos en Model, abrir en `view_overlay` | | Abrir en pestaña nueva | `.on_middle_click(Msg)` | | Preview al pasar el mouse | `.on_pointer_enter(Msg)` / `.on_pointer_leave(Msg)` | | Resize de panel | `.draggable(\|phase,dx,dy\| ...)` acumulando delta en el Model | | Arrastrar entidad de un canvas | `.draggable_at(\|phase,dx,dy,lx0,ly0\| ...)` | | Drag&drop entre zonas | origen: `.drag_payload(id)`; destino: `.on_drop(\|id\| ...)` + `.drop_hover_fill` | | Scroll global | `App::on_wheel(model, delta, cursor, mods)` | | Área de scroll | widget `scroll_y(...)` (autocontenido) o `.on_scroll(\|dx,dy\| ...)` por nodo | | Zoom de canvas (pinch) | `.on_scale(\|phase,factor,fx,fy\| ...)` → `zoom *= factor`, reajustar pan al focal | | Doble-click | `.on_double_tap(Msg)` / `.on_double_tap_at(\|lx,ly,w,h\| ...)`; `+ .double_tap_exclusive()` si el mismo nodo tiene click | | Long-press (mantener) | `.on_long_press(Msg)` / `.on_long_press_at(\|lx,ly,w,h\| ...)` | | Teclado | `App::on_key(model, &KeyEvent) -> Option` | | Foco / Tab | `.focusable(id)` en los nodos + `App::on_focus(model, id)` (ver abajo) | | IME (CJK, acentos) | `App::ime_allowed() -> true` + `App::on_ime(model, &ImeEvent)` (ver abajo) | | Drop de archivos del SO | `App::on_file_drop(model, path)` | **Patrón overlay** (menús/modales): el modelo guarda "menú abierto sí/no". Mientras esté abierto, `view_overlay` devuelve `Some(view)`; clicks fuera se cierran envolviendo los items en un scrim a pantalla completa con `on_click = DismissOverlay`. Cuando el modelo dice cerrado, `view_overlay` devuelve `None`. **Scroll** (widget `llimphi-widget-scroll`). `scroll_y(offset, content_len, viewport_len, content, on_scroll, &palette)` arma un viewport clipeado + contenido desplazado `-offset` + barra arrastrable. Es **stateless**: el offset vive en tu Model. `on_scroll(delta_px)` (rueda y arrastre) emite un delta a sumar; clampealo con `scroll::clamp_offset` en tu `update`. Helpers: `ensure_visible(offset, vp, item_top, item_h)` para llevar la selección a la vista (teclado); `approach(cur, target, factor)` para scroll suave hacia un objetivo (driveado por `Handle::spawn_periodic`). **Scroll 2D / física / slivers** (Tier 5, mismo widget, todo stateless): - `scroll_xy(offset:(x,y), content_size:(w,h), viewport_size:(w,h), content, on_scroll:(dx,dy)→Msg, &palette)` — dos ejes, una barra por eje con overflow. - **Inercia (fling)**: `fling_step(velocity, dt, friction) → (v', delta)` + `fling_settled(v)` — suelta con velocidad y avanza el offset por frame con el ticker (`spawn_periodic`); `FLING_FRICTION`/`FLING_STOP` defaults. **Bounce**: `rubber_band(overscroll, dim)` amortigua el desplazamiento más allá del tope. - **Sliver app-bar colapsable**: `sliver_app_bar(offset, header_max, header_min, header(frac)→View, content, content_len, viewport_len, on_scroll, &palette)` — un único offset colapsa el header (de max a min) y luego scrollea el cuerpo bajo él; `header(frac)` recibe `frac∈[0,1]` para fundir título/subtítulo. Clampeá con `sliver_max_offset(...)`. Helpers puros: `collapsed_height`, `collapse_fraction`, `sticky_y(offset, section_top, section_h, header_h)` para encabezados de sección pegados al tope. Demo: `cargo run -p llimphi-widget-scroll --example scroll_avanzado`. **Foco y teclado.** Marca los nodos navegables con `.focusable(id)` (id `u64` que vos eliges). El runtime es la **única fuente de verdad** del foco: lo mueve con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo enfocable, y te avisa con `App::on_focus(model, Option)`. 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: ```rust 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 CSS con fallbacks pub line_height: f32, // múltiplo; default 1.2 pub runs: Option>, // color por rango de BYTES } ``` - `Center` es el default (apto para labels). Para editores/párrafos usar `.text_aligned(..., Alignment::Start)`. - **Multicolor en una sola pasada de shaping**: `.text_runs(...)` colorea rangos de bytes — es la base del syntax highlighting (un nodo por línea, no por token). Anclado arriba-izquierda; el caller dimensiona el rect. - El runtime mide el texto con parley durante el layout (`compute_with_measure`) para que taffy reserve el alto real del texto envuelto a varias líneas (evita "textos aplastados"). - Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique. --- ## 10. Canvas custom y GPU directo Dos hooks para pintar primitivas no expresables como composición de `View`s. 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) ```rust .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 ```rust .gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { // abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello. // (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC. }) ``` Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de nakui, pineal denso. Rango 100 K – 10 M+ primitivos. **Sí** soporta texto desde 2026-08-05 (§12, `llimphi-glifos` + `add_glyph`) y múltiples grosores de stroke por flush desde el mismo día. Lo que sigue sin soportar es el AA analítico de curvas: los bordes de rects/líneas/tris salen del MSAA 4×, no de cobertura exacta. Para texto **con brush** (gradiente, imagen) o decoraciones sigue haciendo falta la Scene vello de `view_overlay`. ### UI encima de contenido GPU — `View::over` y `paint_over` El orden del frame es `[vello base] → [gpu_paint] → [vello over] → [overlay/ menús]`: **cualquier `View` normal que se solape con un `gpu_paint_with` queda tapado por él** (el blit corre después de la pasada base). Dos salidas: ```rust // Subárbol entero de Views pintado en la pasada over (fills, texto, widgets, // hover — la maquinaria completa). El primitivo para un OSC de reproductor, // un HUD, un rail que solapa un canvas GPU. El hit-test no cambia. View::new(estilo_absoluto).over(true).children(vec![barra_de_transporte]) // Closure vello suelta en la pasada over (sprites/texto AA sobre celdas // instanciadas — dominium, motor voxel). .paint_over(|scene, ts, rect| { ... }) ``` Limitación de ambas: la capa over se compone con alpha sobre la intermedia; clips/alpha/transform de **ancestros fuera** del subárbol no la afectan. Caso canónico de `.over(true)`: el OSC estilo mpv de media-app (`02_ruway/media/media-app/src/vista.rs::overlay_bars_view`) y su fondo de marca (`src/fondo.rs::fondo_overlay`) flotando sobre el video. ### ¿Cuándo cada uno? | Pregunta | vello (`paint_with`) | GPU directo (`gpu_paint_with`) | |---|---|---| | Primitivos/frame | < ~500 K rebuild / < ~2 M Scene reusada | 100 K – 10 M+ | | ¿Cambian cada frame? | sí, rebuild barato | mejor estático (buffer persistente) | | Curvas Bezier | nativas | hay que teselar | | Texto | sí, con brush | sí, color sólido (atlas de cobertura) | | AA fino | sí (analítico) | bordes por MSAA 4×; el texto trae el suyo | **Default: `paint_with`** salvo que ya midas que el volumen lo justifica (factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU expone `GpuPipelines`/`GpuBatch` en `llimphi-raster` (§12). --- ## 11. Theme y paletas `llimphi-theme::Theme` es un struct de slots semánticos de color. Cuatro presets `const`: `Theme::dark()` (default), `light()`, `aurora()`, `sunset()`. ```rust 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 // orden de rotación canónico Theme::by_name(name) -> Option Theme::next_after(current_name) -> Theme // para el theme-switcher ``` Tokens auxiliares en el mismo crate: - `motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms}` + `ease_out_cubic`, `ease_in_out_cubic`, `linear`. - `alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT}` (constantes `u8`). - `radius::{XS=2, SM=4, MD=8, LG=12, XL=20}` (`f64`). **Patrón de widgets**: cada widget define su `XxxPalette` con `Palette::from_theme(&theme)`. Tu app guarda un `Theme` en el Model, deriva las paletas que necesita en `view`, y se las pasa a los widgets. Para cambiar de tema, el `theme-switcher` emite `Msg(next_theme)` y reconstruyes todo. --- ## 12. Capas base ### `llimphi-hal` — superficie ```rust Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result // async trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result; fn present(frame, hal); } WinitSurface::new(hal, window: Arc) -> Result 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 ```rust Renderer::new(hal) -> Result Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color) // GPU directo: GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout GpuBatch::new(&pipelines) .line_width(w) // pluma: vale para lo que se agregue DESPUÉS .add_line(p0,p1,color) .add_polyline(&pts,color) .add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color) .add_disc(cx,cy,r,color) .add_ring(cx,cy,r,stroke,color) .atlas(&AtlasTextura) // sin esto los quads NO se dibujan .add_glyph(x,y,w,h,(u0,v0,u1,v1),color) // quad texturado: el TEXTO .scissor(x,y,w,h) // sólo se toca ese rect (damage) .max_buffer_bytes(bytes) // baja el techo por buffer; normalmente no se toca .primitive_count() -> u32 .flush(device, queue, encoder, view, viewport, load_op) units_per_chunk(stride, max_bytes, multiplo) -> u32 RECT_INSTANCE_STRIDE / LINE_INSTANCE_STRIDE / DISC_INSTANCE_STRIDE / TRI_VERTEX_STRIDE QUAD_INSTANCE_STRIDE // la textura de cobertura contra la que se muestrean los quads: AtlasTextura::new(device, &pipelines, lado) -> Self .subir(queue, pixeles, (x,y,w,h)) // sube SÓLO ese rect, no el atlas entero ``` Re-exporta `vello` y `peniko` (`Color`, `Image`, `Fill`, etc.). Cuatro cosas del `flush` que conviene saber y no se ven en la firma: - **`line_width` es una pluma, no un modo del lote (cambió 2026-08-05).** Cada línea se queda con el grosor que estaba puesto cuando se la agregó, así que una grilla fina y una curva gruesa entran en el mismo `flush`. Antes el grosor era un uniforme del lote entero y dibujar dos grosores obligaba a cortar — y cortar no cuesta una draw call más: **cuesta el ciclo entero otra vez**, porque cada `flush` arma su propia textura MSAA 4×, la resuelve y la compone (2 texturas + 2 render passes por `flush`). El precio es 4 B más por línea (36 en vez de 32), que baja el techo de un solo buffer de 8,4 M de líneas a 7,4 M; el troceado ya lo maneja. - **No hay techo de primitivas.** `max_buffer_size` del dispositivo (256 MB en una Iris Xe) topa a ~8,4 M de instancias en **una** reserva, y pasado eso wgpu rechaza: la app no se pone lenta, se cae. `flush` parte cada tipo en lotes y dibuja uno por lote en orden, así que el alpha-over sale idéntico. En el caso normal es un lote y las mismas cuatro draw calls. `max_buffer_bytes` existe sobre todo para **testear** el troceado sin reservar cientos de MB. - **`scissor` recorta el costo, no sólo el dibujo.** Las texturas intermedias (MSAA 4× + resolve) se crean del tamaño del rect sucio, porque el `LoadOp::Clear` del attachment y el resolve son de toda la superficie y el scissor no los toca. Sin eso, pintar un cursor de 32 px costaba limpiar y resolver la pantalla entera. El rect se pide siempre en **coordenadas de viewport**; `flush` baja el origen a par (las derivadas de `fwidth`, de las que sale el AA del disco, se calculan por bloques de 2×2 alineados al target: un corrimiento impar cambiaría la orla), así que puede tocar una fila y una columna de más. - **Los quads sin atlas no se dibujan, y no revientan.** `add_glyph` acumula igual, pero si nadie llamó a `atlas()` el `flush` los saltea en silencio. La alternativa —un panic adentro del `flush`— sería reventar el frame de una app porque alguien pidió texto antes de tener el atlas listo. Que falte el texto es un síntoma leíble; que se caiga la ventana, no. ### `llimphi-glifos` — el texto del camino GPU-directo ```rust AtlasGlifos::new(lado) -> Self // atlas cuadrado de cobertura, 1 B/px .pixeles() -> &[u8] .lado() -> u32 .version() -> u64 .entradas() -> usize .tomar_sucio() -> Option // qué cambió desde la última vez, y limpia .limpiar() // única salida cuando `fallos` sube .fallos / .aciertos / .rasterizados // contadores públicos quads_de_layout(&mut atlas, &layout, (x,y), &mut Vec) -> usize quads_de_run(&mut atlas, &glyph_run, (x,y), &mut Vec) // multicolor ``` Rasteriza cada glifo **una sola vez** a un mapa de 8 bits y lo empaqueta por estantes; de ahí en más pintar texto es pintar quads. Un renglón de 80 caracteres pasa de 80 contornos rellenos a 80 instancias de 48 B. Lo que hay que saber antes de usarlo: - **No shapea.** Recibe un `parley::Layout` ya resuelto —el que arma `llimphi_text::Typesetter`—, así que el texto por GPU-directo y el texto por vello parten del **mismo** layout. Certificado: `llimphi-test/tests/texto_gpu_directo.rs` pone los dos caminos sobre el mismo layout y mide `Δ64:0` — ningún píxel difiere más de 64/255 — con la caja de lo pintado coincidente y 0,31 % de tinta de diferencia. - **Un color sólido por run.** El atlas guarda cobertura, no color, así que el mismo glifo cacheado sirve para cualquier color sin re-rasterizar — pero un brush (gradiente, imagen recortada al texto) no entra. Eso sigue siendo vello. - **Ni emoji ni glifos de color**: el atlas es de un canal. Pedirían un atlas RGBA y un pipeline propio. - **No hintea**, a propósito: vello rasteriza el contorno crudo, y hintear haría que el mismo texto saliera con métricas distintas por cada camino. - **No crece ni desaloja.** Lleno, devuelve `None` y suma a `fallos`. La salida es `limpiar()` y volver a llenar. Una política de desalojo sin una app que la presione se diseña a ciegas. - **Subí sólo el rect sucio.** `tomar_sucio()` + `AtlasTextura::subir` es el camino esperado; subir el atlas entero por frame anula el punto de cachear (un atlas de 1024² son 1 MiB por cuadro). **Hay DOS atlas de glifos en llimphi, y hay que saber cuál usar.** El otro es `llimphi_widget_terminal::GlyphAtlas`, anterior (Fase 4 del SDD-TERMINAL) y vivo: lo consume el modo TUI de shuma. `llimphi-glifos` se escribió sin advertirlo. Desde el 2026-08-05 **comparten rasterizador** (`llimphi_glifos::Rasterizador`, swash/zeno); antes el terminal tenía `fontdue` propio. Lo que sigue habiendo dos es el **empaquetado**, y eso es de diseño: | | `GlyphAtlas` (widget terminal) | `AtlasGlifos` (`llimphi-glifos`) | |---|---|---| | clave | `char` (charmap, sin shaping) | `(fuente, id, tamaño, subpíxel, síntesis)` | | empaquetado | grilla de celdas iguales | estantes de tamaño variable | | subpíxel | no | 4×4 | | fuentes | una, monoespaciada | cualquiera, varias por renglón | **Cuál usar:** una grilla de terminal, `GlyphAtlas` — puede asumir monoespaciado, celdas enteras y cero shaping, y esas suposiciones son parte de por qué es rápido. Cualquier otro texto, `llimphi-glifos`. **Si empaquetás a tu manera**, no escribas un tercer rasterizador: `Rasterizador::glifo(datos, ClaveGlifo::de_glifo(id, tam))` devuelve el `MapaGlifo` (cobertura + colocación) y vos lo ponés donde quieras. Y para lo que no necesita shaping hay `metricas`, `id_de_caracter` y `avance`. **La migración se hizo contra dos oráculos, no a ojo:** - `tests/metricas_fontdue.rs` — `fontdue` sobrevive como **dev-dependency** para esto: la celda sale **idéntica** en 11/12/13/14/16/18 px, y de 42 glifos comparados sólo **1** difiere (1 px de caja, el `0` a 13 px). - `tests/dos_atlas.rs` — los dos empaquetados entregan el mismo glifo. Y un tropiezo que dejó regla: la primera corrida de `dos_atlas` daba **44 %** de diferencia. No era el rasterizador — `TextBlock::simple` deja `font_family: None` y parley cae a **sans-serif**, así que comparaba dos tipografías. **Si comparás glifos, fijá la familia primero.** **¿Cuándo conviene?** Medido en metal (Iris Xe/Vulkan, `llimphi-gpu-bench` sección «Texto», 12.096 glifos): el total baja **3,6-5,0×** contra vello, y lo que importa es la *forma* — el costo del atlas es casi plano con la cantidad de texto (5,0 → 5,5 ms entre 7.200 y 12.096 glifos) mientras vello crece lineal (13,6 → 26,4 ms). Pero la mitad de **CPU** del atlas es **peor** que la de vello (~0,7×): vello apila un id y una posición por glifo, el atlas hace una búsqueda en tabla y arma un quad. Toda la ganancia viene del lado GPU. O sea: el atlas es para **mucho texto que se repinta seguido** — grilla de terminal, editor, etiquetas sobre un canvas denso. Para un rótulo suelto no compra nada y encima paga el ciclo fijo del `flush` (MSAA + resolve + composite). **El default sigue siendo vello.** ### `llimphi-image` — decode pipeline ```rust decode_bytes(&[u8]) -> Result load_path(&Path, max_bytes: u64) -> Result from_rgba8(rgba: Vec, w: u32, h: u32) -> peniko::Image // mismo decode, pero además dice de qué espacio venía (para editores): decode_bytes_con_origen(&[u8]) -> Result load_path_con_origen(&Path, max_bytes: u64) -> Result Decodificada { imagen: Image, origen: Option, convertida: bool } Decodificada::devolver_al_origen(&self, rgba: &mut [u8]) -> bool // perfiles de color (mod perfil): perfil::leer_icc(&[u8]) -> Option // matrix-shaper; None si no lo es perfil::convertir_a_srgb(&mut [u8], &Perfil) -> bool // entrada: del perfil a sRGB perfil::convertir_desde_srgb(&mut [u8], &Perfil) -> bool // salida: de sRGB al perfil perfil::matriz_a_srgb(&[f32;9]) -> [f32;9] / matriz_desde_srgb(&[f32;9]) -> Option<[f32;9]> perfil::es_identidad(&[f32;9], tol) -> bool Curva::{Lineal, Gamma(f32), Tabla(Vec), Parametrica{tipo, p}} Curva::a_lineal(v) -> f32 / Curva::desde_lineal(lin) -> f32 // una es inversa de la otra ``` Encapsula el patrón `image::ImageReader + to_rgba8 + Blob + Image::new` que cada app que decodifica imágenes tenía duplicado. `max_bytes` aplica al tamaño del archivo en disco (no a la imagen decodificada, que en RGBA8 puede ser mucho mayor — un PNG 4K ocupa ~64 MB descomprimido); `0` deshabilita el cap. Re-exporta `peniko::{Blob, Image, ImageFormat}`. Formatos según las features del crate `image` upstream del workspace (hoy: PNG, JPEG, WEBP). **Gestión de color (2026-08-05).** `decode_bytes` / `load_path` leen el perfil ICC embebido y llevan la imagen a **sRGB**. Antes se tiraba: una foto Display P3 —lo que saca cualquier teléfono desde 2016— se pintaba **sobresaturada**, porque sus rojos, que son más rojos que los de sRGB, se mostraban como si fueran los de sRGB. Nadie tiene que hacer nada: el caller sigue recibiendo RGBA8 sRGB. - Cubre perfiles **matrix-shaper** (matriz 3×3 + curva), que es lo que son sRGB, Display P3, Adobe RGB, Rec.2020 y casi todos los de pantalla y cámara. - **No** cubre los basados en LUT (`A2B0`), como los CMYK de imprenta: ahí no convierte y deja la imagen como estaba, que es mejor que inventar. - Sin perfil se asume sRGB (la convención de la web). Con perfil sRGB, la matriz da la identidad y **no se recorre un solo píxel**. - El recorte a gamut es por saturación de canal: un rojo P3 puro no existe en sRGB y termina en el rojo sRGB más cercano. **Volver al espacio de origen (2026-08-05).** Un editor no puede conformarse con recibir sRGB: si `tullpu` abre una foto Display P3 y la guarda, tiene que guardarla **en P3**, no degradar el archivo del usuario por el solo hecho de haberlo abierto. Para eso están `decode_bytes_con_origen` / `load_path_con_origen`, que devuelven una `Decodificada` con el perfil del archivo al lado de los píxeles. ```rust let d = llimphi_image::load_path_con_origen(ruta, 64 << 20)?; let mut px: Vec = d.imagen.image.data.as_ref().to_vec(); // sRGB, para editar // …edición… d.devolver_al_origen(&mut px); // false si no hay a dónde volver: se guarda sRGB ``` - `origen: None` **no** significa «era sRGB»: significa que no había perfil o que era de los que este crate no entiende (CMYK/LUT). En los dos casos los píxeles quedaron como venían y no hay a dónde volver. - `convertida: false` con `origen: Some(…)` es el caso corriente: el archivo ya venía en sRGB y no se recorrió un píxel. - **La ida y vuelta no es sin pérdida, por dos razones distintas.** La segunda cuantización a 8 bits mete ±1. Y lo que se recortó al **entrar** (un rojo P3 que en sRGB no existe) no vuelve: se perdió en el recorte, no al salir. Editar un P3 sin pérdida pide trabajar en P3; el `Perfil` expuesto es lo que lo habilita. - Quien sólo va a pintar sigue usando `decode_bytes` / `load_path` y no se entera: son las mismas funciones, que ahora delegan y tiran el perfil. ### `llimphi-text` — shaping ```rust 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 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 ```rust 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` 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) ```rust 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 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 ```rust ExternalSurface::new(device, queue) // barato de clonar (Arc interno) .upload(&rgba, w, h) // desde otro hilo/decoder/cámara .view(style) -> View // 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` (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//`, dep `llimphi-widget-`. ### 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, required, helper, error, palette })`. **text-input** — input single-line **con estado** `TextInputState` (`new()`/`masked()`, `text()`, `set_text()`, `apply_key(&KeyEvent) -> bool`, soporta undo/redo + selección con Shift). Render: `text_input_view(&state, placeholder, focused, &palette, on_focus: Msg)`. **text-area** — input **multilínea** con paridad real (mismo motor `EditorState` que el text-input; NO el viejo `String` plano). El estado es `TextInputState::multiline()` (o `llimphi_widget_text_area::new_state()`). `text_area_view(&state, placeholder, focused, rows, &palette, |ev| Msg::Area(ev))` emite `TextAreaEvent` (Key/Ime/Press(x,y)/Drag/DragEnd/Context) ya envueltos; en `update` → `state.handle_area(ev, &mut clipboard)`. Da: **click 2D** (renglón+columna por glifos reales), ↑/↓ entre renglones, selección a través de líneas (arrastre 2D, doble/triple-click, Shift+flechas), copiar/cortar/pegar, undo/redo, IME, Enter=`\n`, **soft-wrap** (la línea larga se envuelve al ancho de la caja) y **scroll vertical** (caret a la vista si hay más renglones que `rows`). Caret + selección pintados por `paint_over` en coords de renglón visual; fuente única del layout: `visual_layout` (render/hit-testing/caret/nav comparten). **↑/↓ navegan por renglón visual** (no por línea lógica: en texto envuelto bajan un renglón, no la línea entera), con columna meta en px preservada (`move_vertical_visual`; Shift extiende selección). El crate `llimphi-widget-text-area` es una fachada que re-exporta esto. Demo: `cargo run -p llimphi-widget-text-input --example area_demo`. **text-area RICO** — el mismo widget con los adornos que antes obligaban a escribirse un pintor propio. `text_area_view_rico(&state, focused, rows, &palette, &Metricas, &Adornos, wrap)`. **Si necesitás un input mono, coloreado, con fantasma o con caret animado, es ESTE, no un `paint_with` a mano.** - `Metricas { font_size, line_h, font_family, pad_x, pad_r, pad_y }` — `Metricas::mono(px)` + `.con_zoom(z)`. La usan pintado, ajuste blando y click→columna: una sola, o el caret cae entre glifos que no son los que se ven. - `Adornos { tramos, ghost, placeholder, caret, ahora_ms, marco }`. `tramos: Vec<(ini, fin, Color)>` en **bytes del texto completo** (se recortan solos a cada renglón); `ghost` = sufijo tenue tras el caret (sólo si el caret está al final); `Placeholder { texto, color, italic, icono, icono_color, alpha }`; `marco: false` = sin caja propia (para un host que ya tiene la suya — el borde del widget es un relleno **opaco** que taparía un backdrop frosted). - `CaretEstilo::vivo()` = parpadeo que queda **sólido** tras cada tecla y **estela** al moverse (el eyecandy en honor a kitty). El reloj lo trae el host: `Adornos::ahora_ms` por cuadro + `state.marcar_actividad(ms)` en cada tecla — el widget no llama a `now()` por dentro (sería intesteable, y un `bool` de fase alternado por un timer externo se muestrea mal cuando el panel repinta lento). - Consultas para un host que **crece con el contenido**: `renglones_visuales()`, `renglones_visuales_de(txt)`, `avance_en_renglon()`, `ancho_caracter()`, `fijar_ancho_caja(px)` (sembrar el ancho antes del primer cuadro, o testear el wrap sin GPU). `text_area_view` es esta misma vista con `Adornos::default()` — un solo renderizador. Uso canónico: el input de la barra de shuma (`02_ruway/shuma/sandbox/shuma-module-shell/src/view/input.rs`), que le pasa los tokens del shell como `tramos` y monta su propio marco con `marco: false`. **slider** — sin estado. `slider_view(label, value, min, max, &palette, on_change: Fn(DragPhase, delta_value) -> Option)`. 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, 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`, `step_active(items, visible, current, ±1)` para nav, `resolve(visible, pos) -> Option`. **progress** — `linear_progress_view(progress, track, fill, height)` y `radial_progress_view(progress, track, fill, stroke_ratio)`. Sin eventos. **spinner** — `spinner_view(color, stroke_ratio, speed_rev_per_sec)`. Animado por reloj absoluto; requiere redraws periódicos (`spawn_periodic`). **rive-button** — botón reactivo dirigido por **máquina de estados** (`llimphi-anim`, estilo Rive) — primer consumidor real de la `StateMachine` fuera del studio. **Stateful** (como `text-editor`): guardas `RiveButton` en tu Model. Tres estados `idle`/`hover`/`press`, cada uno con su clip Lottie; hover por bool (listeners Enter/Exit del motor), press por trigger (`ClipDone` lo devuelve solo). API: `RiveButton::new(idle, hover, press, press_secs, blend_secs)` o `::builtin()` (clips embebidos); `advance(dt)` desde un tick, `pointer(Option<(f64,f64)>)`, `press()` desde el `on_click` del host, `current_state()`/`is_transitioning()`. Render: `view(on_pointer: Fn(Option<(f64,f64)>)->Msg, on_click: Msg)`. Demo: `cargo run -p llimphi-widget-rive-button --example rive_button_demo --release`. **badge** — `count_badge_view(count, BadgeKind)` ("99+" si ≥100) y `dot_badge_view(BadgeKind)`. `BadgeKind::{Info,Success,Warning,Error,Neutral}`. **avatar** — `avatar_view(name, size_px)`: círculo determinista (color por hash del nombre + inicial). **tooltip** — render puro. `tooltip_view(TooltipSpec { anchor, viewport, side: Side, text, palette })`. Se monta en `view_overlay`; la app controla visibilidad con `on_pointer_enter/leave`. **empty** — empty-state. `empty_view(Icon, title, description: Option<&str>, &palette)`. **skeleton** — placeholder con shimmer: una banda de gradiente `[low, high, low]` que **cruza** el rect. `skeleton_view`, `skeleton_box_view(w,h,..)`, `skeleton_line_view(w,..)`. Requiere redraws periódicos (`spawn_periodic(50ms)` mientras haya skeletons visibles). Esas tres arrancan reloj propio y están gateadas a `std`; `skeleton_view_en(inicio, ahora, &pal)` recibe el instante por argumento y es la que sirve **sin `std`** (jaula/kernel), misma doctrina que el `today` del calendar. La aritmética es pura y testeable: `banda_shimmer(ancho, progreso) -> (izq, der)` —la banda entra y sale entera del rect, con piso de ancho para que en un avatar chico el destello no sea un píxel— y `progreso_shimmer(segundos) -> [0,1)`. **carousel** — pager paginado de N páginas. `carousel_view(CarouselSpec { pages, current, wrap: CarouselWrap::{Wrap, Clamp}, show_arrows, palette, on_change: Fn(usize) -> Msg })`. Dots indicadores abajo (clickeables para saltar a la página i) + flechas opcionales `‹` / `›` a los costados. v1 sin swipe; para v2 sumar swipe horizontal usando `draggable_velocity` + `fling_step` para snap-on-release. **fitted-box** — escala un subárbol al slot del padre. `fitted_box((iw, ih), BoxFit::{Contain, Cover, Fill, None, ScaleDown}, || inner_view())` aplica un transform afín al inner (medido por el seam `LayoutBuilder`) para que entre en el slot real, centrado, preservando aspect salvo `Fill`. Útil para imágenes, canvases `paint_with` y texto grande que deben caber en celdas chicas sin que el caller los re-mida. La función pura `compute_fit(slot, inner, fit) -> (sx, sy, dx, dy)` es testeable. **calendar** — vista mensual 6×7 (`calendar_view(CalendarSpec { view_year, view_month, selected, today, week_start, palette, on_select: Fn(NaiveDate)->Msg, on_view_change: Fn(year, month)->Msg })`). Header con `< Mes Año >` para navegar entre meses, fila de iniciales (`L M M J V S D` ó `D L M M J V S` según `WeekStart`), grilla siempre de 6 filas (no reflowea al cambiar de mes). El caller le inyecta `today` — el widget no toca el reloj. Base del date-picker: combinar con `view_overlay` + un `field`/`button` disparador. `calendar_view_ext(spec, CalendarOpciones { limites, rango, navegacion_anual })` suma lo que un `` sí tiene, sin tocar a quien ya usa `calendar_view` (el `Default` de las opciones **es** el calendario de siempre): `Limites{min,max}` inclusive apaga los días fuera de rango (`aria_disabled`, sin click) y las flechas cuyo mes destino esté entero afuera (`mes_habil`); `RangoSeleccion` pinta la banda entre dos extremos y avanza sola con `.click(fecha)` (1º fija inicio, 2º cierra **ordenando**, 3º empieza de nuevo; dos clicks al mismo día = rango de un día), con `contiene`/`es_extremo`/`dias`/ `recortado(&limites)` —que **interseca**, no clampea cada punta— para el host; `navegacion_anual` agrega una fila `« año »` arriba de la de mes (fila aparte y no cuatro flechas en una: el ancho lo fija la grilla y «septiembre» no entraría). Teclado: `cal_move_desde_tecla(&KeyEvent) -> Option` es el mapa del picker nativo (flechas = día/semana, PageUp/Down = mes, **Ctrl**+PageUp/Down = año, Home/End = bordes del mes) y `nav_date_limitada(fecha, mov, &limites)` aterriza en el borde en vez de salirse. **accordion** — secciones plegables (expansion panels). `accordion_view(AccordionSpec{secciones, abiertas: &[usize], modo, on_toggle, palette})` con `Modo::{Una, Varias}`; la máquina de qué queda abierto es `alternar(&abiertas, i, modo) -> Vec` (pura) y `todas(n, modo)` da el «expandir/plegar todo» — en `Modo::Una` devuelve **una**, porque devolver todas dejaría el `Model` en un estado que el propio modo prohíbe. El cuerpo llega como `Fn() -> View` y **no se construye si la sección está cerrada**: en un acordeón de veinte secciones con formularios adentro, armar los diecinueve invisibles es el costo real. `Seccion::con_resumen("Visa ••4242")` muestra el dato a la derecha **sólo cuando está cerrada**. **stepper** — asistente por pasos. `stepper_view(StepperSpec{pasos: &[Paso], actual, cuerpo, on_ir, on_finalizar, palette})`; `Paso::{pendiente, actual, hecho, con_error}`. La regla de navegación es pura: `puede_ir_a(pasos, actual, destino)` —atrás siempre, adelante sólo si el actual está `Hecho`, de un salto sólo si **todos** los intermedios lo están—, más `siguiente`/`anterior`/ `es_ultimo`/`hechos`. En el último paso el botón dice **Finalizar** y emite otro `Msg`. Los botones del pie y los pasos inalcanzables se **apagan** con `aria_disabled`, no desaparecen ni quedan mudos. **form** — **validación** de formularios sobre el `field`. Tres piezas, y la tercera es la que se hace mal en todos lados: ```rust Formulario::new(vec![ // qué se pide CampoDef::new(Id::Correo, "Correo").reglas(vec![Regla::Requerido, Regla::Correo]), CampoDef::new(Id::Edad, "Edad").regla(Regla::Rango(18.0, 120.0)).ayuda("…"), ]) form.validar(&[(Id::Correo, &m.correo), …]) -> Errores // qué está mal: TODOS estado.tocar(id) · estado.enviar() · estado.visibles(&errores) // cuándo se dice campo_view(&form, id, control, &visibles, &pal) // etiqueta + asterisco + error resumen_errores_view(&visibles, Msg::IrA, &pal) -> Option ``` `Regla::{Requerido, LargoMin, LargoMax, Numero, Rango, Correo, Propia(fn)}`. Un valor **vacío pasa todas menos `Requerido`** (si no, un campo opcional en blanco se queja de formato). El error se muestra al **salir** del campo o al **intentar enviar**, nunca mientras se tipea por primera vez; tras el submit se muestran todos, porque esconder alguno sería mentir sobre por qué no se envió. `campo_view` saca del formulario la etiqueta, el **asterisco de obligatorio** (derivado de las reglas, no de un flag aparte) y el error: los tres dejan de escribirse dos veces. `resumen_errores_view` devuelve `None` si no hay nada que decir, y cada ítem es clickeable para ir al campo — en un formulario largo el error puede quedar fuera de pantalla y sólo se ve que el botón no hizo nada. **datetime-picker** — pickers de **fecha** y **hora**, los dos con la misma forma: un `*_trigger` en el formulario (muestra el valor formateado o el placeholder, emite `Msg` al click) y un `*_popup` que el host monta en `App::view_overlay` con scrim que cierra al click-fuera. Sin estado propio: el valor, si está abierto y el mes en foco viven en el `Model`. ```rust date_picker_trigger(DateTrigger{valor, formato, placeholder, enabled, on_open, palette}) date_picker_popup(DatePopup{anchor, viewport, valor, view:(año,mes), today, week_start, opciones: CalendarOpciones, on_pick, on_view_change, on_dismiss, on_clear, acciones: Acciones::hoy_y_limpiar(), palette, theme}) time_picker_trigger(TimeTrigger{..}) · time_picker_popup(TimePopup{.., paso_minutos}) formatear_fecha(d, FormatoFecha::{Iso,DiaMesAnio,Largo}) · parsear_fecha(&str) Hora{h,m}: formatear/parsear (FormatoHora::{H24,H12}) · mover(campo,±1,paso) · al_paso ``` El de fecha **compone el `calendar`**, así que hereda límites min/max, rango, salto de año y mapa de teclas — no reimplementa la grilla. El de hora es grilla de horas + minutos por paso: elegir la hora **conserva** el minuto y viceversa. `parsear_fecha` acepta ISO y día-primero con `-`, `/` o `.`, y **rechaza** el año de dos dígitos (adivinar en una fecha es peor que devolver `None`). `Hora` resuelve el caso que todos yerran: medianoche es 12 AM y mediodía 12 PM, no 0. **banner** — tira de status. `banner_view(BannerKind::{Info,Success,Warning,Error}, message)`. ### Contenedores y layout **panel** — chrome (gradiente + hairline accent). `panel_view(children, PanelStyle)`; `PanelStyle::{from_theme, from_theme_large, neutral}`. `panel_signature_painter(style)` para reusar el look en un `paint_with`. **card** — `card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette)`. **stat-card** — métrica de dashboard. `stat_card_view(label, value, description, accent, &recent_items, &palette)`. **tabs** — `tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg, content: View, tab_height, palette, tab_width })`. Selección la maneja la app. `tabs_view_sliding(spec, indicator_t)` cambia el acento por-tab (que se prende de golpe) por un **realce único que se desliza** hasta `indicator_t` — el índice animado que el host tween-ea con `llimphi-motion` hacia el activo. Como cada tab mide lo que dice su label, interpola **posición y ancho** a la vez (`indicator_bounds_px(&anchos, t)`; los anchos con `tab_widths(&labels, tab_width, closable)`, la misma cuenta que el render y que `strip_width`), así se estira al ir hacia un tab más largo. Con overflow viaja dentro de la tira, o sea que el `strip_offset` lo arrastra solo. `indicator_t = active as f32` da la posición estática, para un host que no quiera animar. **router** — navegación por **pila de rutas** con transiciones de página (el `Navigator` de Flutter, a la Elm). La pila vive en el Model; el widget pinta sólo la ruta actual y anima el cambio con la infra de ghosts del compositor (`animated_switch_styled`). Push entra desde la derecha y lo viejo retrocede 30 % (shared-axis X); pop lo inverso; `Zoom` = fade-through; `Cover` = **push opaco estilo UIKit** (la nueva tapa sin fade; el pop desliza la vieja por encima revelando — scene-split de fantasmas: el saliente se pinta DEBAJO del entrante, en su posición z real); `None` = corte seco sin captura. Los **hero** cruzan rutas gratis (misma key en ambas páginas). ```rust // Model: nav: RouteStack (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) // 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, &palette)`. `PaneSize::{Fixed(px), Flex}`. La app acumula el delta en su Model (con `apply_resize(actual, delta, min, max)`, que clampea por paso). `splitter_two_ext(…, SplitterOpciones { on_toggle, focus_id })` agrega el **doble-click** sobre el divisor y lo hace **enfocable**, que es la puerta del resize por teclado: con el foco puesto, el host traduce la tecla con `ajuste_desde_tecla(&ev, direction) -> Option` (las flechas del eje del divisor —←/→ en `Row`, ↑/↓ en `Column`—, `Shift` para el paso grande, Home/End a los topes, Enter/Espacio para alternar) y la aplica con `aplicar_ajuste`. El colapso lo resuelve `alternar_colapso(actual, recordado, por_defecto) -> (nuevo, a_recordar)`: guarda **cuánto** medía, no que estaba abierto, así restaurar devuelve el layout del usuario y no un ancho de fábrica. **scroll** — área de scroll vertical con barra arrastrable. `scroll_y(offset, content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette)`. Stateless (offset en el Model); rueda autocontenida. Helpers: `clamp_offset`, `ensure_visible` (selección a la vista), `approach` (scroll suave). Ver §8. Para darle scroll al cuerpo de un widget de filas está la pieza **compartida** —la usan `list`, `tree` y `detail-table`, y por eso vive acá y no copiada tres veces—: `cuerpo_scrolleable(contenido, contenido_len, DesplazamientoV{offset, viewport_len, on_scroll}, medidor: Option, &palette)`. `AltoVisible` publica el alto **medido** desde el pintado (el alto disponible no existe hasta que taffy repartió) y `offset_efectivo(offset, viewport_len)` pinta desde arriba mientras no haya medida — `clamp_offset` con viewport `0` da por bueno cualquier offset, así que sin esto una lista re-montada con offset guardado arranca desplazada y salta al cuadro siguiente. **lazy-list** — lista **perezosa** llave-en-mano (Bloque 22 de `PLAN-NUCLEO-RETENIDO.md`). A diferencia de `list`/`grid` (el caller windowea a mano) y de `scroll_y` (recibe el contenido ya construido), recibe un **builder** `item: Fn(usize) -> View` + `total` y **sólo llama `item(i)` para la ventana visible + overscan** — las filas fuera de pantalla no se construyen, no se montan, no se layoutean (costo/frame ∝ lo visible, no ∝ el total). Sin estado (offset en el Model); reusa la barra/física de `scroll`. ```rust lazy_list_fixed(total, item_h, offset, viewport_h, overscan, item: Fn(usize)->View, on_scroll: Fn(f32)->Msg, &ScrollPalette) // extent uniforme, ventana O(1) lazy_list_varied(offsets: &[f32] /*len total+1, tops acumulados*/, offset, viewport_h, overscan, item, on_scroll, &palette) // extent por fila, O(log n) // extent CALCULABLE barato (el sabor a preferir para listas grandes de alto variado): IndiceExtent::nuevo(total, extent: Fn(usize)->f32) // índice por bloques de BLOQUE_EXTENT (1024) lazy_list_extent(&indice, extent, offset, viewport_h, overscan, item, on_scroll, &palette) // guarda 1 float cada 1024 filas en vez de 1 por fila (1M filas: 978 floats ≈ 3,9 KB // contra 4 MB) y NUNCA construye un View para medir. Consulta = binaria + ≤1 bloque. // helpers puros: visible_window(total, item_h, offset, vp, overscan) -> Window{first,count,y0} // visible_window_varied(offsets, offset, vp, overscan), content_height(total, item_h) // visible_window_extent(&indice, &extent, offset, vp, overscan) // IndiceExtent::{y, indice_en, alto_total, len_indice} // altura MEDIDA (listas heterogéneas cuyo alto depende del contenido/reflow): item_offsets(total, ancho, item: Fn(usize)->View) -> Vec // 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, 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` (drag-to-swap por la title bar). `TileSpec { label, content }`. **panes** — árbol binario BSP tipo tmux. La app guarda un `Layout`: ```rust 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,DragPhase,delta)->Option, 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`. ```rust DockRailItem { id: u64, active: bool } dock_rail_view(&items, width: f32, &DockRailPalette, make_icon: Fn(id, size_px, color) -> View, on_activate: Fn(id) -> Msg, // dispara en el press, no pelea con el drag on_drop: Fn(payload_id) -> Option) // None = el rail no acepta drops ``` Layout canónico: el rail va como **overlay absoluto** pegado al borde interno del centro (los dientes sobresalen sobre la rueda/canvas), y el panel del item activo va al costado como un pane en el área resizable. NO es una lista con rótulo: leer cosmos antes de inventar UX (lección 2026-06-05). **fugaces** — la **banda de controles fantasma**: más acciones y estados que píxeles, resueltos por *voz* en vez de por espacio. No pinta íconos ni sabe qué representan — es la **política**, y el caller pone la identidad (trait `Fantasma`: una clave estable para persistir el uso y un `prior` de asiento) y el dibujo de cada cara. Cada fantasma declara por cuadro su [`Nivel`]: `Nada` (sólo en el reveal — acciones puras) · `Ambiente` (mirable, turno lento y al 55 % de alfa) · `Leve` (estado que dura; se turna cada 4 s) · `Evento` (acuse de segundos: **no espera turno**) · `Critica` (sale fija, y todas juntas). En reposo se ve una sola cosa; al acercar el puntero salen todas. Las tres reglas que no son obvias, y que ya costaron reportes: el **orden se congela** bajo el mouse (un click aprende, pero el asiento nuevo rige recién al esfumarse el reveal); la **membresía** también (el que llega a mitad del hover no se inserta, y el que se va deja un **hueco de su ancho**); y el asiento sale de `prior + 2·√usos`, que **satura** — los primeros clicks mandan y después el orden queda quieto, que es lo que permite memorizarlo. Dos consumidores, dos formas del mismo patrón: `pata` lo monta como overlay a la derecha del cabezal de shuma (estados del sistema), y `nahual-shell` como banda al borde derecho de la barra de herramientas (`nahual-shell-llimphi::fugaces`), donde **la acción aparece cuando el contexto la vuelve posible** — marcás dos archivos y sale *cotejar*. ```rust trait Fantasma { fn clave(self) -> &'static str; fn prior(self) -> f32; } elegir_visibles(&[(F, Nivel)], idx, pin) -> Vec // qué se ve sin reveal avanzar_idx(&mut idx, &mut reloj, ahora_us, congelar) -> bool orden_asientos(&todos, usos: Fn(F) -> u32) -> Vec congelar(&todos, &cands, usos, idx, pin, ahora_us) -> Freeze slots(cands: Vec<(F, Nivel, View)>, &Franja, usos, ancho_slot) -> Vec> Uso::abrir(Option) / .bump(f) / .usos(f) // feature `persistencia` ``` El contenedor es del caller: `slots` devuelve la fila lista, no una `View` — la franja caliente, el ancla y el `hover_fill` los pone quien la hospeda. **toolbar** — barra de herramientas moderna: **grupos** de botones-ícono planos (hover redondeado; activo = fondo de selección + ícono en acento; deshabilitado atenuado y sin click) con separadores sutiles entre grupos. Los íconos los dibuja el caller vía closure (mismo contrato que dock-rail); los grupos son **datos** (`Vec`) → la barra es componible y configurable por construcción. Uso canónico: `nahual-shell-llimphi::shell_toolbar` (navegación + modos de vista + acciones). ```rust ToolbarItem::new(icon: Fn(size_px, color) -> View, on_click: Msg) .with_label("subir").active(bool).enabled(bool) ToolbarGroup::new(vec![items...]) toolbar_view(groups: Vec>, height: f32, &ToolbarPalette) -> View // Overflow + tooltips (aditivo; Default = la barra de siempre): let medidor = AnchoBarra::new(); // se lee el cuadro siguiente let anchos = item_widths(&groups); // misma cuenta que el render let visibles = overflow_count(&anchos, medidor.px(), MORE_W, ITEM_GAP); let ocultos = items_ocultos(&groups, visibles); // para armar el popup toolbar_view_ext(groups, 36.0, &pal, ToolbarOpciones { medidor: Some(medidor), visibles: Some(visibles), on_overflow: Some(Msg::AbrirMas), // dibuja «⋯» sólo si sobra algo on_hover: Some(Arc::new(|i| Msg::Tooltip(i))), // Some(i) al entrar, None al salir }) ``` El **ancho disponible** no se sabe hasta que taffy repartió, así que `AnchoBarra` lo publica desde el pintado y el host lo usa en el cuadro siguiente (un frame de latencia en el primero y en el resize — lo mismo que hace cualquier toolbar con overflow). `item_offsets(&groups)` da `(left, width)` de cada ítem contando padding, gaps **y separadores**: es el ancla del `tooltip` (que es render puro y lo monta el host en su `view_overlay`). El menú de overflow también lo monta el host, con `context_menu_view`; `items_ocultos` le da label/estado/`Msg` en orden para que el índice del `on_pick` no se desincronice. **grid** — grilla 2D virtualizada. `ventana_visible(total, vp_w, vp_h, scroll_fila, &metrics) -> VisibleWindow` para virtualizar, luego `grid_view(GridSpec { cells: Vec, cols, metrics, caption, ... })`. **list** — lista vertical virtualizada. `list_view(ListSpec { rows: Vec, total, caption, truncated_hint, row_height, palette })`. La app prefiltra las filas visibles. `list_view_ext(spec, ListOpciones{scroll, medidor})` le da **scroll real** (no sólo `clip`): las filas se desplazan y tienen barra arrastrable, mientras el `caption` y el aviso de truncado quedan fijos — son rótulos de la lista, no contenido de ella. **tree** — árbol expand/collapse. `tree_view(TreeSpec { rows: Vec, row_height, indent_px, palette })`. La app aplana el árbol según nodos expandidos. `tree_view_ext(spec, TreeOpciones{scroll, medidor})` le da el mismo scroll real que la `list`. Cada fila acepta además `icon` (un `View`), `color_label` (teñir por tipo/estado), `trailing`, `on_context` y `on_double`. `tree_view_2d(spec, Vista2D{offset, viewport, ancho_contenido, on_scroll, medidor, fijadas})` agrega lo que un árbol de proyecto necesita y una lista indentada no: **rueda en los dos ejes** (un nombre anidado no entra en un panel angosto y el `…` no lo hace legible) y **filas fijadas** — `FilaFijada{fila, y_natural, huerfana}` se apila contra el borde por el que salió del viewport (`reparto_fijadas`, `cupo_de_pila` = un tercio del alto), y una `huerfana` (su carpeta está plegada, no tiene fila en el cuerpo) se muestra igual. El ancho del contenido se estima con `ancho_de_filas`. El uso canónico es `nada::arbol`, que es el mismo árbol para el editor suelto y para el panel del anfitrión. **navigator** — navegador data-agnóstico de nodos en dos modos conmutables (**árbol** ↔ **grafo**, reusa tree + nodegraph). Render-only: la app guarda `expanded`/`selected`/`mode`. Pasa un bosque de `NavNode { id: u64, label, kind: NavKind (Monad|Group|Dir|File|Other), children }` y callbacks por id. ```rust 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: OptionMsg>) // á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>, &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, buttons: Vec, 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::{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 `Wire`s). ```rust NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec, outputs: Vec } 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, on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option) // + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable) // + wire_dash / edge_dash en NodegraphSpec: predicado por cable/arista → trazo // PUNTEADO. Para la distinción que el color no lleva solo (lo sabido vs lo // supuesto): soltar pinta punteada la clave foránea que sólo se ve en el dato // y sólida la que una persona declaró. // + nodegraph_view_spec(NodegraphSpec{..}) — el editor real: cámara (pan/zoom), // selección, aristas sin pins, y CUERPO EMBEBIDO por nodo. ``` **Un nodo puede contener algo** (2026-08-17). `NodegraphSpec.cuerpo` es `Option<&dyn Fn(NodeId, zoom) -> Option>>`, y `CuerpoNodo` es `{ alto: f32, vista: View }` — el alto en coordenadas de canvas, que el widget escala por el zoom. El cuerpo se dibuja **debajo de la fila de pins**, así que las posiciones de los pins no se mueven y los cables siguen enganchando donde `precompute_wires` los calcula. `None` = el nodo clásico. Para qué: `krama` pinta adentro del nodo el valor con su unidad, la perilla que se arrastra y la curva de `pineal-cartesian`; `tullpu` podría poner el preview del ajuste, `takiy` la onda de su eslabón, `pluma-notebook` el output de la celda. **No va en `NodeSpec`** (que es `Debug + Clone` y no genérico en `Msg`): una `View` adentro lo volvería genérico y rompería sus 12 sitios de construcción. Dos cosas medidas que conviene saber: `fit_to_view` **no** conoce los cuerpos (encuadra sobre la altura de pins, así que queda corto por abajo con nodos altos), y `node_height` suma 6 px fijos que `scaled()` no escala — el nodo entero no se duplica exactamente al duplicar el zoom. **timeline** — scrub clickeable. `timeline_view(progress: f32, &palette, on_seek: Fn(f32 [0..1])->Option)`. **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. ```rust 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 ``` Para una fila, el caller mapea su `Vec` 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) -> 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"). ```rust 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) -> u16 + Send + Sync + 'static, palette: &WaveformPalette) -> View ``` 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. ```rust table_view(&[String], headers, // [] = sin header &[Vec] rows, focused: Option<(row, col)>, focused_state: Option<&TextInputState>, // buffer prestado de la celda en edición add_label: &str, &TablePalette, on_focus_cell: Fn(row, col) -> Msg, // clic en celda on_remove_row: Fn(row) -> Msg, on_add_row: Fn() -> Msg) list_view(&[String], items, focused_row, focused_state, // sola columna sin header add_label, &palette, on_focus_cell: Fn(row)->Msg, on_remove_row, on_add_row) table_height(n_rows, has_header) -> f32 // pre-calcular alto del bloque ``` La variante `list_view` no se confunde con la `list` virtualizada de §13: ésta es **una sola columna editable** que delega en `table_view([], ..)`. **detail-table** — la vista *detalle* de un file manager: grilla read-only con columnas `ColWidth::{Flex(peso), Fixed(px)}` y encabezados que ordenan (▲/▼). `detail_table_view(DetailSpec{columns, rows, sort, row_height, caption, palette}, on_sort)`; `detail_table_view_dnd(..)` agrega arrastre por fila. `detail_table_view_ext(spec, on_sort, DetailOpciones{scroll, medidor, on_resize})` suma **encabezado fijo con cuerpo scrolleable** y los **handles de resize** entre columnas: ```rust DetailScroll { offset, viewport_h, on_scroll: Fn(f32)->Msg } // compone scroll_y AltoCuerpo::new() // publica el alto medido del cuerpo (post-layout) offset_efectivo(offset, viewport_h) // sin medir todavía ⇒ se pinta desde arriba resize_col(ancho, delta, min) // el host aplica el delta del handle ``` El «sticky» no es un truco de posición: con `scroll` el encabezado queda **afuera** del cuerpo desplazado, así que no viaja. El cuerpo se compone del `scroll` canónico (rueda, barra, clamp y fling ya viven ahí). El handle lleva `draggable` y **no** `on_click`, así agarrarlo no dispara el orden de la columna que tiene debajo — que es el bug clásico de esta UI. **Es también la DataTable** read-only ordenable y paginable — no hay una tabla aparte para eso. Lo que faltaba y ya está: ```rust ciclar_orden(sort_actual, col) -> Option<(col, SortDir)> // asc → desc → SIN orden orden_de_filas(&celdas, col, dir, &TipoCol) -> Vec // permutación estable TipoCol::{Texto, Numero, Natural, Propio(fn)} // «archivo2» < «archivo10» Pagina{indice, tamano}: rango(total) · total_paginas · acotada(total) · etiqueta botones_pagina(actual, total, max) -> Vec // 1 … 4 5 6 … 20 pager_view(pagina, total_filas, Fn(usize)->Msg, &palette) ``` `orden_de_filas` devuelve **índices**, no filas movidas: así ordena sin conocer el tipo del caller (una `DetailRow` lleva `Msg` y `View`) y sin perder el orden original, que es al que vuelve el tercer click. `Numero` manda al **final** lo que no parsea (un «—» encabezando no es información) y no hay variante `Fecha`: en ISO ordena bien como `Texto`, y para otros formatos está `Propio` — un parser de fechas acá sería un segundo parser distinto del del `datetime-picker`. **text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda `EditorState`: ```rust 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 text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer) text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer) text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer) syntax_palette_dark(&theme) -> SyntaxPalette // en lib.rs del widget ``` **text-editor-core** — núcleo **agnóstico** (sin GPU, sin Llimphi; sólo `peniko::Color`). Reutilizable en TUI/web/headless. Tipos clave: - `Buffer` (sobre `ropey`): `from_str`, `text`, `insert(offset,s)`, `delete(s,e)`, `offset_to_pos`, `pos_to_offset`, `slice`, `line(n)`. - `Pos { line, col }`, `Selection { anchor, caret }`, `Cursor { caret, anchor: Option, desired_col }` con `move_left/right/up/down/word_left/...`, `selection_range(&buf)`, `collapse`. - Ops: `replace_selection`, `delete_backward/forward`, `indent_or_insert_tab`, `insert_newline_auto_indent` → devuelven `EditDelta { start, removed, inserted, cursor_before, cursor_after }` con `.apply()/.undo()`. - `UndoStack`: `push(delta)`, `undo/redo(&mut buf, &mut cursor) -> bool`, `can_undo/redo`. - `FindState { query, case_sensitive }`: `all_matches`, `find_next`, `find_prev`. - Matching de brackets: `find_bracket_pair(&buf, &cursor) -> Option<(Pos, Pos)>`, `Direction`. - `Clipboard` (trait `get/set`), `MemClipboard`, `NullClipboard`. - `Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity, message: String, source: Option }` (+ ctors `error(..)`, `warning(..)`); `Severity::{Error, Warning, Information, Hint}`. - Highlight tree-sitter: `Language::{Plain, Rust, Python, Wat}` (+ `Language::from_cell_language(s)`); `Highlighter::new(lang)` con `.highlight(&mut self, source: &str) -> Vec>` (un `Vec` **por línea**), `.set_language(lang)`, `.language()`; helpers de módulo `invalidate_tree_cache(lang)` y `apply_pending_edits(lang, &edits)` para el caché incremental. `TokenKind`, `Span`, `SyntaxPalette::color(kind)`. **text-editor-lsp** — cliente LSP por stdin/stdout. `trait LspClient` (fire-and-forget `request_*` + lecturas de caché `latest_*`/`clear_*`): completions, hover, definition, references, rename, formatting, signature help, document symbols. `RustAnalyzerClient::start(workspace_root)`; `NoopLspClient` para tests. **terminal** — superficie de **scrollback infinita virtualizada** (modo línea / bloques / grilla GPU). Reemplaza al `output_pane` clásico: el costo de render es **constante** a scrollback ilimitado — sólo se materializa la ventana visible — bajo un `scroll_y` **propio del widget** (no `transform` del padre, la anti-feature del SDD). Núcleo agnóstico, el caller arma el modelo y el widget pinta (Regla 2). Diseño completo: `02_ruway/shuma/SDD-TERMINAL.md`. Consumidor de referencia: `shuma-module-shell::output_pane_surface`. - **Store** (`Scrollback::new(limit_bytes)`): append-only por línea con índice de offsets O(1), cap por memoria con recorte de frente, `line_id` global **estable** que sobrevive al recorte (`index_of_id`), `push_line`, `slice_text`, `clear`. `dropped()` / `total_pushed()` para el `numbering` global. - **Spill a disco** (`SpillStore::create(path)` + `Scrollback::enable_spill(spill)`): cada línea recortada del frente se appendea al archive; lookup O(1) con `read_spilled(global_id)` + helpers `has_spill`/`spilled_count`/`spill_path` para que el host exponga UX (chip de status, builtin `:scrollback open`, prepend del archive al view). - **Modo línea** (`line_surface(store, scroll_y, viewport_h, metrics, &palette, line_style: Fn(usize, &str) -> LineStyle, on_scroll: Fn(f32) -> Msg, measure: Option>>)`): materializa sólo la ventana visible; scroll sub-renglón (`partial_px`), numeración global 1-based, color base + runs + tinte de fondo inyectados por el caller en `LineStyle`. Helpers puros: `visible_window`, `content_height`, `scroll_to_bottom`. `measure` opcional publica el `viewport_h` real medido al host (regla del SDD: verificar con viewport real). - **Modo bloques** (`block_surface(store, items: Vec>, ...)`): el stream es una secuencia de `Item::chrome(height, view)` (header/badge/etapa de alto fijo que el caller pinta) + `Item::lines(start, end)` (rango del store). Virtualiza sobre alturas mixtas con búsqueda binaria (O(log n) en bloques) — dentro de un `Lines` enorme sólo se materializan las sub-filas visibles. Colapsar = no emitir el `Lines`. Helpers: `blocks_height`, `blocks_scroll_to_bottom`, `gutter_width(&store, metrics)`, `line_top_in_content(&items_geo, row_h, target_line)`. - **Selección + copy** (`block_surface_with_selection` + `SelectionConfig { range: Option<&SelectionRange>, on_drag: Fn(DragPhase, lx0, ly0, dx, dy) -> Option, on_double_click: Fn(lx, ly, w, h) -> Option }`): el widget pinta el overlay translúcido y dispara callbacks; el caller mantiene el rango (`SelectionRange { anchor: Point, head: Point }`, `Point { line, col }` con `col` en **bytes** UTF-8) en su Model y resuelve clicks con `point_at(items, scroll_y, viewport_h, metrics, gutter_w, store, lx, ly)` —o su variante `Copy`-stashable `point_at_geo(&[ItemGeo], ...)` para resolver el frame anterior. `selection_rects` para overlays custom. - **Find** (`find_matches(&store, query, FindOpts { case_insensitive })`): búsqueda sobre el scrollback **completo** (no sólo el viewport), O(total_bytes); `FindMatch { line, start, end }` con `start`/`end` en bytes UTF-8 slice-safe. Nav: `next_match(&matches, current)` / `prev_match`. El caller resuelve el `line` a su `scroll_y` con `line_top_in_content`. - **Grilla GPU** (modo TUI, Fase 4): `CellPipeline::new(device, format)` + `GlyphAtlas` rasterizan cada celda con **quads instanciados** y atlas de glifos (precedente: `atlas` de wawa, Fontdue) — una sola draw call por viewport, escala a 200×80 sin penalty. Tipos: `CellInstance { cell_x/y, uv_x/y/w/h, fg_rgba, bg_rgba }` (POD 32 B), `CellUniforms`, `pack_rgba`, `instances_to_bytes`, `CELL_WGSL`, `CellPipeline::create_atlas_texture`. Wireado al widget contenedor vía `gpu_paint_with`. Activo en shuma con `SHUMA_GPU_GRID=1`. Headless dumps: ```bash 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, theme, viewport, height, on_open: Fn(Option)->Msg, on_command: Fn(&str)->Msg })`; dropdown en `view_overlay` con `menubar_overlay(spec)` o `menubar_overlay_animated(spec, active, appear)`. Navegación por teclado: `menubar_nav`, `menubar_command_at`. **edit-menu** — menú estándar de edición sobre un editor. `EditFlags::from_editor(&state, masked)`, `edit_context_menu(anchor, viewport, &theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss)` → `ContextMenuSpec`. `apply(&mut state, EditAction, &mut clipboard) -> ApplyResult`. `EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}`. **context-menu** — menú contextual genérico (panel elevado con sombra + borde + esquinas redondeadas). `ContextMenuItem::action(label).with_shortcut(..).icon(..) .checked(bool).disabled().destructive().submenu(children)` o `::separator()` (`checked` pinta ✓ en el gutter y alterna estado). `context_menu_view(ContextMenuSpec { anchor, viewport, header, items, active, on_pick: Fn(usize)->Msg, on_dismiss, palette })`; `context_menu_view_ex` con submenús (flyout que **flipea** a la izq. si no cabe) + animación de aparición. Se monta en `view_overlay` con scrim. **Teclado (helpers puros que el host cablea):** `step_active(items, cur, ±1)` (↑/↓, saltea sep/disabled), `edge_active(items, to_end)` (Inicio/Fin), `type_ahead(items, cur, ch)` (mnemónico por 1ª letra). Submenú por teclado = `step_active` sobre `parent.children`. Demo completo: `cargo run -p llimphi-widget-context-menu --example menu_demo`. **theme-switcher** — `theme_switcher_view(¤t: &Theme, on_change: Fn(Theme)->Msg)` (+ `_styled`/`_flex`). Cicla `Theme::next_after`. **shortcuts-help** — overlay "?" con atajos agrupados. `shortcuts_help_view( ShortcutsHelpSpec { title, groups: Vec }>, 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 + open_shortcut(&KeyEvent) -> bool + view(state, ..., to_host: F) -> View + Palette ``` La app guarda `Option` (o el state directo, p. ej. bookmarks), rutea el atajo de apertura con `open_shortcut`, rutea teclas con `on_key`, aplica `Msg`s 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//`. | Módulo | Atajo | Acción que devuelve | Propósito | |---|---|---|---| | **command-palette** | `Ctrl+Shift+P` | `Invoke(String)` | paleta de comandos fuzzy. El host declara `&[Command]` | | **file-picker** | `Ctrl+P` | `Open(PathBuf)` | fuzzy file picker; host pasa `&[PathBuf]` + `root` | | **fif** (find-in-files) | `Ctrl+Shift+F` | `OpenAt{path,line,col}`, `Searched{..}`, `Replaced{..}` | buscar/reemplazar; dual-view (dialog + barra). `search()` / `replace_all()` hacen el I/O | | **diff-viewer** | `Ctrl+Shift+D` | — | diff side-by-side. `DiffState::new(before_label, after_label, before, after)` computa con `similar` | | **mini-map** | `Ctrl+Shift+M` | `JumpTo(line)` | minimapa del buffer; agnóstico del editor (recibe `Snapshot`) | | **bookmarks** | `Ctrl+Alt+B` toggle, `Ctrl+Shift+B` lista, `Ctrl+Alt+N/P` nav | `JumpTo{path,line}` | marcadores per-file persistentes (state directo, no Option) | | **symbol-outline** | `Ctrl+Shift+O` | `GoTo{line,col}` | outline de símbolos; host arma `Vec` (LSP/tree-sitter/custom) | | **selector** | — | — | abstracción portátil abrir/guardar: `trait Selector` (`HostSelector` con PathBuf, `WawaSelector` placeholder content-addressed) | | **plugin-host** | — | `OpenAt{..}`, `SetStatus(..)` | runtime WASM (wasmi) con permisos por bitfield; `PluginHost::load_from_dir`/`invoke(id, cap, args)` | | **shuma-term** | `` Ctrl+` `` | `SetStatus(..)` | terminal integrada. `spawn(cwd)` lanza PTY (`shuma_exec`), `vt100::Parser` renderiza; `Tick` drena el PTY | Patrón típico de integración (command-palette): ```rust struct Model { palette: Option, commands: Vec, /* … */ } 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`. ```rust Workspace::new() .focused() -> PaneId .count() .leaves() -> Vec .layout() -> &Layout .focus(id) .split(Axis) -> PaneId .close() -> Option .resize(&path, delta) .apply(WsMsg) -> WsEffect enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec, f32) } enum WsEffect { None, Created(PaneId), Closed(PaneId) } workspace_view(&ws, &WorkspacePalette, leaf: FnMut(PaneId)->View, // 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`): ```rust // Model: Option (None = "calculando…") + flag dirty + contador de generación. struct Model { x: Option, x_dirty: bool, x_gen: u64 } enum Msg { XComputed(u64, Arc) } // 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`](COMPUTO-FUERA-DEL-HILO-UI.md) y su checklist por app. ### Otras - **Solvers iterativos** (Newton/bisección): cota dura `for _ in 0..N`, nunca `loop {}` con corte pegado al epsilon de f64 — en debug no converge → loop infinito. - **Backend GPU**: preferir Vulkan (`Backends::PRIMARY`); el GL de Mesa sobre Wayland segfaultea en el teardown. Ya está hecho en `Hal::new`, no revertir. - **Un nodo es draggable o clickable**, no ambos. - **`alpha` y `clip`** crean capas intermedias: tienen costo, usar sólo cuando hace falta. - **`paint_with`** no debe dejar `push_layer` sin `pop_layer` ni resetear la Scene. - **Hit-test respeta `.transform()`**: un nodo rotado/escalado/trasladado recibe los clicks donde se ve pintado (el runtime invierte el afín acumulado). Lo que **no** se ajusta todavía: la posición local que reciben los handlers `*_at` se reporta en coords de pantalla, no en el espacio local del nodo transformado. - **GPUI está extinto**: no agregar dependencias ni código GPUI (regla §3 de `CLAUDE.md`). - **Texto en regla pesada**: crear un `Typesetter` por frame es caro (`FontContext::new` enumera fuentes del sistema). El runtime ya cachea uno y lo pasa a `paint_with`. --- ## 16.bis Tests de regresión visual — `llimphi-test` (golden-image) Para certificar que un widget/pantalla **no cambió de aspecto** sin renderizar-y- mirar a mano (CLAUDE.md §8), usar el arnés `llimphi-test`. Renderiza un `View` headless por el adapter de **software** (llvmpipe/lavapipe → mismo píxel host-a- host y en CI) y lo diffea contra un golden con tolerancia perceptual. ```rust // en tests/.rs de tu crate use llimphi_test::assert_golden; #[test] fn mi_card() { let v = mi_widget::card(/* … */); assert_golden!("mi_card", v, 400, 240); // vs tests/goldens/mi_card.png } ``` - **Generar/actualizar goldens:** `LLIMPHI_GOLDEN_UPDATE=1 cargo test -p ` (escribe los PNG en `tests/goldens/`; commitéalos). - **En fallo:** escribe `mi_card.actual.png` + `mi_card.diff.png` (heatmap) al lado del golden y paniquea con `DiffStats { differing_pixels, max_delta, fraction }` — la stat numérica **es** la evidencia, no hace falta abrir el PNG. - **Tolerancia:** `GoldenConfig { max_channel_delta, max_diff_fraction }` (default casi bit-exacto). Para control fino: `Harness::render(...)` + `diff(...)` + `compare_golden(path, &rendered, &cfg)` sueltos. - **Determinismo:** el adapter de software es bit-exacto (el self-test `readback_es_determinista` verifica 0px entre dos renders). Si la máquina no tiene lavapipe, cae al adapter normal y la tolerancia absorbe la diferencia. - **Path GPU-directo** (`GpuBatch`/`gpu_paint_with`, que no pasa por vello): `Harness::render_gpu(w, h, bg, |device, queue, encoder, view, vp| { … })` — misma firma que `View::gpu_paint_with`; limpia a `bg` y te deja pintar directo, después lee de vuelta para el golden (ver `llimphi-test/tests/gpu_golden.rs`). ### El golden es el ÚLTIMO recurso, no el primero Un golden es una imagen bendecida: pasa a ser correcta porque alguien dijo que lo era. Eso funciona para «esto no cambió», pero no contesta «¿esto está bien?», y para bendecirla hay que mirarla — justo lo que §8 pide evitar. Casi siempre hay algo mejor: **el oráculo es la otra manera de calcular lo mismo**. En vez de comparar contra una imagen, se pinta el mismo árbol por dos caminos que tienen que coincidir, y la afirmación pasa a ser objetiva. Los que ya existen en el repo, todos escritos así: | test | los dos caminos | resultado | |---|---|---| | `boundary_cache.rs` | `paint` contra `paint_cached` | 0 px | | `gpu_lotes.rs` | un buffer entero contra troceado | idénticos | | `gpu_grosores.rs` | un lote con dos plumas contra dos lotes de una | idénticos | | `gpu_scissor.rs` | pasada completa contra recorte | mismo píxel exacto | | `cpu_vs_gpu.rs` | `vello_cpu` (wawa) contra `vello` (Linux) | tabla por patrón | | `wire_vs_directo.rs` | por el cable contra en proceso | bit a bit | | `hybrid_vs_cpu.rs` | `vello_hybrid` (Mali) contra CPU | Δ1:0 | | `texto_gpu_directo.rs` | atlas de glifos contra vello | Δ64:0 | ### Cuatro reglas que salieron de escribirlos 1. **Poné el umbral DESPUÉS de medir, no antes.** Los umbrales «prudentes» que se eligen a ojo casi nunca se rozan, y un umbral que nada roza no es un gate. En `hybrid_vs_cpu` el 5 % sobre Δ16 que parecía razonable no habría agarrado el sabotaje; lo medido (0 px sobre Δ1) sí. Dejá el número medido escrito al lado del `assert`, con fecha: es lo que permite decidir el umbral siguiente. 2. **Saboteá el test antes de creerle.** Rompé a mano lo que dice cubrir y mirá que falle *ese* test y no otro. Dos veces en esta tanda el test pasaba con la pieza rota: `boundary_gradiente_imagen` reconstruía el objeto con `default()` en vez de clonarlo, y cada `default()` hacía un `Blob` nuevo — o sea que invalidaba por identidad y nunca llegaba a probar el hash. Cuatro de cinco sabotajes pasaban. 3. **Todo test de igualdad necesita una guarda de arnés.** Dos lienzos vacíos coinciden perfecto. Afirmá además que se pintó algo (`pintados > total/8`), que el caché acertó (`hits > 0`), que se ejecutaron las órdenes (`ejecutadas > 0, salteadas == 0`), que se compararon todos los casos (`probados == patrones().len()`). Sin eso, el test firma cualquier cosa. 4. **Recorré el camino difícil, no el fácil.** `texto_gpu_directo` sube sólo el rect sucio y no el atlas entero, porque es lo que hace una app; y tiene un caso incremental aparte justamente porque en un atlas recién hecho lo sucio arranca en (0,0), donde un error de coordenada pasa desapercibido. Cuándo sí un golden: cuando **no hay** segundo camino. `gpu_golden.rs` (starfield por GPU directo) es el caso — no existe otra forma de producir esa imagen, así que la referencia tiene que ser ella misma. ### Correr la suite contra la GPU de verdad ```bash LLIMPHI_TEST_GPU=1 cargo test --no-fail-fast -p llimphi-test -p llimphi-raster -p llimphi-hybrid ``` El arnés fuerza el adapter de **software** para que un golden valga lo mismo en cualquier máquina, y eso está bien — pero deja una pregunta sin contestar: *¿el hardware hace lo mismo?* Con esa env se ignora la preferencia y se usa la GPU real. Es para preguntar a mano, no para cambiar la referencia. **Medido el 2026-08-05 (Iris Xe/Vulkan contra llvmpipe), y el resultado dice más que cualquier argumento sobre goldens:** | | software | GPU real | |---|---:|---:| | tests en verde | 113 | **112** | El único que se cae es `gpu_golden` — el único que compara contra una imagen bendecida (2,74 % de píxeles, Δ máx 44). **Los ocho oráculos pasan en las dos**, y con casi el mismo número: | test | software | GPU real | |---|---|---| | `texto_gpu_directo` | Δ16:417 Δ64:0 · tinta +0,31 % | Δ16:423 Δ64:0 · tinta +0,29 % | | `hybrid_vs_cpu` | Δ0:236 Δ1:0 | Δ0:232 Δ1:0 | | `boundary_cache` / `gpu_lotes` / `gpu_scissor` | 0 px | 0 px | Un oráculo compara dos cosas que el mismo rasterizador produce, así que la diferencia de rasterizador **se cancela**. Una foto no puede cancelar nada. Por eso `gpu_golden` se saltea solo cuando se pide GPU: así el comando de arriba es una pregunta limpia y no una corrida con un fallo conocido de ruido. --- ## 16.ter Semántica accesible + tests por consulta Un `View` describe **cajas**: posición, color, texto. No dice qué *significan*. Esa segunda capa se declara con `View::role` + `View::aria_*`, y paga dos veces: 1. **Lectores de pantalla, gratis.** El runtime traduce el árbol montado a AccessKit en cada frame (`llimphi-ui::a11y`), y el adapter de Linux habla **AT-SPI2 por DBus** — el mismo bus que usa Orca. No hay que encender nada: `accesskit_winit` ya está cableado en el event loop, sin feature gate. 2. **Tests que preguntan por significado**, no por píxeles (ver más abajo). ### Declarar ```rust View::new(style) .role(Role::Button) // qué ES el nodo .aria_label("Guardar") // cómo se llama (si no hay texto visible) .aria_value("70%") // dato dinámico: valor de un slider, texto de un input .aria_description("…") // contexto extra; el lector lo dice aparte .aria_pressed(activo) // …y los flags: checked/pressed/expanded/ .aria_disabled(!habilitado) // disabled/readonly/required ``` **Dónde va el rol: en el nodo que lleva el `on_click`**, no en el contenedor. AccessKit sólo ofrece `Action::Click` donde hay handler; un rol `Button` sobre una caja sin handler produce un botón que el lector no puede activar. **El nombre accesible** es `aria_label` si está, si no el `text` visible. Por eso un nodo que ya pinta su texto no necesita label — pero si el texto vive en un **hijo** (ícono + etiqueta en una fila), el nodo con el rol queda sin nombre y hay que dárselo explícito. Es el error más común. Roles disponibles (`llimphi_ui::Role`): `Button` `TextInput` `MultilineTextInput` `Heading` `Checkbox` `Switch` `Label` `Link` `Image` `Slider` `ProgressBar` `MenuItem` `Menu` `MenuBar` `Tab` `TabList` `Toolbar` `List` `ListItem` `Tree` `TreeItem` `Table` `Row` `Cell` `ColumnHeader` `Dialog` `Alert` `Status` `Tooltip` `Terminal` `Splitter` `Group`. Si falta uno, agregalo en `llimphi-compositor/src/semantics.rs` **y** mapealo en `llimphi-ui/src/a11y.rs` — cuando aparezca un caller real, no antes. **Cuándo NO declarar:** decorativo puro (divider, gradiente, sombra, `View` envoltorio de layout). Un `Role::Group` en cada caja ensucia la navegación más de lo que ayuda. ### Consultar — `llimphi_test::query` La misma declaración habilita tests que **no miran píxeles**: ```rust use llimphi_test::query::{Sel, Ui}; use llimphi_ui::Role; let ui = Ui::new(app.view(), 800.0, 600.0); assert_eq!(ui.find_all(Role::Tab).len(), 3); let guardar = ui.get(Sel::new().role(Role::Button).label("Guardar")); assert!(guardar.is_enabled()); // Clickear = obtener el Msg y metérselo al update. app.update(guardar.click().unwrap()); ``` - **No necesita GPU** — sólo `mount` + layout. Corre en cualquier CI, a diferencia de `Harness` (que pide lavapipe). - **`Ui::get`** paniquea con el árbol semántico entero en el mensaje si no encuentra (o si hay más de uno). El fallo se lee sin abrir un PNG. - **`Ui::dump()`** vuelca el árbol como texto indentado — la evidencia barata que pide CLAUDE.md §8: ``` · @0,0 510×100 Heading txt:"Datos" @0,0 50×100 TextInput "Nombre" = "sergio" required=true @50,0 200×32 Button "Guardar" [click] @250,0 80×32 Button "Cancelar" disabled [click] @330,0 80×32 · [click] @410,0 100×100 ``` El `·` es un nodo sin rol declarado. El último de ese volcado —`· [click]`— es un canvas con handler y sin semántica: un lector de pantalla no puede anunciarlo. Verlo en el dump **es** el hallazgo. - **Selectores:** `Sel::new()` + `.role() .label() .label_contains() .value() .checked() .enabled() .clickable() .focusable() .role_none()`. Atajos por conversión: `ui.find(Role::Button)` y `ui.find("Guardar")` (por nombre). - **Auditar lo mudo:** `ui.find_all(Sel::new().clickable(true).role_none())` devuelve exactamente los nodos interactivos sin semántica — lo que un lector de pantalla no sabe anunciar. ### Estado de la cobertura **50 de 76 widgets anotados** (medido el 2026-08-27: `grep -rlE '\.role\(|Role::' widgets/*/src`). De los 26 restantes, 25 lo están **a propósito** — ver abajo — y uno, `tablado`, es un hueco real con nombre. Anotados: `accordion` `app-header` `avatar` `badge` `banner` `breadcrumb` `button` `calendar` `chip` `color-picker` `context-menu` `datetime-picker` `detail-table` `dock-rail` `empty` `fab` `field` `form` `gauge` `list` `menubar` `modal` `nodegraph` `progress` `range-slider` `rating` `rive-button` `segmented` `select` `shortcuts-help` `slider` `spinner` `splitter` `stat-card` `status-bar` `stepper` `switch` `table` `tabs` `terminal` `text-editor` `text-input` `theme-switcher` `timeline` `toast` `toolbar` `tooltip` `transport` `tree` `waveform`. **Sin anotar por diseño** (el módulo `semantics` lo pide explícitamente: un `Role::Group` en cada caja ensucia la navegación más de lo que ayuda): - *Contenedores de layout puro* — `card` `panel` `grid` `wrap` `hero` `fitted-box` `tiled` `panes` `scroll` `router` `scaffold` `lazy-list`. La semántica la traen sus hijos. - *Decorativos* — `skeleton` `splash` `wawa-mark`. - *Sin `View` propio* — `app-icon` (resuelve íconos XDG a bytes, no pinta), `clipboard` (que además no es un widget: es `llimphi-clipboard` viviendo en `widgets/`), `text-area` `text-editor-core` `text-editor-lsp` `carousel` (sólo helpers), `edit-menu` (produce `ContextMenuItem`, que ya viene anotado), `gallery` (binario de ejemplo). - *Componen widgets ya anotados* — `navigator` (sobre `tree` + `nodegraph`), `rag-sidebar` (sobre `segmented` + `dock-rail`). - *Devuelve slots, no una `View`* — `fugaces`: la caja hospedadora y el anuncio los pone el caller, que es el único que sabe qué es cada fantasma. ⚠️ **El que no está por decisión sino por hueco:** `tablado`, el nodo donde el anfitrión pinta la `EscenaWire` de una app enjaulada. Por el cable viaja el IR de **escena** y no el de vista (`SDD-JAULA-LINUX.md`), así que del lado del anfitrión no hay roles que anunciar: para un lector de pantalla, una app enjaulada es un rectángulo mudo. Cerrarlo pide que la semántica del huésped cruce el cable, que es diseño pendiente y no una anotación que falte. ### Activación: el rol y el handler no tienen por qué coincidir Los widgets ponen el rol en la **fila** (`TreeItem`, `Row`, `ListItem`) porque es la unidad que el lector navega, pero el `on_click` suele vivir en un **hijo** (el nodo del label). `a11y::click_target` resuelve ese desajuste: 1. Si el nodo mismo tiene handler, es él. 2. Si no, y **declara un rol**, se delega en su subárbol — siempre que haya **exactamente un** descendiente clickeable. 3. Si hay varios, no se delega: la elección sería arbitraria, y esos hijos ya están en el árbol como nodos propios para activarlos directo. El requisito de rol en (2) es lo que evita que toda caja de layout se vuelva activable. El runtime además sintetiza la posición para los handlers `on_click_at` (centro del rect del nodo en el layout del frame) — misma convención que `query::NodeRef::click`. ### Dientes y botones animados: el `label` es obligatorio `DockRailItem` y `RiveButton` no tienen texto en el árbol (el ícono lo pinta un closure opaco), así que el nombre accesible viaja explícito: ```rust DockRailItem::nombrado(id, activo, "Configuración") // recomendado DockRailItem::new(id, activo) // queda mudo RiveButton::builtin().con_label("Ejecutar") ``` Y un patrón que apareció en casi todos: **el ítem sólo-ícono**. Un botón sin texto visible (`toolbar` sin `with_label`, transport, chevrones de calendario, el `×` de una pestaña) es mudo salvo que se le dé `aria_label` a mano. Los que tienen un nombre derivable ya lo llevan; los que dependen del caller están señalados en el código. --- ## 17. Comandos y demos ```bash cargo check --workspace # smoke test mínimo (debe pasar siempre) cargo run -p --release # correr una app cargo run -p --example --release # correr un demo # demos del propio framework: cargo run -p llimphi-ui --example counter --release # bucle Elm completo cargo run -p llimphi-ui --example editor --release # text field + teclado cargo run -p llimphi-ui --example gpu_paint_demo --release cargo run -p llimphi-gallery --release # showcase de TODO el kit cargo test -p llimphi-gallery # la vitrina como CALLER: los widgets responden cargo run -p nada --release # editor real para ejercitar widgets # benchmark GPU directo vs vello (primitivas Y texto — pedile GPU real, en # llvmpipe la mitad de rasterizado no decide nada): cargo run -p llimphi-gpu-bench --release # perfilador por frame (a stderr cada 60 frames: media/p95 por etapa): LLIMPHI_PERF=1 cargo run -p --release # ...con conteo de asignaciones de heap por frame y su desglose por etapa: LLIMPHI_PERF=1 cargo run -p --release --features llimphi-ui/perf-alloc # dónde poner un .repaint_boundary() en TU app (ver más abajo): LLIMPHI_BOUNDARY_HINT=1 cargo run -p --release # un update que tarda de más y congela la UI: LLIMPHI_UI_SLOW_MS=50 cargo run -p --release ``` `LLIMPHI_PERF=N` reporta cada N frames (default 60) el desglose `view/mount/layout/paint/raster/gpu/present` en µs + `layout-reuso`, el caché de `repaint_boundary` y —si está la feature `perf-alloc`— `alloc / () [view N·KB mount N·KB layout N·KB paint N·KB resto N·KB]`, donde `resto` es el tramo posterior a paint (raster vello + GPU + present) y el desglose suma el total. Sin esa feature el tramo de allocs dice `n/a` en vez de ceros falsos. Ojo: los contadores son del proceso, no del hilo — lo que asigne otro hilo durante una etapa cae en esa etapa. El contador es `llimphi_ui::alloc::ContadorAlloc`: si tu app ya elige su allocator, instalalo a mano (`#[global_allocator]`) en vez de usar la feature. El camino `cache_hit` (frame retenido entero) **no** se muestrea a propósito. ### `LLIMPHI_BOUNDARY_HINT` — dónde poner un `repaint_boundary` `.repaint_boundary(clave)` cachea la rasterización de un subárbol quieto y la reusa mientras su contenido no cambie (Bloque 23). El problema práctico nunca fue la API sino **dónde**: marcar un subárbol que en realidad cambia todos los cuadros no es neutro —se paga el hash entero cada frame y no se ahorra nada—, y desde afuera un panel quieto y uno que se reconstruye idéntico a sí mismo se ven igual. Por eso ninguna app lo usaba. ```bash LLIMPHI_BOUNDARY_HINT=1 cargo run -p --release # reporta cada 120 cuadros LLIMPHI_BOUNDARY_HINT=300 … # cada 300 ``` Usá la app un rato con el puntero **fuera** de lo que sospechás. La salida: ``` [llimphi] 2 subárbol(es) quieto(s), 431 nodos que se repintan de gusto — poneles .repaint_boundary(clave): nodo 12 · 287 nodos · (0,48 260×672) · 118 cuadros quieto «Archivos» nodo 640 · 144 nodos · (0,0 1920×48) · 118 cuadros quieto «Inicio» ``` Cada línea es el subárbol **máximo** que vino idéntico: si un panel entero está quieto no se listan también sus hijos. El texto es el primero que aparece adentro, para que lo reconozcas sin contar nodos. Es una **predicción, no una opinión**: el detector consulta las mismas tres funciones que el caché de verdad (posición limpia, paint-puro, y el mismo hash que decide hit o miss), así que lo que señala el caché lo va a acertar. Dos cosas que no dice: - **Si conviene.** Sugiere «esto habría sido cache-hit»; el hash se paga siempre. Por eso ignora subárboles de menos de 16 nodos. - **Que esté quieto para siempre.** Un panel quieto 120 cuadros puede cambiar al siguiente click, y ahí el boundary falla y repinta — correcto, pero sin ahorro. Con el puntero **adentro** de un subárbol, ni el caché lo sirve ni el detector lo sugiere: el hover cambia lo pintado. Y es diagnóstico, no producción — hashea cada candidato por cuadro. Encendelo, anotá, apagalo. **Qué descalifica a un subárbol** (el detector se calla y el boundary, si lo ponés igual, se pinta fresco): cualquier `paint_with`/`gpu_paint_with`, una animación viva, un ripple, un hero, una `mask_image`, o estar bajo un clip/alpha/transform de un ancestro. **Degradé e imagen sí entran** desde 2026-08-05 — antes no, y como un sidebar real casi siempre tiene un ícono o un fondo con degradé, un solo nodo así descalificaba el panel entero y el caché no servía para lo que existía. `llimphi-gallery` (`src/main.rs`) es la **referencia viva** del patrón completo: `Model`/`Msg`/`init`/`update`/`view`/`view_overlay` con overlays mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown). Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; `?` abre atajos; `Esc` cierra el overlay activo. --- ## 18. Cheat-sheet ```rust // ── App mínima ────────────────────────────────────────────── impl App for X { type Model; type Msg; init; update; view; } llimphi_ui::run::(); // ── 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> { if m.open { Some(menu) } else { None } } ``` --- ## 19. Índice de crates > Recontado contra el disco el **2026-08-27**. Son 139 crates en el workspace > raíz más dos que viven en workspaces aparte (`llimphi-jaula`, `llimphi-wawa`: > los backends de `llimphi-text` son excluyentes y no conviven en un proceso). **Núcleo del camino a píxeles** (`02_ruway/llimphi/`): `llimphi-hal` · `llimphi-raster` · `llimphi-text` · `llimphi-glifos` · `llimphi-layout` · `llimphi-compositor` · `llimphi-ui`. **Auxiliares del runtime**: `llimphi-theme` · `llimphi-motion` · `llimphi-icons` · `llimphi-image` · `llimphi-svg` · `llimphi-surface` · `llimphi-entrada` · `llimphi-despacho` · `llimphi-layer` · `llimphi-workspace` · `llimphi-test` (golden-image) · `llimphi-gallery` · `llimphi-gpu-bench` · `demo`. **Los otros caminos a píxeles**: `llimphi-cpu` (`vello_cpu`, el de wawa y el Android sin compute) · `llimphi-hybrid` (`vello_hybrid`) · `llimphi-reproductor` (el pintor único de los dos anfitriones) · `llimphi-cable` · `llimphi-wire-escena` · `llimphi-wire-view` · `llimphi-jaula` y `llimphi-wawa` (*workspaces aparte*). Se comparan por número con `scripts/check-llimphi-cpu-vs-gpu.sh`. **3D, voxel y animación**: `llimphi-3d` · `llimphi-mesh` · `llimphi-voxel` · `llimphi-voxel-render` · `llimphi-voxel-app` · `llimphi-voxel-studio` · `llimphi-anim` · `llimphi-anim-studio` · `llimphi-lottie` · `llimphi-video-plane` · `llimphi-term-graphics`. **Camino web / WASM** (guardián: `scripts/check-llimphi-web.sh`): `llimphi-wasm-core` · `llimphi-wasm-app-sdk` · `llimphi-wasm-runner` · `llimphi-wasm-wasi` · `llimphi-wasm-net` · `llimphi-wasm-registry` · `llimphi-wasm-dist` · `llimphi-wasm-open` · `llimphi-wasm-demo-counter` · `llimphi-wasm-demo-form`. **Widgets** (`widgets/`, dep `llimphi-widget-`) — **76**, más `llimphi-clipboard` que vive ahí sin serlo: accordion · app-header · app-icon · avatar · badge · banner · breadcrumb · button · calendar · card · carousel · chip · color-picker · context-menu · datetime-picker · detail-table · dock-rail · edit-menu · empty · fab · field · fitted-box · form · fugaces · gallery · gauge · grid · hero · lazy-list · list · menubar · modal · navigator · nodegraph · panel · panes · progress · rag-sidebar · range-slider · rating · rive-button · router · scaffold · scroll · segmented · select · shortcuts-help · skeleton · slider · spinner · splash · splitter · stat-card · status-bar · stepper · switch · tablado · table · tabs · terminal · text-area · text-editor · text-editor-core · text-editor-lsp · text-input · theme-switcher · tiled · timeline · toast · toolbar · tooltip · transport · tree · waveform · wawa-mark · wrap. **Módulos** (`modules/`) — **11**: allichay · bookmarks · command-palette · diff-viewer · fif · file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline. **Android** (`android/`): clear-screen-android · vello-hello-android · vello-text-android · hybrid-probe-android · llimphi-hybrid-android. --- > Documentos hermanos: [`SDD.md`](SDD.md) (diseño y roadmap), > [`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) (regla de > concurrencia), [`README.md`](README.md) / [`LEEME.md`](LEEME.md) (overview). > Las firmas de este manual reflejan el código al 2026-06-01; el índice de > crates y los conteos de widgets/módulos se recontaron contra el disco el > 2026-08-27. Ante divergencia, la fuente autoritativa es el `lib.rs` de cada > crate — y para cualquier conteo, el comando.