Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
48 KiB
Manual de Llimphi
Motor gráfico soberano de gioser.
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
- 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: declarás 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
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í escribís 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: ~45 widgets (visuales) + 10 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; guardás `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
}
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 |
.hover_fill(Color) |
color al pasar el cursor (habilita hit-test de hover) |
.radius(f64) |
esquinas redondeadas |
.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%) |
.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)
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)
.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).
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 tocás 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 |
| 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/inercia
(driveado por Handle::spawn_periodic).
Foco y teclado. Marcá los nodos navegables con .focusable(id) (id u64
que vos elegís). 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>). Guardás 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) (insertá como tecleado) o
Disabled. Reportá 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. No soporta texto ni AA
fino ni múltiples grosores de stroke por flush. Para texto encima de un render
GPU, usar view_overlay (segunda Scene vello).
¿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í | no |
| AA fino | sí (analítico) | no (sin MSAA) |
Default: paint_with salvo que ya midas que el volumen lo justifica
(factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU
expone GpuPipelines/GpuBatch en llimphi-raster (§12).
11. Theme y paletas
llimphi-theme::Theme es un struct de slots semánticos de color. Cuatro presets
const: Theme::dark() (default), light(), aurora(), sunset().
pub struct Theme {
pub name: &'static str,
// fondos
pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus,
pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color,
// texto
pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color,
// bordes y acento
pub border, border_focus, accent: Color,
}
Theme::all() -> Vec<Theme> // orden de rotación canónico
Theme::by_name(name) -> Option<Theme>
Theme::next_after(current_name) -> Theme // para el theme-switcher
Tokens auxiliares en el mismo crate:
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 reconstruís 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 tocás HAL si escribís un runtime nuevo.
llimphi-raster — rasterización
Renderer::new(hal) -> Result<Renderer,_>
Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
// GPU directo:
GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout
GpuBatch::new(&pipelines)
.line_width(w) .add_line(p0,p1,color) .add_polyline(&pts,color)
.add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
.primitive_count() -> u32
.flush(device, queue, encoder, view, viewport, load_op)
Re-exporta vello y peniko (Color, Image, Fill, etc.).
llimphi-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: guardás 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 — multilínea con estado TextAreaState (Enter = newline, sin
auto-submit). text_area_view(&state, placeholder, focused, body_height, &palette, on_focus).
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.
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).
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).
badge — count_badge_view(count, BadgeKind) ("99+" si ≥100) y
dot_badge_view(BadgeKind). BadgeKind::{Info,Success,Warning,Error,Neutral}.
avatar — avatar_view(name, size_px): círculo determinista (color por hash
del nombre + inicial).
tooltip — render puro. tooltip_view(TooltipSpec { anchor, viewport, side: Side, text, palette }). Se monta en view_overlay; la app controla visibilidad
con on_pointer_enter/leave.
empty — empty-state. empty_view(Icon, title, description: Option<&str>, &palette).
skeleton — placeholder con shimmer. skeleton_view, skeleton_box_view(w,h,..),
skeleton_line_view(w,..). Requiere redraws periódicos.
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.
splitter — divisor draggable de 2 panes. splitter_two(Direction::{Row,Column}, a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette).
PaneSize::{Fixed(px), Flex}. La app acumula el delta en su Model.
scroll — área de scroll vertical con barra arrastrable. scroll_y(offset, content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette).
Stateless (offset en el Model); rueda autocontenida. Helpers: clamp_offset,
ensure_visible (selección a la vista), approach (scroll suave). Ver §8.
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)
grid — grilla 2D virtualizada. ventana_visible(total, vp_w, vp_h, scroll_fila, &metrics) -> VisibleWindow para virtualizar, luego grid_view(GridSpec { cells: Vec<GridCell { content, label, selected, on_click }>, cols, metrics, caption, ... }).
list — lista vertical virtualizada. list_view(ListSpec { rows: Vec<ListRow { label, selected, on_click }>, total, caption, truncated_hint, row_height, palette }).
La app prefiltra las filas visibles.
tree — árbol expand/collapse. tree_view(TreeSpec { rows: Vec<TreeRow { label, depth, has_children, expanded, selected, on_toggle, on_select }>, row_height, indent_px, palette }). La app aplana el árbol según nodos expandidos.
navigator — navegador data-agnóstico de nodos en dos modos conmutables
(árbol ↔ grafo, reusa tree + nodegraph). Render-only: la app guarda
expanded/selected/mode. Pasa un bosque de NavNode { id: u64, label, kind: NavKind (Monad|Group|Dir|File|Other), children } y callbacks por id.
navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides },
is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg,
on_select: Fn(u64)->Msg, on_context: Option<Fn(u64)->Msg>)
// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.
app-header — app_header(label, actions: Vec<View<Msg>>, &palette).
status-bar — status_bar_view(left, center, right, &palette) con
StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized().
breadcrumb — breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)
(el último segmento no es clickable).
modal — diálogo centrado con scrim. modal_view(ModalSpec { title, body: View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette }).
ModalButton::{primary, cancel, destructive}(label, msg). Se monta en view_overlay.
toast — notificaciones efímeras bottom-right. La app guarda Vec<Toast>
(Toast::{info,success,warning,error}(id, text, duration)), filtra
is_alive(now), y toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg).
splash — splash de arranque (cuatro cuadrantes andinos). splash_view(started_at: Instant, bg, fg_text); basado en tiempo, requiere redraws.
Ricos / interactivos
nodegraph — lienzo de nodos + cables Bezier. Sin estado (la app guarda
posiciones y Wires).
NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
nodegraph_view(&nodes, &wires, &palette, &metrics,
on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)
timeline — scrub clickeable. timeline_view(progress: f32, &palette, on_seek: Fn(f32 [0..1])->Option<Msg>).
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.
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 (look "webpage"). ContextMenuItem:: action(label).with_shortcut(..).icon(..).disabled().destructive().submenu(children)
o ::separator(). context_menu_view(ContextMenuSpec { anchor, viewport, header, items, active, on_pick: Fn(usize)->Msg, on_dismiss, palette }); context_menu_view_ex
con submenús/animación. Se monta en view_overlay con scrim.
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.
17. Comandos y demos
cargo check --workspace # smoke test mínimo (debe pasar siempre)
cargo run -p <crate> --release # correr una app
cargo run -p <crate> --example <demo> --release # correr un demo
# demos del propio framework:
cargo run -p llimphi-ui --example counter --release # bucle Elm completo
cargo run -p llimphi-ui --example editor --release # text field + teclado
cargo run -p llimphi-ui --example gpu_paint_demo --release
cargo run -p llimphi-gallery --release # showcase de TODO el kit
cargo run -p nada --release # editor real para ejercitar widgets
# benchmark GPU directo vs vello:
cargo run -p llimphi-gpu-bench --release
llimphi-gallery (src/main.rs, ~967 líneas) es la referencia viva del
patrón completo: Model/Msg/init/update/view/view_overlay con overlays
mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown).
Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; ? abre
atajos; Esc cierra el overlay activo.
18. Cheat-sheet
// ── App mínima ──────────────────────────────────────────────
impl App for X { type Model; type Msg; init; update; view; }
llimphi_ui::run::<X>();
// ── Nodo ────────────────────────────────────────────────────
View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
.fill(c).hover_fill(c).radius(r).clip(b).alpha(a).transform(xf)
.text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al)
.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 · context-menu · edit-menu ·
empty · field · gallery · grid · list · menubar · modal · navigator · nodegraph ·
panel · panes · progress · segmented · shortcuts-help · skeleton · slider · splash ·
splitter · stat-card · status-bar · switch · tabs · text-area · text-editor ·
text-editor-core · text-editor-lsp · text-input · theme-switcher · tiled ·
timeline · toast · tooltip · tree · 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.