El repo publico se habia quedado en julio: el README anunciaba vello 0.7 + wgpu 27 (dos bumps atras) y faltaban los crates del ultimo mes. - refresco con scripts/actualizar-standalone.py --llimphi (cargo check OK) - llegan llimphi-cpu, llimphi-hybrid, llimphi-glifos y llimphi-wire-escena: los cuatro caminos a pixeles con un solo compositor - MANUAL.md / SDD.md / LEEME.md sincronizados con el monorepo - README: versiones al dia, install por crates.io (cargo add llimphi), seccion "beyond the 2D widget kit" y el indice de crates que apuntaba al propio README ahora apunta a MANUAL.md §19
2205 lines
119 KiB
Markdown
2205 lines
119 KiB
Markdown
# 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<Msg>` — 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<Msg>` — función **pura** que describe la pantalla como
|
||
un árbol de `View`.
|
||
|
||
El runtime hace el bucle: un evento (click/tecla/rueda) produce un `Msg`,
|
||
`update` deriva el nuevo `Model`, `view` reconstruye el árbol, `taffy` calcula
|
||
las cajas, `vello` rasteriza, y se hace swap del frame. **No hay mutabilidad
|
||
compartida, no hay vDOM ajeno, no hay callbacks imperativos**: declaras qué se
|
||
ve y qué `Msg` emite cada nodo.
|
||
|
||
```
|
||
evento ─▶ Msg ─▶ update(model,msg) ─▶ model' ─▶ view(model') ─▶ View<Msg>
|
||
│
|
||
present ◀─ raster(vello) ◀─ layout(taffy) ◀──────────────────────┘
|
||
```
|
||
|
||
Tres reglas de oro:
|
||
1. **`view` es pura** — no muta nada, sólo lee el modelo y arma el árbol.
|
||
2. **Cómputo pesado va a un worker** vía `Handle::spawn`, nunca síncrono en
|
||
`update`/`init`/handlers (congela la ventana → "Not Responding").
|
||
3. **Widgets son visuales y stateless**; el estado vive en tu `Model`.
|
||
**Módulos** sí encapsulan estado + comportamiento.
|
||
|
||
---
|
||
|
||
## 2. Arquitectura — las capas
|
||
|
||
```
|
||
4. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent)
|
||
└ llimphi-compositor . árbol View<Msg>, mount sobre taffy, paint, hit-test (winit-free)
|
||
3. llimphi-layout ....... motor de layout (taffy: flexbox + grid)
|
||
2. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo
|
||
1. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji
|
||
└ 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: **70 widgets** (visuales) + **11 módulos** (features con estado).
|
||
|
||
---
|
||
|
||
## 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::Msg>) -> Self::Model { 0 }
|
||
|
||
fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
|
||
match msg {
|
||
Msg::Increment => model.saturating_add(1),
|
||
Msg::Reset => 0,
|
||
}
|
||
}
|
||
|
||
fn view(model: &Self::Model) -> View<Self::Msg> {
|
||
let boton = View::new(Style {
|
||
size: Size { width: length(160.0), height: length(56.0) },
|
||
align_items: Some(AlignItems::Center),
|
||
justify_content: Some(JustifyContent::Center),
|
||
..Default::default()
|
||
})
|
||
.fill(Color::from_rgba8(60, 200, 130, 255))
|
||
.radius(12.0)
|
||
.text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255))
|
||
.on_click(Msg::Increment);
|
||
|
||
View::new(Style {
|
||
flex_direction: FlexDirection::Column,
|
||
size: Size { width: percent(1.0), height: percent(1.0) },
|
||
align_items: Some(AlignItems::Center),
|
||
justify_content: Some(JustifyContent::Center),
|
||
gap: Size { width: length(0.0), height: length(24.0) },
|
||
..Default::default()
|
||
})
|
||
.fill(Color::from_rgba8(20, 24, 32, 255))
|
||
.children(vec![
|
||
View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE),
|
||
boton,
|
||
])
|
||
}
|
||
}
|
||
|
||
fn main() { llimphi_ui::run::<Counter>(); }
|
||
```
|
||
|
||
`Cargo.toml`:
|
||
```toml
|
||
[dependencies]
|
||
llimphi-ui = { workspace = true }
|
||
llimphi-theme = { workspace = true }
|
||
# + los widgets/modules que uses:
|
||
# llimphi-widget-button = { workspace = true }
|
||
```
|
||
|
||
Corre con `cargo run -p <tu-crate> --release`. El ejemplo vivo está en
|
||
`llimphi-ui/examples/counter.rs`.
|
||
|
||
---
|
||
|
||
## 4. El trait `App` (bucle Elm)
|
||
|
||
Definido en `llimphi-ui/src/lib.rs`. El estado es inmutable; cada evento
|
||
produce un `Model` nuevo.
|
||
|
||
```rust
|
||
pub trait App: 'static {
|
||
type Model: 'static;
|
||
type Msg: Clone + Send + 'static;
|
||
|
||
fn init(handle: &Handle<Self::Msg>) -> Self::Model;
|
||
fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
|
||
fn view(model: &Self::Model) -> View<Self::Msg>;
|
||
|
||
// --- Todo lo de abajo tiene default; sobreescribí lo que necesites ---
|
||
|
||
fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> { None }
|
||
|
||
fn on_wheel(_model: &Self::Model, _delta: WheelDelta,
|
||
_cursor: (f32, f32), _modifiers: Modifiers) -> Option<Self::Msg> { None }
|
||
|
||
/// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta
|
||
/// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo
|
||
/// vidrio"). La transición la maneja tu Model.
|
||
fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> { None }
|
||
|
||
/// Drag&drop de archivos desde el file manager. Un evento por archivo.
|
||
fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> { None }
|
||
|
||
/// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El
|
||
/// runtime administra el foco; guardas `id` en tu Model para pintar el ring
|
||
/// y rutear el teclado. Ver §8 (Foco y teclado).
|
||
fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> { None }
|
||
|
||
/// IME (composición de texto: CJK, acentos muertos, emoji). Opt-in vía
|
||
/// `ime_allowed()` para no robarle el texto a las apps que sólo leen
|
||
/// `on_key`. Flujo: Enabled → Preedit* → Commit/Disabled. Ver §8 (IME).
|
||
fn ime_allowed() -> bool { false }
|
||
fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> { None }
|
||
/// Área del caret en px físicos para ubicar la ventana de candidatos.
|
||
fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { None }
|
||
|
||
fn title() -> &'static str { "llimphi" }
|
||
fn app_id() -> Option<&'static str> { None } // app_id del xdg-toplevel en Wayland
|
||
fn initial_size() -> (u32, u32) { (960, 540) }
|
||
}
|
||
```
|
||
|
||
Punto de entrada: `pub fn run<A: App>()` — corre hasta que el usuario cierre la
|
||
ventana o la app llame `Handle::quit`.
|
||
|
||
**Eventos de teclado** (`KeyEvent`):
|
||
```rust
|
||
pub struct KeyEvent {
|
||
pub key: Key, // re-export de winit; usar NamedKey para teclas especiales
|
||
pub state: KeyState, // Pressed | Released
|
||
pub text: Option<String>, // texto resultante con IME/modifiers; None para flechas etc.
|
||
pub modifiers: Modifiers, // { shift, ctrl, alt, meta }
|
||
pub repeat: bool,
|
||
}
|
||
```
|
||
`Key` y `NamedKey` se re-exportan desde `llimphi_ui`.
|
||
|
||
**Rueda** (`WheelDelta { x, y }`): normalizado a "líneas". Convención CSS:
|
||
`y` positivo = scroll hacia abajo.
|
||
|
||
---
|
||
|
||
## 5. `Handle` — efectos y concurrencia
|
||
|
||
`Handle<Msg>` es `Send + Clone`. Llega a `init` y `update`. Es el único modo
|
||
legítimo de producir efectos sin romper la pureza de la transición.
|
||
|
||
```rust
|
||
impl<Msg: Send + 'static> Handle<Msg> {
|
||
pub fn quit(&self); // cierra la ventana / termina el bucle
|
||
pub fn dispatch(&self, msg: Msg); // encola un Msg para el próximo turno
|
||
pub fn spawn<F: FnOnce() -> Msg + Send + 'static>(&self, f: F); // worker; su Msg reentra al update
|
||
pub fn spawn_periodic<F: Fn() -> Msg + Send + 'static>(&self, period: Duration, f: F); // tick periódico
|
||
pub fn for_test() -> Self; // handle "muerto" para tests sin event loop
|
||
pub fn for_test_with<F: Fn(Msg) + Send + Sync + 'static>(sink: F) -> Self; // tests que SÍ quieren los Msg
|
||
|
||
// --- ventana (sólo la primaria; en un handle `lift`ado son no-op) ---
|
||
pub fn set_fullscreen(&self, on: bool); // pantalla completa sin bordes, en su monitor
|
||
pub fn set_minimized(&self, on: bool); // minimizar / restaurar
|
||
pub fn set_content_type(&self, k: ContentType); // qué mostrás: None/Photo/Video/Game
|
||
pub fn open_window(&self, key: u64, title: impl Into<String>, w: u32, h: u32); // ventana OS secundaria
|
||
pub fn close_window(&self, key: u64);
|
||
}
|
||
```
|
||
|
||
- **`on_modifiers`** (hook del trait `App`) — el `WindowEvent::ModifiersChanged`.
|
||
Existe porque **los handlers de click llevan un `Msg` plano y no reciben los
|
||
modificadores**: para hacer Shift+click (rango) o Ctrl+click (aditivo) en una
|
||
lista, la app guarda el estado en su modelo desde este hook y lo consulta en
|
||
el `update` del click. Devolvé `None` cuando el cambio no te importa (si no,
|
||
cada roce de Shift dispara un update y un repintado).
|
||
|
||
- **`for_test` vs `for_test_with`** — `for_test` **descarta** los `Msg`; ojo con
|
||
eso: la closure de un `spawn` **sí se ejecuta** (para no perder sus efectos),
|
||
sólo se tira el mensaje. Un test que necesitaba el resultado terminaba
|
||
volviendo a hacer el trabajo a mano → el mismo job dos veces, en paralelo, y
|
||
carreras intermitentes sobre el disco. `for_test_with` entrega cada `Msg` a un
|
||
sink (típicamente el `Sender` de un canal, detrás de un `Mutex` porque el sink
|
||
pide `Sync`); el test lo drena y lo realimenta a `update`, que es lo que hace
|
||
el runtime real. Con eso el trabajo corre **una sola vez** y el test es
|
||
determinista.
|
||
|
||
- **`set_fullscreen`** — `Fullscreen::Borderless` sobre el monitor donde ya
|
||
está la ventana (no cambia el modo de video). Es una **petición** al
|
||
compositor 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<Msg>` — el DSL declarativo
|
||
|
||
Un `View` = `Style` de taffy + relleno + texto/imagen/painter + handlers +
|
||
hijos. Todo se arma con builders encadenables (`self -> Self`). Definido en
|
||
`llimphi-compositor/src/view.rs`.
|
||
|
||
```rust
|
||
View::new(style: Style) -> View<Msg>
|
||
```
|
||
|
||
### Apariencia
|
||
| Método | Efecto |
|
||
|---|---|
|
||
| `.fill(Color)` | color de fondo |
|
||
| `.fill_gradient(Gradient)` | relleno con gradiente (autoreado en el cuadrado unidad `[0,1]²`, mapeado al rect). Gana sobre `fill`; `hover_fill` lo overridea en hover |
|
||
| `.hover_fill(Color)` | color al pasar el cursor (habilita hit-test de hover) |
|
||
| `.radius(f64)` | esquinas redondeadas (radio uniforme) |
|
||
| `.radius_corners(tl,tr,br,bl)` | radio **por esquina** (CSS `border-radius` 4 valores); override de `.radius`. El borde sigue las 4 esquinas; la sombra usa el radio escalar |
|
||
| `.shadow(Shadow)` | drop shadow (vello `draw_blurred_rounded_rect`). `Shadow::soft(alpha,blur)` + `.offset(dx,dy)`/`.spread(s)` |
|
||
| `.border(width, Color)` | stroke sobre el contorno redondeado, inset hacia adentro (border-box) |
|
||
| `.alpha(f32)` | opacidad de todo el subtree `[0,1]` (capa intermedia — no gratis) |
|
||
| `.transform(Affine)` | afín 2D alrededor del centro del rect (estilo CSS `transform-origin:50% 50%`) |
|
||
| `.animated(key, Duration)` | animación **implícita** estilo Flutter `AnimatedContainer`: si `fill`/`radius` cambian entre frames, el runtime interpola (ease-out cúbico) en vez de saltar. `key` estable entre rebuilds. `.animated_curve(key,dur,fn)` para otra curva |
|
||
| `.clip(bool)` | recorta hijos al rect (paint + hit-test) |
|
||
| `.image(Image)` | pinta `peniko::Image` centrada, preservando aspect ratio |
|
||
| `.children(Vec<View<Msg>>)` | hijos |
|
||
|
||
### Texto (ver §9)
|
||
```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<String>)
|
||
.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada
|
||
.line_height(mult) // override interlínea (default 1.2)
|
||
.text_weight(f32) // peso CSS: 400 normal, 600 semibold, 700 bold
|
||
.bold() // atajo de text_weight(700.0)
|
||
.ellipsis(n) // clampa a n líneas terminando en … (n=1 = single-line)
|
||
.max_lines(n) // clampa a n líneas sin glifo (corte seco)
|
||
```
|
||
|
||
### Interacción (ver §8)
|
||
```rust
|
||
.on_click(Msg)
|
||
.on_click_at(|lx, ly, w, h| -> Option<Msg>) // posición local + tamaño del rect
|
||
.on_right_click(Msg) / .on_right_click_at(...)
|
||
.on_middle_click(Msg)
|
||
.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
|
||
.draggable(|phase: DragPhase, dx, dy| -> Option<Msg>)
|
||
.draggable_at(|phase, dx, dy, lx0, ly0| -> Option<Msg>) // + posición inicial del press
|
||
.drag_payload(u64) // payload que viaja con el drag
|
||
.on_drop(|payload: u64| -> Option<Msg>) // este nodo es drop target
|
||
.drop_hover_fill(Color) // resaltado mientras un drag lo sobrevuela
|
||
.on_scroll(|dx, dy| -> Option<Msg>) // rueda local (antes del on_wheel global)
|
||
.on_scroll_take(|dx, dy| -> (Option<Msg>, rx, ry)) // reparto PARCIAL: consume lo que puede y el
|
||
// sobrante sigue al ancestro (mismo evento);
|
||
// scroll_y/lazy_list ya lo usan (scroll_take)
|
||
.on_scale(|phase: GesturePhase, factor, fx, fy| -> Option<Msg>) // pinch-to-zoom (Ctrl+rueda / trackpad)
|
||
.on_double_tap(Msg) / .on_double_tap_at(|lx, ly, w, h| ...) // dos clicks rápidos y cercanos
|
||
.on_long_press(Msg) / .on_long_press_at(|lx, ly, w, h| ...) // mantener ~500 ms quieto
|
||
.focusable(u64) // nodo enfocable por Tab/click (id opaco)
|
||
```
|
||
|
||
### Pintura custom (ver §10)
|
||
```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).
|
||
- **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<NodeId, Rect> }`.
|
||
|
||
En el 99% de los casos no tocas `LayoutTree` a mano: lo maneja el runtime al
|
||
montar tu `View`. Sólo armás `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\| ...)` |
|
||
| Long-press (mantener) | `.on_long_press(Msg)` / `.on_long_press_at(\|lx,ly,w,h\| ...)` |
|
||
| Teclado | `App::on_key(model, &KeyEvent) -> Option<Msg>` |
|
||
| Foco / Tab | `.focusable(id)` en los nodos + `App::on_focus(model, id)` (ver abajo) |
|
||
| IME (CJK, acentos) | `App::ime_allowed() -> true` + `App::on_ime(model, &ImeEvent)` (ver abajo) |
|
||
| Drop de archivos del SO | `App::on_file_drop(model, path)` |
|
||
|
||
**Patrón overlay** (menús/modales): el modelo guarda "menú abierto sí/no".
|
||
Mientras esté abierto, `view_overlay` devuelve `Some(view)`; clicks fuera se
|
||
cierran envolviendo los items en un scrim a pantalla completa con
|
||
`on_click = DismissOverlay`. Cuando el modelo dice cerrado, `view_overlay`
|
||
devuelve `None`.
|
||
|
||
**Scroll** (widget `llimphi-widget-scroll`). `scroll_y(offset, content_len,
|
||
viewport_len, content, on_scroll, &palette)` arma un viewport clipeado +
|
||
contenido desplazado `-offset` + barra arrastrable. Es **stateless**: el offset
|
||
vive en tu Model. `on_scroll(delta_px)` (rueda y arrastre) emite un delta a
|
||
sumar; clampealo con `scroll::clamp_offset` en tu `update`. Helpers:
|
||
`ensure_visible(offset, vp, item_top, item_h)` para llevar la selección a la
|
||
vista (teclado); `approach(cur, target, factor)` para scroll suave hacia un
|
||
objetivo (driveado por `Handle::spawn_periodic`).
|
||
|
||
**Scroll 2D / física / slivers** (Tier 5, mismo widget, todo stateless):
|
||
- `scroll_xy(offset:(x,y), content_size:(w,h), viewport_size:(w,h), content,
|
||
on_scroll:(dx,dy)→Msg, &palette)` — dos ejes, una barra por eje con overflow.
|
||
- **Inercia (fling)**: `fling_step(velocity, dt, friction) → (v', delta)` +
|
||
`fling_settled(v)` — suelta con velocidad y avanza el offset por frame con el
|
||
ticker (`spawn_periodic`); `FLING_FRICTION`/`FLING_STOP` defaults. **Bounce**:
|
||
`rubber_band(overscroll, dim)` amortigua el desplazamiento más allá del tope.
|
||
- **Sliver app-bar colapsable**: `sliver_app_bar(offset, header_max, header_min,
|
||
header(frac)→View, content, content_len, viewport_len, on_scroll, &palette)` —
|
||
un único offset colapsa el header (de max a min) y luego scrollea el cuerpo
|
||
bajo él; `header(frac)` recibe `frac∈[0,1]` para fundir título/subtítulo.
|
||
Clampeá con `sliver_max_offset(...)`. Helpers puros: `collapsed_height`,
|
||
`collapse_fraction`, `sticky_y(offset, section_top, section_h, header_h)` para
|
||
encabezados de sección pegados al tope. Demo: `cargo run -p
|
||
llimphi-widget-scroll --example scroll_avanzado`.
|
||
|
||
**Foco y teclado.** Marca los nodos navegables con `.focusable(id)` (id `u64`
|
||
que vos eliges). El runtime es la **única fuente de verdad** del foco: lo mueve
|
||
con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo
|
||
enfocable, y te avisa con `App::on_focus(model, Option<u64>)`. Guardas el id en
|
||
tu Model para (a) pintar el ring (`if model.focus == Some(id) { .fill(accent) }`
|
||
en `view`) y (b) rutear el teclado al campo activo desde `on_key`. No setees el
|
||
foco por tu cuenta vía Msg: quedaría desincronizado del runtime.
|
||
|
||
**IME** (composición de texto). Opt-in: `ime_allowed() -> true`. Con IME activo
|
||
el texto compuesto **no** llega por `KeyEvent.text` sino por `on_ime`:
|
||
`ImeEvent::Enabled` → uno o más `Preedit{text, cursor}` (texto en composición, a
|
||
pintar subrayado en el caret) → `Commit(text)` (inserta como tecleado) o
|
||
`Disabled`. Reporta el área del caret con `ime_cursor_area(model)` para ubicar
|
||
la ventana de candidatos (CJK) junto al cursor.
|
||
|
||
---
|
||
|
||
## 9. Texto
|
||
|
||
`TextSpec` (en compositor) describe el texto de un nodo:
|
||
```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>, // string CSS con fallbacks
|
||
pub line_height: f32, // múltiplo; default 1.2
|
||
pub runs: Option<Vec<(usize, usize, Color)>>, // color por rango de BYTES
|
||
}
|
||
```
|
||
|
||
- `Center` es el default (apto para labels). Para editores/párrafos usar
|
||
`.text_aligned(..., Alignment::Start)`.
|
||
- **Multicolor en una sola pasada de shaping**: `.text_runs(...)` colorea
|
||
rangos de bytes — es la base del syntax highlighting (un nodo por línea, no
|
||
por token). Anclado arriba-izquierda; el caller dimensiona el rect.
|
||
- El runtime mide el texto con parley durante el layout (`compute_with_measure`)
|
||
para que taffy reserve el alto real del texto envuelto a varias líneas
|
||
(evita "textos aplastados").
|
||
- Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique.
|
||
|
||
---
|
||
|
||
## 10. Canvas custom y GPU directo
|
||
|
||
Dos hooks para pintar primitivas no expresables como composición de `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<Theme> // orden de rotación canónico
|
||
Theme::by_name(name) -> Option<Theme>
|
||
Theme::next_after(current_name) -> Theme // para el theme-switcher
|
||
```
|
||
|
||
Tokens auxiliares en el mismo crate:
|
||
- `motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms}` + `ease_out_cubic`,
|
||
`ease_in_out_cubic`, `linear`.
|
||
- `alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT}` (constantes `u8`).
|
||
- `radius::{XS=2, SM=4, MD=8, LG=12, XL=20}` (`f64`).
|
||
|
||
**Patrón de widgets**: cada widget define su `XxxPalette` con
|
||
`Palette::from_theme(&theme)`. Tu app guarda un `Theme` en el Model, deriva las
|
||
paletas que necesita en `view`, y se las pasa a los widgets. Para cambiar de
|
||
tema, el `theme-switcher` emite `Msg(next_theme)` y reconstruyes todo.
|
||
|
||
---
|
||
|
||
## 12. Capas base
|
||
|
||
### `llimphi-hal` — superficie
|
||
```rust
|
||
Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result<Hal, HalError> // async
|
||
trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result<Frame,_>; fn present(frame, hal); }
|
||
WinitSurface::new(hal, window: Arc<Window>) -> Result<Self, HalError>
|
||
Frame::view() -> &wgpu::TextureView; Frame::size() -> (u32,u32)
|
||
```
|
||
`Hal::new` pide adapter `Backends::PRIMARY` (Vulkan) y cae a `all()` sólo si no
|
||
hay — **no volver a `InstanceDescriptor::default()`**: el backend GL de Mesa
|
||
sobre Wayland segfaultea en el teardown. El runtime de `llimphi-ui` ya maneja
|
||
todo esto; sólo tocas HAL si escribes un runtime nuevo.
|
||
|
||
### `llimphi-raster` — rasterización
|
||
```rust
|
||
Renderer::new(hal) -> Result<Renderer,_>
|
||
Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
|
||
// GPU directo:
|
||
GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout
|
||
GpuBatch::new(&pipelines)
|
||
.line_width(w) // pluma: vale para lo que se agregue DESPUÉS
|
||
.add_line(p0,p1,color) .add_polyline(&pts,color)
|
||
.add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
|
||
.add_disc(cx,cy,r,color) .add_ring(cx,cy,r,stroke,color)
|
||
.atlas(&AtlasTextura) // sin esto los quads NO se dibujan
|
||
.add_glyph(x,y,w,h,(u0,v0,u1,v1),color) // quad texturado: el TEXTO
|
||
.scissor(x,y,w,h) // sólo se toca ese rect (damage)
|
||
.max_buffer_bytes(bytes) // baja el techo por buffer; normalmente no se toca
|
||
.primitive_count() -> u32
|
||
.flush(device, queue, encoder, view, viewport, load_op)
|
||
units_per_chunk(stride, max_bytes, multiplo) -> u32
|
||
RECT_INSTANCE_STRIDE / LINE_INSTANCE_STRIDE / DISC_INSTANCE_STRIDE / TRI_VERTEX_STRIDE
|
||
QUAD_INSTANCE_STRIDE
|
||
// la textura de cobertura contra la que se muestrean los quads:
|
||
AtlasTextura::new(device, &pipelines, lado) -> Self
|
||
.subir(queue, pixeles, (x,y,w,h)) // sube SÓLO ese rect, no el atlas entero
|
||
```
|
||
Re-exporta `vello` y `peniko` (`Color`, `Image`, `Fill`, etc.).
|
||
|
||
Cuatro cosas del `flush` que conviene saber y no se ven en la firma:
|
||
|
||
- **`line_width` es una pluma, no un modo del lote (cambió 2026-08-05).** Cada
|
||
línea se queda con el grosor que estaba puesto cuando se la agregó, así que
|
||
una grilla fina y una curva gruesa entran en el mismo `flush`. Antes el grosor
|
||
era un uniforme del lote entero y dibujar dos grosores obligaba a cortar —
|
||
y cortar no cuesta una draw call más: **cuesta el ciclo entero otra vez**,
|
||
porque cada `flush` arma su propia textura MSAA 4×, la resuelve y la compone
|
||
(2 texturas + 2 render passes por `flush`). El precio es 4 B más por línea
|
||
(36 en vez de 32), que baja el techo de un solo buffer de 8,4 M de líneas a
|
||
7,4 M; el troceado ya lo maneja.
|
||
|
||
- **No hay techo de primitivas.** `max_buffer_size` del dispositivo (256 MB en
|
||
una Iris Xe) topa a ~8,4 M de instancias en **una** reserva, y pasado eso wgpu
|
||
rechaza: la app no se pone lenta, se cae. `flush` parte cada tipo en lotes y
|
||
dibuja uno por lote en orden, así que el alpha-over sale idéntico. En el caso
|
||
normal es un lote y las mismas cuatro draw calls. `max_buffer_bytes` existe
|
||
sobre todo para **testear** el troceado sin reservar cientos de MB.
|
||
- **`scissor` recorta el costo, no sólo el dibujo.** Las texturas intermedias
|
||
(MSAA 4× + resolve) se crean del tamaño del rect sucio, porque el
|
||
`LoadOp::Clear` del attachment y el resolve son de toda la superficie y el
|
||
scissor no los toca. Sin eso, pintar un cursor de 32 px costaba limpiar y
|
||
resolver la pantalla entera. El rect se pide siempre en **coordenadas de
|
||
viewport**; `flush` baja el origen a par (las derivadas de `fwidth`, de las
|
||
que sale el AA del disco, se calculan por bloques de 2×2 alineados al target:
|
||
un corrimiento impar cambiaría la orla), así que puede tocar una fila y una
|
||
columna de más.
|
||
- **Los quads sin atlas no se dibujan, y no revientan.** `add_glyph` acumula
|
||
igual, pero si nadie llamó a `atlas()` el `flush` los saltea en silencio. La
|
||
alternativa —un panic adentro del `flush`— sería reventar el frame de una app
|
||
porque alguien pidió texto antes de tener el atlas listo. Que falte el texto es
|
||
un síntoma leíble; que se caiga la ventana, no.
|
||
|
||
### `llimphi-glifos` — el texto del camino GPU-directo
|
||
```rust
|
||
AtlasGlifos::new(lado) -> Self // atlas cuadrado de cobertura, 1 B/px
|
||
.pixeles() -> &[u8] .lado() -> u32 .version() -> u64 .entradas() -> usize
|
||
.tomar_sucio() -> Option<Sucio> // qué cambió desde la última vez, y limpia
|
||
.limpiar() // única salida cuando `fallos` sube
|
||
.fallos / .aciertos / .rasterizados // contadores públicos
|
||
quads_de_layout(&mut atlas, &layout, (x,y), &mut Vec<QuadGlifo>) -> usize
|
||
quads_de_run(&mut atlas, &glyph_run, (x,y), &mut Vec<QuadGlifo>) // multicolor
|
||
```
|
||
Rasteriza cada glifo **una sola vez** a un mapa de 8 bits y lo empaqueta por
|
||
estantes; de ahí en más pintar texto es pintar quads. Un renglón de 80
|
||
caracteres pasa de 80 contornos rellenos a 80 instancias de 48 B.
|
||
|
||
Lo que hay que saber antes de usarlo:
|
||
|
||
- **No shapea.** Recibe un `parley::Layout` ya resuelto —el que arma
|
||
`llimphi_text::Typesetter`—, así que el texto por GPU-directo y el texto por
|
||
vello parten del **mismo** layout. Certificado:
|
||
`llimphi-test/tests/texto_gpu_directo.rs` pone los dos caminos sobre el mismo
|
||
layout y mide `Δ64:0` — ningún píxel difiere más de 64/255 — con la caja de lo
|
||
pintado coincidente y 0,31 % de tinta de diferencia.
|
||
- **Un color sólido por run.** El atlas guarda cobertura, no color, así que el
|
||
mismo glifo cacheado sirve para cualquier color sin re-rasterizar — pero un
|
||
brush (gradiente, imagen recortada al texto) no entra. Eso sigue siendo vello.
|
||
- **Ni emoji ni glifos de color**: el atlas es de un canal. Pedirían un atlas
|
||
RGBA y un pipeline propio.
|
||
- **No hintea**, a propósito: vello rasteriza el contorno crudo, y hintear haría
|
||
que el mismo texto saliera con métricas distintas por cada camino.
|
||
- **No crece ni desaloja.** Lleno, devuelve `None` y suma a `fallos`. La salida
|
||
es `limpiar()` y volver a llenar. Una política de desalojo sin una app que la
|
||
presione se diseña a ciegas.
|
||
- **Subí sólo el rect sucio.** `tomar_sucio()` + `AtlasTextura::subir` es el
|
||
camino esperado; subir el atlas entero por frame anula el punto de cachear (un
|
||
atlas de 1024² son 1 MiB por cuadro).
|
||
|
||
**Hay DOS atlas de glifos en llimphi, y hay que saber cuál usar.** El otro es
|
||
`llimphi_widget_terminal::GlyphAtlas`, anterior (Fase 4 del SDD-TERMINAL) y vivo:
|
||
lo consume el modo TUI de shuma. `llimphi-glifos` se escribió sin advertirlo.
|
||
|
||
Desde el 2026-08-05 **comparten rasterizador** (`llimphi_glifos::Rasterizador`,
|
||
swash/zeno); antes el terminal tenía `fontdue` propio. Lo que sigue habiendo dos
|
||
es el **empaquetado**, y eso es de diseño:
|
||
|
||
| | `GlyphAtlas` (widget terminal) | `AtlasGlifos` (`llimphi-glifos`) |
|
||
|---|---|---|
|
||
| clave | `char` (charmap, sin shaping) | `(fuente, id, tamaño, subpíxel, síntesis)` |
|
||
| empaquetado | grilla de celdas iguales | estantes de tamaño variable |
|
||
| subpíxel | no | 4×4 |
|
||
| fuentes | una, monoespaciada | cualquiera, varias por renglón |
|
||
|
||
**Cuál usar:** una grilla de terminal, `GlyphAtlas` — puede asumir monoespaciado,
|
||
celdas enteras y cero shaping, y esas suposiciones son parte de por qué es
|
||
rápido. Cualquier otro texto, `llimphi-glifos`.
|
||
|
||
**Si empaquetás a tu manera**, no escribas un tercer rasterizador:
|
||
`Rasterizador::glifo(datos, ClaveGlifo::de_glifo(id, tam))` devuelve el
|
||
`MapaGlifo` (cobertura + colocación) y vos lo ponés donde quieras. Y para lo que
|
||
no necesita shaping hay `metricas`, `id_de_caracter` y `avance`.
|
||
|
||
**La migración se hizo contra dos oráculos, no a ojo:**
|
||
- `tests/metricas_fontdue.rs` — `fontdue` sobrevive como **dev-dependency** para
|
||
esto: la celda sale **idéntica** en 11/12/13/14/16/18 px, y de 42 glifos
|
||
comparados sólo **1** difiere (1 px de caja, el `0` a 13 px).
|
||
- `tests/dos_atlas.rs` — los dos empaquetados entregan el mismo glifo.
|
||
|
||
Y un tropiezo que dejó regla: la primera corrida de `dos_atlas` daba **44 %** de
|
||
diferencia. No era el rasterizador — `TextBlock::simple` deja
|
||
`font_family: None` y parley cae a **sans-serif**, así que comparaba dos
|
||
tipografías. **Si comparás glifos, fijá la familia primero.**
|
||
|
||
**¿Cuándo conviene?** Medido en metal (Iris Xe/Vulkan, `llimphi-gpu-bench`
|
||
sección «Texto», 12.096 glifos): el total baja **3,6-5,0×** contra vello, y lo
|
||
que importa es la *forma* — el costo del atlas es casi plano con la cantidad de
|
||
texto (5,0 → 5,5 ms entre 7.200 y 12.096 glifos) mientras vello crece lineal
|
||
(13,6 → 26,4 ms). Pero la mitad de **CPU** del atlas es **peor** que la de vello
|
||
(~0,7×): vello apila un id y una posición por glifo, el atlas hace una búsqueda
|
||
en tabla y arma un quad. Toda la ganancia viene del lado GPU.
|
||
|
||
O sea: el atlas es para **mucho texto que se repinta seguido** — grilla de
|
||
terminal, editor, etiquetas sobre un canvas denso. Para un rótulo suelto no
|
||
compra nada y encima paga el ciclo fijo del `flush` (MSAA + resolve +
|
||
composite). **El default sigue siendo vello.**
|
||
|
||
### `llimphi-image` — decode pipeline
|
||
```rust
|
||
decode_bytes(&[u8]) -> Result<peniko::Image, DecodeError>
|
||
load_path(&Path, max_bytes: u64) -> Result<peniko::Image, DecodeError>
|
||
from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> peniko::Image
|
||
// mismo decode, pero además dice de qué espacio venía (para editores):
|
||
decode_bytes_con_origen(&[u8]) -> Result<Decodificada, DecodeError>
|
||
load_path_con_origen(&Path, max_bytes: u64) -> Result<Decodificada, DecodeError>
|
||
Decodificada { imagen: Image, origen: Option<Perfil>, convertida: bool }
|
||
Decodificada::devolver_al_origen(&self, rgba: &mut [u8]) -> bool
|
||
// perfiles de color (mod perfil):
|
||
perfil::leer_icc(&[u8]) -> Option<Perfil> // matrix-shaper; None si no lo es
|
||
perfil::convertir_a_srgb(&mut [u8], &Perfil) -> bool // entrada: del perfil a sRGB
|
||
perfil::convertir_desde_srgb(&mut [u8], &Perfil) -> bool // salida: de sRGB al perfil
|
||
perfil::matriz_a_srgb(&[f32;9]) -> [f32;9] / matriz_desde_srgb(&[f32;9]) -> Option<[f32;9]>
|
||
perfil::es_identidad(&[f32;9], tol) -> bool
|
||
Curva::{Lineal, Gamma(f32), Tabla(Vec<u16>), Parametrica{tipo, p}}
|
||
Curva::a_lineal(v) -> f32 / Curva::desde_lineal(lin) -> f32 // una es inversa de la otra
|
||
```
|
||
Encapsula el patrón `image::ImageReader + to_rgba8 + Blob + Image::new` que cada
|
||
app que decodifica imágenes tenía duplicado. `max_bytes` aplica al tamaño del
|
||
archivo en disco (no a la imagen decodificada, que en RGBA8 puede ser mucho
|
||
mayor — un PNG 4K ocupa ~64 MB descomprimido); `0` deshabilita el cap.
|
||
Re-exporta `peniko::{Blob, Image, ImageFormat}`. Formatos según las features
|
||
del crate `image` upstream del workspace (hoy: PNG, JPEG, WEBP).
|
||
|
||
**Gestión de color (2026-08-05).** `decode_bytes` / `load_path` leen el perfil
|
||
ICC embebido y llevan la imagen a **sRGB**. Antes se tiraba: una foto Display P3
|
||
—lo que saca cualquier teléfono desde 2016— se pintaba **sobresaturada**, porque
|
||
sus rojos, que son más rojos que los de sRGB, se mostraban como si fueran los de
|
||
sRGB. Nadie tiene que hacer nada: el caller sigue recibiendo RGBA8 sRGB.
|
||
|
||
- Cubre perfiles **matrix-shaper** (matriz 3×3 + curva), que es lo que son sRGB,
|
||
Display P3, Adobe RGB, Rec.2020 y casi todos los de pantalla y cámara.
|
||
- **No** cubre los basados en LUT (`A2B0`), como los CMYK de imprenta: ahí no
|
||
convierte y deja la imagen como estaba, que es mejor que inventar.
|
||
- Sin perfil se asume sRGB (la convención de la web). Con perfil sRGB, la matriz
|
||
da la identidad y **no se recorre un solo píxel**.
|
||
- El recorte a gamut es por saturación de canal: un rojo P3 puro no existe en
|
||
sRGB y termina en el rojo sRGB más cercano.
|
||
**Volver al espacio de origen (2026-08-05).** Un editor no puede conformarse con
|
||
recibir sRGB: si `tullpu` abre una foto Display P3 y la guarda, tiene que
|
||
guardarla **en P3**, no degradar el archivo del usuario por el solo hecho de
|
||
haberlo abierto. Para eso están `decode_bytes_con_origen` / `load_path_con_origen`,
|
||
que devuelven una `Decodificada` con el perfil del archivo al lado de los píxeles.
|
||
|
||
```rust
|
||
let d = llimphi_image::load_path_con_origen(ruta, 64 << 20)?;
|
||
let mut px: Vec<u8> = d.imagen.image.data.as_ref().to_vec(); // sRGB, para editar
|
||
// …edición…
|
||
d.devolver_al_origen(&mut px); // false si no hay a dónde volver: se guarda sRGB
|
||
```
|
||
|
||
- `origen: None` **no** significa «era sRGB»: significa que no había perfil o que
|
||
era de los que este crate no entiende (CMYK/LUT). En los dos casos los píxeles
|
||
quedaron como venían y no hay a dónde volver.
|
||
- `convertida: false` con `origen: Some(…)` es el caso corriente: el archivo ya
|
||
venía en sRGB y no se recorrió un píxel.
|
||
- **La ida y vuelta no es sin pérdida, por dos razones distintas.** La segunda
|
||
cuantización a 8 bits mete ±1. Y lo que se recortó al **entrar** (un rojo P3 que
|
||
en sRGB no existe) no vuelve: se perdió en el recorte, no al salir. Editar un P3
|
||
sin pérdida pide trabajar en P3; el `Perfil` expuesto es lo que lo habilita.
|
||
- Quien sólo va a pintar sigue usando `decode_bytes` / `load_path` y no se entera:
|
||
son las mismas funciones, que ahora delegan y tiran el perfil.
|
||
|
||
### `llimphi-text` — shaping
|
||
```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<RunBrush>
|
||
TextBlock::simple(text, size_px, color, origin)
|
||
layout_block(ts, &block) / measure(ts, &block) -> Measurement
|
||
draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin)
|
||
Alignment::{Start, Center, End, Justify}
|
||
```
|
||
|
||
### `llimphi-motion` — tweens
|
||
```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<T>` en el Model, `animate(...)` en el update, la `view`
|
||
lee `tween.value()` cada repaint. El tween se auto-termina.
|
||
|
||
### `llimphi-icons` — iconos vectoriales (~23, grid 24×24)
|
||
```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<Msg>
|
||
paint_icon(scene, rect, icon, color, stroke_width) // dentro de un paint_with
|
||
```
|
||
`stroke_width` en unidades del grid 24×24 (1.6 es armónico).
|
||
|
||
### `llimphi-surface` — texturas externas
|
||
```rust
|
||
ExternalSurface::new(device, queue) // barato de clonar (Arc<Mutex> interno)
|
||
.upload(&rgba, w, h) // desde otro hilo/decoder/cámara
|
||
.view(style) -> View<Msg> // blittea a su rect en el árbol Elm
|
||
.blit(queue, encoder, dst_view, rect, viewport) // o manual desde gpu_paint_with
|
||
```
|
||
|
||
---
|
||
|
||
## 13. Catálogo de widgets
|
||
|
||
Los widgets son **funciones puras** que devuelven `View<Msg>` (o specs que se
|
||
convierten a `View`). Son **stateless**: el estado vive en tu Model. Convención:
|
||
cada uno trae `XxxPalette::from_theme(&Theme)`. Crates en
|
||
`widgets/<nombre>/`, dep `llimphi-widget-<nombre>`.
|
||
|
||
### Controles
|
||
|
||
**button** — `button_view(label, &ButtonPalette, on_click: Msg) -> View`;
|
||
`button_styled(label, style, alignment, &palette, on_click)`.
|
||
|
||
**field** — wrapper de formulario (label + helper/error + requerido).
|
||
`field_view(FieldSpec { label, control: View<Msg>, required, helper, error, palette })`.
|
||
|
||
**text-input** — input single-line **con estado** `TextInputState`
|
||
(`new()`/`masked()`, `text()`, `set_text()`, `apply_key(&KeyEvent) -> bool`,
|
||
soporta undo/redo + selección con Shift). Render:
|
||
`text_input_view(&state, placeholder, focused, &palette, on_focus: Msg)`.
|
||
|
||
**text-area** — input **multilínea** con paridad real (mismo motor `EditorState`
|
||
que el text-input; NO el viejo `String` plano). El estado es
|
||
`TextInputState::multiline()` (o `llimphi_widget_text_area::new_state()`).
|
||
`text_area_view(&state, placeholder, focused, rows, &palette, |ev| Msg::Area(ev))`
|
||
emite `TextAreaEvent` (Key/Ime/Press(x,y)/Drag/DragEnd/Context) ya envueltos; en
|
||
`update` → `state.handle_area(ev, &mut clipboard)`. Da: **click 2D** (renglón+columna
|
||
por glifos reales), ↑/↓ entre renglones, selección a través de líneas (arrastre 2D,
|
||
doble/triple-click, Shift+flechas), copiar/cortar/pegar, undo/redo, IME, Enter=`\n`,
|
||
**soft-wrap** (la línea larga se envuelve al ancho de la caja) y **scroll vertical**
|
||
(caret a la vista si hay más renglones que `rows`). Caret + selección pintados por
|
||
`paint_over` en coords de renglón visual; fuente única del layout: `visual_layout`
|
||
(render/hit-testing/caret/nav comparten). **↑/↓ navegan por renglón visual** (no por
|
||
línea lógica: en texto envuelto bajan un renglón, no la línea entera), con columna
|
||
meta en px preservada (`move_vertical_visual`; Shift extiende selección). El crate
|
||
`llimphi-widget-text-area` es una fachada que re-exporta esto. Demo:
|
||
`cargo run -p llimphi-widget-text-input --example area_demo`.
|
||
|
||
**text-area RICO** — el mismo widget con los adornos que antes obligaban a
|
||
escribirse un pintor propio. `text_area_view_rico(&state, focused, rows,
|
||
&palette, &Metricas, &Adornos, wrap)`. **Si necesitás un input mono, coloreado,
|
||
con fantasma o con caret animado, es ESTE, no un `paint_with` a mano.**
|
||
- `Metricas { font_size, line_h, font_family, pad_x, pad_r, pad_y }` —
|
||
`Metricas::mono(px)` + `.con_zoom(z)`. La usan pintado, ajuste blando y
|
||
click→columna: una sola, o el caret cae entre glifos que no son los que se ven.
|
||
- `Adornos { tramos, ghost, placeholder, caret, ahora_ms, marco }`.
|
||
`tramos: Vec<(ini, fin, Color)>` en **bytes del texto completo** (se recortan
|
||
solos a cada renglón); `ghost` = sufijo tenue tras el caret (sólo si el caret
|
||
está al final); `Placeholder { texto, color, italic, icono, icono_color, alpha }`;
|
||
`marco: false` = sin caja propia (para un host que ya tiene la suya — el borde
|
||
del widget es un relleno **opaco** que taparía un backdrop frosted).
|
||
- `CaretEstilo::vivo()` = parpadeo que queda **sólido** tras cada tecla y
|
||
**estela** al moverse (el eyecandy en honor a kitty). El reloj lo trae el host:
|
||
`Adornos::ahora_ms` por cuadro + `state.marcar_actividad(ms)` en cada tecla —
|
||
el widget no llama a `now()` por dentro (sería intesteable, y un `bool` de fase
|
||
alternado por un timer externo se muestrea mal cuando el panel repinta lento).
|
||
- Consultas para un host que **crece con el contenido**:
|
||
`renglones_visuales()`, `renglones_visuales_de(txt)`, `avance_en_renglon()`,
|
||
`ancho_caracter()`, `fijar_ancho_caja(px)` (sembrar el ancho antes del primer
|
||
cuadro, o testear el wrap sin GPU).
|
||
`text_area_view` es esta misma vista con `Adornos::default()` — un solo
|
||
renderizador. Uso canónico: el input de la barra de shuma
|
||
(`02_ruway/shuma/sandbox/shuma-module-shell/src/view/input.rs`), que le pasa los
|
||
tokens del shell como `tramos` y monta su propio marco con `marco: false`.
|
||
|
||
**slider** — sin estado. `slider_view(label, value, min, max, &palette,
|
||
on_change: Fn(DragPhase, delta_value) -> Option<Msg>)`. El delta viene en
|
||
unidades, no píxeles.
|
||
|
||
**color-picker** — selector RGBA: swatch actual + paleta de chips + barra
|
||
de hue + sliders R/G/B/A (+ campo `#RRGGBB` opcional). Agnóstico, emite
|
||
`[u8; 4]`. `color_picker_view(rgba: [u8;4], swatches: &[[u8;3]] (p.ej.
|
||
DEFAULT_SWATCHES), &ColorPickerPalette, hex: Option<HexField { focused,
|
||
state: Option<&TextInputState>, on_focus }>, on_change: Fn([u8;4]) -> Msg)`.
|
||
Helpers puros: `rgba_to_hex`, `parse_hex(s, cur_alpha)` (acepta 3/6/8
|
||
dígitos, `#` opcional), `color_picker_height(with_hex)` para pre-calcular
|
||
el alto.
|
||
|
||
**switch** — `switch_view(progress: f32 [0..1], on_toggle: Msg, &palette)`. La
|
||
app guarda el `bool` y opcionalmente anima `progress` con un `Tween`.
|
||
|
||
**segmented** — N opciones exclusivas. `segmented_view(&[&str], selected: usize,
|
||
make_msg: Fn(usize)->Msg, &palette)`.
|
||
|
||
**select** — combobox/dropdown moderno (disparador cerrado + menú flotante en
|
||
`view_overlay`). Soporta **ítems ricos** (`SelectItem { label, sublabel, icon
|
||
(glifo unicode), badge: SelectBadge, enabled }`), **selección múltiple**
|
||
(`selected: &[usize]` con check ✓), **búsqueda** in-menu y **fases async**
|
||
(`SelectPhase::{Loading, Error(&str), Ready(&[SelectItem])}`) para listas que
|
||
vienen de `Handle::spawn`. El cerrado se pinta con `select_trigger_view(selected:
|
||
Option<&SelectItem>, placeholder, open, width, &palette, on_toggle: Msg)`; el
|
||
menú con `select_menu_view(SelectMenuSpec { anchor, viewport, width, phase,
|
||
visible: &[orig_idx], active, selected, query, searchable, empty_text, appear,
|
||
on_pick: Fn(orig_idx)->Msg, on_hover, on_dismiss, on_retry, palette })`. La app
|
||
mantiene `query`/`active`/`visible`/`selected` en el Model. Helpers puros para
|
||
el filtro: `filter(items, query) -> Vec<orig_idx>`, `step_active(items, visible,
|
||
current, ±1)` para nav, `resolve(visible, pos) -> Option<orig_idx>`.
|
||
|
||
**progress** — `linear_progress_view(progress, track, fill, height)` y
|
||
`radial_progress_view(progress, track, fill, stroke_ratio)`. Sin eventos.
|
||
|
||
**spinner** — `spinner_view(color, stroke_ratio, speed_rev_per_sec)`. Animado por
|
||
reloj absoluto; requiere redraws periódicos (`spawn_periodic`).
|
||
|
||
**rive-button** — botón reactivo dirigido por **máquina de estados** (`llimphi-anim`,
|
||
estilo Rive) — primer consumidor real de la `StateMachine` fuera del studio.
|
||
**Stateful** (como `text-editor`): guardas `RiveButton` en tu Model. Tres estados
|
||
`idle`/`hover`/`press`, cada uno con su clip Lottie; hover por bool (listeners
|
||
Enter/Exit del motor), press por trigger (`ClipDone` lo devuelve solo). API:
|
||
`RiveButton::new(idle, hover, press, press_secs, blend_secs)` o `::builtin()`
|
||
(clips embebidos); `advance(dt)` desde un tick, `pointer(Option<(f64,f64)>)`,
|
||
`press()` desde el `on_click` del host, `current_state()`/`is_transitioning()`.
|
||
Render: `view(on_pointer: Fn(Option<(f64,f64)>)->Msg, on_click: Msg)`. Demo:
|
||
`cargo run -p llimphi-widget-rive-button --example rive_button_demo --release`.
|
||
|
||
**badge** — `count_badge_view(count, BadgeKind)` ("99+" si ≥100) y
|
||
`dot_badge_view(BadgeKind)`. `BadgeKind::{Info,Success,Warning,Error,Neutral}`.
|
||
|
||
**avatar** — `avatar_view(name, size_px)`: círculo determinista (color por hash
|
||
del nombre + inicial).
|
||
|
||
**tooltip** — render puro. `tooltip_view(TooltipSpec { anchor, viewport, side:
|
||
Side, text, palette })`. Se monta en `view_overlay`; la app controla visibilidad
|
||
con `on_pointer_enter/leave`.
|
||
|
||
**empty** — empty-state. `empty_view(Icon, title, description: Option<&str>, &palette)`.
|
||
|
||
**skeleton** — placeholder con shimmer: una banda de gradiente `[low, high,
|
||
low]` que **cruza** el rect. `skeleton_view`, `skeleton_box_view(w,h,..)`,
|
||
`skeleton_line_view(w,..)`. Requiere redraws periódicos (`spawn_periodic(50ms)`
|
||
mientras haya skeletons visibles). Esas tres arrancan reloj propio y están
|
||
gateadas a `std`; `skeleton_view_en(inicio, ahora, &pal)` recibe el instante por
|
||
argumento y es la que sirve **sin `std`** (jaula/kernel), misma doctrina que el
|
||
`today` del calendar. La aritmética es pura y testeable: `banda_shimmer(ancho,
|
||
progreso) -> (izq, der)` —la banda entra y sale entera del rect, con piso de
|
||
ancho para que en un avatar chico el destello no sea un píxel— y
|
||
`progreso_shimmer(segundos) -> [0,1)`.
|
||
|
||
**carousel** — pager paginado de N páginas. `carousel_view(CarouselSpec {
|
||
pages, current, wrap: CarouselWrap::{Wrap, Clamp}, show_arrows, palette,
|
||
on_change: Fn(usize) -> Msg })`. Dots indicadores abajo (clickeables para saltar
|
||
a la página i) + flechas opcionales `‹` / `›` a los costados. v1 sin swipe;
|
||
para v2 sumar swipe horizontal usando `draggable_velocity` + `fling_step` para
|
||
snap-on-release.
|
||
|
||
**fitted-box** — escala un subárbol al slot del padre. `fitted_box((iw, ih),
|
||
BoxFit::{Contain, Cover, Fill, None, ScaleDown}, || inner_view())` aplica un
|
||
transform afín al inner (medido por el seam `LayoutBuilder`) para que entre en
|
||
el slot real, centrado, preservando aspect salvo `Fill`. Útil para imágenes,
|
||
canvases `paint_with` y texto grande que deben caber en celdas chicas sin que el
|
||
caller los re-mida. La función pura `compute_fit(slot, inner, fit) ->
|
||
(sx, sy, dx, dy)` es testeable.
|
||
|
||
**calendar** — vista mensual 6×7 (`calendar_view(CalendarSpec { view_year,
|
||
view_month, selected, today, week_start, palette, on_select: Fn(NaiveDate)->Msg,
|
||
on_view_change: Fn(year, month)->Msg })`). Header con `< Mes Año >` para navegar
|
||
entre meses, fila de iniciales (`L M M J V S D` ó `D L M M J V S` según
|
||
`WeekStart`), grilla siempre de 6 filas (no reflowea al cambiar de mes). El
|
||
caller le inyecta `today` — el widget no toca el reloj. Base del date-picker:
|
||
combinar con `view_overlay` + un `field`/`button` disparador.
|
||
`calendar_view_ext(spec, CalendarOpciones { limites, rango, navegacion_anual })`
|
||
suma lo que un `<input type=date>` sí tiene, sin tocar a quien ya usa
|
||
`calendar_view` (el `Default` de las opciones **es** el calendario de siempre):
|
||
`Limites{min,max}` inclusive apaga los días fuera de rango (`aria_disabled`, sin
|
||
click) y las flechas cuyo mes destino esté entero afuera (`mes_habil`);
|
||
`RangoSeleccion` pinta la banda entre dos extremos y avanza sola con
|
||
`.click(fecha)` (1º fija inicio, 2º cierra **ordenando**, 3º empieza de nuevo;
|
||
dos clicks al mismo día = rango de un día), con `contiene`/`es_extremo`/`dias`/
|
||
`recortado(&limites)` —que **interseca**, no clampea cada punta— para el host;
|
||
`navegacion_anual` agrega una fila `« año »` arriba de la de mes (fila aparte y
|
||
no cuatro flechas en una: el ancho lo fija la grilla y «septiembre» no entraría).
|
||
Teclado: `cal_move_desde_tecla(&KeyEvent) -> Option<CalMove>` es el mapa del
|
||
picker nativo (flechas = día/semana, PageUp/Down = mes, **Ctrl**+PageUp/Down =
|
||
año, Home/End = bordes del mes) y `nav_date_limitada(fecha, mov, &limites)`
|
||
aterriza en el borde en vez de salirse.
|
||
|
||
**accordion** — secciones plegables (expansion panels).
|
||
`accordion_view(AccordionSpec{secciones, abiertas: &[usize], modo, on_toggle,
|
||
palette})` con `Modo::{Una, Varias}`; la máquina de qué queda abierto es
|
||
`alternar(&abiertas, i, modo) -> Vec<usize>` (pura) y `todas(n, modo)` da el
|
||
«expandir/plegar todo» — en `Modo::Una` devuelve **una**, porque devolver todas
|
||
dejaría el `Model` en un estado que el propio modo prohíbe. El cuerpo llega como
|
||
`Fn() -> View` y **no se construye si la sección está cerrada**: en un acordeón
|
||
de veinte secciones con formularios adentro, armar los diecinueve invisibles es
|
||
el costo real. `Seccion::con_resumen("Visa ••4242")` muestra el dato a la
|
||
derecha **sólo cuando está cerrada**.
|
||
|
||
**stepper** — asistente por pasos. `stepper_view(StepperSpec{pasos: &[Paso],
|
||
actual, cuerpo, on_ir, on_finalizar, palette})`; `Paso::{pendiente, actual,
|
||
hecho, con_error}`. La regla de navegación es pura: `puede_ir_a(pasos, actual,
|
||
destino)` —atrás siempre, adelante sólo si el actual está `Hecho`, de un salto
|
||
sólo si **todos** los intermedios lo están—, más `siguiente`/`anterior`/
|
||
`es_ultimo`/`hechos`. En el último paso el botón dice **Finalizar** y emite otro
|
||
`Msg`. Los botones del pie y los pasos inalcanzables se **apagan** con
|
||
`aria_disabled`, no desaparecen ni quedan mudos.
|
||
|
||
**form** — **validación** de formularios sobre el `field`. Tres piezas, y la
|
||
tercera es la que se hace mal en todos lados:
|
||
```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<Id> // qué está mal: TODOS
|
||
estado.tocar(id) · estado.enviar() · estado.visibles(&errores) // cuándo se dice
|
||
campo_view(&form, id, control, &visibles, &pal) // etiqueta + asterisco + error
|
||
resumen_errores_view(&visibles, Msg::IrA, &pal) -> Option<View>
|
||
```
|
||
`Regla::{Requerido, LargoMin, LargoMax, Numero, Rango, Correo, Propia(fn)}`. Un
|
||
valor **vacío pasa todas menos `Requerido`** (si no, un campo opcional en blanco
|
||
se queja de formato). El error se muestra al **salir** del campo o al **intentar
|
||
enviar**, nunca mientras se tipea por primera vez; tras el submit se muestran
|
||
todos, porque esconder alguno sería mentir sobre por qué no se envió.
|
||
`campo_view` saca del formulario la etiqueta, el **asterisco de obligatorio**
|
||
(derivado de las reglas, no de un flag aparte) y el error: los tres dejan de
|
||
escribirse dos veces. `resumen_errores_view` devuelve `None` si no hay nada que
|
||
decir, y cada ítem es clickeable para ir al campo — en un formulario largo el
|
||
error puede quedar fuera de pantalla y sólo se ve que el botón no hizo nada.
|
||
|
||
**datetime-picker** — pickers de **fecha** y **hora**, los dos con la misma
|
||
forma: un `*_trigger` en el formulario (muestra el valor formateado o el
|
||
placeholder, emite `Msg` al click) y un `*_popup` que el host monta en
|
||
`App::view_overlay` con scrim que cierra al click-fuera. Sin estado propio: el
|
||
valor, si está abierto y el mes en foco viven en el `Model`.
|
||
```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<Msg>, tab_height, palette, tab_width })`. Selección la maneja la app.
|
||
`tabs_view_sliding(spec, indicator_t)` cambia el acento por-tab (que se prende de
|
||
golpe) por un **realce único que se desliza** hasta `indicator_t` — el índice
|
||
animado que el host tween-ea con `llimphi-motion` hacia el activo. Como cada tab
|
||
mide lo que dice su label, interpola **posición y ancho** a la vez
|
||
(`indicator_bounds_px(&anchos, t)`; los anchos con `tab_widths(&labels,
|
||
tab_width, closable)`, la misma cuenta que el render y que `strip_width`), así se
|
||
estira al ir hacia un tab más largo. Con overflow viaja dentro de la tira, o sea
|
||
que el `strip_offset` lo arrastra solo. `indicator_t = active as f32` da la
|
||
posición estática, para un host que no quiera animar.
|
||
|
||
**router** — navegación por **pila de rutas** con transiciones de página
|
||
(el `Navigator` de Flutter, a la Elm). La pila vive en el Model; el widget
|
||
pinta sólo la ruta actual y anima el cambio con la infra de ghosts del
|
||
compositor (`animated_switch_styled`). Push entra desde la derecha y lo viejo
|
||
retrocede 30 % (shared-axis X); pop lo inverso; `Zoom` = fade-through;
|
||
`Cover` = **push opaco estilo UIKit** (la nueva tapa sin fade; el pop desliza
|
||
la vieja por encima revelando — scene-split de fantasmas: el saliente se
|
||
pinta DEBAJO del entrante, en su posición z real); `None` = corte seco sin
|
||
captura. Los **hero** cruzan rutas gratis (misma key en ambas páginas).
|
||
```rust
|
||
// Model: nav: RouteStack<Ruta> (enum propio: Clone + PartialEq + Hash)
|
||
m.nav.push(Ruta::Detalle(7)); m.nav.pop(); m.nav.replace(r); m.nav.pop_to_root();
|
||
router_view(key, &m.nav, PageTransition::{None,Fade,Slide,Zoom,Cover}, dur,
|
||
page: Fn(&Ruta) -> View<Msg>) // mide su slot vía LayoutBuilder
|
||
router_view_sized(key, &m.nav, tr, dur, (w, h), page) // slot explícito — para
|
||
// páginas que anidan otro layout_builder (límite v1 del seam: sin anidar)
|
||
```
|
||
Demo: `cargo run -p llimphi-widget-router --example router_demo --release`
|
||
(hero volando entre Lista y Detalle; `T` cicla la transición, Esc vuelve).
|
||
|
||
**hero** — shared-element transition (el Hero de Flutter auténtico): un nodo
|
||
con la misma `key` que aparece en otro rect en un frame posterior **vuela**
|
||
del rect viejo al nuevo (el `HeroRegistry` del runtime interpola `transform`).
|
||
`hero_view(key, child)` (480 ms) / `hero_quick(key, child)` (320 ms), o el
|
||
primitive `View::hero(key, dur)` directo. Key estable y única entre los heroes
|
||
de un mismo frame.
|
||
|
||
**splitter** — divisor draggable de 2 panes. `splitter_two(Direction::{Row,Column},
|
||
a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette)`.
|
||
`PaneSize::{Fixed(px), Flex}`. La app acumula el delta en su Model (con
|
||
`apply_resize(actual, delta, min, max)`, que clampea por paso).
|
||
`splitter_two_ext(…, SplitterOpciones { on_toggle, focus_id })` agrega el
|
||
**doble-click** sobre el divisor y lo hace **enfocable**, que es la puerta del
|
||
resize por teclado: con el foco puesto, el host traduce la tecla con
|
||
`ajuste_desde_tecla(&ev, direction) -> Option<AjusteSplitter>` (las flechas del
|
||
eje del divisor —←/→ en `Row`, ↑/↓ en `Column`—, `Shift` para el paso grande,
|
||
Home/End a los topes, Enter/Espacio para alternar) y la aplica con
|
||
`aplicar_ajuste`. El colapso lo resuelve `alternar_colapso(actual, recordado,
|
||
por_defecto) -> (nuevo, a_recordar)`: guarda **cuánto** medía, no que estaba
|
||
abierto, así restaurar devuelve el layout del usuario y no un ancho de fábrica.
|
||
|
||
**scroll** — área de scroll vertical con barra arrastrable. `scroll_y(offset,
|
||
content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette)`.
|
||
Stateless (offset en el Model); rueda autocontenida. Helpers: `clamp_offset`,
|
||
`ensure_visible` (selección a la vista), `approach` (scroll suave). Ver §8.
|
||
Para darle scroll al cuerpo de un widget de filas está la pieza **compartida**
|
||
—la usan `list`, `tree` y `detail-table`, y por eso vive acá y no copiada tres
|
||
veces—: `cuerpo_scrolleable(contenido, contenido_len, DesplazamientoV{offset,
|
||
viewport_len, on_scroll}, medidor: Option<AltoVisible>, &palette)`.
|
||
`AltoVisible` publica el alto **medido** desde el pintado (el alto disponible no
|
||
existe hasta que taffy repartió) y `offset_efectivo(offset, viewport_len)` pinta
|
||
desde arriba mientras no haya medida — `clamp_offset` con viewport `0` da por
|
||
bueno cualquier offset, así que sin esto una lista re-montada con offset
|
||
guardado arranca desplazada y salta al cuadro siguiente.
|
||
|
||
**lazy-list** — lista **perezosa** llave-en-mano (Bloque 22 de
|
||
`PLAN-NUCLEO-RETENIDO.md`). A diferencia de `list`/`grid` (el caller windowea a
|
||
mano) y de `scroll_y` (recibe el contenido ya construido), recibe un **builder**
|
||
`item: Fn(usize) -> View` + `total` y **sólo llama `item(i)` para la ventana
|
||
visible + overscan** — las filas fuera de pantalla no se construyen, no se
|
||
montan, no se layoutean (costo/frame ∝ lo visible, no ∝ el total). Sin estado
|
||
(offset en el Model); reusa la barra/física de `scroll`.
|
||
```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<f32> // mide cada ítem → offsets acumulados
|
||
// el caller lo llama al cambiar contenido/ancho, cachea el Vec, y por frame usa
|
||
// lazy_list_varied(&offsets, ...). Usa llimphi_compositor::measured_height(view, ancho).
|
||
// SECCIONES con encabezado sticky llave-en-mano (agenda/contactos):
|
||
lazy_list_sections(rows_por_seccion: &[usize], item_h, header_h, offset, viewport_h,
|
||
overscan, header: Fn(s)->View, item: Fn(s, fila_local)->View, on_scroll, &palette)
|
||
// el header de la sección activa queda PINEADO al tope y la siguiente lo empuja;
|
||
// darle fill opaco (pinta encima de las filas). Ventana O(#secciones + visibles).
|
||
// puros: section_tops, sections_window -> Vec<SectionUnit>, sticky_section
|
||
// re-exporta de scroll: clamp_offset, fling_step, rubber_band, sticky_y, DEFAULT_OVERSCAN, ScrollPalette
|
||
```
|
||
Demo: `cargo run -p llimphi-widget-lazy-list --example stress_lazy --release -- --nodes 1000000`.
|
||
|
||
**tiled** — grilla auto cols×rows de tiles con title bar. `tiled_view(tiles, &palette)`,
|
||
`tiled_view_cols(tiles, cols, &palette)`, y variantes `*_reorderable*` con
|
||
`on_reorder: Fn(from, to)->Option<Msg>` (drag-to-swap por la title bar). `TileSpec { label, content }`.
|
||
|
||
**panes** — árbol binario BSP tipo tmux. La app guarda un `Layout`:
|
||
```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<Side>,DragPhase,delta)->Option<Msg>,
|
||
on_focus: Fn(PaneId)->Msg, &palette)
|
||
```
|
||
|
||
**dock-rail** — rail vertical de **dientes** para sidebars acoplables. Cada
|
||
diente es una pestaña: **barra de acento de 3px pegada al borde interno**
|
||
(encendida cuando está activo) + icono centrado. Apilados en una franja
|
||
flotante que **sobresale del panel** hacia el centro — el patrón canónico
|
||
de cosmos (`01_yachay/cosmos/cosmos-app-llimphi/src/chrome.rs`:
|
||
`dock_rail_overlay` / `dock_panel_for`). Cada item se identifica por un `u64`
|
||
opaco; el caller mantiene el orden, qué está activo, y el mapping
|
||
`u64 ↔ DominioPropio` (típicamente vía `enum::{to_u64,from_u64}`). Cada
|
||
diente es **arrastrable** (su id como payload) y el rail entero es **drop
|
||
target** → mover un diente de un sidebar a otro soltándolo sobre el rail
|
||
opuesto. Render-only, agnóstico del `Msg`.
|
||
```rust
|
||
DockRailItem { id: u64, active: bool }
|
||
dock_rail_view(&items, width: f32, &DockRailPalette,
|
||
make_icon: Fn(id, size_px, color) -> View<Msg>,
|
||
on_activate: Fn(id) -> Msg, // dispara en el press, no pelea con el drag
|
||
on_drop: Fn(payload_id) -> Option<Msg>) // None = el rail no acepta drops
|
||
```
|
||
Layout canónico: el rail va como **overlay absoluto** pegado al borde interno
|
||
del centro (los dientes sobresalen sobre la rueda/canvas), y el panel del
|
||
item activo va al costado como un pane en el área resizable. NO es una lista
|
||
con rótulo: leer cosmos antes de inventar UX (lección 2026-06-05).
|
||
|
||
**toolbar** — barra de herramientas moderna: **grupos** de botones-ícono
|
||
planos (hover redondeado; activo = fondo de selección + ícono en acento;
|
||
deshabilitado atenuado y sin click) con separadores sutiles entre grupos.
|
||
Los íconos los dibuja el caller vía closure (mismo contrato que dock-rail);
|
||
los grupos son **datos** (`Vec<ToolbarGroup>`) → la barra es componible y
|
||
configurable por construcción. Uso canónico:
|
||
`nahual-shell-llimphi::shell_toolbar` (navegación + modos de vista + acciones).
|
||
```rust
|
||
ToolbarItem::new(icon: Fn(size_px, color) -> View<Msg>, on_click: Msg)
|
||
.with_label("subir").active(bool).enabled(bool)
|
||
ToolbarGroup::new(vec![items...])
|
||
toolbar_view(groups: Vec<ToolbarGroup<Msg>>, height: f32, &ToolbarPalette) -> View<Msg>
|
||
// Overflow + tooltips (aditivo; Default = la barra de siempre):
|
||
let medidor = AnchoBarra::new(); // se lee el cuadro siguiente
|
||
let anchos = item_widths(&groups); // misma cuenta que el render
|
||
let visibles = overflow_count(&anchos, medidor.px(), MORE_W, ITEM_GAP);
|
||
let ocultos = items_ocultos(&groups, visibles); // para armar el popup
|
||
toolbar_view_ext(groups, 36.0, &pal, ToolbarOpciones {
|
||
medidor: Some(medidor), visibles: Some(visibles),
|
||
on_overflow: Some(Msg::AbrirMas), // dibuja «⋯» sólo si sobra algo
|
||
on_hover: Some(Arc::new(|i| Msg::Tooltip(i))), // Some(i) al entrar, None al salir
|
||
})
|
||
```
|
||
El **ancho disponible** no se sabe hasta que taffy repartió, así que `AnchoBarra`
|
||
lo publica desde el pintado y el host lo usa en el cuadro siguiente (un frame de
|
||
latencia en el primero y en el resize — lo mismo que hace cualquier toolbar con
|
||
overflow). `item_offsets(&groups)` da `(left, width)` de cada ítem contando
|
||
padding, gaps **y separadores**: es el ancla del `tooltip` (que es render puro y
|
||
lo monta el host en su `view_overlay`). El menú de overflow también lo monta el
|
||
host, con `context_menu_view`; `items_ocultos` le da label/estado/`Msg` en orden
|
||
para que el índice del `on_pick` no se desincronice.
|
||
|
||
**grid** — grilla 2D virtualizada. `ventana_visible(total, vp_w, vp_h, scroll_fila,
|
||
&metrics) -> VisibleWindow` para virtualizar, luego `grid_view(GridSpec { cells:
|
||
Vec<GridCell { content, label, selected, on_click }>, cols, metrics, caption, ... })`.
|
||
|
||
**list** — lista vertical virtualizada. `list_view(ListSpec { rows: Vec<ListRow {
|
||
label, selected, on_click }>, total, caption, truncated_hint, row_height, palette })`.
|
||
La app prefiltra las filas visibles. `list_view_ext(spec, ListOpciones{scroll,
|
||
medidor})` le da **scroll real** (no sólo `clip`): las filas se desplazan y
|
||
tienen barra arrastrable, mientras el `caption` y el aviso de truncado quedan
|
||
fijos — son rótulos de la lista, no contenido de ella.
|
||
|
||
**tree** — árbol expand/collapse. `tree_view(TreeSpec { rows: Vec<TreeRow { label,
|
||
depth, has_children, expanded, selected, on_toggle, on_select }>, row_height,
|
||
indent_px, palette })`. La app aplana el árbol según nodos expandidos.
|
||
`tree_view_ext(spec, TreeOpciones{scroll, medidor})` le da el mismo scroll real
|
||
que la `list`.
|
||
|
||
**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: Option<Fn(u64)->Msg>)
|
||
// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
|
||
// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
|
||
// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.
|
||
```
|
||
|
||
**app-header** — `app_header(label, actions: Vec<View<Msg>>, &palette)`.
|
||
|
||
**status-bar** — `status_bar_view(left, center, right, &palette)` con
|
||
`StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized()`.
|
||
|
||
**breadcrumb** — `breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)`
|
||
(el último segmento no es clickable).
|
||
|
||
**modal** — diálogo centrado con scrim. `modal_view(ModalSpec { title, body:
|
||
View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette })`.
|
||
`ModalButton::{primary, cancel, destructive}(label, msg)`. Se monta en `view_overlay`.
|
||
|
||
**toast** — notificaciones efímeras bottom-right. La app guarda `Vec<Toast>`
|
||
(`Toast::{info,success,warning,error}(id, text, duration)`), filtra
|
||
`is_alive(now)`, y `toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg)`.
|
||
|
||
**splash** — splash de arranque (cuatro cuadrantes andinos). `splash_view(started_at:
|
||
Instant, bg, fg_text)`; basado en tiempo, requiere redraws.
|
||
|
||
### Ricos / interactivos
|
||
|
||
**nodegraph** — lienzo de nodos + cables Bezier. Sin estado (la app guarda
|
||
posiciones y `Wire`s).
|
||
```rust
|
||
NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
|
||
Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
|
||
nodegraph_view(&nodes, &wires, &palette, &metrics,
|
||
on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
|
||
on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
|
||
// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)
|
||
```
|
||
|
||
**timeline** — scrub clickeable. `timeline_view(progress: f32, &palette,
|
||
on_seek: Fn(f32 [0..1])->Option<Msg>)`.
|
||
|
||
**transport** — botones de **transporte de reproductor** (play/pause/prev/
|
||
next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq). Stateless:
|
||
el caller arma un `TransportButton` por cada botón con el flag que define su
|
||
estado activo (p.ej. `PlayPause { playing }`, `Mute { muted }`,
|
||
`Record { recording }`), lo pasa al widget, y recibe el `TransportAction`
|
||
semántico en un closure cuando el usuario clickea. El widget elige el icono
|
||
(de `llimphi-icons`), el color activo, el rojo de REC y arma la acción
|
||
(p.ej. `SeekBack { secs }` → `TransportAction::SeekBy(-secs)`). Quien
|
||
traduce `TransportAction` al comando del dominio (`MediaCommand`, etc.) es
|
||
la app.
|
||
```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<Msg>
|
||
```
|
||
Para una fila, el caller mapea su `Vec<TransportButton>` con
|
||
`.iter().map(|b| transport_button_view(*b, &pal, on_action.clone()))` y los
|
||
pone como children de una `View` con `flex_direction: Row` y `gap:
|
||
palette.gap`. Consumidor de referencia: `02_ruway/media/media-app::bar_item_view`.
|
||
|
||
**waveform** — visor de **forma de onda en vivo**. Stateless y agnóstico de
|
||
cpal/AudioProbe: el caller pasa un closure `Fn(&mut Vec<f32>) -> u16` que
|
||
rellena un buffer con los últimos samples intercalados y devuelve cuántos
|
||
**canales** trae; el widget hace el fold a mono y dibuja un **envelope
|
||
min/max por columna** (polígono cerrado con relleno tenue + stroke top/bot)
|
||
sobre una línea central que siempre está como "ground" — paint-only, sin
|
||
mouse. Si el closure devuelve `0` canales, el widget pinta sólo la línea
|
||
central ("visor vivo, sin señal").
|
||
```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<f32>) -> u16 + Send + Sync + 'static,
|
||
palette: &WaveformPalette) -> View<Msg>
|
||
```
|
||
Consumidor de referencia: `02_ruway/media/media-app::waveform_panel` (cablea
|
||
un `AudioProbe::snapshot`).
|
||
|
||
**table** — tabla **editable** de celdas-texto: filas/columnas + agregar/quitar
|
||
fila + foco por celda. Stateless — la app posee el foco y el buffer de la
|
||
celda en edición; el resto se pinta desde `&rows`. Pensado para datos planos
|
||
editables (paramétricos, scratchpad, listas curables); no es la `list`/`tree`
|
||
virtualizada read-only ni un grid de hoja de cálculo.
|
||
```rust
|
||
table_view(&[String], headers, // [] = sin header
|
||
&[Vec<String>] rows,
|
||
focused: Option<(row, col)>,
|
||
focused_state: Option<&TextInputState>, // buffer prestado de la celda en edición
|
||
add_label: &str, &TablePalette,
|
||
on_focus_cell: Fn(row, col) -> Msg, // clic en celda
|
||
on_remove_row: Fn(row) -> Msg,
|
||
on_add_row: Fn() -> Msg)
|
||
list_view(&[String], items, focused_row, focused_state, // sola columna sin header
|
||
add_label, &palette, on_focus_cell: Fn(row)->Msg, on_remove_row, on_add_row)
|
||
table_height(n_rows, has_header) -> f32 // pre-calcular alto del bloque
|
||
```
|
||
La variante `list_view` no se confunde con la `list` virtualizada de §13:
|
||
ésta es **una sola columna editable** que delega en `table_view([], ..)`.
|
||
|
||
**detail-table** — la vista *detalle* de un file manager: grilla read-only con
|
||
columnas `ColWidth::{Flex(peso), Fixed(px)}` y encabezados que ordenan (▲/▼).
|
||
`detail_table_view(DetailSpec{columns, rows, sort, row_height, caption,
|
||
palette}, on_sort)`; `detail_table_view_dnd(..)` agrega arrastre por fila.
|
||
`detail_table_view_ext(spec, on_sort, DetailOpciones{scroll, medidor,
|
||
on_resize})` suma **encabezado fijo con cuerpo scrolleable** y los **handles de
|
||
resize** entre columnas:
|
||
```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<usize> // permutación estable
|
||
TipoCol::{Texto, Numero, Natural, Propio(fn)} // «archivo2» < «archivo10»
|
||
Pagina{indice, tamano}: rango(total) · total_paginas · acotada(total) · etiqueta
|
||
botones_pagina(actual, total, max) -> Vec<BotonPagina> // 1 … 4 5 6 … 20
|
||
pager_view(pagina, total_filas, Fn(usize)->Msg, &palette)
|
||
```
|
||
`orden_de_filas` devuelve **índices**, no filas movidas: así ordena sin conocer
|
||
el tipo del caller (una `DetailRow` lleva `Msg` y `View`) y sin perder el orden
|
||
original, que es al que vuelve el tercer click. `Numero` manda al **final** lo
|
||
que no parsea (un «—» encabezando no es información) y no hay variante `Fecha`:
|
||
en ISO ordena bien como `Texto`, y para otros formatos está `Propio` — un parser
|
||
de fechas acá sería un segundo parser distinto del del `datetime-picker`.
|
||
|
||
**text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda
|
||
`EditorState`:
|
||
```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<Msg>
|
||
text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer)
|
||
text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer)
|
||
text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer)
|
||
syntax_palette_dark(&theme) -> SyntaxPalette // en lib.rs del widget
|
||
```
|
||
|
||
**text-editor-core** — núcleo **agnóstico** (sin GPU, sin Llimphi; sólo
|
||
`peniko::Color`). Reutilizable en TUI/web/headless. Tipos clave:
|
||
- `Buffer` (sobre `ropey`): `from_str`, `text`, `insert(offset,s)`, `delete(s,e)`,
|
||
`offset_to_pos`, `pos_to_offset`, `slice`, `line(n)`.
|
||
- `Pos { line, col }`, `Selection { anchor, caret }`, `Cursor { caret, anchor:
|
||
Option, desired_col }` con `move_left/right/up/down/word_left/...`,
|
||
`selection_range(&buf)`, `collapse`.
|
||
- Ops: `replace_selection`, `delete_backward/forward`, `indent_or_insert_tab`,
|
||
`insert_newline_auto_indent` → devuelven `EditDelta { start, removed, inserted,
|
||
cursor_before, cursor_after }` con `.apply()/.undo()`.
|
||
- `UndoStack`: `push(delta)`, `undo/redo(&mut buf, &mut cursor) -> bool`, `can_undo/redo`.
|
||
- `FindState { query, case_sensitive }`: `all_matches`, `find_next`, `find_prev`.
|
||
- Matching de brackets: `find_bracket_pair(&buf, &cursor) -> Option<(Pos, Pos)>`, `Direction`.
|
||
- `Clipboard` (trait `get/set`), `MemClipboard`, `NullClipboard`.
|
||
- `Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity,
|
||
message: String, source: Option<String> }` (+ ctors `error(..)`, `warning(..)`);
|
||
`Severity::{Error, Warning, Information, Hint}`.
|
||
- Highlight tree-sitter: `Language::{Plain, Rust, Python, Wat}`
|
||
(+ `Language::from_cell_language(s)`); `Highlighter::new(lang)` con
|
||
`.highlight(&mut self, source: &str) -> Vec<Vec<Span>>` (un `Vec<Span>` **por
|
||
línea**), `.set_language(lang)`, `.language()`; helpers de módulo
|
||
`invalidate_tree_cache(lang)` y `apply_pending_edits(lang, &edits)` para el
|
||
caché incremental. `TokenKind`, `Span`, `SyntaxPalette::color(kind)`.
|
||
|
||
**text-editor-lsp** — cliente LSP por stdin/stdout. `trait LspClient` (fire-and-forget
|
||
`request_*` + lecturas de caché `latest_*`/`clear_*`): completions, hover,
|
||
definition, references, rename, formatting, signature help, document symbols.
|
||
`RustAnalyzerClient::start(workspace_root)`; `NoopLspClient` para tests.
|
||
|
||
**terminal** — superficie de **scrollback infinita virtualizada** (modo línea /
|
||
bloques / grilla GPU). Reemplaza al `output_pane` clásico: el costo de render
|
||
es **constante** a scrollback ilimitado — sólo se materializa la ventana visible
|
||
— bajo un `scroll_y` **propio del widget** (no `transform` del padre, la
|
||
anti-feature del SDD). Núcleo agnóstico, el caller arma el modelo y el widget
|
||
pinta (Regla 2). Diseño completo: `02_ruway/shuma/SDD-TERMINAL.md`. Consumidor
|
||
de referencia: `shuma-module-shell::output_pane_surface`.
|
||
- **Store** (`Scrollback::new(limit_bytes)`): append-only por línea con índice
|
||
de offsets O(1), cap por memoria con recorte de frente, `line_id` global
|
||
**estable** que sobrevive al recorte (`index_of_id`), `push_line`,
|
||
`slice_text`, `clear`. `dropped()` / `total_pushed()` para el `numbering`
|
||
global.
|
||
- **Spill a disco** (`SpillStore::create(path)` + `Scrollback::enable_spill(spill)`):
|
||
cada línea recortada del frente se appendea al archive; lookup O(1) con
|
||
`read_spilled(global_id)` + helpers `has_spill`/`spilled_count`/`spill_path`
|
||
para que el host exponga UX (chip de status, builtin `:scrollback open`,
|
||
prepend del archive al view).
|
||
- **Modo línea** (`line_surface(store, scroll_y, viewport_h, metrics, &palette,
|
||
line_style: Fn(usize, &str) -> LineStyle, on_scroll: Fn(f32) -> Msg, measure:
|
||
Option<Arc<Mutex<f32>>>)`): materializa sólo la ventana visible; scroll
|
||
sub-renglón (`partial_px`), numeración global 1-based, color base + runs +
|
||
tinte de fondo inyectados por el caller en `LineStyle`. Helpers puros:
|
||
`visible_window`, `content_height`, `scroll_to_bottom`. `measure` opcional
|
||
publica el `viewport_h` real medido al host (regla del SDD: verificar con
|
||
viewport real).
|
||
- **Modo bloques** (`block_surface(store, items: Vec<Item<Msg>>, ...)`): el
|
||
stream es una secuencia de `Item::chrome(height, view)` (header/badge/etapa
|
||
de alto fijo que el caller pinta) + `Item::lines(start, end)` (rango del
|
||
store). Virtualiza sobre alturas mixtas con búsqueda binaria (O(log n) en
|
||
bloques) — dentro de un `Lines` enorme sólo se materializan las sub-filas
|
||
visibles. Colapsar = no emitir el `Lines`. Helpers: `blocks_height`,
|
||
`blocks_scroll_to_bottom`, `gutter_width(&store, metrics)`,
|
||
`line_top_in_content(&items_geo, row_h, target_line)`.
|
||
- **Selección + copy** (`block_surface_with_selection` + `SelectionConfig { range:
|
||
Option<&SelectionRange>, on_drag: Fn(DragPhase, lx0, ly0, dx, dy) -> Option<Msg>,
|
||
on_double_click: Fn(lx, ly, w, h) -> Option<Msg> }`): el widget pinta el
|
||
overlay translúcido y dispara callbacks; el caller mantiene el rango
|
||
(`SelectionRange { anchor: Point, head: Point }`, `Point { line, col }` con
|
||
`col` en **bytes** UTF-8) en su Model y resuelve clicks con `point_at(items,
|
||
scroll_y, viewport_h, metrics, gutter_w, store, lx, ly)` —o su variante
|
||
`Copy`-stashable `point_at_geo(&[ItemGeo], ...)` para resolver el frame
|
||
anterior. `selection_rects` para overlays custom.
|
||
- **Find** (`find_matches(&store, query, FindOpts { case_insensitive })`):
|
||
búsqueda sobre el scrollback **completo** (no sólo el viewport), O(total_bytes);
|
||
`FindMatch { line, start, end }` con `start`/`end` en bytes UTF-8 slice-safe.
|
||
Nav: `next_match(&matches, current)` / `prev_match`. El caller resuelve el
|
||
`line` a su `scroll_y` con `line_top_in_content`.
|
||
- **Grilla GPU** (modo TUI, Fase 4): `CellPipeline::new(device, format)` +
|
||
`GlyphAtlas` rasterizan cada celda con **quads instanciados** y atlas de
|
||
glifos (precedente: `atlas` de wawa, Fontdue) — una sola draw call por
|
||
viewport, escala a 200×80 sin penalty. Tipos: `CellInstance { cell_x/y,
|
||
uv_x/y/w/h, fg_rgba, bg_rgba }` (POD 32 B), `CellUniforms`, `pack_rgba`,
|
||
`instances_to_bytes`, `CELL_WGSL`, `CellPipeline::create_atlas_texture`.
|
||
Wireado al widget contenedor vía `gpu_paint_with`. Activo en shuma con
|
||
`SHUMA_GPU_GRID=1`.
|
||
|
||
Headless dumps:
|
||
```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<usize>, theme, viewport, height, on_open: Fn(Option<usize>)->Msg,
|
||
on_command: Fn(&str)->Msg })`; dropdown en `view_overlay` con `menubar_overlay(spec)`
|
||
o `menubar_overlay_animated(spec, active, appear)`. Navegación por teclado:
|
||
`menubar_nav`, `menubar_command_at`.
|
||
|
||
**edit-menu** — menú estándar de edición sobre un editor.
|
||
`EditFlags::from_editor(&state, masked)`, `edit_context_menu(anchor, viewport,
|
||
&theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss)` →
|
||
`ContextMenuSpec`. `apply(&mut state, EditAction, &mut clipboard) -> ApplyResult`.
|
||
`EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}`.
|
||
|
||
**context-menu** — menú contextual genérico (panel elevado con sombra + borde +
|
||
esquinas redondeadas). `ContextMenuItem::action(label).with_shortcut(..).icon(..)
|
||
.checked(bool).disabled().destructive().submenu(children)` o `::separator()`
|
||
(`checked` pinta ✓ en el gutter y alterna estado). `context_menu_view(ContextMenuSpec
|
||
{ anchor, viewport, header, items, active, on_pick: Fn(usize)->Msg, on_dismiss,
|
||
palette })`; `context_menu_view_ex` con submenús (flyout que **flipea** a la izq. si
|
||
no cabe) + animación de aparición. Se monta en `view_overlay` con scrim. **Teclado
|
||
(helpers puros que el host cablea):** `step_active(items, cur, ±1)` (↑/↓, saltea
|
||
sep/disabled), `edge_active(items, to_end)` (Inicio/Fin), `type_ahead(items, cur, ch)`
|
||
(mnemónico por 1ª letra). Submenú por teclado = `step_active` sobre `parent.children`.
|
||
Demo completo: `cargo run -p llimphi-widget-context-menu --example menu_demo`.
|
||
|
||
**theme-switcher** — `theme_switcher_view(¤t: &Theme, on_change: Fn(Theme)->Msg)`
|
||
(+ `_styled`/`_flex`). Cicla `Theme::next_after`.
|
||
|
||
**shortcuts-help** — overlay "?" con atajos agrupados. `shortcuts_help_view(
|
||
ShortcutsHelpSpec { title, groups: Vec<ShortcutGroup { title, entries:
|
||
Vec<ShortcutEntry { keys, description }> }>, viewport, on_dismiss, palette })`.
|
||
|
||
**wawa-mark** — sello vectorial del SO wawa. `wawa_mark_view(&WawaMarkPalette)`;
|
||
`paint_mark(scene, rect, &palette)` para canvas custom. Usar en contenedor cuadrado.
|
||
|
||
---
|
||
|
||
## 14. Catálogo de módulos
|
||
|
||
Los módulos encapsulan **estado + comportamiento** (a diferencia de los widgets).
|
||
Todos siguen el mismo contrato:
|
||
|
||
```
|
||
State + Msg + Action + apply(state, msg, ...) -> Action
|
||
+ on_key(state, &KeyEvent) -> Option<Msg>
|
||
+ open_shortcut(&KeyEvent) -> bool
|
||
+ view(state, ..., to_host: F) -> View<HostMsg>
|
||
+ Palette
|
||
```
|
||
|
||
La app guarda `Option<ModuleState>` (o el state directo, p. ej. bookmarks),
|
||
rutea el atajo de apertura con `open_shortcut`, rutea teclas con `on_key`, aplica
|
||
`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/<nombre>/`.
|
||
|
||
| Módulo | Atajo | Acción que devuelve | Propósito |
|
||
|---|---|---|---|
|
||
| **command-palette** | `Ctrl+Shift+P` | `Invoke(String)` | paleta de comandos fuzzy. El host declara `&[Command]` |
|
||
| **file-picker** | `Ctrl+P` | `Open(PathBuf)` | fuzzy file picker; host pasa `&[PathBuf]` + `root` |
|
||
| **fif** (find-in-files) | `Ctrl+Shift+F` | `OpenAt{path,line,col}`, `Searched{..}`, `Replaced{..}` | buscar/reemplazar; dual-view (dialog + barra). `search()` / `replace_all()` hacen el I/O |
|
||
| **diff-viewer** | `Ctrl+Shift+D` | — | diff side-by-side. `DiffState::new(before_label, after_label, before, after)` computa con `similar` |
|
||
| **mini-map** | `Ctrl+Shift+M` | `JumpTo(line)` | minimapa del buffer; agnóstico del editor (recibe `Snapshot`) |
|
||
| **bookmarks** | `Ctrl+Alt+B` toggle, `Ctrl+Shift+B` lista, `Ctrl+Alt+N/P` nav | `JumpTo{path,line}` | marcadores per-file persistentes (state directo, no Option) |
|
||
| **symbol-outline** | `Ctrl+Shift+O` | `GoTo{line,col}` | outline de símbolos; host arma `Vec<SymbolItem>` (LSP/tree-sitter/custom) |
|
||
| **selector** | — | — | abstracción portátil abrir/guardar: `trait Selector` (`HostSelector` con PathBuf, `WawaSelector` placeholder content-addressed) |
|
||
| **plugin-host** | — | `OpenAt{..}`, `SetStatus(..)` | runtime WASM (wasmi) con permisos por bitfield; `PluginHost::load_from_dir`/`invoke(id, cap, args)` |
|
||
| **shuma-term** | `` Ctrl+` `` | `SetStatus(..)` | terminal integrada. `spawn(cwd)` lanza PTY (`shuma_exec`), `vt100::Parser` renderiza; `Tick` drena el PTY |
|
||
|
||
Patrón típico de integración (command-palette):
|
||
```rust
|
||
struct Model { palette: Option<PaletteState>, commands: Vec<Command>, /* … */ }
|
||
enum Msg { Palette(PaletteMsg), /* … */ }
|
||
|
||
// on_key:
|
||
if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); }
|
||
if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); }
|
||
|
||
// update:
|
||
Msg::Palette(m) => {
|
||
if let Some(state) = model.palette.as_mut() {
|
||
match command_palette::apply(state, m, &model.commands) {
|
||
PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; }
|
||
PaletteAction::Close => model.palette = None,
|
||
PaletteAction::None => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// view_overlay:
|
||
model.palette.as_ref().map(|s|
|
||
command_palette::view(s, &model.commands, &palette, Msg::Palette))
|
||
```
|
||
|
||
---
|
||
|
||
## 15. `llimphi-workspace` — chasis tipo tmux
|
||
|
||
Monta cualquier componente en un layout intercambiable con splits resizables
|
||
(máquina de estados de foco/split/cierre + chrome estándar). Construido sobre
|
||
`llimphi-widget-panes`.
|
||
|
||
```rust
|
||
Workspace::new()
|
||
.focused() -> PaneId .count() .leaves() -> Vec<PaneId> .layout() -> &Layout
|
||
.focus(id) .split(Axis) -> PaneId .close() -> Option<PaneId> .resize(&path, delta)
|
||
.apply(WsMsg) -> WsEffect
|
||
|
||
enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec<Side>, f32) }
|
||
enum WsEffect { None, Created(PaneId), Closed(PaneId) }
|
||
|
||
workspace_view(&ws, &WorkspacePalette,
|
||
leaf: FnMut(PaneId)->View<Host>, // materializa el contenido de cada panel
|
||
lift: Fn(WsMsg)->Host) // sube los Msg del chasis a tu Msg
|
||
```
|
||
|
||
Patrón: `enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }`. En `update`,
|
||
`ws.apply(msg)` te avisa con `WsEffect::{Created,Closed}(id)` para que crees o
|
||
destruyas el estado del panel correspondiente.
|
||
|
||
---
|
||
|
||
## 16. Reglas duras y gotchas
|
||
|
||
### 🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE)
|
||
Ningún `update`/`init`/handler puede ejecutar trabajo **síncrono** pesado
|
||
(efemérides, simulación, IO, parse, embeddings, layout de árboles grandes).
|
||
Bloquea el hilo → "Not Responding". **`init` corre dentro de `resumed`, después
|
||
de crear la ventana**, así que un cómputo pesado ahí ya congela una ventana
|
||
visible.
|
||
|
||
Patrón (referencia: `cosmos-app-llimphi`):
|
||
```rust
|
||
// Model: Option<Resultado> (None = "calculando…") + flag dirty + contador de generación.
|
||
struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64 }
|
||
enum Msg { XComputed(u64, Arc<Resultado>) }
|
||
|
||
// al FINAL de update() (que tiene el Handle):
|
||
if m.x_dirty {
|
||
m.x_dirty = false;
|
||
m.x_gen = m.x_gen.wrapping_add(1);
|
||
let (gen, input) = (m.x_gen, m.input.clone()); // sólo lo que el worker necesita (Send)
|
||
handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input))));
|
||
}
|
||
// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un
|
||
// resultado tardío pise a uno más nuevo en drags/toggles rápidos).
|
||
Msg::XComputed(gen, x) => if gen == m.x_gen {
|
||
m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
|
||
}
|
||
```
|
||
La **generación** es imprescindible si el recálculo se dispara seguido. Ver
|
||
[`COMPUTO-FUERA-DEL-HILO-UI.md`](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/<algo>.rs de tu crate
|
||
use llimphi_test::assert_golden;
|
||
|
||
#[test]
|
||
fn mi_card() {
|
||
let v = mi_widget::card(/* … */);
|
||
assert_golden!("mi_card", v, 400, 240); // vs tests/goldens/mi_card.png
|
||
}
|
||
```
|
||
|
||
- **Generar/actualizar goldens:** `LLIMPHI_GOLDEN_UPDATE=1 cargo test -p <crate>`
|
||
(escribe los PNG en `tests/goldens/`; commitéalos).
|
||
- **En fallo:** escribe `mi_card.actual.png` + `mi_card.diff.png` (heatmap) al lado
|
||
del golden y paniquea con `DiffStats { differing_pixels, max_delta, fraction }`
|
||
— la stat numérica **es** la evidencia, no hace falta abrir el PNG.
|
||
- **Tolerancia:** `GoldenConfig { max_channel_delta, max_diff_fraction }` (default
|
||
casi bit-exacto). Para control fino: `Harness::render(...)` + `diff(...)` +
|
||
`compare_golden(path, &rendered, &cfg)` sueltos.
|
||
- **Determinismo:** el adapter de software es bit-exacto (el self-test
|
||
`readback_es_determinista` verifica 0px entre dos renders). Si la máquina no
|
||
tiene lavapipe, cae al adapter normal y la tolerancia absorbe la diferencia.
|
||
- **Path GPU-directo** (`GpuBatch`/`gpu_paint_with`, que no pasa por vello):
|
||
`Harness::render_gpu(w, h, bg, |device, queue, encoder, view, vp| { … })` —
|
||
misma firma que `View::gpu_paint_with`; limpia a `bg` y te deja pintar directo,
|
||
después lee de vuelta para el golden (ver `llimphi-test/tests/gpu_golden.rs`).
|
||
|
||
### El golden es el ÚLTIMO recurso, no el primero
|
||
|
||
Un golden es una imagen bendecida: pasa a ser correcta porque alguien dijo que
|
||
lo era. Eso funciona para «esto no cambió», pero no contesta «¿esto está bien?»,
|
||
y para bendecirla hay que mirarla — justo lo que §8 pide evitar.
|
||
|
||
Casi siempre hay algo mejor: **el oráculo es la otra manera de calcular lo
|
||
mismo**. En vez de comparar contra una imagen, se pinta el mismo árbol por dos
|
||
caminos que tienen que coincidir, y la afirmación pasa a ser objetiva. Los que
|
||
ya existen en el repo, todos escritos así:
|
||
|
||
| test | los dos caminos | resultado |
|
||
|---|---|---|
|
||
| `boundary_cache.rs` | `paint` contra `paint_cached` | 0 px |
|
||
| `gpu_lotes.rs` | un buffer entero contra troceado | idénticos |
|
||
| `gpu_grosores.rs` | un lote con dos plumas contra dos lotes de una | idénticos |
|
||
| `gpu_scissor.rs` | pasada completa contra recorte | mismo píxel exacto |
|
||
| `cpu_vs_gpu.rs` | `vello_cpu` (wawa) contra `vello` (Linux) | tabla por patrón |
|
||
| `wire_vs_directo.rs` | por el cable contra en proceso | bit a bit |
|
||
| `hybrid_vs_cpu.rs` | `vello_hybrid` (Mali) contra CPU | Δ1:0 |
|
||
| `texto_gpu_directo.rs` | atlas de glifos contra vello | Δ64:0 |
|
||
|
||
### Cuatro reglas que salieron de escribirlos
|
||
|
||
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
|
||
|
||
**46 de 70 widgets anotados.** Los 24 restantes lo están **a propósito** — ver
|
||
abajo.
|
||
|
||
Anotados: `app-header` `avatar` `badge` `banner` `breadcrumb` `button`
|
||
`calendar` `chip` `color-picker` `context-menu` `detail-table` `dock-rail`
|
||
`empty` `fab` `field` `gauge` `list` `menubar` `modal` `nodegraph` `progress`
|
||
`range-slider` `rating` `rive-button` `segmented` `select` `shortcuts-help`
|
||
`slider` `spinner` `splitter` `stat-card` `status-bar` `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* — `clipboard` `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`).
|
||
|
||
### 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 <crate> --release # correr una app
|
||
cargo run -p <crate> --example <demo> --release # correr un demo
|
||
|
||
# demos del propio framework:
|
||
cargo run -p llimphi-ui --example counter --release # bucle Elm completo
|
||
cargo run -p llimphi-ui --example editor --release # text field + teclado
|
||
cargo run -p llimphi-ui --example gpu_paint_demo --release
|
||
cargo run -p llimphi-gallery --release # showcase de TODO el kit
|
||
cargo test -p llimphi-gallery # la vitrina como CALLER: los widgets responden
|
||
cargo run -p nada --release # editor real para ejercitar widgets
|
||
|
||
# benchmark GPU directo vs vello (primitivas Y texto — pedile GPU real, en
|
||
# llvmpipe la mitad de rasterizado no decide nada):
|
||
cargo run -p llimphi-gpu-bench --release
|
||
|
||
# perfilador por frame (a stderr cada 60 frames: media/p95 por etapa):
|
||
LLIMPHI_PERF=1 cargo run -p <crate> --release
|
||
# ...con conteo de asignaciones de heap por frame y su desglose por etapa:
|
||
LLIMPHI_PERF=1 cargo run -p <crate> --release --features llimphi-ui/perf-alloc
|
||
|
||
# dónde poner un .repaint_boundary() en TU app (ver más abajo):
|
||
LLIMPHI_BOUNDARY_HINT=1 cargo run -p <crate> --release
|
||
# un update que tarda de más y congela la UI:
|
||
LLIMPHI_UI_SLOW_MS=50 cargo run -p <crate> --release
|
||
```
|
||
|
||
`LLIMPHI_PERF=N` reporta cada N frames (default 60) el desglose
|
||
`view/mount/layout/paint/raster/gpu/present` en µs + `layout-reuso`, el caché de
|
||
`repaint_boundary` y —si está la feature `perf-alloc`— `alloc <media>/<p95>
|
||
(<KB>) [view N·KB mount N·KB layout N·KB paint N·KB resto N·KB]`, donde `resto`
|
||
es el tramo posterior a paint (raster vello + GPU + present) y el desglose suma
|
||
el total. Sin esa feature el tramo de allocs dice `n/a` en vez de ceros falsos.
|
||
Ojo: los contadores son del proceso, no del hilo — lo que asigne otro hilo
|
||
durante una etapa cae en esa etapa. El contador es
|
||
`llimphi_ui::alloc::ContadorAlloc`: si tu app ya elige su allocator, instalalo a
|
||
mano (`#[global_allocator]`) en vez de usar la feature. El camino `cache_hit`
|
||
(frame retenido entero) **no** se muestrea a propósito.
|
||
|
||
### `LLIMPHI_BOUNDARY_HINT` — dónde poner un `repaint_boundary`
|
||
|
||
`.repaint_boundary(clave)` cachea la rasterización de un subárbol quieto y la
|
||
reusa mientras su contenido no cambie (Bloque 23). El problema práctico nunca fue
|
||
la API sino **dónde**: marcar un subárbol que en realidad cambia todos los
|
||
cuadros no es neutro —se paga el hash entero cada frame y no se ahorra nada—, y
|
||
desde afuera un panel quieto y uno que se reconstruye idéntico a sí mismo se ven
|
||
igual. Por eso ninguna app lo usaba.
|
||
|
||
```bash
|
||
LLIMPHI_BOUNDARY_HINT=1 cargo run -p <tu-app> --release # reporta cada 120 cuadros
|
||
LLIMPHI_BOUNDARY_HINT=300 … # cada 300
|
||
```
|
||
|
||
Usá la app un rato con el puntero **fuera** de lo que sospechás. La salida:
|
||
|
||
```
|
||
[llimphi] 2 subárbol(es) quieto(s), 431 nodos que se repintan de gusto
|
||
— poneles .repaint_boundary(clave):
|
||
nodo 12 · 287 nodos · (0,48 260×672) · 118 cuadros quieto «Archivos»
|
||
nodo 640 · 144 nodos · (0,0 1920×48) · 118 cuadros quieto «Inicio»
|
||
```
|
||
|
||
Cada línea es el subárbol **máximo** que vino idéntico: si un panel entero está
|
||
quieto no se listan también sus hijos. El texto es el primero que aparece
|
||
adentro, para que lo reconozcas sin contar nodos.
|
||
|
||
Es una **predicción, no una opinión**: el detector consulta las mismas tres
|
||
funciones que el caché de verdad (posición limpia, paint-puro, y el mismo hash
|
||
que decide hit o miss), así que lo que señala el caché lo va a acertar. Dos
|
||
cosas que no dice:
|
||
|
||
- **Si conviene.** Sugiere «esto habría sido cache-hit»; el hash se paga siempre.
|
||
Por eso ignora subárboles de menos de 16 nodos.
|
||
- **Que esté quieto para siempre.** Un panel quieto 120 cuadros puede cambiar al
|
||
siguiente click, y ahí el boundary falla y repinta — correcto, pero sin ahorro.
|
||
|
||
Con el puntero **adentro** de un subárbol, ni el caché lo sirve ni el detector lo
|
||
sugiere: el hover cambia lo pintado. Y es diagnóstico, no producción — hashea
|
||
cada candidato por cuadro. Encendelo, anotá, apagalo.
|
||
|
||
**Qué descalifica a un subárbol** (el detector se calla y el boundary, si lo
|
||
ponés igual, se pinta fresco): cualquier `paint_with`/`gpu_paint_with`, una
|
||
animación viva, un ripple, un hero, una `mask_image`, o estar bajo un
|
||
clip/alpha/transform de un ancestro. **Degradé e imagen sí entran** desde
|
||
2026-08-05 — antes no, y como un sidebar real casi siempre tiene un ícono o un
|
||
fondo con degradé, un solo nodo así descalificaba el panel entero y el caché no
|
||
servía para lo que existía.
|
||
|
||
`llimphi-gallery` (`src/main.rs`) es la **referencia viva** del
|
||
patrón completo: `Model`/`Msg`/`init`/`update`/`view`/`view_overlay` con overlays
|
||
mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown).
|
||
Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; `?` abre
|
||
atajos; `Esc` cierra el overlay activo.
|
||
|
||
---
|
||
|
||
## 18. Cheat-sheet
|
||
|
||
```rust
|
||
// ── App mínima ──────────────────────────────────────────────
|
||
impl App for X { type Model; type Msg; init; update; view; }
|
||
llimphi_ui::run::<X>();
|
||
|
||
// ── Nodo ────────────────────────────────────────────────────
|
||
View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
|
||
.fill(c).fill_gradient(g).hover_fill(c).radius(r).radius_corners(tl,tr,br,bl).shadow(sh).border(w,c).clip(b).alpha(a).transform(xf).animated(key,dur)
|
||
.text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al) | .text_weight(w) | .bold() | .ellipsis(n) | .max_lines(n)
|
||
.image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{})
|
||
.on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m)
|
||
.on_pointer_enter(m) | .on_pointer_leave(m)
|
||
.draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|)
|
||
.drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c)
|
||
.children(vec![..])
|
||
|
||
// ── Efectos ─────────────────────────────────────────────────
|
||
handle.spawn(|| Msg::Done(compute())); // worker → reentra al update
|
||
handle.spawn_periodic(dur, || Msg::Tick); // feed periódico
|
||
handle.dispatch(Msg::X); handle.quit();
|
||
|
||
// ── Estilo de layout (taffy prelude) ────────────────────────
|
||
length(px) percent(0..1) Dimension::auto()
|
||
FlexDirection::{Row,Column} AlignItems::{Start,Center,End,Stretch}
|
||
JustifyContent::{Start,Center,End,SpaceBetween}
|
||
|
||
// ── Theme ───────────────────────────────────────────────────
|
||
Theme::dark()/light()/aurora()/sunset(); Theme::next_after(name); XxxPalette::from_theme(&t)
|
||
|
||
// ── Overlay (menús/modales) ─────────────────────────────────
|
||
fn view_overlay(m) -> Option<View<Msg>> { if m.open { Some(menu) } else { None } }
|
||
```
|
||
|
||
---
|
||
|
||
## 19. Índice de crates
|
||
|
||
**Framework** (`02_ruway/llimphi/`):
|
||
`llimphi-hal` · `llimphi-raster` · `llimphi-text` · `llimphi-layout` ·
|
||
`llimphi-compositor` · `llimphi-ui` · `llimphi-theme` · `llimphi-motion` ·
|
||
`llimphi-icons` · `llimphi-surface` · `llimphi-workspace` · `llimphi-gallery` ·
|
||
`llimphi-gpu-bench`.
|
||
|
||
**Widgets** (`widgets/`, dep `llimphi-widget-<n>`): app-header · avatar · badge ·
|
||
banner · breadcrumb · button · card · clipboard · color-picker · context-menu ·
|
||
dock-rail · edit-menu · empty · field · gallery · grid · lazy-list · list · menubar · modal ·
|
||
navigator · nodegraph · panel · panes · progress · segmented · select ·
|
||
shortcuts-help · skeleton · slider · splash · splitter · stat-card · status-bar ·
|
||
switch · table · tabs · terminal · text-area · text-editor · text-editor-core ·
|
||
text-editor-lsp · text-input · theme-switcher · tiled · timeline · toast ·
|
||
toolbar · tooltip · transport · tree · waveform · wawa-mark.
|
||
|
||
**Módulos** (`modules/`): bookmarks · command-palette · diff-viewer · fif ·
|
||
file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline.
|
||
|
||
**Android** (`android/`): clear-screen-android · vello-hello-android ·
|
||
vello-text-android.
|
||
|
||
---
|
||
|
||
> Documentos hermanos: [`SDD.md`](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; ante divergencia,
|
||
> la fuente autoritativa es el `lib.rs` de cada crate.
|