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
119 KiB
Manual de Llimphi
Motor gráfico soberano de tawasuyu.
wgpu+vello+taffy+parley, bucle Elminput → update → view → layout → raster → present. Reemplazo total de GPUI (extinto 2026-05-26): toda app gráfica de la suite corre sobre Llimphi.
Este documento es la referencia de uso orientada a humanos y a IA.
Está organizado para salto directo: cada capa, widget y módulo trae su API
real (firmas copiadas del código). Para el porqué arquitectónico ver
SDD.md; para la regla de concurrencia ver
COMPUTO-FUERA-DEL-HILO-UI.md.
Índice
- Modelo mental en 60 segundos
- Arquitectura — las capas
- Quickstart — la app mínima
- El trait
App(bucle Elm) Handle— efectos y concurrenciaView<Msg>— el DSL declarativo- Layout (
taffy/Style) - Eventos e interacción
- Texto
- Canvas custom y GPU directo
- Theme y paletas
- Capas base (hal · raster · text · motion · icons · surface)
- Catálogo de widgets
- Catálogo de módulos
llimphi-workspace— chasis tipo tmux- Reglas duras y gotchas 16.bis. Tests de regresión visual (golden-image) 16.ter. Semántica accesible y tests por consulta
- Comandos y demos
- Cheat-sheet
- Índice de crates
1. Modelo mental en 60 segundos
Llimphi es Elm sobre la GPU. Una app es un tipo que implementa el trait
App con cuatro piezas:
Model— estado inmutable de la app.Msg— todo lo que puede pasar (Clone + Send).update(model, msg, handle) -> model— transición pura que devuelve un modelo nuevo.view(&model) -> View<Msg>— función pura que describe la pantalla como un árbol deView.
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:
viewes pura — no muta nada, sólo lee el modelo y arma el árbol.- Cómputo pesado va a un worker vía
Handle::spawn, nunca síncrono enupdate/init/handlers (congela la ventana → "Not Responding"). - Widgets son visuales y stateless; el estado vive en tu
Model. Módulos sí encapsulan estado + comportamiento.
2. Arquitectura — las capas
4. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent)
└ llimphi-compositor . árbol View<Msg>, mount sobre taffy, paint, hit-test (winit-free)
3. llimphi-layout ....... motor de layout (taffy: flexbox + grid)
2. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo
1. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji
└ llimphi-glifos ..... atlas de cobertura: el texto del camino GPU-directo
0. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer)
El split compositor/runtime (2026-05-31) es importante: llimphi-compositor
es winit-free (sólo View, mount, paint, hit-test). llimphi-ui lo corre
sobre winit y re-exporta todo el compositor, así escribes llimphi_ui::View
sin enterarte del split. Esto habilita un futuro runtime sobre el framebuffer
del kernel wawa reusando el mismo compositor.
Auxiliares: llimphi-theme (paletas), llimphi-motion (tweens),
llimphi-icons (iconos vectoriales), llimphi-surface (texturas externas),
llimphi-workspace (chasis tmux), llimphi-gallery (showcase).
Catálogo: 70 widgets (visuales) + 11 módulos (features con estado).
3. Quickstart — la app mínima
use llimphi_ui::llimphi_layout::taffy::prelude::*;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{App, Handle, View};
#[derive(Clone)]
enum Msg { Increment, Reset }
struct Counter;
impl App for Counter {
type Model = u32;
type Msg = Msg;
fn title() -> &'static str { "llimphi · counter" }
fn init(_: &Handle<Self::Msg>) -> Self::Model { 0 }
fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Increment => model.saturating_add(1),
Msg::Reset => 0,
}
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let boton = View::new(Style {
size: Size { width: length(160.0), height: length(56.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(Color::from_rgba8(60, 200, 130, 255))
.radius(12.0)
.text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255))
.on_click(Msg::Increment);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0), height: percent(1.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(0.0), height: length(24.0) },
..Default::default()
})
.fill(Color::from_rgba8(20, 24, 32, 255))
.children(vec![
View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE),
boton,
])
}
}
fn main() { llimphi_ui::run::<Counter>(); }
Cargo.toml:
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
# + los widgets/modules que uses:
# llimphi-widget-button = { workspace = true }
Corre con cargo run -p <tu-crate> --release. El ejemplo vivo está en
llimphi-ui/examples/counter.rs.
4. El trait App (bucle Elm)
Definido en llimphi-ui/src/lib.rs. El estado es inmutable; cada evento
produce un Model nuevo.
pub trait App: 'static {
type Model: 'static;
type Msg: Clone + Send + 'static;
fn init(handle: &Handle<Self::Msg>) -> Self::Model;
fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
fn view(model: &Self::Model) -> View<Self::Msg>;
// --- Todo lo de abajo tiene default; sobreescribí lo que necesites ---
fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> { None }
fn on_wheel(_model: &Self::Model, _delta: WheelDelta,
_cursor: (f32, f32), _modifiers: Modifiers) -> Option<Self::Msg> { None }
/// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta
/// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo
/// vidrio"). La transición la maneja tu Model.
fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> { None }
/// Drag&drop de archivos desde el file manager. Un evento por archivo.
fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> { None }
/// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El
/// runtime administra el foco; guardas `id` en tu Model para pintar el ring
/// y rutear el teclado. Ver §8 (Foco y teclado).
fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> { None }
/// IME (composición de texto: CJK, acentos muertos, emoji). Opt-in vía
/// `ime_allowed()` para no robarle el texto a las apps que sólo leen
/// `on_key`. Flujo: Enabled → Preedit* → Commit/Disabled. Ver §8 (IME).
fn ime_allowed() -> bool { false }
fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> { None }
/// Área del caret en px físicos para ubicar la ventana de candidatos.
fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { None }
fn title() -> &'static str { "llimphi" }
fn app_id() -> Option<&'static str> { None } // app_id del xdg-toplevel en Wayland
fn initial_size() -> (u32, u32) { (960, 540) }
}
Punto de entrada: pub fn run<A: App>() — corre hasta que el usuario cierre la
ventana o la app llame Handle::quit.
Eventos de teclado (KeyEvent):
pub struct KeyEvent {
pub key: Key, // re-export de winit; usar NamedKey para teclas especiales
pub state: KeyState, // Pressed | Released
pub text: Option<String>, // texto resultante con IME/modifiers; None para flechas etc.
pub modifiers: Modifiers, // { shift, ctrl, alt, meta }
pub repeat: bool,
}
Key y NamedKey se re-exportan desde llimphi_ui.
Rueda (WheelDelta { x, y }): normalizado a "líneas". Convención CSS:
y positivo = scroll hacia abajo.
5. Handle — efectos y concurrencia
Handle<Msg> es Send + Clone. Llega a init y update. Es el único modo
legítimo de producir efectos sin romper la pureza de la transición.
impl<Msg: Send + 'static> Handle<Msg> {
pub fn quit(&self); // cierra la ventana / termina el bucle
pub fn dispatch(&self, msg: Msg); // encola un Msg para el próximo turno
pub fn spawn<F: FnOnce() -> Msg + Send + 'static>(&self, f: F); // worker; su Msg reentra al update
pub fn spawn_periodic<F: Fn() -> Msg + Send + 'static>(&self, period: Duration, f: F); // tick periódico
pub fn for_test() -> Self; // handle "muerto" para tests sin event loop
pub fn for_test_with<F: Fn(Msg) + Send + Sync + 'static>(sink: F) -> Self; // tests que SÍ quieren los Msg
// --- 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 traitApp) — elWindowEvent::ModifiersChanged. Existe porque los handlers de click llevan unMsgplano 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 elupdatedel click. DevolvéNonecuando el cambio no te importa (si no, cada roce de Shift dispara un update y un repintado). -
for_testvsfor_test_with—for_testdescarta losMsg; ojo con eso: la closure de unspawnsí 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_withentrega cadaMsga un sink (típicamente elSenderde un canal, detrás de unMutexporque el sink pideSync); el test lo drena y lo realimenta aupdate, que es lo que hace el runtime real. Con eso el trabajo corre una sola vez y el test es determinista. -
set_fullscreen—Fullscreen::Borderlesssobre 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, tuboolqueda 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 declaraVideoal arrancar y vuelve aPhotoal pausar (un video en pausa es una imagen fija) o aNoneal 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_displayy se envuelve lawl_surfacede winit — el mismo cruce que validó el spikellimphi-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). ElMsgque devuelve la closure se entrega alupdateen el hilo de UI. Este es el patrón obligatorio para todo cómputo pesado (§16). -
spawn_periodic— feeds a intervalos: ticks de simulación (~11 Hz en dominium), polling, animaciones por reloj. El thread muere cuando se cierra el event loop.
6. View<Msg> — el DSL declarativo
Un View = Style de taffy + relleno + texto/imagen/painter + handlers +
hijos. Todo se arma con builders encadenables (self -> Self). Definido en
llimphi-compositor/src/view.rs.
View::new(style: Style) -> View<Msg>
Apariencia
| Método | Efecto |
|---|---|
.fill(Color) |
color de fondo |
.fill_gradient(Gradient) |
relleno con gradiente (autoreado en el cuadrado unidad [0,1]², mapeado al rect). Gana sobre fill; hover_fill lo overridea en hover |
.hover_fill(Color) |
color al pasar el cursor (habilita hit-test de hover) |
.radius(f64) |
esquinas redondeadas (radio uniforme) |
.radius_corners(tl,tr,br,bl) |
radio por esquina (CSS border-radius 4 valores); override de .radius. El borde sigue las 4 esquinas; la sombra usa el radio escalar |
.shadow(Shadow) |
drop shadow (vello draw_blurred_rounded_rect). Shadow::soft(alpha,blur) + .offset(dx,dy)/.spread(s) |
.border(width, Color) |
stroke sobre el contorno redondeado, inset hacia adentro (border-box) |
.alpha(f32) |
opacidad de todo el subtree [0,1] (capa intermedia — no gratis) |
.transform(Affine) |
afín 2D alrededor del centro del rect (estilo CSS transform-origin:50% 50%) |
.animated(key, Duration) |
animación implícita estilo Flutter AnimatedContainer: si fill/radius cambian entre frames, el runtime interpola (ease-out cúbico) en vez de saltar. key estable entre rebuilds. .animated_curve(key,dur,fn) para otra curva |
.clip(bool) |
recorta hijos al rect (paint + hit-test) |
.image(Image) |
pinta peniko::Image centrada, preservando aspect ratio |
.children(Vec<View<Msg>>) |
hijos |
Texto (ver §9)
.text(content, size_px, color) // centrado
.text_aligned(content, size_px, color, Alignment)
.text_aligned_italic(content, size_px, color, Alignment, italic)
.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option<String>)
.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada
.line_height(mult) // override interlínea (default 1.2)
.text_weight(f32) // peso CSS: 400 normal, 600 semibold, 700 bold
.bold() // atajo de text_weight(700.0)
.ellipsis(n) // clampa a n líneas terminando en … (n=1 = single-line)
.max_lines(n) // clampa a n líneas sin glifo (corte seco)
Interacción (ver §8)
.on_click(Msg)
.on_click_at(|lx, ly, w, h| -> Option<Msg>) // posición local + tamaño del rect
.on_right_click(Msg) / .on_right_click_at(...)
.on_middle_click(Msg)
.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
.draggable(|phase: DragPhase, dx, dy| -> Option<Msg>)
.draggable_at(|phase, dx, dy, lx0, ly0| -> Option<Msg>) // + posición inicial del press
.drag_payload(u64) // payload que viaja con el drag
.on_drop(|payload: u64| -> Option<Msg>) // este nodo es drop target
.drop_hover_fill(Color) // resaltado mientras un drag lo sobrevuela
.on_scroll(|dx, dy| -> Option<Msg>) // rueda local (antes del on_wheel global)
.on_scroll_take(|dx, dy| -> (Option<Msg>, rx, ry)) // reparto PARCIAL: consume lo que puede y el
// sobrante sigue al ancestro (mismo evento);
// scroll_y/lazy_list ya lo usan (scroll_take)
.on_scale(|phase: GesturePhase, factor, fx, fy| -> Option<Msg>) // pinch-to-zoom (Ctrl+rueda / trackpad)
.on_double_tap(Msg) / .on_double_tap_at(|lx, ly, w, h| ...) // dos clicks rápidos y cercanos
.on_long_press(Msg) / .on_long_press_at(|lx, ly, w, h| ...) // mantener ~500 ms quieto
.focusable(u64) // nodo enfocable por Tab/click (id opaco)
Pintura custom (ver §10)
.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... })
.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... })
Notas clave:
- Un nodo es draggable o clickable, no ambos:
draggablesobreescribeon_click. - Las variantes
*_atganan 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 porCursorMoved,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 conon_click/draggabledel mismo nodo. El caso limpio (sin disparos cruzados) es ponerlos en un nodo que no tengaon_click— p. ej. un canvas condraggable(pan) +on_scale(zoom) +on_long_press(marca).GesturePhase=Begin/Update/End; enon_scale,factores multiplicativo incremental (>1agranda) y(fx, fy)el focal local —Ctrl+ruedalo sintetiza en cualquier desktop (Wayland/Windows no emiten el pinch del trackpad; macOS sí, víaPinchGesture). El long-press lo arbitra el tiempo (~500 ms quieto); moverse (>8px) o soltar lo cancela. Demo completo:cargo run -p llimphi-ui --example gestos --release.
7. Layout (taffy / Style)
Style es el taffy::Style directo, re-exportado vía
llimphi_ui::llimphi_layout::taffy::prelude::*. Es Flexbox + CSS Grid puro.
Campos más usados:
Style {
flex_direction: FlexDirection::Row | Column,
size: Size { width, height }, // length(px) | percent(0..1) | Dimension::auto()
min_size, max_size,
flex_grow: f32, flex_shrink: f32,
align_items: Some(AlignItems::{Start,Center,End,Stretch}),
justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}),
gap: Size { width, height },
padding: Rect { left, right, top, bottom }, // con length(px)
margin: Rect { ... },
..Default::default()
}
Helpers de prelude: length(px), percent(frac), auto(), Dimension,
Size, Rect, FlexDirection, AlignItems, JustifyContent.
llimphi-layout además expone:
LayoutTree::new()/.clear()(reuso entre frames),.leaf(style),.node(style, &children),.compute(...),.compute_with_measure(F).Rect { x, y, w, h }yComputedLayout { rects: HashMap<NodeId, Rect> }.
En el 99% de los casos no tocas LayoutTree a mano: lo maneja el runtime al
montar tu View. Sólo armás Styles.
8. Eventos e interacción
| Quiero… | Cómo |
|---|---|
| Botón / fila clickable | .on_click(Msg) (+ .hover_fill para feedback) |
| Saber dónde se clickeó (canvas) | .on_click_at(|lx,ly,w,h| ...) → convertir a coords de mundo |
| Menú contextual | .on_right_click(Msg::OpenMenu{..}), guardar pos en Model, abrir en view_overlay |
| Abrir en pestaña nueva | .on_middle_click(Msg) |
| Preview al pasar el mouse | .on_pointer_enter(Msg) / .on_pointer_leave(Msg) |
| Resize de panel | .draggable(|phase,dx,dy| ...) acumulando delta en el Model |
| Arrastrar entidad de un canvas | .draggable_at(|phase,dx,dy,lx0,ly0| ...) |
| Drag&drop entre zonas | origen: .drag_payload(id); destino: .on_drop(|id| ...) + .drop_hover_fill |
| Scroll global | App::on_wheel(model, delta, cursor, mods) |
| Área de scroll | widget scroll_y(...) (autocontenido) o .on_scroll(|dx,dy| ...) por nodo |
| Zoom de canvas (pinch) | .on_scale(|phase,factor,fx,fy| ...) → zoom *= factor, reajustar pan al focal |
| Doble-click | .on_double_tap(Msg) / .on_double_tap_at(|lx,ly,w,h| ...) |
| Long-press (mantener) | .on_long_press(Msg) / .on_long_press_at(|lx,ly,w,h| ...) |
| Teclado | App::on_key(model, &KeyEvent) -> Option<Msg> |
| Foco / Tab | .focusable(id) en los nodos + App::on_focus(model, id) (ver abajo) |
| IME (CJK, acentos) | App::ime_allowed() -> true + App::on_ime(model, &ImeEvent) (ver abajo) |
| Drop de archivos del SO | App::on_file_drop(model, path) |
Patrón overlay (menús/modales): el modelo guarda "menú abierto sí/no".
Mientras esté abierto, view_overlay devuelve Some(view); clicks fuera se
cierran envolviendo los items en un scrim a pantalla completa con
on_click = DismissOverlay. Cuando el modelo dice cerrado, view_overlay
devuelve None.
Scroll (widget llimphi-widget-scroll). scroll_y(offset, content_len, viewport_len, content, on_scroll, &palette) arma un viewport clipeado +
contenido desplazado -offset + barra arrastrable. Es stateless: el offset
vive en tu Model. on_scroll(delta_px) (rueda y arrastre) emite un delta a
sumar; clampealo con scroll::clamp_offset en tu update. Helpers:
ensure_visible(offset, vp, item_top, item_h) para llevar la selección a la
vista (teclado); approach(cur, target, factor) para scroll suave hacia un
objetivo (driveado por Handle::spawn_periodic).
Scroll 2D / física / slivers (Tier 5, mismo widget, todo stateless):
scroll_xy(offset:(x,y), content_size:(w,h), viewport_size:(w,h), content, on_scroll:(dx,dy)→Msg, &palette)— dos ejes, una barra por eje con overflow.- Inercia (fling):
fling_step(velocity, dt, friction) → (v', delta)+fling_settled(v)— suelta con velocidad y avanza el offset por frame con el ticker (spawn_periodic);FLING_FRICTION/FLING_STOPdefaults. 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)recibefrac∈[0,1]para fundir título/subtítulo. Clampeá consliver_max_offset(...). Helpers puros:collapsed_height,collapse_fraction,sticky_y(offset, section_top, section_h, header_h)para encabezados de sección pegados al tope. Demo:cargo run -p llimphi-widget-scroll --example scroll_avanzado.
Foco y teclado. Marca los nodos navegables con .focusable(id) (id u64
que vos eliges). El runtime es la única fuente de verdad del foco: lo mueve
con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo
enfocable, y te avisa con App::on_focus(model, Option<u64>). Guardas el id en
tu Model para (a) pintar el ring (if model.focus == Some(id) { .fill(accent) }
en view) y (b) rutear el teclado al campo activo desde on_key. No setees el
foco por tu cuenta vía Msg: quedaría desincronizado del runtime.
IME (composición de texto). Opt-in: ime_allowed() -> true. Con IME activo
el texto compuesto no llega por KeyEvent.text sino por on_ime:
ImeEvent::Enabled → uno o más Preedit{text, cursor} (texto en composición, a
pintar subrayado en el caret) → Commit(text) (inserta como tecleado) o
Disabled. Reporta el área del caret con ime_cursor_area(model) para ubicar
la ventana de candidatos (CJK) junto al cursor.
9. Texto
TextSpec (en compositor) describe el texto de un nodo:
pub struct TextSpec {
pub content: String,
pub size_px: f32,
pub color: Color,
pub alignment: Alignment, // Start | Center | End | Justify
pub italic: bool,
pub font_family: Option<String>, // string CSS con fallbacks
pub line_height: f32, // múltiplo; default 1.2
pub runs: Option<Vec<(usize, usize, Color)>>, // color por rango de BYTES
}
Centeres el default (apto para labels). Para editores/párrafos usar.text_aligned(..., Alignment::Start).- Multicolor en una sola pasada de shaping:
.text_runs(...)colorea rangos de bytes — es la base del syntax highlighting (un nodo por línea, no por token). Anclado arriba-izquierda; el caller dimensiona el rect. - El runtime mide el texto con parley durante el layout (
compute_with_measure) para que taffy reserve el alto real del texto envuelto a varias líneas (evita "textos aplastados"). - Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique.
10. Canvas custom y GPU directo
Dos hooks para pintar primitivas no expresables como composición de Views.
Conviven en el mismo árbol; el runtime pinta toda la pasada vello primero,
luego los gpu_painter en orden DFS.
paint_with — vía vello (el default)
.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| {
// dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`.
// NO dejar push_layer sin pop_layer; NO resetear la scene.
})
Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal. Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada).
gpu_paint_with — sube vertex buffers directo a wgpu, salta vello
.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| {
// abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello.
// (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC.
})
Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de
nakui, pineal denso. Rango 100 K – 10 M+ primitivos. Sí soporta texto desde
2026-08-05 (§12, llimphi-glifos + add_glyph) y múltiples grosores de stroke
por flush desde el mismo día. Lo que sigue sin soportar es el AA analítico de
curvas: los bordes de rects/líneas/tris salen del MSAA 4×, no de cobertura
exacta. Para texto con brush (gradiente, imagen) o decoraciones sigue
haciendo falta la Scene vello de view_overlay.
UI encima de contenido GPU — View::over y paint_over
El orden del frame es [vello base] → [gpu_paint] → [vello over] → [overlay/ menús]: cualquier View normal que se solape con un gpu_paint_with queda
tapado por él (el blit corre después de la pasada base). Dos salidas:
// Subárbol entero de Views pintado en la pasada over (fills, texto, widgets,
// hover — la maquinaria completa). El primitivo para un OSC de reproductor,
// un HUD, un rail que solapa un canvas GPU. El hit-test no cambia.
View::new(estilo_absoluto).over(true).children(vec![barra_de_transporte])
// Closure vello suelta en la pasada over (sprites/texto AA sobre celdas
// instanciadas — dominium, motor voxel).
.paint_over(|scene, ts, rect| { ... })
Limitación de ambas: la capa over se compone con alpha sobre la intermedia;
clips/alpha/transform de ancestros fuera del subárbol no la afectan. Caso
canónico de .over(true): el OSC estilo mpv de media-app
(02_ruway/media/media-app/src/vista.rs::overlay_bars_view) y su fondo de
marca (src/fondo.rs::fondo_overlay) flotando sobre el video.
¿Cuándo cada uno?
| Pregunta | vello (paint_with) |
GPU directo (gpu_paint_with) |
|---|---|---|
| Primitivos/frame | < ~500 K rebuild / < ~2 M Scene reusada | 100 K – 10 M+ |
| ¿Cambian cada frame? | sí, rebuild barato | mejor estático (buffer persistente) |
| Curvas Bezier | nativas | hay que teselar |
| Texto | sí, con brush | sí, color sólido (atlas de cobertura) |
| AA fino | sí (analítico) | bordes por MSAA 4×; el texto trae el suyo |
Default: paint_with salvo que ya midas que el volumen lo justifica
(factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU
expone GpuPipelines/GpuBatch en llimphi-raster (§12).
11. Theme y paletas
llimphi-theme::Theme es un struct de slots semánticos de color. Cuatro presets
const: Theme::dark() (default), light(), aurora(), sunset().
pub struct Theme {
pub name: &'static str,
// fondos
pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus,
pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color,
// texto
pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color,
// bordes y acento
pub border, border_focus, accent: Color,
}
Theme::all() -> Vec<Theme> // orden de rotación canónico
Theme::by_name(name) -> Option<Theme>
Theme::next_after(current_name) -> Theme // para el theme-switcher
Tokens auxiliares en el mismo crate:
motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms}+ease_out_cubic,ease_in_out_cubic,linear.alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT}(constantesu8).radius::{XS=2, SM=4, MD=8, LG=12, XL=20}(f64).
Patrón de widgets: cada widget define su XxxPalette con
Palette::from_theme(&theme). Tu app guarda un Theme en el Model, deriva las
paletas que necesita en view, y se las pasa a los widgets. Para cambiar de
tema, el theme-switcher emite Msg(next_theme) y reconstruyes todo.
12. Capas base
llimphi-hal — superficie
Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result<Hal, HalError> // async
trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result<Frame,_>; fn present(frame, hal); }
WinitSurface::new(hal, window: Arc<Window>) -> Result<Self, HalError>
Frame::view() -> &wgpu::TextureView; Frame::size() -> (u32,u32)
Hal::new pide adapter Backends::PRIMARY (Vulkan) y cae a all() sólo si no
hay — no volver a InstanceDescriptor::default(): el backend GL de Mesa
sobre Wayland segfaultea en el teardown. El runtime de llimphi-ui ya maneja
todo esto; sólo tocas HAL si escribes un runtime nuevo.
llimphi-raster — rasterización
Renderer::new(hal) -> Result<Renderer,_>
Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
// GPU directo:
GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout
GpuBatch::new(&pipelines)
.line_width(w) // pluma: vale para lo que se agregue DESPUÉS
.add_line(p0,p1,color) .add_polyline(&pts,color)
.add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
.add_disc(cx,cy,r,color) .add_ring(cx,cy,r,stroke,color)
.atlas(&AtlasTextura) // sin esto los quads NO se dibujan
.add_glyph(x,y,w,h,(u0,v0,u1,v1),color) // quad texturado: el TEXTO
.scissor(x,y,w,h) // sólo se toca ese rect (damage)
.max_buffer_bytes(bytes) // baja el techo por buffer; normalmente no se toca
.primitive_count() -> u32
.flush(device, queue, encoder, view, viewport, load_op)
units_per_chunk(stride, max_bytes, multiplo) -> u32
RECT_INSTANCE_STRIDE / LINE_INSTANCE_STRIDE / DISC_INSTANCE_STRIDE / TRI_VERTEX_STRIDE
QUAD_INSTANCE_STRIDE
// la textura de cobertura contra la que se muestrean los quads:
AtlasTextura::new(device, &pipelines, lado) -> Self
.subir(queue, pixeles, (x,y,w,h)) // sube SÓLO ese rect, no el atlas entero
Re-exporta vello y peniko (Color, Image, Fill, etc.).
Cuatro cosas del flush que conviene saber y no se ven en la firma:
-
line_widthes 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 mismoflush. 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 cadaflusharma su propia textura MSAA 4×, la resuelve y la compone (2 texturas + 2 render passes porflush). 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_sizedel 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.flushparte 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_bytesexiste sobre todo para testear el troceado sin reservar cientos de MB. -
scissorrecorta el costo, no sólo el dibujo. Las texturas intermedias (MSAA 4× + resolve) se crean del tamaño del rect sucio, porque elLoadOp::Cleardel 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;flushbaja el origen a par (las derivadas defwidth, 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_glyphacumula igual, pero si nadie llamó aatlas()elflushlos saltea en silencio. La alternativa —un panic adentro delflush— sería reventar el frame de una app porque alguien pidió texto antes de tener el atlas listo. Que falte el texto es un síntoma leíble; que se caiga la ventana, no.
llimphi-glifos — el texto del camino GPU-directo
AtlasGlifos::new(lado) -> Self // atlas cuadrado de cobertura, 1 B/px
.pixeles() -> &[u8] .lado() -> u32 .version() -> u64 .entradas() -> usize
.tomar_sucio() -> Option<Sucio> // qué cambió desde la última vez, y limpia
.limpiar() // única salida cuando `fallos` sube
.fallos / .aciertos / .rasterizados // contadores públicos
quads_de_layout(&mut atlas, &layout, (x,y), &mut Vec<QuadGlifo>) -> usize
quads_de_run(&mut atlas, &glyph_run, (x,y), &mut Vec<QuadGlifo>) // multicolor
Rasteriza cada glifo una sola vez a un mapa de 8 bits y lo empaqueta por estantes; de ahí en más pintar texto es pintar quads. Un renglón de 80 caracteres pasa de 80 contornos rellenos a 80 instancias de 48 B.
Lo que hay que saber antes de usarlo:
- No shapea. Recibe un
parley::Layoutya resuelto —el que armallimphi_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.rspone 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
Noney suma afallos. La salida eslimpiar()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::subires 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—fontduesobrevive 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, el0a 13 px).tests/dos_atlas.rs— los dos empaquetados entregan el mismo glifo.
Y un tropiezo que dejó regla: la primera corrida de dos_atlas daba 44 % de
diferencia. No era el rasterizador — TextBlock::simple deja
font_family: None y parley cae a sans-serif, así que comparaba dos
tipografías. Si comparás glifos, fijá la familia primero.
¿Cuándo conviene? Medido en metal (Iris Xe/Vulkan, llimphi-gpu-bench
sección «Texto», 12.096 glifos): el total baja 3,6-5,0× contra vello, y lo
que importa es la forma — el costo del atlas es casi plano con la cantidad de
texto (5,0 → 5,5 ms entre 7.200 y 12.096 glifos) mientras vello crece lineal
(13,6 → 26,4 ms). Pero la mitad de CPU del atlas es peor que la de vello
(~0,7×): vello apila un id y una posición por glifo, el atlas hace una búsqueda
en tabla y arma un quad. Toda la ganancia viene del lado GPU.
O sea: el atlas es para mucho texto que se repinta seguido — grilla de
terminal, editor, etiquetas sobre un canvas denso. Para un rótulo suelto no
compra nada y encima paga el ciclo fijo del flush (MSAA + resolve +
composite). El default sigue siendo vello.
llimphi-image — decode pipeline
decode_bytes(&[u8]) -> Result<peniko::Image, DecodeError>
load_path(&Path, max_bytes: u64) -> Result<peniko::Image, DecodeError>
from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> peniko::Image
// mismo decode, pero además dice de qué espacio venía (para editores):
decode_bytes_con_origen(&[u8]) -> Result<Decodificada, DecodeError>
load_path_con_origen(&Path, max_bytes: u64) -> Result<Decodificada, DecodeError>
Decodificada { imagen: Image, origen: Option<Perfil>, convertida: bool }
Decodificada::devolver_al_origen(&self, rgba: &mut [u8]) -> bool
// perfiles de color (mod perfil):
perfil::leer_icc(&[u8]) -> Option<Perfil> // matrix-shaper; None si no lo es
perfil::convertir_a_srgb(&mut [u8], &Perfil) -> bool // entrada: del perfil a sRGB
perfil::convertir_desde_srgb(&mut [u8], &Perfil) -> bool // salida: de sRGB al perfil
perfil::matriz_a_srgb(&[f32;9]) -> [f32;9] / matriz_desde_srgb(&[f32;9]) -> Option<[f32;9]>
perfil::es_identidad(&[f32;9], tol) -> bool
Curva::{Lineal, Gamma(f32), Tabla(Vec<u16>), Parametrica{tipo, p}}
Curva::a_lineal(v) -> f32 / Curva::desde_lineal(lin) -> f32 // una es inversa de la otra
Encapsula el patrón image::ImageReader + to_rgba8 + Blob + Image::new que cada
app que decodifica imágenes tenía duplicado. max_bytes aplica al tamaño del
archivo en disco (no a la imagen decodificada, que en RGBA8 puede ser mucho
mayor — un PNG 4K ocupa ~64 MB descomprimido); 0 deshabilita el cap.
Re-exporta peniko::{Blob, Image, ImageFormat}. Formatos según las features
del crate image upstream del workspace (hoy: PNG, JPEG, WEBP).
Gestión de color (2026-08-05). decode_bytes / load_path leen el perfil
ICC embebido y llevan la imagen a sRGB. Antes se tiraba: una foto Display P3
—lo que saca cualquier teléfono desde 2016— se pintaba sobresaturada, porque
sus rojos, que son más rojos que los de sRGB, se mostraban como si fueran los de
sRGB. Nadie tiene que hacer nada: el caller sigue recibiendo RGBA8 sRGB.
- Cubre perfiles matrix-shaper (matriz 3×3 + curva), que es lo que son sRGB, Display P3, Adobe RGB, Rec.2020 y casi todos los de pantalla y cámara.
- No cubre los basados en LUT (
A2B0), como los CMYK de imprenta: ahí no convierte y deja la imagen como estaba, que es mejor que inventar. - Sin perfil se asume sRGB (la convención de la web). Con perfil sRGB, la matriz da la identidad y no se recorre un solo píxel.
- El recorte a gamut es por saturación de canal: un rojo P3 puro no existe en
sRGB y termina en el rojo sRGB más cercano.
Volver al espacio de origen (2026-08-05). Un editor no puede conformarse con
recibir sRGB: si
tullpuabre 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ándecode_bytes_con_origen/load_path_con_origen, que devuelven unaDecodificadacon el perfil del archivo al lado de los píxeles.
let d = llimphi_image::load_path_con_origen(ruta, 64 << 20)?;
let mut px: Vec<u8> = d.imagen.image.data.as_ref().to_vec(); // sRGB, para editar
// …edición…
d.devolver_al_origen(&mut px); // false si no hay a dónde volver: se guarda sRGB
origen: Noneno 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: falseconorigen: 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
Perfilexpuesto es lo que lo habilita. - Quien sólo va a pintar sigue usando
decode_bytes/load_pathy no se entera: son las mismas funciones, que ahora delegan y tiran el perfil.
llimphi-text — shaping
Typesetter::new() // una por proceso (FontContext es caro)
.layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()>
.layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout<RunBrush>
TextBlock::simple(text, size_px, color, origin)
layout_block(ts, &block) / measure(ts, &block) -> Measurement
draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin)
Alignment::{Start, Center, End, Justify}
llimphi-motion — tweens
trait Lerp { fn lerp(self, other, t: f32) -> Self; } // impl para f32,f64,(f32,f32),(f64,f64),Color
Tween::new(from, to, duration, easing: fn(f32)->f32) // o Tween::idle(value)
tween.value() / .progress() / .done()
animate(handle, duration, make_msg) // arranca los ticks del tween
Patrón: guardas Tween<T> en el Model, animate(...) en el update, la view
lee tween.value() cada repaint. El tween se auto-termina.
llimphi-icons — iconos vectoriales (~23, grid 24×24)
Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right,
Home, Search, Info, Warning, Error, Bell, Settings, More, ...}
icon_view(Icon, color, stroke_width) -> View<Msg>
paint_icon(scene, rect, icon, color, stroke_width) // dentro de un paint_with
stroke_width en unidades del grid 24×24 (1.6 es armónico).
llimphi-surface — texturas externas
ExternalSurface::new(device, queue) // barato de clonar (Arc<Mutex> interno)
.upload(&rgba, w, h) // desde otro hilo/decoder/cámara
.view(style) -> View<Msg> // blittea a su rect en el árbol Elm
.blit(queue, encoder, dst_view, rect, viewport) // o manual desde gpu_paint_with
13. Catálogo de widgets
Los widgets son funciones puras que devuelven View<Msg> (o specs que se
convierten a View). Son stateless: el estado vive en tu Model. Convención:
cada uno trae XxxPalette::from_theme(&Theme). Crates en
widgets/<nombre>/, dep llimphi-widget-<nombre>.
Controles
button — button_view(label, &ButtonPalette, on_click: Msg) -> View;
button_styled(label, style, alignment, &palette, on_click).
field — wrapper de formulario (label + helper/error + requerido).
field_view(FieldSpec { label, control: View<Msg>, required, helper, error, palette }).
text-input — input single-line con estado TextInputState
(new()/masked(), text(), set_text(), apply_key(&KeyEvent) -> bool,
soporta undo/redo + selección con Shift). Render:
text_input_view(&state, placeholder, focused, &palette, on_focus: Msg).
text-area — input multilínea con paridad real (mismo motor EditorState
que el text-input; NO el viejo String plano). El estado es
TextInputState::multiline() (o llimphi_widget_text_area::new_state()).
text_area_view(&state, placeholder, focused, rows, &palette, |ev| Msg::Area(ev))
emite TextAreaEvent (Key/Ime/Press(x,y)/Drag/DragEnd/Context) ya envueltos; en
update → state.handle_area(ev, &mut clipboard). Da: click 2D (renglón+columna
por glifos reales), ↑/↓ entre renglones, selección a través de líneas (arrastre 2D,
doble/triple-click, Shift+flechas), copiar/cortar/pegar, undo/redo, IME, Enter=\n,
soft-wrap (la línea larga se envuelve al ancho de la caja) y scroll vertical
(caret a la vista si hay más renglones que rows). Caret + selección pintados por
paint_over en coords de renglón visual; fuente única del layout: visual_layout
(render/hit-testing/caret/nav comparten). ↑/↓ navegan por renglón visual (no por
línea lógica: en texto envuelto bajan un renglón, no la línea entera), con columna
meta en px preservada (move_vertical_visual; Shift extiende selección). El crate
llimphi-widget-text-area es una fachada que re-exporta esto. Demo:
cargo run -p llimphi-widget-text-input --example area_demo.
text-area RICO — el mismo widget con los adornos que antes obligaban a
escribirse un pintor propio. text_area_view_rico(&state, focused, rows, &palette, &Metricas, &Adornos, wrap). Si necesitás un input mono, coloreado,
con fantasma o con caret animado, es ESTE, no un paint_with a mano.
Metricas { font_size, line_h, font_family, pad_x, pad_r, pad_y }—Metricas::mono(px)+.con_zoom(z). La usan pintado, ajuste blando y click→columna: una sola, o el caret cae entre glifos que no son los que se ven.Adornos { tramos, ghost, placeholder, caret, ahora_ms, marco }.tramos: Vec<(ini, fin, Color)>en bytes del texto completo (se recortan solos a cada renglón);ghost= sufijo tenue tras el caret (sólo si el caret está al final);Placeholder { texto, color, italic, icono, icono_color, alpha };marco: false= sin caja propia (para un host que ya tiene la suya — el borde del widget es un relleno opaco que taparía un backdrop frosted).CaretEstilo::vivo()= parpadeo que queda sólido tras cada tecla y estela al moverse (el eyecandy en honor a kitty). El reloj lo trae el host:Adornos::ahora_mspor cuadro +state.marcar_actividad(ms)en cada tecla — el widget no llama anow()por dentro (sería intesteable, y unboolde 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_viewes esta misma vista conAdornos::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 comotramosy monta su propio marco conmarco: 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:
Formulario::new(vec![ // qué se pide
CampoDef::new(Id::Correo, "Correo").reglas(vec![Regla::Requerido, Regla::Correo]),
CampoDef::new(Id::Edad, "Edad").regla(Regla::Rango(18.0, 120.0)).ayuda("…"),
])
form.validar(&[(Id::Correo, &m.correo), …]) -> Errores<Id> // qué está mal: TODOS
estado.tocar(id) · estado.enviar() · estado.visibles(&errores) // cuándo se dice
campo_view(&form, id, control, &visibles, &pal) // etiqueta + asterisco + error
resumen_errores_view(&visibles, Msg::IrA, &pal) -> Option<View>
Regla::{Requerido, LargoMin, LargoMax, Numero, Rango, Correo, Propia(fn)}. Un
valor vacío pasa todas menos Requerido (si no, un campo opcional en blanco
se queja de formato). El error se muestra al salir del campo o al intentar
enviar, nunca mientras se tipea por primera vez; tras el submit se muestran
todos, porque esconder alguno sería mentir sobre por qué no se envió.
campo_view saca del formulario la etiqueta, el asterisco de obligatorio
(derivado de las reglas, no de un flag aparte) y el error: los tres dejan de
escribirse dos veces. resumen_errores_view devuelve None si no hay nada que
decir, y cada ítem es clickeable para ir al campo — en un formulario largo el
error puede quedar fuera de pantalla y sólo se ve que el botón no hizo nada.
datetime-picker — pickers de fecha y hora, los dos con la misma
forma: un *_trigger en el formulario (muestra el valor formateado o el
placeholder, emite Msg al click) y un *_popup que el host monta en
App::view_overlay con scrim que cierra al click-fuera. Sin estado propio: el
valor, si está abierto y el mes en foco viven en el Model.
date_picker_trigger(DateTrigger{valor, formato, placeholder, enabled, on_open, palette})
date_picker_popup(DatePopup{anchor, viewport, valor, view:(año,mes), today,
week_start, opciones: CalendarOpciones, on_pick, on_view_change, on_dismiss,
on_clear, acciones: Acciones::hoy_y_limpiar(), palette, theme})
time_picker_trigger(TimeTrigger{..}) · time_picker_popup(TimePopup{.., paso_minutos})
formatear_fecha(d, FormatoFecha::{Iso,DiaMesAnio,Largo}) · parsear_fecha(&str)
Hora{h,m}: formatear/parsear (FormatoHora::{H24,H12}) · mover(campo,±1,paso) · al_paso
El de fecha compone el calendar, así que hereda límites min/max, rango,
salto de año y mapa de teclas — no reimplementa la grilla. El de hora es grilla
de horas + minutos por paso: elegir la hora conserva el minuto y viceversa.
parsear_fecha acepta ISO y día-primero con -, / o ., y rechaza el año
de dos dígitos (adivinar en una fecha es peor que devolver None). Hora
resuelve el caso que todos yerran: medianoche es 12 AM y mediodía 12 PM, no 0.
banner — tira de status. banner_view(BannerKind::{Info,Success,Warning,Error}, message).
Contenedores y layout
panel — chrome (gradiente + hairline accent). panel_view(children, PanelStyle);
PanelStyle::{from_theme, from_theme_large, neutral}. panel_signature_painter(style)
para reusar el look en un paint_with.
card — card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette).
stat-card — métrica de dashboard. stat_card_view(label, value, description, accent, &recent_items, &palette).
tabs — tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg, content: View<Msg>, tab_height, palette, tab_width }). Selección la maneja la app.
tabs_view_sliding(spec, indicator_t) cambia el acento por-tab (que se prende de
golpe) por un realce único que se desliza hasta indicator_t — el índice
animado que el host tween-ea con llimphi-motion hacia el activo. Como cada tab
mide lo que dice su label, interpola posición y ancho a la vez
(indicator_bounds_px(&anchos, t); los anchos con tab_widths(&labels, tab_width, closable), la misma cuenta que el render y que strip_width), así se
estira al ir hacia un tab más largo. Con overflow viaja dentro de la tira, o sea
que el strip_offset lo arrastra solo. indicator_t = active as f32 da la
posición estática, para un host que no quiera animar.
router — navegación por pila de rutas con transiciones de página
(el Navigator de Flutter, a la Elm). La pila vive en el Model; el widget
pinta sólo la ruta actual y anima el cambio con la infra de ghosts del
compositor (animated_switch_styled). Push entra desde la derecha y lo viejo
retrocede 30 % (shared-axis X); pop lo inverso; Zoom = fade-through;
Cover = push opaco estilo UIKit (la nueva tapa sin fade; el pop desliza
la vieja por encima revelando — scene-split de fantasmas: el saliente se
pinta DEBAJO del entrante, en su posición z real); None = corte seco sin
captura. Los hero cruzan rutas gratis (misma key en ambas páginas).
// Model: nav: RouteStack<Ruta> (enum propio: Clone + PartialEq + Hash)
m.nav.push(Ruta::Detalle(7)); m.nav.pop(); m.nav.replace(r); m.nav.pop_to_root();
router_view(key, &m.nav, PageTransition::{None,Fade,Slide,Zoom,Cover}, dur,
page: Fn(&Ruta) -> View<Msg>) // mide su slot vía LayoutBuilder
router_view_sized(key, &m.nav, tr, dur, (w, h), page) // slot explícito — para
// páginas que anidan otro layout_builder (límite v1 del seam: sin anidar)
Demo: cargo run -p llimphi-widget-router --example router_demo --release
(hero volando entre Lista y Detalle; T cicla la transición, Esc vuelve).
hero — shared-element transition (el Hero de Flutter auténtico): un nodo
con la misma key que aparece en otro rect en un frame posterior vuela
del rect viejo al nuevo (el HeroRegistry del runtime interpola transform).
hero_view(key, child) (480 ms) / hero_quick(key, child) (320 ms), o el
primitive View::hero(key, dur) directo. Key estable y única entre los heroes
de un mismo frame.
splitter — divisor draggable de 2 panes. splitter_two(Direction::{Row,Column}, a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette).
PaneSize::{Fixed(px), Flex}. La app acumula el delta en su Model (con
apply_resize(actual, delta, min, max), que clampea por paso).
splitter_two_ext(…, SplitterOpciones { on_toggle, focus_id }) agrega el
doble-click sobre el divisor y lo hace enfocable, que es la puerta del
resize por teclado: con el foco puesto, el host traduce la tecla con
ajuste_desde_tecla(&ev, direction) -> Option<AjusteSplitter> (las flechas del
eje del divisor —←/→ en Row, ↑/↓ en Column—, Shift para el paso grande,
Home/End a los topes, Enter/Espacio para alternar) y la aplica con
aplicar_ajuste. El colapso lo resuelve alternar_colapso(actual, recordado, por_defecto) -> (nuevo, a_recordar): guarda cuánto medía, no que estaba
abierto, así restaurar devuelve el layout del usuario y no un ancho de fábrica.
scroll — área de scroll vertical con barra arrastrable. scroll_y(offset, content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette).
Stateless (offset en el Model); rueda autocontenida. Helpers: clamp_offset,
ensure_visible (selección a la vista), approach (scroll suave). Ver §8.
Para darle scroll al cuerpo de un widget de filas está la pieza compartida
—la usan list, tree y detail-table, y por eso vive acá y no copiada tres
veces—: cuerpo_scrolleable(contenido, contenido_len, DesplazamientoV{offset, viewport_len, on_scroll}, medidor: Option<AltoVisible>, &palette).
AltoVisible publica el alto medido desde el pintado (el alto disponible no
existe hasta que taffy repartió) y offset_efectivo(offset, viewport_len) pinta
desde arriba mientras no haya medida — clamp_offset con viewport 0 da por
bueno cualquier offset, así que sin esto una lista re-montada con offset
guardado arranca desplazada y salta al cuadro siguiente.
lazy-list — lista perezosa llave-en-mano (Bloque 22 de
PLAN-NUCLEO-RETENIDO.md). A diferencia de list/grid (el caller windowea a
mano) y de scroll_y (recibe el contenido ya construido), recibe un builder
item: Fn(usize) -> View + total y sólo llama item(i) para la ventana
visible + overscan — las filas fuera de pantalla no se construyen, no se
montan, no se layoutean (costo/frame ∝ lo visible, no ∝ el total). Sin estado
(offset en el Model); reusa la barra/física de scroll.
lazy_list_fixed(total, item_h, offset, viewport_h, overscan,
item: Fn(usize)->View, on_scroll: Fn(f32)->Msg, &ScrollPalette) // extent uniforme, ventana O(1)
lazy_list_varied(offsets: &[f32] /*len total+1, tops acumulados*/, offset, viewport_h,
overscan, item, on_scroll, &palette) // extent por fila, O(log n)
// extent CALCULABLE barato (el sabor a preferir para listas grandes de alto variado):
IndiceExtent::nuevo(total, extent: Fn(usize)->f32) // índice por bloques de BLOQUE_EXTENT (1024)
lazy_list_extent(&indice, extent, offset, viewport_h, overscan, item, on_scroll, &palette)
// guarda 1 float cada 1024 filas en vez de 1 por fila (1M filas: 978 floats ≈ 3,9 KB
// contra 4 MB) y NUNCA construye un View para medir. Consulta = binaria + ≤1 bloque.
// helpers puros: visible_window(total, item_h, offset, vp, overscan) -> Window{first,count,y0}
// visible_window_varied(offsets, offset, vp, overscan), content_height(total, item_h)
// visible_window_extent(&indice, &extent, offset, vp, overscan)
// IndiceExtent::{y, indice_en, alto_total, len_indice}
// altura MEDIDA (listas heterogéneas cuyo alto depende del contenido/reflow):
item_offsets(total, ancho, item: Fn(usize)->View) -> Vec<f32> // mide cada ítem → offsets acumulados
// el caller lo llama al cambiar contenido/ancho, cachea el Vec, y por frame usa
// lazy_list_varied(&offsets, ...). Usa llimphi_compositor::measured_height(view, ancho).
// SECCIONES con encabezado sticky llave-en-mano (agenda/contactos):
lazy_list_sections(rows_por_seccion: &[usize], item_h, header_h, offset, viewport_h,
overscan, header: Fn(s)->View, item: Fn(s, fila_local)->View, on_scroll, &palette)
// el header de la sección activa queda PINEADO al tope y la siguiente lo empuja;
// darle fill opaco (pinta encima de las filas). Ventana O(#secciones + visibles).
// puros: section_tops, sections_window -> Vec<SectionUnit>, sticky_section
// re-exporta de scroll: clamp_offset, fling_step, rubber_band, sticky_y, DEFAULT_OVERSCAN, ScrollPalette
Demo: cargo run -p llimphi-widget-lazy-list --example stress_lazy --release -- --nodes 1000000.
tiled — grilla auto cols×rows de tiles con title bar. tiled_view(tiles, &palette),
tiled_view_cols(tiles, cols, &palette), y variantes *_reorderable* con
on_reorder: Fn(from, to)->Option<Msg> (drag-to-swap por la title bar). TileSpec { label, content }.
panes — árbol binario BSP tipo tmux. La app guarda un Layout:
Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second }
layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves()
panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec<Side>,DragPhase,delta)->Option<Msg>,
on_focus: Fn(PaneId)->Msg, &palette)
dock-rail — rail vertical de dientes para sidebars acoplables. Cada
diente es una pestaña: barra de acento de 3px pegada al borde interno
(encendida cuando está activo) + icono centrado. Apilados en una franja
flotante que sobresale del panel hacia el centro — el patrón canónico
de cosmos (01_yachay/cosmos/cosmos-app-llimphi/src/chrome.rs:
dock_rail_overlay / dock_panel_for). Cada item se identifica por un u64
opaco; el caller mantiene el orden, qué está activo, y el mapping
u64 ↔ DominioPropio (típicamente vía enum::{to_u64,from_u64}). Cada
diente es arrastrable (su id como payload) y el rail entero es drop
target → mover un diente de un sidebar a otro soltándolo sobre el rail
opuesto. Render-only, agnóstico del Msg.
DockRailItem { id: u64, active: bool }
dock_rail_view(&items, width: f32, &DockRailPalette,
make_icon: Fn(id, size_px, color) -> View<Msg>,
on_activate: Fn(id) -> Msg, // dispara en el press, no pelea con el drag
on_drop: Fn(payload_id) -> Option<Msg>) // None = el rail no acepta drops
Layout canónico: el rail va como overlay absoluto pegado al borde interno del centro (los dientes sobresalen sobre la rueda/canvas), y el panel del item activo va al costado como un pane en el área resizable. NO es una lista con rótulo: leer cosmos antes de inventar UX (lección 2026-06-05).
toolbar — barra de herramientas moderna: grupos de botones-ícono
planos (hover redondeado; activo = fondo de selección + ícono en acento;
deshabilitado atenuado y sin click) con separadores sutiles entre grupos.
Los íconos los dibuja el caller vía closure (mismo contrato que dock-rail);
los grupos son datos (Vec<ToolbarGroup>) → la barra es componible y
configurable por construcción. Uso canónico:
nahual-shell-llimphi::shell_toolbar (navegación + modos de vista + acciones).
ToolbarItem::new(icon: Fn(size_px, color) -> View<Msg>, on_click: Msg)
.with_label("subir").active(bool).enabled(bool)
ToolbarGroup::new(vec![items...])
toolbar_view(groups: Vec<ToolbarGroup<Msg>>, height: f32, &ToolbarPalette) -> View<Msg>
// 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.
navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides },
is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg,
on_select: Fn(u64)->Msg, on_context: Option<Fn(u64)->Msg>)
// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.
app-header — app_header(label, actions: Vec<View<Msg>>, &palette).
status-bar — status_bar_view(left, center, right, &palette) con
StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized().
breadcrumb — breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)
(el último segmento no es clickable).
modal — diálogo centrado con scrim. modal_view(ModalSpec { title, body: View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette }).
ModalButton::{primary, cancel, destructive}(label, msg). Se monta en view_overlay.
toast — notificaciones efímeras bottom-right. La app guarda Vec<Toast>
(Toast::{info,success,warning,error}(id, text, duration)), filtra
is_alive(now), y toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg).
splash — splash de arranque (cuatro cuadrantes andinos). splash_view(started_at: Instant, bg, fg_text); basado en tiempo, requiere redraws.
Ricos / interactivos
nodegraph — lienzo de nodos + cables Bezier. Sin estado (la app guarda
posiciones y Wires).
NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
nodegraph_view(&nodes, &wires, &palette, &metrics,
on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)
timeline — scrub clickeable. timeline_view(progress: f32, &palette, on_seek: Fn(f32 [0..1])->Option<Msg>).
transport — botones de transporte de reproductor (play/pause/prev/
next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq). Stateless:
el caller arma un TransportButton por cada botón con el flag que define su
estado activo (p.ej. PlayPause { playing }, Mute { muted },
Record { recording }), lo pasa al widget, y recibe el TransportAction
semántico en un closure cuando el usuario clickea. El widget elige el icono
(de llimphi-icons), el color activo, el rojo de REC y arma la acción
(p.ej. SeekBack { secs } → TransportAction::SeekBy(-secs)). Quien
traduce TransportAction al comando del dominio (MediaCommand, etc.) es
la app.
TransportPalette { bg, bg_active, bg_hover, fg, fg_active, fg_record,
btn_w, btn_h, radius, icon_stroke, gap }
TransportPalette::from_theme(&Theme)
TransportAction::{TogglePlay, Stop, Prev, Next, SeekBy(i64), VolumeBy(f32),
ToggleMute, CycleRepeat, ToggleShuffle, SpeedStep(i32),
SpeedReset, Snapshot, ToggleRecord, ToggleEqualizer}
TransportButton::{PlayPause{playing}, Stop, Prev, Next,
SeekBack{secs}, SeekForward{secs},
VolumeDown{step}, VolumeUp{step},
Mute{muted}, Repeat{active}, Shuffle{active},
SpeedDown, SpeedUp, SpeedReset{is_default},
Snapshot, Record{recording}, Equalizer{enabled}}
transport_button_view(button, &palette, on_action: Fn(TransportAction)->Msg) -> View<Msg>
Para una fila, el caller mapea su Vec<TransportButton> con
.iter().map(|b| transport_button_view(*b, &pal, on_action.clone())) y los
pone como children de una View con flex_direction: Row y gap: palette.gap. Consumidor de referencia: 02_ruway/media/media-app::bar_item_view.
waveform — visor de forma de onda en vivo. Stateless y agnóstico de
cpal/AudioProbe: el caller pasa un closure Fn(&mut Vec<f32>) -> u16 que
rellena un buffer con los últimos samples intercalados y devuelve cuántos
canales trae; el widget hace el fold a mono y dibuja un envelope
min/max por columna (polígono cerrado con relleno tenue + stroke top/bot)
sobre una línea central que siempre está como "ground" — paint-only, sin
mouse. Si el closure devuelve 0 canales, el widget pinta sólo la línea
central ("visor vivo, sin señal").
WaveformPalette { bg, center, stroke, fill, radius, pad_x, pad_y, stroke_w }
WaveformPalette::from_theme(&Theme) // bg=bg_panel_alt, stroke=accent, fill=accent@α70
waveform_view(source: Fn(&mut Vec<f32>) -> u16 + Send + Sync + 'static,
palette: &WaveformPalette) -> View<Msg>
Consumidor de referencia: 02_ruway/media/media-app::waveform_panel (cablea
un AudioProbe::snapshot).
table — tabla editable de celdas-texto: filas/columnas + agregar/quitar
fila + foco por celda. Stateless — la app posee el foco y el buffer de la
celda en edición; el resto se pinta desde &rows. Pensado para datos planos
editables (paramétricos, scratchpad, listas curables); no es la list/tree
virtualizada read-only ni un grid de hoja de cálculo.
table_view(&[String], headers, // [] = sin header
&[Vec<String>] rows,
focused: Option<(row, col)>,
focused_state: Option<&TextInputState>, // buffer prestado de la celda en edición
add_label: &str, &TablePalette,
on_focus_cell: Fn(row, col) -> Msg, // clic en celda
on_remove_row: Fn(row) -> Msg,
on_add_row: Fn() -> Msg)
list_view(&[String], items, focused_row, focused_state, // sola columna sin header
add_label, &palette, on_focus_cell: Fn(row)->Msg, on_remove_row, on_add_row)
table_height(n_rows, has_header) -> f32 // pre-calcular alto del bloque
La variante list_view no se confunde con la list virtualizada de §13:
ésta es una sola columna editable que delega en table_view([], ..).
detail-table — la vista detalle de un file manager: grilla read-only con
columnas ColWidth::{Flex(peso), Fixed(px)} y encabezados que ordenan (▲/▼).
detail_table_view(DetailSpec{columns, rows, sort, row_height, caption, palette}, on_sort); detail_table_view_dnd(..) agrega arrastre por fila.
detail_table_view_ext(spec, on_sort, DetailOpciones{scroll, medidor, on_resize}) suma encabezado fijo con cuerpo scrolleable y los handles de
resize entre columnas:
DetailScroll { offset, viewport_h, on_scroll: Fn(f32)->Msg } // compone scroll_y
AltoCuerpo::new() // publica el alto medido del cuerpo (post-layout)
offset_efectivo(offset, viewport_h) // sin medir todavía ⇒ se pinta desde arriba
resize_col(ancho, delta, min) // el host aplica el delta del handle
El «sticky» no es un truco de posición: con scroll el encabezado queda
afuera del cuerpo desplazado, así que no viaja. El cuerpo se compone del
scroll canónico (rueda, barra, clamp y fling ya viven ahí). El handle lleva
draggable y no on_click, así agarrarlo no dispara el orden de la columna
que tiene debajo — que es el bug clásico de esta UI.
Es también la DataTable read-only ordenable y paginable — no hay una tabla aparte para eso. Lo que faltaba y ya está:
ciclar_orden(sort_actual, col) -> Option<(col, SortDir)> // asc → desc → SIN orden
orden_de_filas(&celdas, col, dir, &TipoCol) -> Vec<usize> // permutación estable
TipoCol::{Texto, Numero, Natural, Propio(fn)} // «archivo2» < «archivo10»
Pagina{indice, tamano}: rango(total) · total_paginas · acotada(total) · etiqueta
botones_pagina(actual, total, max) -> Vec<BotonPagina> // 1 … 4 5 6 … 20
pager_view(pagina, total_filas, Fn(usize)->Msg, &palette)
orden_de_filas devuelve índices, no filas movidas: así ordena sin conocer
el tipo del caller (una DetailRow lleva Msg y View) y sin perder el orden
original, que es al que vuelve el tercer click. Numero manda al final lo
que no parsea (un «—» encabezando no es información) y no hay variante Fecha:
en ISO ordena bien como Texto, y para otros formatos está Propio — un parser
de fechas acá sería un segundo parser distinto del del datetime-picker.
text-editor — editor IDE (capa visual sobre el core agnóstico). La app guarda
EditorState:
EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo();
.add_cursor_at(line,col); .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult;
.ensure_caret_visible(visible_lines)
// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option<Msg>
text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer)
text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer)
text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer)
syntax_palette_dark(&theme) -> SyntaxPalette // en lib.rs del widget
text-editor-core — núcleo agnóstico (sin GPU, sin Llimphi; sólo
peniko::Color). Reutilizable en TUI/web/headless. Tipos clave:
Buffer(sobreropey):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 }conmove_left/right/up/down/word_left/...,selection_range(&buf),collapse.- Ops:
replace_selection,delete_backward/forward,indent_or_insert_tab,insert_newline_auto_indent→ devuelvenEditDelta { 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(traitget/set),MemClipboard,NullClipboard.Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity, message: String, source: Option<String> }(+ ctorserror(..),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>>(unVec<Span>por línea),.set_language(lang),.language(); helpers de móduloinvalidate_tree_cache(lang)yapply_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_idglobal estable que sobrevive al recorte (index_of_id),push_line,slice_text,clear.dropped()/total_pushed()para elnumberingglobal. - Spill a disco (
SpillStore::create(path)+Scrollback::enable_spill(spill)): cada línea recortada del frente se appendea al archive; lookup O(1) conread_spilled(global_id)+ helpershas_spill/spilled_count/spill_pathpara 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 enLineStyle. Helpers puros:visible_window,content_height,scroll_to_bottom.measureopcional publica elviewport_hreal medido al host (regla del SDD: verificar con viewport real). - Modo bloques (
block_surface(store, items: Vec<Item<Msg>>, ...)): el stream es una secuencia deItem::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 unLinesenorme sólo se materializan las sub-filas visibles. Colapsar = no emitir elLines. 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 }concolen bytes UTF-8) en su Model y resuelve clicks conpoint_at(items, scroll_y, viewport_h, metrics, gutter_w, store, lx, ly)—o su varianteCopy-stashablepoint_at_geo(&[ItemGeo], ...)para resolver el frame anterior.selection_rectspara 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 }constart/enden bytes UTF-8 slice-safe. Nav:next_match(&matches, current)/prev_match. El caller resuelve ellinea suscroll_yconline_top_in_content. - Grilla GPU (modo TUI, Fase 4):
CellPipeline::new(device, format)+GlyphAtlasrasterizan cada celda con quads instanciados y atlas de glifos (precedente:atlasde 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íagpu_paint_with. Activo en shuma conSHUMA_GPU_GRID=1.
Headless dumps:
cargo run -p llimphi-widget-terminal --example dump_terminal # modo línea, 1M renglones
cargo run -p llimphi-widget-terminal --example dump_blocks # bloques + flood 500k
clipboard — portapapeles del sistema vía arboard. SystemClipboard::new(),
is_available(), impl Clipboard. No-op silencioso si no hay display (CI headless).
menubar — barra de menú mac-style. menubar_view(&MenuBarSpec { menu: &AppMenu, open: Option<usize>, theme, viewport, height, on_open: Fn(Option<usize>)->Msg, on_command: Fn(&str)->Msg }); dropdown en view_overlay con menubar_overlay(spec)
o menubar_overlay_animated(spec, active, appear). Navegación por teclado:
menubar_nav, menubar_command_at.
edit-menu — menú estándar de edición sobre un editor.
EditFlags::from_editor(&state, masked), edit_context_menu(anchor, viewport, &theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss) →
ContextMenuSpec. apply(&mut state, EditAction, &mut clipboard) -> ApplyResult.
EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}.
context-menu — menú contextual genérico (panel elevado con sombra + borde +
esquinas redondeadas). ContextMenuItem::action(label).with_shortcut(..).icon(..) .checked(bool).disabled().destructive().submenu(children) o ::separator()
(checked pinta ✓ en el gutter y alterna estado). context_menu_view(ContextMenuSpec { anchor, viewport, header, items, active, on_pick: Fn(usize)->Msg, on_dismiss, palette }); context_menu_view_ex con submenús (flyout que flipea a la izq. si
no cabe) + animación de aparición. Se monta en view_overlay con scrim. Teclado
(helpers puros que el host cablea): step_active(items, cur, ±1) (↑/↓, saltea
sep/disabled), edge_active(items, to_end) (Inicio/Fin), type_ahead(items, cur, ch)
(mnemónico por 1ª letra). Submenú por teclado = step_active sobre parent.children.
Demo completo: cargo run -p llimphi-widget-context-menu --example menu_demo.
theme-switcher — theme_switcher_view(¤t: &Theme, on_change: Fn(Theme)->Msg)
(+ _styled/_flex). Cicla Theme::next_after.
shortcuts-help — overlay "?" con atajos agrupados. shortcuts_help_view( ShortcutsHelpSpec { title, groups: Vec<ShortcutGroup { title, entries: Vec<ShortcutEntry { keys, description }> }>, viewport, on_dismiss, palette }).
wawa-mark — sello vectorial del SO wawa. wawa_mark_view(&WawaMarkPalette);
paint_mark(scene, rect, &palette) para canvas custom. Usar en contenedor cuadrado.
14. Catálogo de módulos
Los módulos encapsulan estado + comportamiento (a diferencia de los widgets). Todos siguen el mismo contrato:
State + Msg + Action + apply(state, msg, ...) -> Action
+ on_key(state, &KeyEvent) -> Option<Msg>
+ open_shortcut(&KeyEvent) -> bool
+ view(state, ..., to_host: F) -> View<HostMsg>
+ Palette
La app guarda Option<ModuleState> (o el state directo, p. ej. bookmarks),
rutea el atajo de apertura con open_shortcut, rutea teclas con on_key, aplica
Msgs con apply, y monta el view pasando un mapeo to_host: Fn(ModuleMsg) -> HostMsg. Cuando apply devuelve una Action (p. ej. Invoke(id), OpenAt{..},
GoTo{..}), la app ejecuta el efecto. Crates en modules/<nombre>/.
| Módulo | Atajo | Acción que devuelve | Propósito |
|---|---|---|---|
| command-palette | Ctrl+Shift+P |
Invoke(String) |
paleta de comandos fuzzy. El host declara &[Command] |
| file-picker | Ctrl+P |
Open(PathBuf) |
fuzzy file picker; host pasa &[PathBuf] + root |
| fif (find-in-files) | Ctrl+Shift+F |
OpenAt{path,line,col}, Searched{..}, Replaced{..} |
buscar/reemplazar; dual-view (dialog + barra). search() / replace_all() hacen el I/O |
| diff-viewer | Ctrl+Shift+D |
— | diff side-by-side. DiffState::new(before_label, after_label, before, after) computa con similar |
| mini-map | Ctrl+Shift+M |
JumpTo(line) |
minimapa del buffer; agnóstico del editor (recibe Snapshot) |
| bookmarks | Ctrl+Alt+B toggle, Ctrl+Shift+B lista, Ctrl+Alt+N/P nav |
JumpTo{path,line} |
marcadores per-file persistentes (state directo, no Option) |
| symbol-outline | Ctrl+Shift+O |
GoTo{line,col} |
outline de símbolos; host arma Vec<SymbolItem> (LSP/tree-sitter/custom) |
| selector | — | — | abstracción portátil abrir/guardar: trait Selector (HostSelector con PathBuf, WawaSelector placeholder content-addressed) |
| plugin-host | — | OpenAt{..}, SetStatus(..) |
runtime WASM (wasmi) con permisos por bitfield; PluginHost::load_from_dir/invoke(id, cap, args) |
| shuma-term | Ctrl+` |
SetStatus(..) |
terminal integrada. spawn(cwd) lanza PTY (shuma_exec), vt100::Parser renderiza; Tick drena el PTY |
Patrón típico de integración (command-palette):
struct Model { palette: Option<PaletteState>, commands: Vec<Command>, /* … */ }
enum Msg { Palette(PaletteMsg), /* … */ }
// on_key:
if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); }
if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); }
// update:
Msg::Palette(m) => {
if let Some(state) = model.palette.as_mut() {
match command_palette::apply(state, m, &model.commands) {
PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; }
PaletteAction::Close => model.palette = None,
PaletteAction::None => {}
}
}
}
// view_overlay:
model.palette.as_ref().map(|s|
command_palette::view(s, &model.commands, &palette, Msg::Palette))
15. llimphi-workspace — chasis tipo tmux
Monta cualquier componente en un layout intercambiable con splits resizables
(máquina de estados de foco/split/cierre + chrome estándar). Construido sobre
llimphi-widget-panes.
Workspace::new()
.focused() -> PaneId .count() .leaves() -> Vec<PaneId> .layout() -> &Layout
.focus(id) .split(Axis) -> PaneId .close() -> Option<PaneId> .resize(&path, delta)
.apply(WsMsg) -> WsEffect
enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec<Side>, f32) }
enum WsEffect { None, Created(PaneId), Closed(PaneId) }
workspace_view(&ws, &WorkspacePalette,
leaf: FnMut(PaneId)->View<Host>, // materializa el contenido de cada panel
lift: Fn(WsMsg)->Host) // sube los Msg del chasis a tu Msg
Patrón: enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }. En update,
ws.apply(msg) te avisa con WsEffect::{Created,Closed}(id) para que crees o
destruyas el estado del panel correspondiente.
16. Reglas duras y gotchas
🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE)
Ningún update/init/handler puede ejecutar trabajo síncrono pesado
(efemérides, simulación, IO, parse, embeddings, layout de árboles grandes).
Bloquea el hilo → "Not Responding". init corre dentro de resumed, después
de crear la ventana, así que un cómputo pesado ahí ya congela una ventana
visible.
Patrón (referencia: cosmos-app-llimphi):
// Model: Option<Resultado> (None = "calculando…") + flag dirty + contador de generación.
struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64 }
enum Msg { XComputed(u64, Arc<Resultado>) }
// al FINAL de update() (que tiene el Handle):
if m.x_dirty {
m.x_dirty = false;
m.x_gen = m.x_gen.wrapping_add(1);
let (gen, input) = (m.x_gen, m.input.clone()); // sólo lo que el worker necesita (Send)
handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input))));
}
// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un
// resultado tardío pise a uno más nuevo en drags/toggles rápidos).
Msg::XComputed(gen, x) => if gen == m.x_gen {
m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
}
La generación es imprescindible si el recálculo se dispara seguido. Ver
COMPUTO-FUERA-DEL-HILO-UI.md y su checklist por app.
Otras
- Solvers iterativos (Newton/bisección): cota dura
for _ in 0..N, nuncaloop {}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 enHal::new, no revertir. - Un nodo es draggable o clickable, no ambos.
alphayclipcrean capas intermedias: tienen costo, usar sólo cuando hace falta.paint_withno debe dejarpush_layersinpop_layerni 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*_atse 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
Typesetterpor frame es caro (FontContext::newenumera fuentes del sistema). El runtime ya cachea uno y lo pasa apaint_with.
16.bis Tests de regresión visual — llimphi-test (golden-image)
Para certificar que un widget/pantalla no cambió de aspecto sin renderizar-y-
mirar a mano (CLAUDE.md §8), usar el arnés llimphi-test. Renderiza un View
headless por el adapter de software (llvmpipe/lavapipe → mismo píxel host-a-
host y en CI) y lo diffea contra un golden con tolerancia perceptual.
// en tests/<algo>.rs de tu crate
use llimphi_test::assert_golden;
#[test]
fn mi_card() {
let v = mi_widget::card(/* … */);
assert_golden!("mi_card", v, 400, 240); // vs tests/goldens/mi_card.png
}
- Generar/actualizar goldens:
LLIMPHI_GOLDEN_UPDATE=1 cargo test -p <crate>(escribe los PNG entests/goldens/; commitéalos). - En fallo: escribe
mi_card.actual.png+mi_card.diff.png(heatmap) al lado del golden y paniquea conDiffStats { 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_deterministaverifica 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 queView::gpu_paint_with; limpia abgy te deja pintar directo, después lee de vuelta para el golden (verllimphi-test/tests/gpu_golden.rs).
El golden es el ÚLTIMO recurso, no el primero
Un golden es una imagen bendecida: pasa a ser correcta porque alguien dijo que lo era. Eso funciona para «esto no cambió», pero no contesta «¿esto está bien?», y para bendecirla hay que mirarla — justo lo que §8 pide evitar.
Casi siempre hay algo mejor: el oráculo es la otra manera de calcular lo mismo. En vez de comparar contra una imagen, se pinta el mismo árbol por dos caminos que tienen que coincidir, y la afirmación pasa a ser objetiva. Los que ya existen en el repo, todos escritos así:
| test | los dos caminos | resultado |
|---|---|---|
boundary_cache.rs |
paint contra paint_cached |
0 px |
gpu_lotes.rs |
un buffer entero contra troceado | idénticos |
gpu_grosores.rs |
un lote con dos plumas contra dos lotes de una | idénticos |
gpu_scissor.rs |
pasada completa contra recorte | mismo píxel exacto |
cpu_vs_gpu.rs |
vello_cpu (wawa) contra vello (Linux) |
tabla por patrón |
wire_vs_directo.rs |
por el cable contra en proceso | bit a bit |
hybrid_vs_cpu.rs |
vello_hybrid (Mali) contra CPU |
Δ1:0 |
texto_gpu_directo.rs |
atlas de glifos contra vello | Δ64:0 |
Cuatro reglas que salieron de escribirlos
- Poné el umbral DESPUÉS de medir, no antes. Los umbrales «prudentes» que
se eligen a ojo casi nunca se rozan, y un umbral que nada roza no es un gate.
En
hybrid_vs_cpuel 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 delassert, con fecha: es lo que permite decidir el umbral siguiente. - Saboteá el test antes de creerle. Rompé a mano lo que dice cubrir y mirá
que falle ese test y no otro. Dos veces en esta tanda el test pasaba con la
pieza rota:
boundary_gradiente_imagenreconstruía el objeto condefault()en vez de clonarlo, y cadadefault()hacía unBlobnuevo — o sea que invalidaba por identidad y nunca llegaba a probar el hash. Cuatro de cinco sabotajes pasaban. - Todo test de igualdad necesita una guarda de arnés. Dos lienzos vacíos
coinciden perfecto. Afirmá además que se pintó algo (
pintados > total/8), que el caché acertó (hits > 0), que se ejecutaron las órdenes (ejecutadas > 0, salteadas == 0), que se compararon todos los casos (probados == patrones().len()). Sin eso, el test firma cualquier cosa. - Recorré el camino difícil, no el fácil.
texto_gpu_directosube sólo el rect sucio y no el atlas entero, porque es lo que hace una app; y tiene un caso incremental aparte justamente porque en un atlas recién hecho lo sucio arranca en (0,0), donde un error de coordenada pasa desapercibido.
Cuándo sí un golden: cuando no hay segundo camino. gpu_golden.rs (starfield
por GPU directo) es el caso — no existe otra forma de producir esa imagen, así
que la referencia tiene que ser ella misma.
Correr la suite contra la GPU de verdad
LLIMPHI_TEST_GPU=1 cargo test --no-fail-fast -p llimphi-test -p llimphi-raster -p llimphi-hybrid
El arnés fuerza el adapter de software para que un golden valga lo mismo en cualquier máquina, y eso está bien — pero deja una pregunta sin contestar: ¿el hardware hace lo mismo? Con esa env se ignora la preferencia y se usa la GPU real. Es para preguntar a mano, no para cambiar la referencia.
Medido el 2026-08-05 (Iris Xe/Vulkan contra llvmpipe), y el resultado dice más que cualquier argumento sobre goldens:
| software | GPU real | |
|---|---|---|
| tests en verde | 113 | 112 |
El único que se cae es gpu_golden — el único que compara contra una imagen
bendecida (2,74 % de píxeles, Δ máx 44). Los ocho oráculos pasan en las dos,
y con casi el mismo número:
| test | software | GPU real |
|---|---|---|
texto_gpu_directo |
Δ16:417 Δ64:0 · tinta +0,31 % | Δ16:423 Δ64:0 · tinta +0,29 % |
hybrid_vs_cpu |
Δ0:236 Δ1:0 | Δ0:232 Δ1:0 |
boundary_cache / gpu_lotes / gpu_scissor |
0 px | 0 px |
Un oráculo compara dos cosas que el mismo rasterizador produce, así que la
diferencia de rasterizador se cancela. Una foto no puede cancelar nada. Por
eso gpu_golden se saltea solo cuando se pide GPU: así el comando de arriba es
una pregunta limpia y no una corrida con un fallo conocido de ruido.
16.ter Semántica accesible + tests por consulta
Un View describe cajas: posición, color, texto. No dice qué significan.
Esa segunda capa se declara con View::role + View::aria_*, y paga dos veces:
- Lectores de pantalla, 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_winitya está cableado en el event loop, sin feature gate. - Tests que preguntan por significado, no por píxeles (ver más abajo).
Declarar
View::new(style)
.role(Role::Button) // qué ES el nodo
.aria_label("Guardar") // cómo se llama (si no hay texto visible)
.aria_value("70%") // dato dinámico: valor de un slider, texto de un input
.aria_description("…") // contexto extra; el lector lo dice aparte
.aria_pressed(activo) // …y los flags: checked/pressed/expanded/
.aria_disabled(!habilitado) // disabled/readonly/required
Dónde va el rol: en el nodo que lleva el on_click, no en el contenedor.
AccessKit sólo ofrece Action::Click donde hay handler; un rol Button sobre
una caja sin handler produce un botón que el lector no puede activar.
El nombre accesible es aria_label si está, si no el text visible. Por eso
un nodo que ya pinta su texto no necesita label — pero si el texto vive en un
hijo (ícono + etiqueta en una fila), el nodo con el rol queda sin nombre y hay
que dárselo explícito. Es el error más común.
Roles disponibles (llimphi_ui::Role): Button TextInput MultilineTextInput
Heading Checkbox Switch Label Link Image Slider ProgressBar
MenuItem Menu MenuBar Tab TabList Toolbar List ListItem Tree
TreeItem Table Row Cell ColumnHeader Dialog Alert Status
Tooltip Terminal Splitter Group. Si falta uno, agregalo en
llimphi-compositor/src/semantics.rs y mapealo en llimphi-ui/src/a11y.rs —
cuando aparezca un caller real, no antes.
Cuándo NO declarar: decorativo puro (divider, gradiente, sombra, View
envoltorio de layout). Un Role::Group en cada caja ensucia la navegación más de
lo que ayuda.
Consultar — llimphi_test::query
La misma declaración habilita tests que no miran píxeles:
use llimphi_test::query::{Sel, Ui};
use llimphi_ui::Role;
let ui = Ui::new(app.view(), 800.0, 600.0);
assert_eq!(ui.find_all(Role::Tab).len(), 3);
let guardar = ui.get(Sel::new().role(Role::Button).label("Guardar"));
assert!(guardar.is_enabled());
// Clickear = obtener el Msg y metérselo al update.
app.update(guardar.click().unwrap());
-
No necesita GPU — sólo
mount+ layout. Corre en cualquier CI, a diferencia deHarness(que pide lavapipe). -
Ui::getpaniquea 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×100El
·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)yui.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 —
cardpanelgridwrapherofitted-boxtiledpanesscrollrouterscaffoldlazy-list. La semántica la traen sus hijos. - Decorativos —
skeletonsplashwawa-mark. - Sin
Viewpropio —clipboardtext-areatext-editor-coretext-editor-lspcarousel(sólo helpers),edit-menu(produceContextMenuItem, que ya viene anotado),gallery(binario de ejemplo). - Componen widgets ya anotados —
navigator(sobretree+nodegraph),rag-sidebar(sobresegmented+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:
- Si el nodo mismo tiene handler, es él.
- Si no, y declara un rol, se delega en su subárbol — siempre que haya exactamente un descendiente clickeable.
- 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:
DockRailItem::nombrado(id, activo, "Configuración") // recomendado
DockRailItem::new(id, activo) // queda mudo
RiveButton::builtin().con_label("Ejecutar")
Y un patrón que apareció en casi todos: el ítem sólo-ícono. Un botón sin
texto visible (toolbar sin with_label, transport, chevrones de calendario,
el × de una pestaña) es mudo salvo que se le dé aria_label a mano. Los que
tienen un nombre derivable ya lo llevan; los que dependen del caller están
señalados en el código.
17. Comandos y demos
cargo check --workspace # smoke test mínimo (debe pasar siempre)
cargo run -p <crate> --release # correr una app
cargo run -p <crate> --example <demo> --release # correr un demo
# demos del propio framework:
cargo run -p llimphi-ui --example counter --release # bucle Elm completo
cargo run -p llimphi-ui --example editor --release # text field + teclado
cargo run -p llimphi-ui --example gpu_paint_demo --release
cargo run -p llimphi-gallery --release # showcase de TODO el kit
cargo test -p llimphi-gallery # la vitrina como CALLER: los widgets responden
cargo run -p nada --release # editor real para ejercitar widgets
# benchmark GPU directo vs vello (primitivas Y texto — pedile GPU real, en
# llvmpipe la mitad de rasterizado no decide nada):
cargo run -p llimphi-gpu-bench --release
# perfilador por frame (a stderr cada 60 frames: media/p95 por etapa):
LLIMPHI_PERF=1 cargo run -p <crate> --release
# ...con conteo de asignaciones de heap por frame y su desglose por etapa:
LLIMPHI_PERF=1 cargo run -p <crate> --release --features llimphi-ui/perf-alloc
# dónde poner un .repaint_boundary() en TU app (ver más abajo):
LLIMPHI_BOUNDARY_HINT=1 cargo run -p <crate> --release
# un update que tarda de más y congela la UI:
LLIMPHI_UI_SLOW_MS=50 cargo run -p <crate> --release
LLIMPHI_PERF=N reporta cada N frames (default 60) el desglose
view/mount/layout/paint/raster/gpu/present en µs + layout-reuso, el caché de
repaint_boundary y —si está la feature perf-alloc— alloc <media>/<p95> (<KB>) [view N·KB mount N·KB layout N·KB paint N·KB resto N·KB], donde resto
es el tramo posterior a paint (raster vello + GPU + present) y el desglose suma
el total. Sin esa feature el tramo de allocs dice n/a en vez de ceros falsos.
Ojo: los contadores son del proceso, no del hilo — lo que asigne otro hilo
durante una etapa cae en esa etapa. El contador es
llimphi_ui::alloc::ContadorAlloc: si tu app ya elige su allocator, instalalo a
mano (#[global_allocator]) en vez de usar la feature. El camino cache_hit
(frame retenido entero) no se muestrea a propósito.
LLIMPHI_BOUNDARY_HINT — dónde poner un repaint_boundary
.repaint_boundary(clave) cachea la rasterización de un subárbol quieto y la
reusa mientras su contenido no cambie (Bloque 23). El problema práctico nunca fue
la API sino dónde: marcar un subárbol que en realidad cambia todos los
cuadros no es neutro —se paga el hash entero cada frame y no se ahorra nada—, y
desde afuera un panel quieto y uno que se reconstruye idéntico a sí mismo se ven
igual. Por eso ninguna app lo usaba.
LLIMPHI_BOUNDARY_HINT=1 cargo run -p <tu-app> --release # reporta cada 120 cuadros
LLIMPHI_BOUNDARY_HINT=300 … # cada 300
Usá la app un rato con el puntero fuera de lo que sospechás. La salida:
[llimphi] 2 subárbol(es) quieto(s), 431 nodos que se repintan de gusto
— poneles .repaint_boundary(clave):
nodo 12 · 287 nodos · (0,48 260×672) · 118 cuadros quieto «Archivos»
nodo 640 · 144 nodos · (0,0 1920×48) · 118 cuadros quieto «Inicio»
Cada línea es el subárbol máximo que vino idéntico: si un panel entero está quieto no se listan también sus hijos. El texto es el primero que aparece adentro, para que lo reconozcas sin contar nodos.
Es una predicción, no una opinión: el detector consulta las mismas tres funciones que el caché de verdad (posición limpia, paint-puro, y el mismo hash que decide hit o miss), así que lo que señala el caché lo va a acertar. Dos cosas que no dice:
- Si conviene. Sugiere «esto habría sido cache-hit»; el hash se paga siempre. Por eso ignora subárboles de menos de 16 nodos.
- Que esté quieto para siempre. Un panel quieto 120 cuadros puede cambiar al siguiente click, y ahí el boundary falla y repinta — correcto, pero sin ahorro.
Con el puntero adentro de un subárbol, ni el caché lo sirve ni el detector lo sugiere: el hover cambia lo pintado. Y es diagnóstico, no producción — hashea cada candidato por cuadro. Encendelo, anotá, apagalo.
Qué descalifica a un subárbol (el detector se calla y el boundary, si lo
ponés igual, se pinta fresco): cualquier paint_with/gpu_paint_with, una
animación viva, un ripple, un hero, una mask_image, o estar bajo un
clip/alpha/transform de un ancestro. Degradé e imagen sí entran desde
2026-08-05 — antes no, y como un sidebar real casi siempre tiene un ícono o un
fondo con degradé, un solo nodo así descalificaba el panel entero y el caché no
servía para lo que existía.
llimphi-gallery (src/main.rs) es la referencia viva del
patrón completo: Model/Msg/init/update/view/view_overlay con overlays
mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown).
Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; ? abre
atajos; Esc cierra el overlay activo.
18. Cheat-sheet
// ── App mínima ──────────────────────────────────────────────
impl App for X { type Model; type Msg; init; update; view; }
llimphi_ui::run::<X>();
// ── Nodo ────────────────────────────────────────────────────
View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
.fill(c).fill_gradient(g).hover_fill(c).radius(r).radius_corners(tl,tr,br,bl).shadow(sh).border(w,c).clip(b).alpha(a).transform(xf).animated(key,dur)
.text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al) | .text_weight(w) | .bold() | .ellipsis(n) | .max_lines(n)
.image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{})
.on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m)
.on_pointer_enter(m) | .on_pointer_leave(m)
.draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|)
.drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c)
.children(vec![..])
// ── Efectos ─────────────────────────────────────────────────
handle.spawn(|| Msg::Done(compute())); // worker → reentra al update
handle.spawn_periodic(dur, || Msg::Tick); // feed periódico
handle.dispatch(Msg::X); handle.quit();
// ── Estilo de layout (taffy prelude) ────────────────────────
length(px) percent(0..1) Dimension::auto()
FlexDirection::{Row,Column} AlignItems::{Start,Center,End,Stretch}
JustifyContent::{Start,Center,End,SpaceBetween}
// ── Theme ───────────────────────────────────────────────────
Theme::dark()/light()/aurora()/sunset(); Theme::next_after(name); XxxPalette::from_theme(&t)
// ── Overlay (menús/modales) ─────────────────────────────────
fn view_overlay(m) -> Option<View<Msg>> { if m.open { Some(menu) } else { None } }
19. Índice de crates
Framework (02_ruway/llimphi/):
llimphi-hal · llimphi-raster · llimphi-text · llimphi-layout ·
llimphi-compositor · llimphi-ui · llimphi-theme · llimphi-motion ·
llimphi-icons · llimphi-surface · llimphi-workspace · llimphi-gallery ·
llimphi-gpu-bench.
Widgets (widgets/, dep llimphi-widget-<n>): app-header · avatar · badge ·
banner · breadcrumb · button · card · clipboard · color-picker · context-menu ·
dock-rail · edit-menu · empty · field · gallery · grid · lazy-list · list · menubar · modal ·
navigator · nodegraph · panel · panes · progress · segmented · select ·
shortcuts-help · skeleton · slider · splash · splitter · stat-card · status-bar ·
switch · table · tabs · terminal · text-area · text-editor · text-editor-core ·
text-editor-lsp · text-input · theme-switcher · tiled · timeline · toast ·
toolbar · tooltip · transport · tree · waveform · wawa-mark.
Módulos (modules/): bookmarks · command-palette · diff-viewer · fif ·
file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline.
Android (android/): clear-screen-android · vello-hello-android ·
vello-text-android.
Documentos hermanos:
SDD.md(diseño y roadmap),COMPUTO-FUERA-DEL-HILO-UI.md(regla de concurrencia),README.md/LEEME.md(overview). Las firmas de este manual reflejan el código al 2026-06-01; ante divergencia, la fuente autoritativa es ellib.rsde cada crate.