e65e9cc623
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>
1042 lines
48 KiB
Markdown
1042 lines
48 KiB
Markdown
# Manual de Llimphi
|
||
|
||
> Motor gráfico soberano de gioser. `wgpu` + `vello` + `taffy` + `parley`,
|
||
> bucle Elm `input → update → view → layout → raster → present`.
|
||
> Reemplazo total de GPUI (extinto 2026-05-26): toda app gráfica de la suite
|
||
> corre sobre Llimphi.
|
||
|
||
Este documento es la **referencia de uso** orientada a humanos y a IA.
|
||
Está organizado para salto directo: cada capa, widget y módulo trae su API
|
||
real (firmas copiadas del código). Para el **porqué** arquitectónico ver
|
||
[`SDD.md`](SDD.md); para la regla de concurrencia ver
|
||
[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md).
|
||
|
||
---
|
||
|
||
## Índice
|
||
|
||
1. [Modelo mental en 60 segundos](#1-modelo-mental-en-60-segundos)
|
||
2. [Arquitectura — las capas](#2-arquitectura--las-capas)
|
||
3. [Quickstart — la app mínima](#3-quickstart--la-app-mínima)
|
||
4. [El trait `App` (bucle Elm)](#4-el-trait-app-bucle-elm)
|
||
5. [`Handle` — efectos y concurrencia](#5-handle--efectos-y-concurrencia)
|
||
6. [`View<Msg>` — el DSL declarativo](#6-viewmsg--el-dsl-declarativo)
|
||
7. [Layout (`taffy` / `Style`)](#7-layout-taffy--style)
|
||
8. [Eventos e interacción](#8-eventos-e-interacción)
|
||
9. [Texto](#9-texto)
|
||
10. [Canvas custom y GPU directo](#10-canvas-custom-y-gpu-directo)
|
||
11. [Theme y paletas](#11-theme-y-paletas)
|
||
12. [Capas base (hal · raster · text · motion · icons · surface)](#12-capas-base)
|
||
13. [Catálogo de widgets](#13-catálogo-de-widgets)
|
||
14. [Catálogo de módulos](#14-catálogo-de-módulos)
|
||
15. [`llimphi-workspace` — chasis tipo tmux](#15-llimphi-workspace--chasis-tipo-tmux)
|
||
16. [Reglas duras y gotchas](#16-reglas-duras-y-gotchas)
|
||
17. [Comandos y demos](#17-comandos-y-demos)
|
||
18. [Cheat-sheet](#18-cheat-sheet)
|
||
19. [Índice de crates](#19-índice-de-crates)
|
||
|
||
---
|
||
|
||
## 1. Modelo mental en 60 segundos
|
||
|
||
Llimphi es **Elm sobre la GPU**. Una app es un tipo que implementa el trait
|
||
`App` con cuatro piezas:
|
||
|
||
- `Model` — estado **inmutable** de la app.
|
||
- `Msg` — todo lo que puede pasar (`Clone + Send`).
|
||
- `update(model, msg, handle) -> model` — transición **pura** que devuelve un
|
||
modelo nuevo.
|
||
- `view(&model) -> View<Msg>` — función **pura** que describe la pantalla como
|
||
un árbol de `View`.
|
||
|
||
El runtime hace el bucle: un evento (click/tecla/rueda) produce un `Msg`,
|
||
`update` deriva el nuevo `Model`, `view` reconstruye el árbol, `taffy` calcula
|
||
las cajas, `vello` rasteriza, y se hace swap del frame. **No hay mutabilidad
|
||
compartida, no hay vDOM ajeno, no hay callbacks imperativos**: 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:
|
||
1. **`view` es pura** — no muta nada, sólo lee el modelo y arma el árbol.
|
||
2. **Cómputo pesado va a un worker** vía `Handle::spawn`, nunca síncrono en
|
||
`update`/`init`/handlers (congela la ventana → "Not Responding").
|
||
3. **Widgets son visuales y stateless**; el estado vive en tu `Model`.
|
||
**Módulos** sí encapsulan estado + comportamiento.
|
||
|
||
---
|
||
|
||
## 2. Arquitectura — las capas
|
||
|
||
```
|
||
4. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent)
|
||
└ llimphi-compositor . árbol View<Msg>, mount sobre taffy, paint, hit-test (winit-free)
|
||
3. llimphi-layout ....... motor de layout (taffy: flexbox + grid)
|
||
2. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo
|
||
1. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji
|
||
0. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer)
|
||
```
|
||
|
||
El **split compositor/runtime** (2026-05-31) es importante: `llimphi-compositor`
|
||
es *winit-free* (sólo `View`, `mount`, `paint`, hit-test). `llimphi-ui` lo corre
|
||
sobre winit y **re-exporta todo el compositor**, así 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
|
||
|
||
```rust
|
||
use llimphi_ui::llimphi_layout::taffy::prelude::*;
|
||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||
use llimphi_ui::{App, Handle, View};
|
||
|
||
#[derive(Clone)]
|
||
enum Msg { Increment, Reset }
|
||
|
||
struct Counter;
|
||
|
||
impl App for Counter {
|
||
type Model = u32;
|
||
type Msg = Msg;
|
||
|
||
fn title() -> &'static str { "llimphi · counter" }
|
||
|
||
fn init(_: &Handle<Self::Msg>) -> Self::Model { 0 }
|
||
|
||
fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
|
||
match msg {
|
||
Msg::Increment => model.saturating_add(1),
|
||
Msg::Reset => 0,
|
||
}
|
||
}
|
||
|
||
fn view(model: &Self::Model) -> View<Self::Msg> {
|
||
let boton = View::new(Style {
|
||
size: Size { width: length(160.0), height: length(56.0) },
|
||
align_items: Some(AlignItems::Center),
|
||
justify_content: Some(JustifyContent::Center),
|
||
..Default::default()
|
||
})
|
||
.fill(Color::from_rgba8(60, 200, 130, 255))
|
||
.radius(12.0)
|
||
.text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255))
|
||
.on_click(Msg::Increment);
|
||
|
||
View::new(Style {
|
||
flex_direction: FlexDirection::Column,
|
||
size: Size { width: percent(1.0), height: percent(1.0) },
|
||
align_items: Some(AlignItems::Center),
|
||
justify_content: Some(JustifyContent::Center),
|
||
gap: Size { width: length(0.0), height: length(24.0) },
|
||
..Default::default()
|
||
})
|
||
.fill(Color::from_rgba8(20, 24, 32, 255))
|
||
.children(vec![
|
||
View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE),
|
||
boton,
|
||
])
|
||
}
|
||
}
|
||
|
||
fn main() { llimphi_ui::run::<Counter>(); }
|
||
```
|
||
|
||
`Cargo.toml`:
|
||
```toml
|
||
[dependencies]
|
||
llimphi-ui = { workspace = true }
|
||
llimphi-theme = { workspace = true }
|
||
# + los widgets/modules que uses:
|
||
# llimphi-widget-button = { workspace = true }
|
||
```
|
||
|
||
Corre con `cargo run -p <tu-crate> --release`. El ejemplo vivo está en
|
||
`llimphi-ui/examples/counter.rs`.
|
||
|
||
---
|
||
|
||
## 4. El trait `App` (bucle Elm)
|
||
|
||
Definido en `llimphi-ui/src/lib.rs`. El estado es inmutable; cada evento
|
||
produce un `Model` nuevo.
|
||
|
||
```rust
|
||
pub trait App: 'static {
|
||
type Model: 'static;
|
||
type Msg: Clone + Send + 'static;
|
||
|
||
fn init(handle: &Handle<Self::Msg>) -> Self::Model;
|
||
fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
|
||
fn view(model: &Self::Model) -> View<Self::Msg>;
|
||
|
||
// --- Todo lo de abajo tiene default; sobreescribí lo que necesites ---
|
||
|
||
fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> { None }
|
||
|
||
fn on_wheel(_model: &Self::Model, _delta: WheelDelta,
|
||
_cursor: (f32, f32), _modifiers: Modifiers) -> Option<Self::Msg> { None }
|
||
|
||
/// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta
|
||
/// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo
|
||
/// vidrio"). La transición la maneja tu Model.
|
||
fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> { None }
|
||
|
||
/// Drag&drop de archivos desde el file manager. Un evento por archivo.
|
||
fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> { None }
|
||
|
||
/// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El
|
||
/// runtime administra el foco; 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`):
|
||
```rust
|
||
pub struct KeyEvent {
|
||
pub key: Key, // re-export de winit; usar NamedKey para teclas especiales
|
||
pub state: KeyState, // Pressed | Released
|
||
pub text: Option<String>, // texto resultante con IME/modifiers; None para flechas etc.
|
||
pub modifiers: Modifiers, // { shift, ctrl, alt, meta }
|
||
pub repeat: bool,
|
||
}
|
||
```
|
||
`Key` y `NamedKey` se re-exportan desde `llimphi_ui`.
|
||
|
||
**Rueda** (`WheelDelta { x, y }`): normalizado a "líneas". Convención CSS:
|
||
`y` positivo = scroll hacia abajo.
|
||
|
||
---
|
||
|
||
## 5. `Handle` — efectos y concurrencia
|
||
|
||
`Handle<Msg>` es `Send + Clone`. Llega a `init` y `update`. Es el único modo
|
||
legítimo de producir efectos sin romper la pureza de la transición.
|
||
|
||
```rust
|
||
impl<Msg: Send + 'static> Handle<Msg> {
|
||
pub fn quit(&self); // cierra la ventana / termina el bucle
|
||
pub fn dispatch(&self, msg: Msg); // encola un Msg para el próximo turno
|
||
pub fn spawn<F: FnOnce() -> Msg + Send + 'static>(&self, f: F); // worker; su Msg reentra al update
|
||
pub fn spawn_periodic<F: Fn() -> Msg + Send + 'static>(&self, period: Duration, f: F); // tick periódico
|
||
pub fn for_test() -> Self; // handle "muerto" para tests sin event loop
|
||
}
|
||
```
|
||
|
||
- **`spawn`** — trabajo bloqueante (IO, PAM, parse, efemérides). El `Msg` que
|
||
devuelve la closure se entrega al `update` en el hilo de UI. **Este es el
|
||
patrón obligatorio para todo cómputo pesado** (§16).
|
||
- **`spawn_periodic`** — feeds a intervalos: ticks de simulación (~11 Hz en
|
||
dominium), polling, animaciones por reloj. El thread muere cuando se cierra
|
||
el event loop.
|
||
|
||
---
|
||
|
||
## 6. `View<Msg>` — el DSL declarativo
|
||
|
||
Un `View` = `Style` de taffy + relleno + texto/imagen/painter + handlers +
|
||
hijos. Todo se arma con builders encadenables (`self -> Self`). Definido en
|
||
`llimphi-compositor/src/view.rs`.
|
||
|
||
```rust
|
||
View::new(style: Style) -> View<Msg>
|
||
```
|
||
|
||
### Apariencia
|
||
| Método | Efecto |
|
||
|---|---|
|
||
| `.fill(Color)` | color de fondo |
|
||
| `.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)
|
||
```rust
|
||
.text(content, size_px, color) // centrado
|
||
.text_aligned(content, size_px, color, Alignment)
|
||
.text_aligned_italic(content, size_px, color, Alignment, italic)
|
||
.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option<String>)
|
||
.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada
|
||
.line_height(mult) // override interlínea (default 1.2)
|
||
```
|
||
|
||
### Interacción (ver §8)
|
||
```rust
|
||
.on_click(Msg)
|
||
.on_click_at(|lx, ly, w, h| -> Option<Msg>) // posición local + tamaño del rect
|
||
.on_right_click(Msg) / .on_right_click_at(...)
|
||
.on_middle_click(Msg)
|
||
.on_pointer_enter(Msg) / .on_pointer_leave(Msg)
|
||
.draggable(|phase: DragPhase, dx, dy| -> Option<Msg>)
|
||
.draggable_at(|phase, dx, dy, lx0, ly0| -> Option<Msg>) // + posición inicial del press
|
||
.drag_payload(u64) // payload que viaja con el drag
|
||
.on_drop(|payload: u64| -> Option<Msg>) // este nodo es drop target
|
||
.drop_hover_fill(Color) // resaltado mientras un drag lo sobrevuela
|
||
.on_scroll(|dx, dy| -> Option<Msg>) // rueda local (antes del on_wheel global)
|
||
.focusable(u64) // nodo enfocable por Tab/click (id opaco)
|
||
```
|
||
|
||
### Pintura custom (ver §10)
|
||
```rust
|
||
.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... })
|
||
.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... })
|
||
```
|
||
|
||
Notas clave:
|
||
- **Un nodo es draggable *o* clickable**, no ambos: `draggable` sobreescribe
|
||
`on_click`.
|
||
- Las variantes `*_at` ganan sobre las simples si ambas están.
|
||
- `PaintRect { x, y, w, h }` es el rect **absoluto** del nodo en píxeles físicos.
|
||
- `DragPhase` = `Move` (un evento por `CursorMoved`, `dx/dy` = delta **desde el
|
||
evento anterior**, no acumulado) | `End` (al soltar).
|
||
|
||
---
|
||
|
||
## 7. Layout (`taffy` / `Style`)
|
||
|
||
`Style` es el `taffy::Style` directo, re-exportado vía
|
||
`llimphi_ui::llimphi_layout::taffy::prelude::*`. Es Flexbox + CSS Grid puro.
|
||
|
||
Campos más usados:
|
||
```rust
|
||
Style {
|
||
flex_direction: FlexDirection::Row | Column,
|
||
size: Size { width, height }, // length(px) | percent(0..1) | Dimension::auto()
|
||
min_size, max_size,
|
||
flex_grow: f32, flex_shrink: f32,
|
||
align_items: Some(AlignItems::{Start,Center,End,Stretch}),
|
||
justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}),
|
||
gap: Size { width, height },
|
||
padding: Rect { left, right, top, bottom }, // con length(px)
|
||
margin: Rect { ... },
|
||
..Default::default()
|
||
}
|
||
```
|
||
|
||
Helpers de `prelude`: `length(px)`, `percent(frac)`, `auto()`, `Dimension`,
|
||
`Size`, `Rect`, `FlexDirection`, `AlignItems`, `JustifyContent`.
|
||
|
||
`llimphi-layout` además expone:
|
||
- `LayoutTree::new()` / `.clear()` (reuso entre frames), `.leaf(style)`,
|
||
`.node(style, &children)`, `.compute(...)`, `.compute_with_measure(F)`.
|
||
- `Rect { x, y, w, h }` y `ComputedLayout { rects: HashMap<NodeId, Rect> }`.
|
||
|
||
En el 99% de los casos no tocás `LayoutTree` a mano: lo maneja el runtime al
|
||
montar tu `View`. Sólo armás `Style`s.
|
||
|
||
---
|
||
|
||
## 8. Eventos e interacción
|
||
|
||
| Quiero… | Cómo |
|
||
|---|---|
|
||
| Botón / fila clickable | `.on_click(Msg)` (+ `.hover_fill` para feedback) |
|
||
| Saber dónde se clickeó (canvas) | `.on_click_at(\|lx,ly,w,h\| ...)` → convertir a coords de mundo |
|
||
| Menú contextual | `.on_right_click(Msg::OpenMenu{..})`, guardar pos en Model, abrir en `view_overlay` |
|
||
| Abrir en pestaña nueva | `.on_middle_click(Msg)` |
|
||
| Preview al pasar el mouse | `.on_pointer_enter(Msg)` / `.on_pointer_leave(Msg)` |
|
||
| Resize de panel | `.draggable(\|phase,dx,dy\| ...)` acumulando delta en el Model |
|
||
| Arrastrar entidad de un canvas | `.draggable_at(\|phase,dx,dy,lx0,ly0\| ...)` |
|
||
| Drag&drop entre zonas | origen: `.drag_payload(id)`; destino: `.on_drop(\|id\| ...)` + `.drop_hover_fill` |
|
||
| Scroll global | `App::on_wheel(model, delta, cursor, mods)` |
|
||
| Área de scroll | widget `scroll_y(...)` (autocontenido) o `.on_scroll(\|dx,dy\| ...)` por nodo |
|
||
| 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:
|
||
```rust
|
||
pub struct TextSpec {
|
||
pub content: String,
|
||
pub size_px: f32,
|
||
pub color: Color,
|
||
pub alignment: Alignment, // Start | Center | End | Justify
|
||
pub italic: bool,
|
||
pub font_family: Option<String>, // string CSS con fallbacks
|
||
pub line_height: f32, // múltiplo; default 1.2
|
||
pub runs: Option<Vec<(usize, usize, Color)>>, // color por rango de BYTES
|
||
}
|
||
```
|
||
|
||
- `Center` es el default (apto para labels). Para editores/párrafos usar
|
||
`.text_aligned(..., Alignment::Start)`.
|
||
- **Multicolor en una sola pasada de shaping**: `.text_runs(...)` colorea
|
||
rangos de bytes — es la base del syntax highlighting (un nodo por línea, no
|
||
por token). Anclado arriba-izquierda; el caller dimensiona el rect.
|
||
- El runtime mide el texto con parley durante el layout (`compute_with_measure`)
|
||
para que taffy reserve el alto real del texto envuelto a varias líneas
|
||
(evita "textos aplastados").
|
||
- Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique.
|
||
|
||
---
|
||
|
||
## 10. Canvas custom y GPU directo
|
||
|
||
Dos hooks para pintar primitivas no expresables como composición de `View`s.
|
||
Conviven en el mismo árbol; el runtime pinta **toda la pasada vello primero**,
|
||
luego los `gpu_painter` en orden DFS.
|
||
|
||
### `paint_with` — vía vello (el default)
|
||
```rust
|
||
.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| {
|
||
// dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`.
|
||
// NO dejar push_layer sin pop_layer; NO resetear la scene.
|
||
})
|
||
```
|
||
Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal.
|
||
Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada).
|
||
|
||
### `gpu_paint_with` — sube vertex buffers directo a wgpu, salta vello
|
||
```rust
|
||
.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| {
|
||
// abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello.
|
||
// (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC.
|
||
})
|
||
```
|
||
Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de
|
||
nakui, pineal denso. Rango 100 K – 10 M+ primitivos. **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()`.
|
||
|
||
```rust
|
||
pub struct Theme {
|
||
pub name: &'static str,
|
||
// fondos
|
||
pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus,
|
||
pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color,
|
||
// texto
|
||
pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color,
|
||
// bordes y acento
|
||
pub border, border_focus, accent: Color,
|
||
}
|
||
|
||
Theme::all() -> Vec<Theme> // orden de rotación canónico
|
||
Theme::by_name(name) -> Option<Theme>
|
||
Theme::next_after(current_name) -> Theme // para el theme-switcher
|
||
```
|
||
|
||
Tokens auxiliares en el mismo crate:
|
||
- `motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms}` + `ease_out_cubic`,
|
||
`ease_in_out_cubic`, `linear`.
|
||
- `alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT}` (constantes `u8`).
|
||
- `radius::{XS=2, SM=4, MD=8, LG=12, XL=20}` (`f64`).
|
||
|
||
**Patrón de widgets**: cada widget define su `XxxPalette` con
|
||
`Palette::from_theme(&theme)`. Tu app guarda un `Theme` en el Model, deriva las
|
||
paletas que necesita en `view`, y se las pasa a los widgets. Para cambiar de
|
||
tema, el `theme-switcher` emite `Msg(next_theme)` y reconstruís todo.
|
||
|
||
---
|
||
|
||
## 12. Capas base
|
||
|
||
### `llimphi-hal` — superficie
|
||
```rust
|
||
Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result<Hal, HalError> // async
|
||
trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result<Frame,_>; fn present(frame, hal); }
|
||
WinitSurface::new(hal, window: Arc<Window>) -> Result<Self, HalError>
|
||
Frame::view() -> &wgpu::TextureView; Frame::size() -> (u32,u32)
|
||
```
|
||
`Hal::new` pide adapter `Backends::PRIMARY` (Vulkan) y cae a `all()` sólo si no
|
||
hay — **no volver a `InstanceDescriptor::default()`**: el backend GL de Mesa
|
||
sobre Wayland segfaultea en el teardown. El runtime de `llimphi-ui` ya maneja
|
||
todo esto; sólo tocás HAL si escribís un runtime nuevo.
|
||
|
||
### `llimphi-raster` — rasterización
|
||
```rust
|
||
Renderer::new(hal) -> Result<Renderer,_>
|
||
Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color)
|
||
// GPU directo:
|
||
GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout
|
||
GpuBatch::new(&pipelines)
|
||
.line_width(w) .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
|
||
```rust
|
||
Typesetter::new() // una por proceso (FontContext es caro)
|
||
.layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()>
|
||
.layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout<RunBrush>
|
||
TextBlock::simple(text, size_px, color, origin)
|
||
layout_block(ts, &block) / measure(ts, &block) -> Measurement
|
||
draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin)
|
||
Alignment::{Start, Center, End, Justify}
|
||
```
|
||
|
||
### `llimphi-motion` — tweens
|
||
```rust
|
||
trait Lerp { fn lerp(self, other, t: f32) -> Self; } // impl para f32,f64,(f32,f32),(f64,f64),Color
|
||
Tween::new(from, to, duration, easing: fn(f32)->f32) // o Tween::idle(value)
|
||
tween.value() / .progress() / .done()
|
||
animate(handle, duration, make_msg) // arranca los ticks del tween
|
||
```
|
||
Patrón: 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)
|
||
```rust
|
||
Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right,
|
||
Home, Search, Info, Warning, Error, Bell, Settings, More, ...}
|
||
icon_view(Icon, color, stroke_width) -> View<Msg>
|
||
paint_icon(scene, rect, icon, color, stroke_width) // dentro de un paint_with
|
||
```
|
||
`stroke_width` en unidades del grid 24×24 (1.6 es armónico).
|
||
|
||
### `llimphi-surface` — texturas externas
|
||
```rust
|
||
ExternalSurface::new(device, queue) // barato de clonar (Arc<Mutex> interno)
|
||
.upload(&rgba, w, h) // desde otro hilo/decoder/cámara
|
||
.view(style) -> View<Msg> // blittea a su rect en el árbol Elm
|
||
.blit(queue, encoder, dst_view, rect, viewport) // o manual desde gpu_paint_with
|
||
```
|
||
|
||
---
|
||
|
||
## 13. Catálogo de widgets
|
||
|
||
Los widgets son **funciones puras** que devuelven `View<Msg>` (o specs que se
|
||
convierten a `View`). Son **stateless**: el estado vive en tu Model. Convención:
|
||
cada uno trae `XxxPalette::from_theme(&Theme)`. Crates en
|
||
`widgets/<nombre>/`, dep `llimphi-widget-<nombre>`.
|
||
|
||
### Controles
|
||
|
||
**button** — `button_view(label, &ButtonPalette, on_click: Msg) -> View`;
|
||
`button_styled(label, style, alignment, &palette, on_click)`.
|
||
|
||
**field** — wrapper de formulario (label + helper/error + requerido).
|
||
`field_view(FieldSpec { label, control: View<Msg>, required, helper, error, palette })`.
|
||
|
||
**text-input** — input single-line **con estado** `TextInputState`
|
||
(`new()`/`masked()`, `text()`, `set_text()`, `apply_key(&KeyEvent) -> bool`,
|
||
soporta undo/redo + selección con Shift). Render:
|
||
`text_input_view(&state, placeholder, focused, &palette, on_focus: Msg)`.
|
||
|
||
**text-area** — 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`:
|
||
```rust
|
||
Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second }
|
||
layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves()
|
||
panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec<Side>,DragPhase,delta)->Option<Msg>,
|
||
on_focus: Fn(PaneId)->Msg, &palette)
|
||
```
|
||
|
||
**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.
|
||
```rust
|
||
navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides },
|
||
is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg,
|
||
on_select: Fn(u64)->Msg, on_context: Option<Fn(u64)->Msg>)
|
||
// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de
|
||
// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para
|
||
// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser.
|
||
```
|
||
|
||
**app-header** — `app_header(label, actions: Vec<View<Msg>>, &palette)`.
|
||
|
||
**status-bar** — `status_bar_view(left, center, right, &palette)` con
|
||
`StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized()`.
|
||
|
||
**breadcrumb** — `breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)`
|
||
(el último segmento no es clickable).
|
||
|
||
**modal** — diálogo centrado con scrim. `modal_view(ModalSpec { title, body:
|
||
View<Msg>, buttons: Vec<ModalButton>, size, viewport, on_dismiss, palette })`.
|
||
`ModalButton::{primary, cancel, destructive}(label, msg)`. Se monta en `view_overlay`.
|
||
|
||
**toast** — notificaciones efímeras bottom-right. La app guarda `Vec<Toast>`
|
||
(`Toast::{info,success,warning,error}(id, text, duration)`), filtra
|
||
`is_alive(now)`, y `toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg)`.
|
||
|
||
**splash** — splash de arranque (cuatro cuadrantes andinos). `splash_view(started_at:
|
||
Instant, bg, fg_text)`; basado en tiempo, requiere redraws.
|
||
|
||
### Ricos / interactivos
|
||
|
||
**nodegraph** — lienzo de nodos + cables Bezier. Sin estado (la app guarda
|
||
posiciones y `Wire`s).
|
||
```rust
|
||
NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec<String>, outputs: Vec<String> }
|
||
Wire { from_node, from_output: PinIdx(u16), to_node, to_input }
|
||
nodegraph_view(&nodes, &wires, &palette, &metrics,
|
||
on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option<Msg>,
|
||
on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option<Msg>)
|
||
// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable)
|
||
```
|
||
|
||
**timeline** — scrub clickeable. `timeline_view(progress: f32, &palette,
|
||
on_seek: Fn(f32 [0..1])->Option<Msg>)`.
|
||
|
||
**text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda
|
||
`EditorState`:
|
||
```rust
|
||
EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo();
|
||
.add_cursor_at(line,col); .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult;
|
||
.ensure_caret_visible(visible_lines)
|
||
// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option<Msg>
|
||
text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer)
|
||
text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer)
|
||
text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer)
|
||
syntax_palette_dark(&theme) -> SyntaxPalette // en lib.rs del widget
|
||
```
|
||
|
||
**text-editor-core** — núcleo **agnóstico** (sin GPU, sin Llimphi; sólo
|
||
`peniko::Color`). Reutilizable en TUI/web/headless. Tipos clave:
|
||
- `Buffer` (sobre `ropey`): `from_str`, `text`, `insert(offset,s)`, `delete(s,e)`,
|
||
`offset_to_pos`, `pos_to_offset`, `slice`, `line(n)`.
|
||
- `Pos { line, col }`, `Selection { anchor, caret }`, `Cursor { caret, anchor:
|
||
Option, desired_col }` con `move_left/right/up/down/word_left/...`,
|
||
`selection_range(&buf)`, `collapse`.
|
||
- Ops: `replace_selection`, `delete_backward/forward`, `indent_or_insert_tab`,
|
||
`insert_newline_auto_indent` → devuelven `EditDelta { start, removed, inserted,
|
||
cursor_before, cursor_after }` con `.apply()/.undo()`.
|
||
- `UndoStack`: `push(delta)`, `undo/redo(&mut buf, &mut cursor) -> bool`, `can_undo/redo`.
|
||
- `FindState { query, case_sensitive }`: `all_matches`, `find_next`, `find_prev`.
|
||
- Matching de brackets: `find_bracket_pair(&buf, &cursor) -> Option<(Pos, Pos)>`, `Direction`.
|
||
- `Clipboard` (trait `get/set`), `MemClipboard`, `NullClipboard`.
|
||
- `Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity,
|
||
message: String, source: Option<String> }` (+ ctors `error(..)`, `warning(..)`);
|
||
`Severity::{Error, Warning, Information, Hint}`.
|
||
- Highlight tree-sitter: `Language::{Plain, Rust, Python, Wat}`
|
||
(+ `Language::from_cell_language(s)`); `Highlighter::new(lang)` con
|
||
`.highlight(&mut self, source: &str) -> Vec<Vec<Span>>` (un `Vec<Span>` **por
|
||
línea**), `.set_language(lang)`, `.language()`; helpers de módulo
|
||
`invalidate_tree_cache(lang)` y `apply_pending_edits(lang, &edits)` para el
|
||
caché incremental. `TokenKind`, `Span`, `SyntaxPalette::color(kind)`.
|
||
|
||
**text-editor-lsp** — cliente LSP por stdin/stdout. `trait LspClient` (fire-and-forget
|
||
`request_*` + lecturas de caché `latest_*`/`clear_*`): completions, hover,
|
||
definition, references, rename, formatting, signature help, document symbols.
|
||
`RustAnalyzerClient::start(workspace_root)`; `NoopLspClient` para tests.
|
||
|
||
**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
|
||
`Msg`s con `apply`, y monta el `view` pasando un mapeo `to_host: Fn(ModuleMsg) ->
|
||
HostMsg`. Cuando `apply` devuelve una `Action` (p. ej. `Invoke(id)`, `OpenAt{..}`,
|
||
`GoTo{..}`), la app ejecuta el efecto. Crates en `modules/<nombre>/`.
|
||
|
||
| Módulo | Atajo | Acción que devuelve | Propósito |
|
||
|---|---|---|---|
|
||
| **command-palette** | `Ctrl+Shift+P` | `Invoke(String)` | paleta de comandos fuzzy. El host declara `&[Command]` |
|
||
| **file-picker** | `Ctrl+P` | `Open(PathBuf)` | fuzzy file picker; host pasa `&[PathBuf]` + `root` |
|
||
| **fif** (find-in-files) | `Ctrl+Shift+F` | `OpenAt{path,line,col}`, `Searched{..}`, `Replaced{..}` | buscar/reemplazar; dual-view (dialog + barra). `search()` / `replace_all()` hacen el I/O |
|
||
| **diff-viewer** | `Ctrl+Shift+D` | — | diff side-by-side. `DiffState::new(before_label, after_label, before, after)` computa con `similar` |
|
||
| **mini-map** | `Ctrl+Shift+M` | `JumpTo(line)` | minimapa del buffer; agnóstico del editor (recibe `Snapshot`) |
|
||
| **bookmarks** | `Ctrl+Alt+B` toggle, `Ctrl+Shift+B` lista, `Ctrl+Alt+N/P` nav | `JumpTo{path,line}` | marcadores per-file persistentes (state directo, no Option) |
|
||
| **symbol-outline** | `Ctrl+Shift+O` | `GoTo{line,col}` | outline de símbolos; host arma `Vec<SymbolItem>` (LSP/tree-sitter/custom) |
|
||
| **selector** | — | — | abstracción portátil abrir/guardar: `trait Selector` (`HostSelector` con PathBuf, `WawaSelector` placeholder content-addressed) |
|
||
| **plugin-host** | — | `OpenAt{..}`, `SetStatus(..)` | runtime WASM (wasmi) con permisos por bitfield; `PluginHost::load_from_dir`/`invoke(id, cap, args)` |
|
||
| **shuma-term** | `` Ctrl+` `` | `SetStatus(..)` | terminal integrada. `spawn(cwd)` lanza PTY (`shuma_exec`), `vt100::Parser` renderiza; `Tick` drena el PTY |
|
||
|
||
Patrón típico de integración (command-palette):
|
||
```rust
|
||
struct Model { palette: Option<PaletteState>, commands: Vec<Command>, /* … */ }
|
||
enum Msg { Palette(PaletteMsg), /* … */ }
|
||
|
||
// on_key:
|
||
if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); }
|
||
if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); }
|
||
|
||
// update:
|
||
Msg::Palette(m) => {
|
||
if let Some(state) = model.palette.as_mut() {
|
||
match command_palette::apply(state, m, &model.commands) {
|
||
PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; }
|
||
PaletteAction::Close => model.palette = None,
|
||
PaletteAction::None => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// view_overlay:
|
||
model.palette.as_ref().map(|s|
|
||
command_palette::view(s, &model.commands, &palette, Msg::Palette))
|
||
```
|
||
|
||
---
|
||
|
||
## 15. `llimphi-workspace` — chasis tipo tmux
|
||
|
||
Monta cualquier componente en un layout intercambiable con splits resizables
|
||
(máquina de estados de foco/split/cierre + chrome estándar). Construido sobre
|
||
`llimphi-widget-panes`.
|
||
|
||
```rust
|
||
Workspace::new()
|
||
.focused() -> PaneId .count() .leaves() -> Vec<PaneId> .layout() -> &Layout
|
||
.focus(id) .split(Axis) -> PaneId .close() -> Option<PaneId> .resize(&path, delta)
|
||
.apply(WsMsg) -> WsEffect
|
||
|
||
enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec<Side>, f32) }
|
||
enum WsEffect { None, Created(PaneId), Closed(PaneId) }
|
||
|
||
workspace_view(&ws, &WorkspacePalette,
|
||
leaf: FnMut(PaneId)->View<Host>, // materializa el contenido de cada panel
|
||
lift: Fn(WsMsg)->Host) // sube los Msg del chasis a tu Msg
|
||
```
|
||
|
||
Patrón: `enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }`. En `update`,
|
||
`ws.apply(msg)` te avisa con `WsEffect::{Created,Closed}(id)` para que crees o
|
||
destruyas el estado del panel correspondiente.
|
||
|
||
---
|
||
|
||
## 16. Reglas duras y gotchas
|
||
|
||
### 🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE)
|
||
Ningún `update`/`init`/handler puede ejecutar trabajo **síncrono** pesado
|
||
(efemérides, simulación, IO, parse, embeddings, layout de árboles grandes).
|
||
Bloquea el hilo → "Not Responding". **`init` corre dentro de `resumed`, después
|
||
de crear la ventana**, así que un cómputo pesado ahí ya congela una ventana
|
||
visible.
|
||
|
||
Patrón (referencia: `cosmos-app-llimphi`):
|
||
```rust
|
||
// Model: Option<Resultado> (None = "calculando…") + flag dirty + contador de generación.
|
||
struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64 }
|
||
enum Msg { XComputed(u64, Arc<Resultado>) }
|
||
|
||
// al FINAL de update() (que tiene el Handle):
|
||
if m.x_dirty {
|
||
m.x_dirty = false;
|
||
m.x_gen = m.x_gen.wrapping_add(1);
|
||
let (gen, input) = (m.x_gen, m.input.clone()); // sólo lo que el worker necesita (Send)
|
||
handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input))));
|
||
}
|
||
// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un
|
||
// resultado tardío pise a uno más nuevo en drags/toggles rápidos).
|
||
Msg::XComputed(gen, x) => if gen == m.x_gen {
|
||
m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
|
||
}
|
||
```
|
||
La **generación** es imprescindible si el recálculo se dispara seguido. Ver
|
||
[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) y su checklist por app.
|
||
|
||
### Otras
|
||
- **Solvers iterativos** (Newton/bisección): cota dura `for _ in 0..N`, nunca
|
||
`loop {}` con corte pegado al epsilon de f64 — en debug no converge → loop
|
||
infinito.
|
||
- **Backend GPU**: preferir Vulkan (`Backends::PRIMARY`); el GL de Mesa sobre
|
||
Wayland segfaultea en el teardown. Ya está hecho en `Hal::new`, no revertir.
|
||
- **Un nodo es draggable o clickable**, no ambos.
|
||
- **`alpha` y `clip`** crean capas intermedias: tienen costo, usar sólo cuando
|
||
hace falta.
|
||
- **`paint_with`** no debe dejar `push_layer` sin `pop_layer` ni resetear la
|
||
Scene.
|
||
- **Hit-test respeta `.transform()`**: un nodo rotado/escalado/trasladado recibe
|
||
los clicks donde se ve pintado (el runtime invierte el afín acumulado). Lo que
|
||
**no** se ajusta todavía: la posición local que reciben los handlers `*_at` se
|
||
reporta en coords de pantalla, no en el espacio local del nodo transformado.
|
||
- **GPUI está extinto**: no agregar dependencias ni código GPUI (regla §3 de
|
||
`CLAUDE.md`).
|
||
- **Texto en regla pesada**: crear un `Typesetter` por frame es caro
|
||
(`FontContext::new` enumera fuentes del sistema). El runtime ya cachea uno y lo
|
||
pasa a `paint_with`.
|
||
|
||
---
|
||
|
||
## 17. Comandos y demos
|
||
|
||
```bash
|
||
cargo check --workspace # smoke test mínimo (debe pasar siempre)
|
||
cargo run -p <crate> --release # correr una app
|
||
cargo run -p <crate> --example <demo> --release # correr un demo
|
||
|
||
# demos del propio framework:
|
||
cargo run -p llimphi-ui --example counter --release # bucle Elm completo
|
||
cargo run -p llimphi-ui --example editor --release # text field + teclado
|
||
cargo run -p llimphi-ui --example gpu_paint_demo --release
|
||
cargo run -p llimphi-gallery --release # showcase de TODO el kit
|
||
cargo 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
|
||
|
||
```rust
|
||
// ── App mínima ──────────────────────────────────────────────
|
||
impl App for X { type Model; type Msg; init; update; view; }
|
||
llimphi_ui::run::<X>();
|
||
|
||
// ── Nodo ────────────────────────────────────────────────────
|
||
View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() })
|
||
.fill(c).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`](SDD.md) (diseño y roadmap),
|
||
> [`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) (regla de
|
||
> concurrencia), [`README.md`](README.md) / [`LEEME.md`](LEEME.md) (overview).
|
||
> Las firmas de este manual reflejan el código al 2026-06-01; ante divergencia,
|
||
> la fuente autoritativa es el `lib.rs` de cada crate.
|