feat: llimphi standalone — framework UI soberano extraído del monorepo

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>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
+122
View File
@@ -0,0 +1,122 @@
# Cómputo pesado fuera del hilo de UI — regla dura de Llimphi
> **PRIORIDAD URGENTE.** Patrón a aplicar a **todas** las apps Llimphi.
> Origen: el "Not Responding" de cosmos (2026-05-31). Implementación de
> referencia: `01_yachay/cosmos/cosmos-app-llimphi` (commits `added8b3`,
> `9f221983`).
## La regla
Ningún `App::update`, `App::init` ni handler (`on_key`/`on_wheel`/…) debe
ejecutar trabajo pesado **síncrono**. Bloquea el hilo de UI → la ventana no
repinta, no responde, no cierra → "Not Responding". Es el antipatrón win32 de
trabajo pesado en el message loop.
Crítico: en winit, **`App::init()` corre dentro de `resumed`, DESPUÉS de crear
la ventana**. Un cómputo pesado en init congela la ventana ya visible.
Se nota brutal en **debug** (sin optimizar, 1050× más lento; además debug
*panica* en overflow donde release *wrappea*). Pero la mala arquitectura está
igual en release: una carta pesada, una máquina lenta o un dataset grande la
exponen.
"Pesado" = efemérides/simulación, layout de árboles grandes, IO de disco/red,
parse, embeddings, compresión… cualquier cosa que pueda pasar de ~unos ms.
## El patrón (mover a un worker)
```rust
// 1) Mensaje de resultado: u64 = generación; Arc<T> porque Msg: Clone.
enum Msg { /**/ XComputed(u64, std::sync::Arc<Resultado>) }
// 2) En el Model: el resultado es Option (None = "calculando…"),
// más un flag dirty y un contador de generación.
struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64, /**/ }
// 3) recompute_x sólo marca dirty (los helpers no tienen el Handle).
fn recompute_x(m: &mut Model) { m.x_dirty = true; }
// 4) Al FINAL de update() (que SÍ tiene el Handle): si está sucio, bumpear
// generación, clonar los inputs y despachar a un worker.
if m.x_dirty {
m.x_dirty = false;
m.x_gen = m.x_gen.wrapping_add(1);
let gen = m.x_gen;
let input = m.input.clone(); // sólo lo que el worker necesita
handle.spawn(move || Msg::XComputed(gen, std::sync::Arc::new(compute(&input))));
}
// 5) Arm del resultado: aplicar SÓLO si la generación sigue vigente
// (un recálculo posterior ya dejó viejo a este). try_unwrap evita copiar
// (el Arc llega con refcount 1 porque el Msg no se clona en el camino).
Msg::XComputed(gen, x) => {
if gen == m.x_gen {
m.x = Some(std::sync::Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
}
}
// 6) En init: arrancar con None y despachar el primer cómputo a un worker
// (init tiene el Handle). La vista pinta "calculando…" mientras tanto.
// 7) En la vista: match &model.x { Some(v) => panel(v), None => calculando() }
```
Notas:
- El campo `Option<T>` exige `T: Clone` (para el fallback de `try_unwrap`).
- La **generación** evita que un resultado tardío pise a uno más nuevo
(drags, toggles rápidos). Imprescindible si el recálculo puede dispararse
seguido.
- Inputs al worker deben ser `Send` (clonar `Chart`, `Vec`, etc.).
- No hace falta async-ear lo barato: en cosmos el render de la carta quedó
síncrono (con el solver acotado son ms); sólo el astro (144 muestras × 10
cuerpos) fue a worker.
## Soluciones colaterales de la misma cacería (ya aplicadas, no revertir)
- **Preferir Vulkan en `llimphi-hal`** (`Hal::new`, commit `9f221983`): pedir
adapter con `Backends::PRIMARY` y caer a `all()` (incluye GL) sólo si no hay
PRIMARY. El backend **GL de Mesa sobre Wayland segfaultea en el teardown**
(`eglTerminate → wl_proxy_marshal` sobre conexión muerta, exit 139 sin
panic). Es infra compartida → ya beneficia a todas las apps. No volver a
`InstanceDescriptor::default()`.
- **Acotar solvers iterativos** (`cosmos-ephemeris`, Kepler, commit `added8b3`):
un `loop {}` con corte `dl.abs() < 1e-15` (pegado al epsilon de f64) entra en
ciclo límite y NO converge para ciertos inputs → loop infinito. Release
fusiona flops (FMA) y converge; debug no. **Todo solver Newton/bisección
lleva cota dura** (`for _ in 0..N`), no `loop {}`.
## Cómo diagnosticar (sin ptrace; `ptrace_scope=1` bloquea gdb a no-hijos)
- `/proc/$PID/wchan` del hilo principal: `do_epoll_wait` = ocioso sano;
`__futex_wait` = deadlock de lock; estado `R` sostenido = spin o cómputo en
el hilo de UI; `dma_fence`/`drm` = GPU; `poll` sobre fd `wayland-0` = frame
callback.
- gdb **como PADRE** sí puede (lanzar la app *bajo* gdb): backtrace del spin/
segfault. La pila de wgpu revela el backend (`wgpu_hal::gles` vs vulkan).
- Trazar con un `eprintln` ENTER/DONE para distinguir "una llamada que no
termina" (loop infinito) de "se llama repetidas veces" (storm de dispatch).
- En debug arranca como `cargo run` (binario `target/debug`); el release puede
ocultar el bug (float/overflow distintos).
## Checklist — auditar y aplicar a cada app
Buscar trabajo pesado en `init`/`update`/handlers y moverlo a worker:
- [x] `01_yachay/cosmos/cosmos-app-llimphi` (referencia)
- [ ] `00_unanchay/pluma/pluma-app`
- [ ] `00_unanchay/pluma/pluma-editor-llimphi`
- [ ] `00_unanchay/pluma/pluma-notebook-llimphi`
- [ ] `00_unanchay/puriy/puriy-llimphi` (motor JS/render — alto riesgo)
- [ ] `00_unanchay/khipu/khipu-app`
- [ ] `00_unanchay/chaka/chaka-app-llimphi`
- [ ] `01_yachay/dominium/dominium-app-llimphi`
- [ ] `01_yachay/nakui/nakui-ui-llimphi`, `nakui-sheet-llimphi`, `nakui-explorer-llimphi`
- [ ] `01_yachay/iniy/iniy-explorer-llimphi`
- [ ] `01_yachay/tinkuy/tinkuy-llimphi` (simulación — alto riesgo)
- [ ] `02_ruway/ayni/ayni-llimphi`
- [ ] `02_ruway/chasqui/chasqui-explorer-llimphi`, `chasqui-broker-explorer-llimphi`
- [ ] `02_ruway/nada`, `02_ruway/mirada/*-llimphi`
- [ ] `pineal-*` (charting — revisar si el cómputo de series corre en update)
(Lista de partida: `grep -rl 'llimphi-ui' --include=Cargo.toml`. Los widgets/
modules/demos rara vez hacen cómputo pesado; foco en las apps de dominio.)
Generated
+3848
View File
File diff suppressed because it is too large Load Diff
+441
View File
@@ -0,0 +1,441 @@
# Cargo.toml raíz STANDALONE de Llimphi — dry-run de extracción.
# Generado desde la raíz de gioser quitando el prefijo 02_ruway/llimphi/ a los
# path-deps internos. Excluye los 3 crates acoplados al resto del workspace
# (menubar→app-bus, shuma-term→shuma-exec, plugin-host→card-core) y los demos
# gallery que los agregan, más android (target propio).
[workspace]
resolver = "2"
members = [
"llimphi-hal", "llimphi-raster", "llimphi-layout", "llimphi-text",
"llimphi-ui", "llimphi-theme", "llimphi-surface", "llimphi-motion",
"llimphi-icons", "llimphi-compositor", "llimphi-workspace",
"widgets/*", "modules/*",
]
exclude = [
"android",
"llimphi-gallery", "llimphi-gpu-bench",
"widgets/gallery", "widgets/menubar",
"modules/shuma-term", "modules/plugin-host",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
license = "MIT"
authors = ["Sergio <gerencia@jlsoltech.com>"]
publish = false
repository = "https://gitea.gioser.net/sergio/llimphi"
[workspace.dependencies]
# === Registro de apps / menú global ===
app-bus = { path = "shared/app-bus" }
# === Serialización ===
serde = { version = "1", features = ["derive"] }
serde_json = "1"
lsp-types = "0.97"
serde-big-array = "0.5"
postcard = { version = "1", features = ["use-std"] }
toml = "0.8"
ron = "0.8"
bincode = "1"
base64 = "0.22"
# === Errores ===
thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores
anyhow = "1"
# === Async ===
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] }
async-trait = "0.1"
futures = "0.3"
# === Observabilidad ===
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# === Linux primitives (arje) ===
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] }
libc = "0.2"
# === IDs / Hash / Crypto ===
ulid = { version = "1", features = ["serde"] }
uuid = { version = "1", features = ["v4", "rng-getrandom"] }
sha2 = "0.10"
blake3 = "1.5"
ed25519-dalek = "2"
aes-gcm = "0.10"
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
# === WASM (arje) ===
# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para
# que el ABI WASM del host sea idéntico en Linux y en bare-metal.
wasmi = "1.0"
wat = "1"
# === Storage / DB ===
sled = "0.34"
rusqlite = { version = "0.31", features = ["bundled", "blob"] }
# === Ingesta de documentos (iniy-ingest: PDF / EPUB) ===
pdf-extract = "0.7"
epub = "2.1"
# === Bulk import Wikipedia (iniy-wiki dump) ===
bzip2 = "0.4"
# === Compresión (minga multi-bundle) ===
zstd = "0.13"
# === HTTP server (iniy-server) ===
axum = "0.7"
tower = "0.5"
# === ANN sobre embeddings (iniy nli --ann) ===
instant-distance = "0.6"
# === P2P (minga) ===
libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
libp2p-stream = "=0.4.0-alpha"
libp2p-allow-block-list = "0.6"
# === SSH (ssh, sandokan RemoteEngine, matilda) ===
russh = "0.54"
# === Math determinista cross-platform (dominium) ===
libm = "0.2"
# === SMF (takiy-midi) ===
# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path.
midly = "0.5"
# === Code parsing (minga) ===
arboard = "3"
ropey = "1.6"
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
tree-sitter-python = "0.23"
tree-sitter-typescript = "0.23"
tree-sitter-javascript = "0.23"
tree-sitter-go = "0.23"
# === FS notify ===
notify = "6.1"
# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) ===
petgraph = "0.6"
# === Image decoding (nahual-image-viewer-llimphi) ===
# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless).
# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app
# los pide específicamente.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# === FUSE (minga-vfs) ===
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime).
fuser = { version = "0.15", default-features = false }
# === CLI / auth (minga) ===
clap = { version = "4", features = ["derive"] }
rpassword = "7"
# === PAM (auth-core) ===
pam = "0.8"
# === D-Bus (arje compat) ===
zbus = { version = "4", default-features = false, features = ["tokio"] }
# === Tests ===
tempfile = "3"
# === Llimphi (motor gráfico soberano) ===
# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24.
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
wgpu = "24"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
vello = "0.5"
taffy = "0.9"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
parley = "0.4"
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
llimphi-ui = { path = "llimphi-ui" }
# Paleta semántica compartida por las apps y los widgets.
llimphi-theme = { path = "llimphi-theme" }
# Tweens y helpers de animación sobre el bucle Elm.
llimphi-motion = { path = "llimphi-motion" }
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
llimphi-icons = { path = "llimphi-icons" }
# Widgets reusables sobre llimphi-ui — uno por crate.
llimphi-widget-app-header = { path = "widgets/app-header" }
llimphi-widget-banner = { path = "widgets/banner" }
llimphi-widget-button = { path = "widgets/button" }
llimphi-widget-card = { path = "widgets/card" }
llimphi-clipboard = { path = "widgets/clipboard" }
llimphi-widget-context-menu = { path = "widgets/context-menu" }
llimphi-widget-edit-menu = { path = "widgets/edit-menu" }
llimphi-widget-menubar = { path = "widgets/menubar" }
llimphi-widget-list = { path = "widgets/list" }
llimphi-widget-grid = { path = "widgets/grid" }
llimphi-widget-slider = { path = "widgets/slider" }
llimphi-widget-scroll = { path = "widgets/scroll" }
llimphi-widget-splitter = { path = "widgets/splitter" }
llimphi-widget-stat-card = { path = "widgets/stat-card" }
llimphi-widget-tabs = { path = "widgets/tabs" }
llimphi-module-command-palette = { path = "modules/command-palette" }
llimphi-module-diff-viewer = { path = "modules/diff-viewer" }
llimphi-module-fif = { path = "modules/fif" }
llimphi-module-file-picker = { path = "modules/file-picker" }
llimphi-module-bookmarks = { path = "modules/bookmarks" }
llimphi-module-mini-map = { path = "modules/mini-map" }
llimphi-module-shuma-term = { path = "modules/shuma-term" }
llimphi-module-symbol-outline = { path = "modules/symbol-outline" }
llimphi-plugin-host = { path = "modules/plugin-host" }
llimphi-widget-theme-switcher = { path = "widgets/theme-switcher" }
llimphi-widget-text-area = { path = "widgets/text-area" }
llimphi-widget-text-editor-core = { path = "widgets/text-editor-core" }
llimphi-widget-text-editor = { path = "widgets/text-editor" }
llimphi-widget-text-editor-lsp = { path = "widgets/text-editor-lsp" }
llimphi-widget-text-input = { path = "widgets/text-input" }
llimphi-widget-tiled = { path = "widgets/tiled" }
llimphi-widget-nodegraph = { path = "widgets/nodegraph" }
llimphi-widget-tree = { path = "widgets/tree" }
llimphi-widget-navigator = { path = "widgets/navigator" }
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
llimphi-widget-wawa-mark = { path = "widgets/wawa-mark" }
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
# modal, empty, status-bar, shortcuts-help, splash).
llimphi-widget-tooltip = { path = "widgets/tooltip" }
llimphi-widget-spinner = { path = "widgets/spinner" }
llimphi-widget-progress = { path = "widgets/progress" }
llimphi-widget-toast = { path = "widgets/toast" }
llimphi-widget-modal = { path = "widgets/modal" }
llimphi-widget-empty = { path = "widgets/empty" }
llimphi-widget-status-bar = { path = "widgets/status-bar" }
llimphi-widget-shortcuts-help = { path = "widgets/shortcuts-help" }
llimphi-widget-timeline = { path = "widgets/timeline" }
llimphi-widget-splash = { path = "widgets/splash" }
# Controles de formulario y signaling (switch, segmented, breadcrumb,
# badge, avatar, skeleton, field).
llimphi-widget-switch = { path = "widgets/switch" }
llimphi-widget-segmented = { path = "widgets/segmented" }
llimphi-widget-dock-rail = { path = "widgets/dock-rail" }
llimphi-widget-breadcrumb = { path = "widgets/breadcrumb" }
llimphi-widget-badge = { path = "widgets/badge" }
llimphi-widget-avatar = { path = "widgets/avatar" }
llimphi-widget-skeleton = { path = "widgets/skeleton" }
llimphi-widget-field = { path = "widgets/field" }
# Firma visual transversal (gradient sutil + hairline accent).
llimphi-widget-panel = { path = "widgets/panel" }
llimphi-widget-panes = { path = "widgets/panes" }
llimphi-workspace = { path = "llimphi-workspace" }
# Abstracción Selector — host (paths) + wawa (khipus).
llimphi-module-selector = { path = "modules/selector" }
# === Filesystem helpers ===
directories = "5"
# === Diff line-based (llimphi-module-diff-viewer) ===
# `similar` es la crate de facto: implementa Myers + Patience + LCS,
# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete),
# zero deps fuera de std. La 2.x es estable hace años.
similar = "2"
# === Fuzzy matching (shuma-history) ===
# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct,
# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple
# que necesitamos (Matcher + Pattern + score).
nucleo-matcher = "0.3"
# === Transporte autenticado (shuma-link) ===
# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente
# conoce la pubkey del servidor, server descubre la del cliente y la
# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s.
# La versión 0.9 viene pinneada por libp2p, así nos alineamos.
snow = "0.9"
hex = "0.4"
# === PTY + emulador de terminal (shuma-exec, módulos REPL) ===
# portable-pty aloja un PTY cross-platform; lo usamos para los
# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad.
# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor
# movement + erase + screen state) y mantiene un buffer de pantalla
# renderizable como grid.
portable-pty = "0.9"
vt100 = "0.16"
# === WASM web (gioser) ===
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = "0.3"
glam = "0.30"
# === Markdown (pluma) ===
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
# === Archivos comprimidos (nahual archive viewer) ===
# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos,
# por eso default-features=false alcanza para ZIP. Para tar.gz sí
# descomprimimos en streaming con flate2 (ya declarado arriba), saltando
# los datos de cada entrada — sólo leemos headers.
zip = { version = "2.4", default-features = false }
tar = { version = "0.4", default-features = false }
# === Fuentes (nahual font viewer) ===
# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths.
ttf-parser = "0.25"
# ============================================================
# Intra-workspace deps de nahual (referenciadas por workspace = true)
# ============================================================
nahual-text-viewer-llimphi = { path = "02_ruway/nahual/nahual-text-viewer-llimphi" }
nahual-image-viewer-llimphi = { path = "02_ruway/nahual/nahual-image-viewer-llimphi" }
nahual-thumb-core = { path = "02_ruway/nahual/nahual-thumb-core" }
nahual-gallery-llimphi = { path = "02_ruway/nahual/nahual-gallery-llimphi" }
nahual-video-viewer-llimphi = { path = "02_ruway/nahual/nahual-video-viewer-llimphi" }
nahual-card-viewer-llimphi = { path = "02_ruway/nahual/nahual-card-viewer-llimphi" }
nahual-audio-viewer-llimphi = { path = "02_ruway/nahual/nahual-audio-viewer-llimphi" }
nahual-tree-viewer-llimphi = { path = "02_ruway/nahual/nahual-tree-viewer-llimphi" }
nahual-hex-viewer-llimphi = { path = "02_ruway/nahual/nahual-hex-viewer-llimphi" }
nahual-table-viewer-llimphi = { path = "02_ruway/nahual/nahual-table-viewer-llimphi" }
nahual-markdown-viewer-llimphi = { path = "02_ruway/nahual/nahual-markdown-viewer-llimphi" }
nahual-archive-viewer-llimphi = { path = "02_ruway/nahual/nahual-archive-viewer-llimphi" }
nahual-font-viewer-llimphi = { path = "02_ruway/nahual/nahual-font-viewer-llimphi" }
nahual-map-viewer-llimphi = { path = "02_ruway/nahual/nahual-map-viewer-llimphi" }
nahual-geo-core = { path = "02_ruway/nahual/nahual-geo-core" }
nahual-viewer-core = { path = "02_ruway/nahual/nahual-viewer-core" }
nahual-file-explorer-llimphi = { path = "02_ruway/nahual/nahual-file-explorer-llimphi" }
# ============================================================
# Intra-workspace deps de pineal (módulo de gráficos)
# ============================================================
pineal-core = { path = "00_unanchay/pineal/pineal-core" }
pineal-render = { path = "00_unanchay/pineal/pineal-render" }
pineal-cartesian = { path = "00_unanchay/pineal/pineal-cartesian" }
pineal-stream = { path = "00_unanchay/pineal/pineal-stream" }
pineal-mesh = { path = "00_unanchay/pineal/pineal-mesh" }
pineal-financial = { path = "00_unanchay/pineal/pineal-financial" }
pineal-polar = { path = "00_unanchay/pineal/pineal-polar" }
pineal-heatmap = { path = "00_unanchay/pineal/pineal-heatmap" }
pineal-treemap = { path = "00_unanchay/pineal/pineal-treemap" }
pineal-flow = { path = "00_unanchay/pineal/pineal-flow" }
pineal-phosphor = { path = "00_unanchay/pineal/pineal-phosphor" }
pineal-export = { path = "00_unanchay/pineal/pineal-export" }
pineal-hexbin = { path = "00_unanchay/pineal/pineal-hexbin" }
pineal-contour = { path = "00_unanchay/pineal/pineal-contour" }
pineal-bars = { path = "00_unanchay/pineal/pineal-bars" }
pineal = { path = "00_unanchay/pineal/pineal-umbrella" }
# ============================================================
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
# ============================================================
iniy-core = { path = "01_yachay/iniy/iniy-core" }
iniy-ingest = { path = "01_yachay/iniy/iniy-ingest" }
iniy-extract = { path = "01_yachay/iniy/iniy-extract" }
iniy-nli = { path = "01_yachay/iniy/iniy-nli" }
iniy-nli-llm = { path = "01_yachay/iniy/iniy-nli-llm" }
iniy-graph = { path = "01_yachay/iniy/iniy-graph" }
iniy-store = { path = "01_yachay/iniy/iniy-store" }
# === auto: declarados por crates internos faltantes ===
cosmos-coords = { path = "01_yachay/cosmos/cosmos-coords" }
cosmos-core = { path = "01_yachay/cosmos/cosmos-core" }
cosmos-ephemeris = { path = "01_yachay/cosmos/cosmos-ephemeris" }
cosmos-time = { path = "01_yachay/cosmos/cosmos-time" }
cosmos-wcs = { path = "01_yachay/cosmos/cosmos-wcs" }
# === auto: externas de eternal ===
celestial-eop-data = { version = "0.1"}
approx = "0.5"
byteorder = "1.5"
cc = "1.0"
chrono = "0.4"
crc32fast = "1.4"
criterion = "0.5"
csv = "1.4"
flate2 = "1.0"
glob = "0.3"
indicatif = "0.18"
lz4_flex = "0.11"
memmap2 = "0.9"
mockito = "1.0"
ndarray = "0.15"
num-traits = "0.2"
once_cell = "1.19"
parking_lot = "0.12"
png = "0.18"
proptest = "1.4"
quick-xml = "0.31"
rayon = "1.8"
regex = "1.11"
reqwest = "0.12"
tiff = "0.11"
wide = "0.7"
wiremock = "0.6"
# === i18n (rimay-localize) ===
fluent-bundle = "0.15"
unic-langid = { version = "0.9", features = ["macros"] }
sys-locale = "0.3"
# === Servo (puriy-engine) ===
# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever
# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no
# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente
# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer
# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista,
# evita pull de tokio en el engine.
html5ever = "0.39"
markup5ever = "0.39"
markup5ever_rcdom = "0.39"
cssparser = "0.35"
url = "2"
ureq = { version = "2", default-features = false, features = ["tls"] }
# === takiy-synth (SoundFont MIDI) ===
# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador
# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc).
rustysynth = "1.3"
# === takiy-playback (audio device output) ===
# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en
# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para
# abrir el device default y empujar muestras f32 — nada de mezclado
# ni efectos en el callback.
cpal = "0.15"
# === media-source-wav (decoder PCM en disco) ===
# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM
# entero (8/16/24/32) y float (32). Suficiente para abrir samples y
# stems de prueba sin meter ffmpeg/symphonia.
hound = "3.5"
# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) ===
# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre
# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre
# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg)
# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac:
# ese tier patentado entra por shared/foreign-av.
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] }
# === media-source-opus (decoder Opus NATIVO puro-Rust) ===
# Opus es el formato de audio nativo de gioser (par del video AV1). ogg
# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus
# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video.
ogg = "0.9"
opus-wave = "3"
# === media-source-webm (demux nativo Matroska/WebM) ===
# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los
# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1
# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo.
matroska-demuxer = "0.7"
+90
View File
@@ -0,0 +1,90 @@
# llimphi
> Framework de UI nativa: HAL · raster · layout · text · theme · ui — más widgets y módulos.
`llimphi` es el motor gráfico que comparten todas las apps del monorepo. Pipeline retained-mode declarativa sobre `vello` + `wgpu` + `taffy`, con shaping `fontdue`/`harfbuzz`, theme `Dark/Light/Aurora/Sunset`, HAL multiplataforma (Wayland · X11 · Win32 · Android · Wawa).
**Manual de uso:** [MANUAL.md](MANUAL.md) — referencia completa (bucle Elm, DSL `View<Msg>`, los ~44 widgets y 10 módulos, GPU directo, gotchas) para humanos e IA. Diseño y roadmap: [SDD.md](SDD.md).
Filosofía: **un widget no se diseña pensando en mockups; se diseña con lo que `vello` y `taffy` pueden hacer.**
## Instalación
```sh
# usar como dep en otro crate:
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-... = { workspace = true }
```
## Compatibilidad
- **Linux/Wayland** — backend principal.
- **Linux/X11** — via XWayland (mediante `winit`).
- **macOS / Windows** — `winit` + `wgpu`.
- **Android** — `clear-screen-android`, `vello-hello-android`, `vello-text-android` para validar el HAL móvil.
- **Wawa bare-metal** — HAL alterno sobre framebuffer.
## Crates: framework
| Crate | Rol |
|---|---|
| [`llimphi-hal`](llimphi-hal/README.md) | Abstracción de superficie (winit / framebuffer / android). |
| [`llimphi-raster`](llimphi-raster/README.md) | Rasterizer vello + cache de scenes. |
| [`llimphi-layout`](llimphi-layout/README.md) | Layout taffy + extensiones. |
| [`llimphi-text`](llimphi-text/README.md) | Shaping + fonts (Fontdue/HarfBuzz). |
| [`llimphi-theme`](llimphi-theme/README.md) | Themes Dark/Light/Aurora/Sunset + paleta. |
| [`llimphi-ui`](llimphi-ui/README.md) | `View<Msg>` retained-mode + Elm-arch. |
## Crates: widgets (visuales reactivos)
| Widget | Función |
|---|---|
| [`button`](widgets/button/README.md) | Botón con variantes. |
| [`text-input`](widgets/text-input/README.md) | Input single-line. |
| [`text-area`](widgets/text-area/README.md) | Textarea multi-line. |
| [`text-editor`](widgets/text-editor/README.md) | Editor (rope · cursor · undo · highlight · clipboard · find). |
| [`text-editor-lsp`](widgets/text-editor-lsp/README.md) | Editor + LSP. |
| [`tree`](widgets/tree/README.md) | Árbol jerárquico. |
| [`list`](widgets/list/README.md) | Lista virtualizada. |
| [`tabs`](widgets/tabs/README.md) | Tabs con cierre. |
| [`splitter`](widgets/splitter/README.md) | Splitter horizontal/vertical. |
| [`tiled`](widgets/tiled/README.md) | Tiled window manager dentro de la app. |
| [`slider`](widgets/slider/README.md) | Slider con tick marks. |
| [`gallery`](widgets/gallery/README.md) | Grid de cards. |
| [`card`](widgets/card/README.md) | Card base. |
| [`stat-card`](widgets/stat-card/README.md) | Card para métricas. |
| [`banner`](widgets/banner/README.md) | Banner / alerts. |
| [`app-header`](widgets/app-header/README.md) | Header común de app. |
| [`context-menu`](widgets/context-menu/README.md) | Menú contextual (look distintivo). |
| [`theme-switcher`](widgets/theme-switcher/README.md) | Selector de tema. |
| [`nodegraph`](widgets/nodegraph/README.md) | Lienzo de nodos + cables Bezier. |
## Crates: modules (feature funcional con estado)
| Module | Función |
|---|---|
| [`command-palette`](modules/command-palette/README.md) | Paleta de comandos. |
| [`diff-viewer`](modules/diff-viewer/README.md) | Diff side-by-side. |
| [`fif`](modules/fif/README.md) | Find-in-files. |
| [`file-picker`](modules/file-picker/README.md) | Picker de archivos. |
| [`mini-map`](modules/mini-map/README.md) | Mini-mapa del editor. |
| [`bookmarks`](modules/bookmarks/README.md) | Bookmarks por archivo. |
| [`symbol-outline`](modules/symbol-outline/README.md) | Outline de símbolos LSP. |
| [`plugin-host`](modules/plugin-host/README.md) | Host para plugins WASM. |
| [`shuma-term`](modules/shuma-term/README.md) | Terminal embebida (shell shuma). |
## Crates: android
| Crate | Rol |
|---|---|
| [`clear-screen-android`](android/clear-screen-android/README.md) | Smoke test HAL Android. |
| [`vello-hello-android`](android/vello-hello-android/README.md) | Vello hello-world Android. |
| [`vello-text-android`](android/vello-text-android/README.md) | Text shaping Android. |
## Consideraciones
- **Una sola API: `View<Msg>` declarativa**. Sin imperativo, sin DOM virtual ajeno.
- **El mismo árbol corre en Wayland y Wawa**: HAL abstrae la superficie, el resto es idéntico.
- Los widgets son **puramente visuales**; los módulos encapsulan estado + comportamiento.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sergio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1041
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
# llimphi
> Native UI framework: HAL · raster · layout · text · theme · ui — plus widgets and modules.
`llimphi` is a sovereign, retained-mode UI framework with an Elm-style loop (`input → update → view → layout → raster → present`). Declarative pipeline over `vello` + `wgpu` + `taffy` + `parley`, with `Dark/Light/Aurora/Sunset` themes and a multi-platform HAL (Wayland · X11 · Win32 · Android · Wawa bare-metal). It powers a full Rust application suite; this repository is the framework extracted to stand on its own.
**Usage manual:** [MANUAL.md](MANUAL.md) — full reference (Elm loop, `View<Msg>` DSL, the ~44 widgets and 10 modules, GPU path, gotchas) for humans and AI. Design rationale and roadmap: [SDD.md](SDD.md).
Philosophy: **widgets aren't designed against mockups; they're designed with what `vello` and `taffy` can do.**
## Quick start
```sh
git clone https://gitea.gioser.net/sergio/llimphi.git
cd llimphi
cargo run -p llimphi-ui --example counter # ~124 LOC: the full Elm loop on screen
```
## Install
```toml
[dependencies]
llimphi-ui = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-theme = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# widgets are one crate each — pull only what you use:
llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
```
## Compatibility
- **Linux/Wayland** — primary backend.
- **Linux/X11** — via XWayland.
- **macOS / Windows** — `winit` + `wgpu`.
- **Android** — HAL via `android` crates.
- **Wawa bare-metal** — alternative framebuffer HAL.
Crates listed in [README.md](README.md) (framework, widgets, modules, android).
## Considerations
- **Single API: declarative `View<Msg>`.** No imperative, no foreign vDOM.
- **Same scene tree on Wayland and Wawa**: HAL abstracts the surface.
- Widgets are **purely visual**; modules encapsulate state + behavior.
+35
View File
@@ -0,0 +1,35 @@
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
# llimphi
> Natural UI framework: HAL · raster · layout · text · theme · ui — widgetkuna + modules.
`llimphi` monorepupa llapan apps tukuyniqlla grafico motor. Retained-mode declarativo pipeline (`vello` + `wgpu` + `taffy`), `fontdue`/`harfbuzz` shaping, `Dark/Light/Aurora/Sunset` themes, multi-superficie HAL (Wayland · X11 · Win32 · Android · Wawa). Detalle [SDD.md](SDD.md)-pi.
**Imayna llamk'ana qillqa (manual):** [MANUAL.md](MANUAL.md) — hunt'asqa referencia (Elm muyuy, `View<Msg>` DSL, ~44 widgetkuna, 10 modulekuna, GPU ñan). Runakunapaq IA-paqpas.
Yuyaynin: **widget mana mockuppi munakun; vello + taffy atisqankuwan ruwasqa.**
## Churay
```sh
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
```
## Tinkuy
- **Linux/Wayland** — ñawpaq backend.
- **Linux/X11** — XWayland-rayku.
- **macOS / Windows** — `winit` + `wgpu`.
- **Android** — `android` cratekuna HAL.
- **Wawa bare-metal** — sapan framebuffer HAL.
Crateskunaq listako [README.md](README.md)-pi.
## Yuyaykunaq
- **Sapan API: declarativo `View<Msg>`.** Mana imperativo, mana hawanka vDOM.
- **Kikin escena Wayland Wawapipas**: HAL superficie huñun.
- Widgets **ch'uya rikuq**; módulos estado + ruway huñun.
+366
View File
@@ -0,0 +1,366 @@
# Llimphi — motor gráfico soberano
> Llimphi (quechua: *color / brillo / pigmento*, en el sentido de "pintar la pantalla"). Tipo: **NATIVE GPU rendering suite**.
> **Regla dura para apps:** nada de cómputo pesado síncrono en `App::update`/`init`/handlers — congela la UI ("Not Responding"). Ver [COMPUTO-FUERA-DEL-HILO-UI.md](COMPUTO-FUERA-DEL-HILO-UI.md) (patrón worker + checklist por app, prioridad urgente).
> **¿Buscás cómo *usar* Llimphi?** Este SDD es el *porqué* (diseño, fases, roadmap). La referencia de *uso* — bucle Elm, DSL `View<Msg>`, catálogo de widgets/módulos, GPU directo — está en [MANUAL.md](MANUAL.md), verificada contra el código.
## Tesis
Soberanía total sobre el píxel. Renderizar las geometrías exactas del simulador cósmico (`cosmos`), el compositor (`mirada`), las apps de escritorio (`nahual`) y el visor (`pluma`) sin cajas negras de Apple/Google/navegadores. Reemplazo total de **GPUI** en la pila gioser.
## Anatomía — 4 capas estrictas (S₀ → S₂)
Cada capa hace **una sola cosa** con precisión matemática.
```
[ CUADRANTE III · 0x02 RUWAY ]
4. llimphi-ui — Lógica de Interfaz (Árbol Monádico / DAG UI)
│ (manejo de estado, eventos de teclado/ratón)
3. llimphi-layout — Motor de Layout (Cálculo Espacial)
│ (cajas, dimensiones, restricciones flex/grid)
2. llimphi-raster — Rasterizador Vectorial (La Brocha Fina)
│ (primitivas matemáticas → píxeles via Compute Shaders)
1. llimphi-hal — Abstracción de Hardware (Puente al Silicio)
│ (GPU o Framebuffer, sin importar el OS)
[ HARDWARE · GPU / Pantalla ]
```
## Fases de forja
### Fase 1 — Puente al Silicio (`llimphi-hal`)
Aislar el motor del sistema operativo. Llimphi debe pintar tanto en una ventana Wayland controlada por `mirada` como en el framebuffer directo al arrancar `wawa`.
- **Abstractor:** `wgpu` (impl Rust de WebGPU sobre Vulkan nativo). Control de memoria seguro, bajísima sobrecarga.
- **Ventana:** `winit` para desarrollo en Linux. La arquitectura define un **trait `Surface`** abstracto: el día de mañana se desenchufa `winit` y se le pasa el puntero de memoria bruto del kernel `wawa`.
- **Hito:** Compilar, iniciar Vulkan por debajo, limpiar la pantalla pintándola de un solo color gris plomo a 144 Hz.
### Fase 2 — Brocha Matemática (`llimphi-raster`)
Pintar curvas y grafos orbitales con precisión Δ < 10⁻⁹ rad sin destrozar la CPU. En lugar de rasterizar píxel por píxel, **delegar todo el cálculo vectorial a los Compute Shaders de la GPU**.
- **Motor:** `vello`.
- **Integración:** Conectar la textura de salida de `wgpu` como lienzo destino de `vello`.
- **Ejecución:** Construir una `Scene` en `vello`. Pasarle primitivas geométricas puras (líneas, curvas de Bézier, texto).
- **Hito:** Renderizar en pantalla el grafo de un nodo estático con anti-aliasing perfecto calculado íntegramente por la GPU.
### Fase 3 — Física del Espacio (`llimphi-layout`)
Posicionar dinámicamente paneles, texto y ventanas requiere resolver ecuaciones de restricciones espaciales. No escribir un sistema propio de márgenes/padding: es un sumidero infinito.
- **Motor:** `taffy` (de la gente de Dioxus). Algoritmos Flexbox + CSS Grid en Rust puro.
- **Flujo:** Antes de decirle a `llimphi-raster` dónde pintar, pasar el árbol de nodos a `taffy` para calcular las coordenadas `(x, y, width, height)` absolutas de toda la interfaz.
- **Hito:** Paneles laterales y cajas que se redimensionan automáticamente, calculados en < 1 ms por frame.
### Fase 4 — Árbol de Estado Monádico (`llimphi-ui`)
El mayor problema de las interfaces (y por qué falló el paradigma OOP en esto) es el manejo del estado. Aquí se inyecta la cosmovisión estructural.
- **Arquitectura:** Nada de mutabilidad compartida (`Rc<RefCell<...>>` disperso). Unidireccional estilo Elm o **DAG (Grafo Acíclico Dirigido)**: el estado de la aplicación es **inmutable** y cada evento (click, tecla) genera una **nueva versión** del estado.
- **Bucle:**
1. El usuario hace click (Input).
2. El evento actualiza el Estado Global.
3. El Estado Global reconstruye el Árbol UI.
4. El Árbol pasa por `llimphi-layout` (Layout).
5. Las coordenadas resultantes generan primitivas para `llimphi-raster` (Scene).
6. `llimphi-hal` renderiza y hace el swap de la pantalla.
## Veredicto arquitectónico
No es una biblioteca genérica. Es un **motor de combate**. `wgpu + vello + taffy + DAG monádico` da un frontend capaz de competir en rendimiento con los mejores editores del mundo, diseñado como **traje a medida** para las topologías de gioser. Sin abstracciones de navegadores, sin cajas negras de Apple/Google.
## Pila exacta (sin negociación)
| Capa | Crate raíz | Deps externas |
|---|---|---|
| HAL | `llimphi-hal` | `wgpu`, `winit`, `raw-window-handle` |
| Raster | `llimphi-raster` | `vello`, `vello_encoding`, `peniko` |
| Text | `llimphi-text` | `parley` (shaping + fontique + swash, hereda vello via raster) |
| Layout | `llimphi-layout` | `taffy` |
| UI | `llimphi-ui` | `llimphi-{hal,raster,layout,text}` |
## Migración GPUI → Llimphi
Apps actualmente en GPUI que deben portarse:
- `02_ruway/nahual/*` (todas las apps GPUI: shell, file-explorer, database-explorer, image-viewer, text-viewer + 8 libs + 12 widgets)
- `02_ruway/mirada/mirada-launcher`, `mirada-portal`, `mirada-greeter`
- `00_unanchay/pluma/pluma-editor-gpui`
- `01_yachay/dominium/dominium-canvas-gpui`
- `01_yachay/cosmos/cosmos-app` (canvas + panels GPUI)
**Estrategia:** Las apps mantienen su lógica de dominio en sus `*-core` agnósticos. Solo se reemplaza la capa de presentación: en lugar de `use gpui::*`, pasan a usar `use llimphi_ui::*`.
## Estado (2026-05-31)
### Hecho
- Las 5 capas del framework en producción: `llimphi-hal` (wgpu+winit), `llimphi-raster` (vello), `llimphi-text` (parley, ahora con vello directo y texto multicolor en una pasada), `llimphi-layout` (taffy, con `LayoutTree::clear()` para reuso entre frames), `llimphi-ui` (bucle Elm + runtime winit).
- Split compositor/runtime: `llimphi-compositor` (winit-free: View tree, mount, paint/paint_gpu, hit-test) separado de `llimphi-ui` (runtime winit) → habilita un futuro runtime sobre el framebuffer de `wawa` sin winit.
- GPUI extinto (2026-05-26): toda app gráfica de la suite corre sobre Llimphi.
- Backend GPU directo (sin vello) completo y validado en hardware real (Iris Xe): `GpuPipelines` + `GpuBatch` + `View::gpu_paint_with`; ~11× vs vello a 1M puntos persistente, >140 fps.
- Catálogo de ~44 widgets: incluye text-editor (split en `-core` agnóstico + `-lsp`), nodegraph, tiled/panes/splitter, tree, list, grid (virtualizada 2D), gallery, timeline (scrub clickeable), menubar/edit-menu/context-menu, clipboard del sistema, tabs, modal, toast, y la familia de controles (button/field/slider/switch/segmented/...).
- 10 módulos compuestos: command-palette, diff-viewer, fif (find-in-files), file-picker, bookmarks, mini-map, shuma-term, symbol-outline, selector, plugin-host.
- `llimphi-workspace` (chasis tipo tmux) + `llimphi-gallery` (showcase) + `llimphi-motion`/`llimphi-icons`/`llimphi-surface` auxiliares.
### Pendiente
- Runtime sobre framebuffer de `wawa` (`WawaFramebufferSurface`) reusando el compositor winit-free — habilitado por el split pero aún no escrito.
- Backend GPU directo: sin MSAA/AA fino, sin texto, una sola `line_width` por flush; falta primer caller real denso (cosmos starfield) que mida una falla concreta antes de extender shaders.
- Widgets `llimphi-widget-{transport, waveform}` aún por extraer (la nota de media los deja como futuro no bloqueante).
- Investigación abierta: cuelgue/deadlock de apps Llimphi tras click/scroll (hipótesis `get_current_texture` Wayland FIFO) — pendiente reproducir+backtrace.
## Estado — bitácora histórica
- **2026-05-25:** SDD escrito. Esqueletos de los 4 crates creados.
- **2026-05-25 (tarde):** Las 4 fases en código y compilando. Examples:
- `cargo run -p llimphi-hal --example clear_screen --release` — ventana gris plomo a refresh del display ✅ (verificado en hardware).
- `cargo run -p llimphi-raster --example render_node --release` — nodo con AA perfecto vía vello/wgpu.
- `cargo run -p llimphi-layout --example layout_panels --release` — sidebar + header/body/footer flex que se reorganiza al resize.
- `cargo run -p llimphi-ui --example counter --release` — bucle Elm completo: click hit-test → update → view → layout → raster → present.
- **2026-05-25 (noche):** quinto crate `llimphi-text` (skrifa + vello). Bug de `max_storage_buffers_per_shader_stage` corregido (`Limits::default()` en vez de `downlevel`). `View::text()` permite poner texto centrado en cualquier nodo. Examples:
- `cargo run -p llimphi-text --example hello_text --release` — "Llimphi" + tagline sobre fondo negro.
- `counter` ahora muestra el número real (no barras) y los botones llevan label.
- **2026-05-25 (cierre):** dos fixes de hardware + parley.
- **Storage write fix:** swapchain de muchos adapters Linux/Vulkan no acepta storage writes en Rgba8Unorm. Patrón nuevo: textura intermedia con `STORAGE_BINDING | TEXTURE_BINDING` donde pinta vello + `TextureBlitter` que la copia al swapchain en `Surface::present(frame, &hal)`. Cambio de API: `frame.present()``surface.present(frame, &hal)`.
- **Paint-order fix:** `mount_recursive` registraba en post-orden y el background del root tapaba a los hijos. Ahora pre-orden depth-first.
- **Parley:** llimphi-text reescrito sobre parley. API nueva: `Typesetter` (cachea FontContext + LayoutContext), `TextBlock { text, size_px, color, origin, max_width, alignment, line_height }`, `Alignment { Start, Center, End, Justify }`, `measure(&mut ts, &block)`. Bidi + ligatures + fallback CJK/emoji vía fontique. `hello_text` muestra título + párrafo justificado con script mixto Latin/Arabic/CJK.
- **2026-05-25 (cierre+1):** teclado en `llimphi-ui`. `App` gana `fn on_key(model, &KeyEvent) -> Option<Msg>` con default `None`. Re-export `Key` y `NamedKey` de winit. Runtime mantiene `Modifiers` state vía `ModifiersChanged`. `TextSpec` gana `alignment` (default `Center`, los labels de botón siguen igual) + `View::text_aligned(...)`. Example nuevo `editor`: text field con char insertion, backspace, enter, tab→4-spaces, ctrl+L limpia.
- **2026-05-26:** migración GPUI → Llimphi **completada**. GPUI queda extinto: toda app gráfica de la suite (pluma, mirada, cosmos, dominium, nahual, iniy, khipu, chasqui…) corre sobre Llimphi. No se agrega código nuevo sobre GPUI (ver regla dura §3 de `CLAUDE.md`).
- **2026-05-31:** split de `llimphi-widget-text-editor` (4328 LOC) → núcleo agnóstico `llimphi-widget-text-editor-core` (buffer/cursor/ops/undo/bracket/find/diagnostics/clipboard/highlight, sin render: sólo `peniko::Color`) + widget Llimphi (state + view) que lo re-exporta. Núcleo reutilizable en TUI/web/headless. `LayoutTree::clear()` para reusar el árbol taffy entre frames (`llimphi-layout`).
- **2026-05-31 (texto multicolor):** syntax highlighting en una sola pasada de shaping. `llimphi-text` gana `RunBrush` + `Typesetter::layout_runs` (color por rango de bytes vía `parley::RangedBuilder`/`StyleProperty::Brush`) + `draw_layout_runs`; `View::text_runs` lo expone. El editor pasó de un nodo (+ layout parley) por token a uno por línea.
- **2026-05-31 (split compositor/runtime):** `llimphi-ui` (1943 LOC) partido para separar la composición declarativa del runtime winit:
- **`llimphi-compositor`** (nuevo, **winit-free**): el árbol `View<Msg>`, `mount` sobre taffy, `paint`/`paint_gpu` a `vello::Scene` y el hit-test. Depende sólo de `llimphi-layout` + `llimphi-text` + `vello` + `wgpu` (este último sólo por la firma de `GpuPaintFn`; `wgpu` no es windowing). **No depende de `llimphi-hal`.**
- **`llimphi-ui`**: queda como el runtime winit (`App`/`Handle`/`run`/event loop/`KeyEvent`) y re-exporta el compositor entero → los consumidores siguen usando `llimphi_ui::View` etc. sin cambios.
- Prerrequisito habilitado: `llimphi-text` ahora depende de `vello` directo (no de `llimphi-raster`), así que la pila de render (`compositor``text`/`vello`) es winit-free. Eso abre la puerta a un runtime sobre el framebuffer del kernel `wawa` (`WawaFramebufferSurface`) que reuse el mismo compositor sin arrastrar winit. `Renderer` (lo único que necesita `llimphi-hal`) se queda en `llimphi-raster`, consumido por `llimphi-ui`.
## Roadmap — GPU directo wgpu (sin vello)
### Por qué
`llimphi-raster` traduce hoy todo a `vello::Scene` (BezPath / kurbo /
peniko) y vello rasteriza vía compute shaders. Para 99 % de la suite
sobra: pluma editor, shuma shell, mirada compositor, nahual, iniy, khipu,
chasqui explorer, etc. pintan decenas a centenas de primitivos por frame.
El techo aparece cuando una app necesita rendir **>1 M primitivos por
frame**. En ese régimen el overhead de construir `BezPath`, ensamblar
buffers para los shaders internos de vello y hacer una pasada compute
por cada batch domina sobre el tiempo de raster real. Casos concretos
en gioser:
| App | Carga potencial | Trigger probable |
|---|---|---|
| **cosmos** | Catálogo Gaia DR3, mapas de cielo enteros | Starfield denso o sky-survey overlay |
| **tinkuy** | Particle engine N→∞ por diseño | Sim con > 10⁵ partículas |
| **nakui** | 100 K filas × 26 cols = 2.6 M celdas potencialmente visibles | Viewport con dataset grande |
| **dominium** | Mean-field con N agentes | Cuando se pase de 10³ a 10⁵ |
| **pineal** | Sus painters ya producen `Vec<f32>` interleaved (principio P1) — son los primeros listos para consumir el backend | Cualquiera de los anteriores que use pineal-* |
El techo es **horizontal**. Resolverlo en cualquier app individual sería
duplicación; el lugar es el motor.
### Qué es
Un backend alternativo en `llimphi-raster` que **salta vello** y sube
los slices de coordenadas directamente a vertex buffers `wgpu`, dispara
shaders WGSL chiquitos y emite una draw call por batch.
```
hoy: painter → vello::Scene → BezPath → vello → wgpu → GPU
con esto: painter → GpuBatch → vertex buffer → wgpu → GPU
```
El trait que ven las apps (`Canvas` para pineal, `View::paint_with` para
llimphi-ui) **no cambia**. Cambia el implementador por debajo cuando se
elige "modo GPU directo".
### Trade-offs vs vello
| | Vello (hoy) | GPU directo |
|---|---|---|
| AA | Analítico, perfecto | MSAA hardware o supersample en shader |
| Curvas suaves | Bezier nativo | Hay que teselar primero |
| Texto | Sí, vello + parley | No — usar vello para text aunque coexista |
| Throughput primitivos | Bueno hasta ~100 K | Apto para 110 M |
| Costo de mantener | Cero (vello lo mantiene Linebender) | Shaders WGSL + pipelines propias |
Decisión: los dos backends **coexisten**. La app elige por hint
(`View::gpu_paint_with` para denso, `paint_with` para todo lo demás).
### Plan de tareas
**Fase 0 — Spike de medición (½ día). ✓ HECHO (2026-05-28).**
Benchmark sintético: pintar 100 K, 500 K y 1 M puntos con `SceneCanvas`
actual vs un mock GPU-directo (vertex buffer + shader trivial). Si el
factor no es ≥ 5× en el rango de 500 K, abortar — vello ya es
suficiente y no vale el costo de mantenimiento. Métrica de éxito: 60 fps
con 1 M puntos en GPU mid (Radeon 5500M, Intel Iris Xe).
Implementado en `llimphi-raster/examples/spike_gpu_directo.rs`. Cubre
ambos backends contra una textura `Rgba8Unorm` 1024×1024 headless,
warmup 5 + 15 frames medidos, bloquea hasta GPU idle (`Maintain::Wait`)
para que los `ms` reportados sean tiempo real CPU+GPU.
El binario `llimphi-gpu-bench` (en su propio crate) reporta info del
adapter wgpu + corre dos escenarios distintos: **rebuild por frame**
(LCG + `write_buffer` de 12-160 MB por frame, peor caso) y
**persistente** (buffer/Scene preparados UNA vez, bucle medido sólo
emite la draw call — caso real de cosmos/tinkuy/nakui).
**Resultados — Intel Iris Xe (TGL GT2), Mesa 26.1.1, Vulkan, 2026-05-28:**
Rebuild por frame:
| N | vello ms | directo ms | factor |
|---:|---:|---:|---:|
| 25K | 7.3 | 1.2 | **6.05×** |
| 50K | 12.9 | 1.4 | **8.94×** |
| 100K | 21.7 | 3.2 | **6.67×** |
| 200K | 26.1 | 6.1 | 4.30× |
| 500K | 94.4 | 18.0 | **5.25×** |
| 1M | 202.4 | 49.0 | 4.13× |
Persistente (datos fijos, sólo redraw):
| N | vello ms | directo ms | factor | fps directo |
|---:|---:|---:|---:|---:|
| 100K | 18.6 | 0.8 | **22.55×** | 1210 |
| 500K | 34.1 | 3.4 | **9.97×** | 293 |
| 1M | 83.1 | 7.1 | **11.76×** | 141 |
| 2M | 101.7 | 16.0 | **6.37×** | 63 |
| 5M | crash | 41.8 | — | 24 |
| 10M | crash | 79.7 | — | 13 |
Veredictos contra el criterio del SDD:
- **Factor ≥5× a 500K**: ✓ PASA. Rebuild 5.25×, persistente 9.97×.
- **≥60 fps @ 1M**: ✓ PASA en persistente (141 fps); falla en rebuild
(22 fps) — pero rebuild no es el use case real.
- **Techo de vello**: ~2 M paths en GPU mid. Más alto que mi hipótesis
inicial (que era 200300 K, contaminada por llvmpipe), pero existe.
El path directo escala lineal a >10 M sin crashes.
Conclusión: el GPU directo cumple su propósito. La diferencia entre
rebuild y persistente (520×) confirma que el patrón correcto es
"datos cambian → vello, datos estáticos → GPU directo persistente".
**Fase 1 — Hook en `llimphi-ui` (12 días).**
Hoy `View::paint_with(F)` da
`F: Fn(&mut vello::Scene, &mut Typesetter, PaintRect)`. Agregar:
```rust
View::gpu_paint_with(F)
where F: Fn(&wgpu::Device, &wgpu::Queue,
&mut wgpu::CommandEncoder,
&wgpu::TextureView, PaintRect)
```
El runtime de llimphi-ui ya tiene `Device`/`Queue` para vello; sólo hay
que exponer el `CommandEncoder` y `TextureView` del frame durante el
mount/paint. Compatibilidad: ambos hooks coexisten en el mismo View
tree; el orden de pintura sigue siendo pre-orden DFS.
**Fase 2 — Pipelines y shaders en `llimphi-raster` (35 días).**
Tres pipelines WGSL precompiladas y cacheadas:
- `lines_pipeline` — line list, anchura uniforme (expandida a tris en
vertex shader como hace pineal-export::png).
- `tris_pipeline` — triangle list con per-vertex color.
- `rects_pipeline` — instanced quad con per-instance `[x, y, w, h, color]`.
Vertex format común: `[x: f32, y: f32, rgba: u32]`. Sin texturas; eso
queda para una fase posterior si aparece demanda.
**Fase 3 — `GpuBatch` accumulator (23 días).**
Estructura que las apps usan dentro del callback:
```rust
let mut batch = GpuBatch::new(device);
batch.add_lines(&coords, color);
batch.add_tris(&coords, &colors);
batch.add_rect(rect, color);
batch.flush(encoder, view); // 1 draw call por pipeline usada
```
Grow strategy: vertex buffer dobla capacidad cada vez que se queda
chico. Sin copy back — vive del frame, se reusa el siguiente.
**Fase 4 — `GpuSceneCanvas` en pineal-render (1 día).**
Wrapper que implementa el trait `Canvas` de pineal usando `GpuBatch`
por debajo. Cero cambios en los painters. Permite usar el catálogo
entero de pineal en modo denso simplemente eligiendo el otro
constructor de Canvas dentro del `gpu_paint_with`.
**Fase 5 — Primer caller real (cosmos starfield, 23 días).**
Adaptar `cosmos-canvas-llimphi` para subir todas las estrellas del
viewport en una draw call usando `gpu_paint_with`. Métrica: dataset
HYG (~120 K estrellas brillantes) renderizadas a 144 fps en GPU mid.
**Fase 6 — Tests + demo + SDD (1 día). ✓ HECHO (2026-05-28).**
- `llimphi-raster/examples/gpu_million_points.rs`: usa `GpuPipelines` +
`GpuBatch` puros (sin app, sin runtime Elm) para pintar N rects
sintéticos. Validación headless del HAL + bench de referencia
post-implementación. Smoke en `tests/gpu_batch_smoke.rs`.
- Tabla "cuándo elegir" → abajo.
- Pineal SDD §4 actualizado con `GpuSceneCanvas` en producción.
### ¿Cuándo elegir vello vs GPU directo?
| Pregunta | Vello (`paint_with`) | GPU directo (`gpu_paint_with`) |
|---|---|---|
| ¿Cuántos primitivos por frame? | < ~500 K (rebuild) o < ~2 M (Scene reusada) | 100 K 10 M+ |
| ¿Los datos cambian cada frame? | Sí — vello rebuild es barato hasta 500 K | Posible pero con coste de `write_buffer`; ideal estático |
| ¿Curvas Bezier nativas? | Sí | No (teselar antes) |
| ¿Texto? | Sí | No — usar vello hermano u overlay |
| ¿AA fino requerido? | Sí (analítico) | No (sin MSAA todavía) |
| ¿Múltiples grosores de stroke? | Sí | Una sola `line_width` por flush |
| ¿Anti-fluctuación de pixel? | Sí | Subpixel jitter visible |
| Ejemplos de uso | pluma editor, shuma shell, mirada, nahual, iniy, khipu, chasqui explorer, dominium UI | cosmos starfield denso, tinkuy particles, nakui viewport, pineal denso |
Default razonable: **`paint_with`** salvo que el caller ya midió que el
volumen lo justifica. El costo de mantener un pipeline + WGSL propios
es alto comparado con seguir usando vello.
Patrón "buffer persistente": para el use case denso real (catálogo
fijo, particles iniciales, dataset estático), construir el
`wgpu::Buffer` y `BindGroup` UNA vez con `GpuPipelines::{rects, tris,
lines, bind_layout}` expuestos y emitir el draw call manualmente
desde el `gpu_paint_with` reusando esos recursos. Eso da factores
~11× vs vello a 1M en GPU mid (medido Iris Xe), y >140 fps.
`GpuBatch` queda para datos transitorios (UI dinámica densa).
Convivencia: una misma `View` puede registrar AMBOS hooks. El runtime
pinta vello primero (toda la Scene), luego ejecuta los GPU painters
en orden DFS. Para texto encima de un render GPU denso, se usa
`App::view_overlay` (segunda Scene vello sobre el main).
**Estimado total: 1015 días de trabajo concentrado.**
**Trabajo real (1 día, 2026-05-28):** todas las fases completas, sólo
falta validar el criterio formal (≥5× a 500K, 60 fps @ 1M) en GPU mid
real — el bench corrió en llvmpipe.
### Trigger
No empezar hasta tener un caller real que mida una falla concreta.
El candidato natural es cosmos (starfield Gaia o sky-survey overlay).
Hasta entonces, el item queda acá en este SDD como decisión arquitectónica
tomada — todas las apps saben que el techo existe y que la salida
está diseñada.
### No-objetivos explícitos
- **No** reemplazar vello. Coexisten — vello para vector/text/AA fino,
GPU directo para volumen.
- **No** hacer un layer de abstracción tipo Skia. El trait `Canvas` de
pineal y el `paint_with` de llimphi son la abstracción; no se agrega
más arriba.
- **No** soportar texto en el backend GPU directo. Texto siempre por
vello+parley; si una vista mezcla millones de puntos + labels, hace
`gpu_paint_with` para los puntos y un `paint_with` superpuesto para
los labels.
+108
View File
@@ -0,0 +1,108 @@
# Llimphi · Android
Port nativo de Llimphi a Android. Una `NativeActivity` en C que
delega al `android_main` que `android-activity` exporta desde la
`.so` Rust, idéntico patrón que un binario `main()` en desktop.
## Estado
| crate | estado |
|---|---|
| `clear-screen-android` | ✓ APK firmado v2, instalable en Android 7+ |
| resto de apps Llimphi | pendientes — el patrón es reusar `android_main` |
## Tesis
El motor Llimphi (HAL + raster + layout + text + ui) **no se toca**.
Lo único nuevo por target Android es:
1. Entry-point `#[no_mangle] android_main(app: AndroidApp)` en vez de
`fn main()`.
2. Construir el `EventLoop` con `with_android_app(app)` para que
`winit` reciba `Resumed` / `Suspended` / `InputAvailable` desde el
Looper de Android.
3. Recrear la `Surface` en cada `Resumed`: Android invalida la
NativeWindow al pasar a background. El `App::state: Option<State>`
ya está estructurado para eso.
Las apps existentes que viven sobre Llimphi compilan sin cambios — lo
que se reescribe es el **lifecycle wrapper**, no la lógica de render
ni los widgets.
## Cómo construir
Una sola pasada — el script wrapper:
```sh
./scripts/build-android.sh clear-screen-android
```
Resultado: `target/x/release/android/clear-screen-android.apk`
firmado con APK Signature Scheme v2, listo para
`adb install -r <apk>`.
## Setup inicial (una vez por máquina)
```sh
# Targets Rust
rustup target add aarch64-linux-android x86_64-linux-android
# Wrapper de build de Rust mobile (binario `x`)
cargo install xbuild
# NDK r27c (~640 MB descomprimido, ~1.5 GB)
curl -L -o /tmp/ndk.zip \
https://dl.google.com/android/repository/android-ndk-r27c-linux.zip
unzip /tmp/ndk.zip -d $HOME/
export ANDROID_NDK_HOME=$HOME/android-ndk-r27c
# SDK (sólo build-tools + platform-tools, no se necesita la plataforma
# completa porque el APK se genera con aapt2 + apksigner del SDK).
# En Artix viene del paquete `android-sdk-build-tools`.
```
El script `build-android.sh` genera automáticamente un PEM RSA2048
self-signed en `~/.local/share/llimphi-android/debug.pem` la primera
vez que corre. Para firma de release usar un PEM propio y exportarlo
en `LLIMPHI_PEM`.
## Estructura del APK generado
```
clear-screen-android.apk
├── AndroidManifest.xml ← xbuild genera; NativeActivity
└── lib/arm64-v8a/
└── libclear_screen_android.so ← 7.5 MB sin strip, ~2 MB stripped
```
Sin assets, sin recursos, sin Java/Kotlin. Todo el "código" de la app
es la `.so` Rust. El bootstrap Java de NativeActivity lo provee el
framework Android.
## Apps por portar (orden de menor a mayor fricción)
Las apps que **menos** se modifican al portar son las que ya tienen
poca interacción con teclado/mouse y mucho rendering:
1. **mirada-image-viewer-llimphi** — visor de imágenes, gestos = ok
2. **nahual-text-viewer-llimphi** — sólo scroll + zoom
3. **nahual-image-viewer-llimphi** — idem
4. **pluma-md-reader** — visor markdown, mismo patrón que la web
5. **chasqui-explorer-llimphi** — listas y tarjetas, taps obvios
6. **shuma-shell-llimphi** — teclado virtual, ya casi no usa shortcuts
7. **mirada-app-llimphi** — el compositor; touch desktop = problema UX
Las apps con paleta de comandos (nada, pluma-app full) son las
**últimas** porque su UX core (Ctrl+Shift+P, multi-pane splitter,
file picker) necesita ser repensada para touch.
## Próximos hitos
- **Tier 1.5**: hello-world con vello rasterizando un texto + figura
(smoke test del stack raster completo en Android).
- **Tier 2**: portar `mirada-image-viewer-llimphi` — primer APK
funcional con UI real.
- **Tier 3**: input handling proper (touch events, soft keyboard,
back button), theming responsivo (dpi/density).
- **Tier 4**: distribución (Play Store internal track, F-Droid build
reproducible).
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "clear-screen-android"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Demo Android Tier 1: pinta la pantalla con LEAD_GRAY usando llimphi-hal sobre Android NativeActivity."
# Android NativeActivity carga la lib nativa como .so via dlopen; el
# binario final es una `cdylib` con `android_main` exportado. xbuild /
# cargo-apk se encargan de empaquetar el .so dentro del APK.
[lib]
crate-type = ["cdylib"]
[dependencies]
llimphi-hal = { path = "../../llimphi-hal" }
# Activamos el feature de NativeActivity en winit para que linkee con la
# clase NativeActivity del NDK y reciba eventos de surface/input desde la
# Activity Java/Kotlin generada por android-activity.
winit = { workspace = true, features = ["android-native-activity"] }
wgpu.workspace = true
pollster.workspace = true
# `log` se declara aquí (no en el bloque condicional Android) para que
# `cargo check --workspace` en host pase: los macros de `log` son no-op
# sin logger instalado. En Android, `android_logger` (más abajo) instala
# el sink real hacia `logcat`.
log = "0.4"
[target.'cfg(target_os = "android")'.dependencies]
android-activity = { version = "0.6", features = ["native-activity"] }
android_logger = "0.14"
# Metadata para xbuild / cargo-apk — define el manifiesto Android que se
# inyecta en el APK final.
[package.metadata.android]
package = "net.gioser.llimphi.clearscreen"
build_targets = ["aarch64-linux-android", "x86_64-linux-android"]
min_sdk_version = 24
target_sdk_version = 34
[package.metadata.android.application]
label = "Llimphi · clear_screen"
debuggable = true
[package.metadata.android.application.activity]
config_changes = "orientation|screenSize|keyboardHidden"
launch_mode = "singleTop"
orientation = "unspecified"
+11
View File
@@ -0,0 +1,11 @@
# clear-screen-android
> Smoke test del HAL Android de [llimphi](../../README.md).
App mínima que limpia la pantalla con un color sólido. Sirve para verificar que el HAL Android compila + corre + dibuja sin que el resto del stack ofusque el problema.
## Build
```sh
cargo apk build -p clear-screen-android
```
+11
View File
@@ -0,0 +1,11 @@
# clear-screen-android
> Android HAL smoke test of [llimphi](../../README.md).
Minimal app that clears the screen with a solid color. Verifies the Android HAL compiles + runs + draws without the rest of the stack obscuring the problem.
## Build
```sh
cargo apk build -p clear-screen-android
```
+291
View File
@@ -0,0 +1,291 @@
//! Demo Tier 1 Android: pinta la pantalla con LEAD_GRAY usando llimphi-hal.
//!
//! Logging exhaustivo en cada paso del bootstrap para diagnosticar
//! cuelgues en device real desde `adb logcat -s llimphi-android:V`.
//! Panic hook captura backtraces a logcat — sin esto el crash es
//! invisible (Android cierra el proceso silenciosamente).
//!
//! Orden de inicialización en `resumed`:
//! 1. crear Window via winit
//! 2. crear wgpu::Instance
//! 3. crear Surface con la NativeWindow
//! 4. request_adapter pasándole compatible_surface=Some(&surface)
//! 5. request_device
//! 6. configurar surface (formato, tamaño)
//! 7. crear textura intermedia + blitter (llimphi-hal::WinitSurface)
//!
//! El orden 3 antes que 4 es lo que **garantiza** que el adapter
//! elegido sabe presentar a esa NativeWindow concreta. Llamar
//! `Hal::new(None)` (como hacía la primera versión) elige un adapter
//! "cualquiera" y después la creación de surface puede fallar — o
//! peor, parecer OK y crashear en el primer `present`.
use std::sync::Arc;
use std::time::Instant;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{wgpu, Hal, Surface, WinitSurface};
const LEAD_GRAY: wgpu::Color = wgpu::Color {
r: 0.235,
g: 0.239,
b: 0.247,
a: 1.0,
};
const TAG: &str = "llimphi-android";
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
}
struct App {
state: Option<State>,
frames: u64,
last_report: Instant,
}
impl App {
fn new() -> Self {
Self {
state: None,
frames: 0,
last_report: Instant::now(),
}
}
/// Bootstrap: crea el estado completo o devuelve un mensaje
/// explicando dónde falló. **No panic-ea** — los panics en
/// `android_main` arrancan la cierre del proceso antes que el
/// logcat flushee.
fn boot(&self, event_loop: &ActiveEventLoop) -> Result<State, String> {
log::info!("[boot] 1/7 creando Window");
let window = event_loop
.create_window(WindowAttributes::default().with_title("llimphi · clear_screen"))
.map_err(|e| format!("create_window: {e}"))?;
let window = Arc::new(window);
let size = window.inner_size();
log::info!(
"[boot] window ok · inner_size = {}x{}",
size.width,
size.height
);
log::info!("[boot] 2/7 creando wgpu::Instance");
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
..Default::default()
});
log::info!("[boot] instance ok · backends activos = {:?}", instance);
log::info!("[boot] 3/7 creando Surface contra la NativeWindow");
let surface = instance
.create_surface(window.clone())
.map_err(|e| format!("create_surface: {e}"))?;
log::info!("[boot] surface creada");
log::info!("[boot] 4/7 request_adapter (compatible_surface=Some)");
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
}))
.ok_or_else(|| "request_adapter devolvió None — sin GPU compatible".to_string())?;
let info = adapter.get_info();
log::info!(
"[boot] adapter ok · backend={:?} name={:?} driver={:?}",
info.backend,
info.name,
info.driver_info
);
log::info!("[boot] 5/7 request_device");
// En Android (Mali/Adreno entry-level) Limits::default suele exceder
// el hardware. using_resolution recorta lo recortable preservando
// los counts mínimos (5 storage buffers/stage que vello necesita).
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("clear-screen-android-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
))
.map_err(|e| format!("request_device: {e}"))?;
log::info!("[boot] device + queue ok");
log::info!("[boot] 6/7 ensamblando Hal");
let hal = Hal {
instance,
adapter,
device,
queue,
};
log::info!("[boot] 7/7 envolviendo en WinitSurface (intermediate + blitter)");
// Crítico: usar `from_surface` (no `new`), pasando la surface que
// ya creamos en el paso 3. `WinitSurface::new` haría un segundo
// create_surface contra la misma NativeWindow y Android responde
// ERROR_NATIVE_WINDOW_IN_USE_KHR → panic.
let llimphi_surface = WinitSurface::from_surface(&hal, window.clone(), surface)
.map_err(|e| format!("WinitSurface::from_surface: {e}"))?;
log::info!("[boot] ✓ bootstrap completo, pidiendo redraw");
window.request_redraw();
Ok(State {
window,
hal,
surface: llimphi_surface,
})
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
log::info!("Resumed event");
match self.boot(event_loop) {
Ok(state) => self.state = Some(state),
Err(e) => {
log::error!("BOOT FAILED: {e}");
// No exit-amos para que el process siga vivo y se vea el
// log; el usuario cerrará la app manualmente.
}
}
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
log::info!("Suspended event — liberando surface");
self.state = None;
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => {
log::info!("CloseRequested");
event_loop.exit();
}
WindowEvent::Resized(size) => {
log::info!("Resized → {}x{}", size.width, size.height);
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(e) => {
log::warn!("acquire falló ({e}); reconfigurando");
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let mut encoder =
state
.hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("clear_screen-encoder"),
});
{
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("clear_screen-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: frame.view(),
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(LEAD_GRAY),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
}
state.hal.queue.submit(std::iter::once(encoder.finish()));
state.surface.present(frame, &state.hal);
self.frames += 1;
let elapsed = self.last_report.elapsed();
if elapsed.as_secs() >= 1 {
let fps = self.frames as f64 / elapsed.as_secs_f64();
log::info!("{fps:.1} fps");
self.frames = 0;
self.last_report = Instant::now();
}
state.window.request_redraw();
}
_ => {}
}
}
}
#[cfg(target_os = "android")]
fn install_panic_logger() {
// Sin esto los panic son invisibles: Android mata el proceso antes
// que la línea de stderr llegue a logcat. set_hook redirige el panic
// info a log::error que sí sale en logcat (vía android_logger).
std::panic::set_hook(Box::new(|info| {
let payload = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("<unknown panic payload>");
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "<unknown location>".into());
log::error!("PANIC at {location} — {payload}");
// Forzar flush stdio del android_logger (mejor que nada).
}));
}
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: android_activity::AndroidApp) {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag(TAG),
);
install_panic_logger();
log::info!("android_main START");
use llimphi_hal::winit::event_loop::EventLoopBuilder;
use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid;
let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build()
{
Ok(el) => el,
Err(e) => {
log::error!("EventLoop::build failed: {e}");
return;
}
};
event_loop.set_control_flow(ControlFlow::Poll);
log::info!("event_loop construido, entrando a run_app");
let mut app_handler = App::new();
if let Err(e) = event_loop.run_app(&mut app_handler) {
log::error!("run_app: {e}");
}
log::info!("android_main END");
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# ============================================================================
# build-android.sh — empaca un crate Llimphi-Android como APK firmado.
#
# Uso:
# ./build-android.sh <crate-dir> [arch] [profile]
#
# crate-dir : path al Cargo.toml del crate Android (cdylib + android_main)
# arch : arm64 | x64 (default arm64)
# profile : release | debug (default release)
#
# Requisitos:
# - rustup target add aarch64-linux-android x86_64-linux-android
# - cargo install xbuild (binario `x`)
# - cargo install cargo-ndk (opcional, sólo si querés build sin APK)
# - NDK r27+ en $ANDROID_NDK_HOME
# - Android SDK en $ANDROID_HOME (cmdline-tools + build-tools)
# - PEM dev en $LLIMPHI_PEM (se crea automáticamente la primera vez)
#
# Resultado:
# target/x/<profile>/android/<crate>.apk — APK firmado v2, instalable con
# `adb install -r <apk>`.
# ============================================================================
set -euo pipefail
CRATE_DIR="${1:?se requiere crate-dir como primer argumento}"
ARCH="${2:-arm64}"
PROFILE="${3:-release}"
# --- toolchain -------------------------------------------------------------
: "${ANDROID_NDK_HOME:=/home/sergio/android-ndk-r27c}"
: "${ANDROID_NDK_ROOT:=$ANDROID_NDK_HOME}"
: "${ANDROID_HOME:=/opt/android-sdk}"
: "${LLIMPHI_PEM:=$HOME/.local/share/llimphi-android/debug.pem}"
export ANDROID_NDK_HOME ANDROID_NDK_ROOT ANDROID_HOME
X_BIN="${X_BIN:-$HOME/.cargo/bin/x}"
test -x "$X_BIN" || { echo "❌ xbuild (cargo install xbuild)"; exit 1; }
test -d "$ANDROID_NDK_HOME" || { echo "❌ NDK no encontrado en $ANDROID_NDK_HOME"; exit 1; }
test -d "$ANDROID_HOME" || { echo "❌ SDK no encontrado en $ANDROID_HOME"; exit 1; }
# --- PEM de firma dev (RSA 2048 + cert auto-firmado) -----------------------
if [ ! -f "$LLIMPHI_PEM" ]; then
echo "→ generando PEM de firma dev en $LLIMPHI_PEM"
mkdir -p "$(dirname "$LLIMPHI_PEM")"
openssl req -x509 -newkey rsa:2048 \
-keyout "${LLIMPHI_PEM}.key" \
-out "${LLIMPHI_PEM}.cert" \
-days 36500 -nodes \
-subj "/CN=llimphi-dev/O=gioser/C=AR" 2>/dev/null
cat "${LLIMPHI_PEM}.key" "${LLIMPHI_PEM}.cert" > "$LLIMPHI_PEM"
fi
# --- flags -----------------------------------------------------------------
PROFILE_FLAG="--release"
[ "$PROFILE" = "debug" ] && PROFILE_FLAG="--debug"
# --- build ----------------------------------------------------------------
cd "$CRATE_DIR"
CRATE_NAME=$(grep '^name *=' Cargo.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
echo "→ building $CRATE_NAME · $ARCH · $PROFILE"
"$X_BIN" build \
--platform android \
--arch "$ARCH" \
--format apk \
$PROFILE_FLAG \
--pem "$LLIMPHI_PEM"
# --- locate + verify -------------------------------------------------------
APK=$(find ../../../../target/x/$PROFILE/android -name "${CRATE_NAME}.apk" 2>/dev/null | head -1)
[ -z "$APK" ] && APK=$(find . -name "${CRATE_NAME}.apk" 2>/dev/null | head -1)
[ -z "$APK" ] && { echo "❌ APK no encontrado"; exit 1; }
APK=$(readlink -f "$APK")
SIZE=$(du -h "$APK" | cut -f1)
APKSIGNER="$ANDROID_HOME/build-tools/37.0.0/apksigner"
if [ -x "$APKSIGNER" ]; then
if "$APKSIGNER" verify --min-sdk-version 24 "$APK" 2>/dev/null; then
echo "✓ firma verificada (APK Signature Scheme v2)"
else
echo "⚠ firma no verifica"
fi
fi
echo "$APK ($SIZE)"
echo
echo "Instalar en device:"
echo " adb install -r $APK"
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "vello-hello-android"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Tier 1.5 Android: vello + llimphi-raster pintando una chacana animada como smoke test del stack completo."
[lib]
crate-type = ["cdylib"]
[dependencies]
llimphi-hal = { path = "../../llimphi-hal" }
llimphi-raster = { path = "../../llimphi-raster" }
winit = { workspace = true, features = ["android-native-activity"] }
wgpu.workspace = true
vello.workspace = true
pollster.workspace = true
# `log` se declara aquí (no en el bloque condicional Android) para que
# `cargo check --workspace` en host pase: los macros de `log` son no-op
# sin logger instalado. En Android, `android_logger` (más abajo) instala
# el sink real hacia `logcat`.
log = "0.4"
[target.'cfg(target_os = "android")'.dependencies]
android-activity = { version = "0.6", features = ["native-activity"] }
android_logger = "0.14"
[package.metadata.android]
package = "net.gioser.llimphi.vellohello"
build_targets = ["aarch64-linux-android", "x86_64-linux-android"]
min_sdk_version = 24
target_sdk_version = 34
[package.metadata.android.application]
label = "Llimphi · vello-hello"
debuggable = true
[package.metadata.android.application.activity]
config_changes = "orientation|screenSize|keyboardHidden"
launch_mode = "singleTop"
orientation = "unspecified"
+11
View File
@@ -0,0 +1,11 @@
# vello-hello-android
> Vello hello-world Android de [llimphi](../../README.md).
App que dibuja un par de shapes con `vello` sobre el HAL Android. Siguiente paso después de [`clear-screen-android`](../clear-screen-android/README.md): valida que vello/wgpu corren en el dispositivo.
## Build
```sh
cargo apk build -p vello-hello-android
```
+11
View File
@@ -0,0 +1,11 @@
# vello-hello-android
> Vello hello-world Android of [llimphi](../../README.md).
App that draws a couple of shapes with `vello` over the Android HAL. Next step after [`clear-screen-android`](../clear-screen-android/README.md): validates that vello/wgpu run on the device.
## Build
```sh
cargo apk build -p vello-hello-android
```
+376
View File
@@ -0,0 +1,376 @@
//! Tier 1.5 Android: chacana animada con vello + llimphi-raster.
//!
//! Smoke test del stack raster completo en device móvil:
//! wgpu (Vulkan/Adreno) → llimphi-hal (intermediate Rgba8) →
//! vello::Scene (kurbo paths + peniko brushes) →
//! llimphi_raster::Renderer (compute pipeline AA) →
//! blit a swapchain.
//!
//! El bootstrap es el mismo orden estricto que `clear-screen-android`:
//! create_surface antes que request_adapter (compatible_surface=Some),
//! WinitSurface::from_surface (no `new`), panic hook al logcat.
//!
//! Si esta app pinta y mantiene fps en device, todas las apps Llimphi
//! basadas en vello están listas para portar mecánicamente — solo hay
//! que envolver su `build_scene` con este shell.
use std::sync::Arc;
use std::time::Instant;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{wgpu, Hal, Surface, WinitSurface};
use llimphi_raster::kurbo::{Affine, BezPath, Circle, Stroke};
use llimphi_raster::peniko::{Color, Fill};
use llimphi_raster::{vello, Renderer};
const TAG: &str = "llimphi-vello";
// Paleta gioser (mismos hex que la web/Llimphi-theme).
const COSMOS_NIGHT: Color = Color::from_rgba8(0x0E, 0x10, 0x16, 255);
const ACCENT_CYAN: Color = Color::from_rgba8(0xA6, 0xD8, 0xFF, 255);
const ACCENT_AMBER: Color = Color::from_rgba8(0xE8, 0xC9, 0x7A, 255);
const ACCENT_BLUE: Color = Color::from_rgba8(0x6E, 0x8C, 0xDC, 255);
const ACCENT_VIOLET: Color = Color::from_rgba8(0xC3, 0x9C, 0xE8, 255);
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
renderer: Renderer,
scene: vello::Scene,
}
struct App {
state: Option<State>,
started: Instant,
frames: u64,
last_report: Instant,
}
impl App {
fn new() -> Self {
let now = Instant::now();
Self {
state: None,
started: now,
frames: 0,
last_report: now,
}
}
fn boot(&self, event_loop: &ActiveEventLoop) -> Result<State, String> {
log::info!("[boot] 1/8 Window");
let window = event_loop
.create_window(WindowAttributes::default().with_title("llimphi · vello-hello"))
.map_err(|e| format!("create_window: {e}"))?;
let window = Arc::new(window);
log::info!("[boot] 2/8 wgpu::Instance");
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
..Default::default()
});
log::info!("[boot] 3/8 Surface (única create_surface en este boot)");
let surface = instance
.create_surface(window.clone())
.map_err(|e| format!("create_surface: {e}"))?;
log::info!("[boot] 4/8 Adapter compatible con surface");
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
}))
.ok_or_else(|| "request_adapter → None".to_string())?;
let info = adapter.get_info();
log::info!(
"[boot] adapter ok · {:?} · {} · {:?}",
info.backend,
info.name,
info.driver_info
);
log::info!("[boot] 5/8 Device + Queue");
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("vello-hello-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
))
.map_err(|e| format!("request_device: {e}"))?;
log::info!("[boot] 6/8 Hal");
let hal = Hal {
instance,
adapter,
device,
queue,
};
log::info!("[boot] 7/8 WinitSurface::from_surface");
let surface = WinitSurface::from_surface(&hal, window.clone(), surface)
.map_err(|e| format!("WinitSurface: {e}"))?;
log::info!("[boot] 8/8 vello Renderer");
let renderer =
Renderer::new(&hal).map_err(|e| format!("Renderer::new: {e}"))?;
log::info!("[boot] ✓ stack raster listo, primer redraw");
window.request_redraw();
Ok(State {
window,
hal,
surface,
renderer,
scene: vello::Scene::new(),
})
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
log::info!("Resumed");
match self.boot(event_loop) {
Ok(s) => self.state = Some(s),
Err(e) => log::error!("BOOT FAILED: {e}"),
}
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
log::info!("Suspended — liberando state");
self.state = None;
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
log::info!("Resized → {}x{}", size.width, size.height);
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(e) => {
log::warn!("acquire {e}, reconfig");
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let (w, h) = frame.size();
let t = self.started.elapsed().as_secs_f64();
state.scene.reset();
build_chacana(&mut state.scene, w as f64, h as f64, t);
if let Err(e) = state.renderer.render(
&state.hal,
&state.scene,
&frame,
COSMOS_NIGHT,
) {
log::error!("render: {e}");
}
state.surface.present(frame, &state.hal);
self.frames += 1;
let elapsed = self.last_report.elapsed();
if elapsed.as_secs() >= 1 {
let fps = self.frames as f64 / elapsed.as_secs_f64();
log::info!("{fps:.1} fps · {w}x{h}");
self.frames = 0;
self.last_report = Instant::now();
}
state.window.request_redraw();
}
_ => {}
}
}
}
/// Construye la chacana (cruz andina escalonada) animada, centrada en el
/// viewport. El sol central late con sin(t); cuatro rayos cardinales
/// rotan en una vuelta cada 12 s; halo cyan constante.
fn build_chacana(scene: &mut vello::Scene, w: f64, h: f64, t: f64) {
let cx = w * 0.5;
let cy = h * 0.5;
let unit = (w.min(h)) * 0.06; // tamaño de la escala de la cruz
// Halo radial (anillo cyan suave)
scene.stroke(
&Stroke::new(2.0),
Affine::IDENTITY,
Color::from_rgba8(0xA6, 0xD8, 0xFF, 80),
None,
&Circle::new((cx, cy), unit * 4.6),
);
scene.stroke(
&Stroke::new(1.0),
Affine::IDENTITY,
Color::from_rgba8(0xA6, 0xD8, 0xFF, 140),
None,
&Circle::new((cx, cy), unit * 4.0),
);
// Rayos cardinales rotantes (4 trazos a 90°)
let theta = t * (std::f64::consts::TAU / 12.0); // 1 vuelta cada 12 s
let rotate = Affine::translate((cx, cy)) * Affine::rotate(theta);
for i in 0..4 {
let angle = i as f64 * std::f64::consts::FRAC_PI_2;
let dir = (angle.cos(), angle.sin());
let mut p = BezPath::new();
p.move_to((dir.0 * unit * 3.2, dir.1 * unit * 3.2));
p.line_to((dir.0 * unit * 4.4, dir.1 * unit * 4.4));
scene.stroke(
&Stroke::new(1.5),
rotate,
ACCENT_BLUE,
None,
&p,
);
}
// Chacana: cruz escalonada de 12 puntas. Construida como BezPath.
// La forma clásica: cuadrado central + escalones en 4 direcciones.
let chacana = chacana_path(unit);
let center = Affine::translate((cx, cy));
// Glow ambar exterior
scene.stroke(
&Stroke::new(6.0),
center,
Color::from_rgba8(0xE8, 0xC9, 0x7A, 110),
None,
&chacana,
);
// Outline cyan
scene.stroke(
&Stroke::new(2.0),
center,
ACCENT_CYAN,
None,
&chacana,
);
// Relleno violeta tenue
scene.fill(
Fill::NonZero,
center,
Color::from_rgba8(0xC3, 0x9C, 0xE8, 40),
None,
&chacana,
);
// Sol central que late
let pulse = 1.0 + 0.18 * (t * 1.8).sin();
let r_sun = unit * 0.7 * pulse;
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
ACCENT_AMBER,
None,
&Circle::new((cx, cy), r_sun),
);
// Corona
scene.stroke(
&Stroke::new(1.0),
Affine::IDENTITY,
Color::from_rgba8(0xE8, 0xC9, 0x7A, 120),
None,
&Circle::new((cx, cy), r_sun * 1.7),
);
// Punto interior violeta para contraste
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
ACCENT_VIOLET,
None,
&Circle::new((cx, cy), r_sun * 0.35),
);
}
/// Path de la chacana centrada en el origen, con `u` como ancho de cada
/// escalón. Reconstruye la forma clásica de 12 esquinas escalonadas
/// (3 escalones por cada brazo cardinal).
fn chacana_path(u: f64) -> BezPath {
let mut p = BezPath::new();
// Empezamos en la esquina superior-derecha del brazo norte y vamos
// en sentido horario alrededor de toda la cruz.
p.move_to((u, 3.0 * u));
p.line_to((u, u));
p.line_to((3.0 * u, u));
p.line_to((3.0 * u, -u));
p.line_to((u, -u));
p.line_to((u, -3.0 * u));
p.line_to((-u, -3.0 * u));
p.line_to((-u, -u));
p.line_to((-3.0 * u, -u));
p.line_to((-3.0 * u, u));
p.line_to((-u, u));
p.line_to((-u, 3.0 * u));
p.close_path();
p
}
#[cfg(target_os = "android")]
fn install_panic_logger() {
std::panic::set_hook(Box::new(|info| {
let payload = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("<unknown>");
let loc = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "<?>".into());
log::error!("PANIC at {loc} — {payload}");
}));
}
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: android_activity::AndroidApp) {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag(TAG),
);
install_panic_logger();
log::info!("android_main START");
use llimphi_hal::winit::event_loop::EventLoopBuilder;
use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid;
let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build()
{
Ok(el) => el,
Err(e) => {
log::error!("EventLoop: {e}");
return;
}
};
event_loop.set_control_flow(ControlFlow::Poll);
let mut handler = App::new();
if let Err(e) = event_loop.run_app(&mut handler) {
log::error!("run_app: {e}");
}
}
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "vello-text-android"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Tier 1.75 Android: parley + vello + llimphi-text rasterizando texto multi-script con fallback CJK/Arabic via fontique."
[lib]
crate-type = ["cdylib"]
[dependencies]
llimphi-hal = { path = "../../llimphi-hal" }
llimphi-raster = { path = "../../llimphi-raster" }
llimphi-text = { path = "../../llimphi-text" }
winit = { workspace = true, features = ["android-native-activity"] }
wgpu.workspace = true
vello.workspace = true
pollster.workspace = true
# `log` se declara aquí (no en el bloque condicional Android) para que
# `cargo check --workspace` en host pase: los macros de `log` son no-op
# sin logger instalado. En Android, `android_logger` (más abajo) instala
# el sink real hacia `logcat`.
log = "0.4"
[target.'cfg(target_os = "android")'.dependencies]
android-activity = { version = "0.6", features = ["native-activity"] }
android_logger = "0.14"
[package.metadata.android]
package = "net.gioser.llimphi.vellotext"
build_targets = ["aarch64-linux-android", "x86_64-linux-android"]
min_sdk_version = 24
target_sdk_version = 34
[package.metadata.android.application]
label = "Llimphi · vello-text"
debuggable = true
[package.metadata.android.application.activity]
config_changes = "orientation|screenSize|keyboardHidden"
launch_mode = "singleTop"
orientation = "unspecified"
+11
View File
@@ -0,0 +1,11 @@
# vello-text-android
> Text shaping Android de [llimphi](../../README.md).
Dibuja texto con `vello` + `fontdue` sobre Android. Tercer hito: confirma que [`llimphi-text`](../../llimphi-text/README.md) shapea correctamente con DPI de móvil.
## Build
```sh
cargo apk build -p vello-text-android
```
+11
View File
@@ -0,0 +1,11 @@
# vello-text-android
> Android text shaping of [llimphi](../../README.md).
Draws text with `vello` + `fontdue` on Android. Third milestone: confirms [`llimphi-text`](../../llimphi-text/README.md) shapes correctly with mobile DPI.
## Build
```sh
cargo apk build -p vello-text-android
```
+406
View File
@@ -0,0 +1,406 @@
//! Tier 1.75 Android: texto multi-script con parley + vello + llimphi-text.
//!
//! Verifica que en Android funciona:
//! - parley::FontContext::new() resolviendo fuentes via fontique sobre
//! /system/fonts (Roboto + Noto fallback CJK/Arabic vienen en todas
//! las builds AOSP).
//! - shaping con kerning, ligaduras, bidi, fallback inter-script en
//! una misma línea.
//! - rasterización de glifos por vello::Scene::draw_glyphs (compute
//! pipeline sobre la intermediate Rgba8).
//!
//! Si esta corre estable y se ven los tres scripts (latino, arábigo,
//! CJK) sin tofu (cuadrados vacíos), llimphi-ui está habilitado en
//! Android — el resto de las apps (text-viewer, file-explorer,
//! pluma-md-reader) usan exactamente esta misma pipa.
//!
//! El factor de scale por DPI se calcula desde el `inner_size` real
//! del Window que Android nos pasa (ya incluye la densidad del
//! display). En desktop el window es 960x540 lógico; en mobile típico
//! es ~1080x2400 físico → fuentes 2-3× más grandes para legibilidad.
use std::sync::Arc;
use std::time::Instant;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{wgpu, Hal, Surface, WinitSurface};
use llimphi_raster::peniko::Color;
use llimphi_raster::vello;
use llimphi_text::{draw_block, Alignment, TextBlock, Typesetter};
const TAG: &str = "llimphi-text";
const COSMOS_NIGHT: Color = Color::from_rgba8(0x0E, 0x10, 0x16, 255);
const FG_TEXT: Color = Color::from_rgba8(0xD6, 0xDE, 0xE8, 255);
const FG_MUTED: Color = Color::from_rgba8(0x8C, 0x98, 0xAA, 255);
const ACCENT: Color = Color::from_rgba8(0x6E, 0x8C, 0xDC, 255);
const AMBER: Color = Color::from_rgba8(0xE8, 0xC9, 0x7A, 255);
const PARRAFO: &str = "Llimphi pinta vector preciso sobre el silicio: \
geometrías exactas, sin cajas negras. شكراً 你好 こんにちは — el shaping \
de parley maneja kerning, ligaduras y fallback CJK/Árabe en la misma \
línea, resuelto por fontique sobre las fuentes Noto de Android.";
const TECNICO: &str = "stack: wgpu(Vulkan) → llimphi-hal → vello compute → \
parley shaping → fontique fallback. APK firmado v2, ~7 MB stripped.";
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
renderer: llimphi_raster::Renderer,
scene: vello::Scene,
typesetter: Typesetter,
}
struct App {
state: Option<State>,
frames: u64,
last_report: Instant,
/// `None` antes del primer present; al loguearse pasa a `Some` para
/// no spamear. Mide el tiempo "tiempo en pantalla" real del usuario.
first_paint: Option<Instant>,
started: Instant,
}
impl App {
fn new() -> Self {
Self {
state: None,
frames: 0,
last_report: Instant::now(),
first_paint: None,
started: Instant::now(),
}
}
fn boot(&self, event_loop: &ActiveEventLoop) -> Result<State, String> {
// Timings paso a paso — Android tarda 3-5s en el cold-start,
// queremos saber si es vello shader compile, fontique scan,
// request_device, o el primer render. `step` toma el delta
// desde la marca anterior y lo loguea.
let t0 = Instant::now();
let mut tprev = t0;
let mut step = |name: &str| {
let now = Instant::now();
let dt = now.duration_since(tprev);
let total = now.duration_since(t0);
log::info!(
"[boot+{:>5}ms] {} (+{}ms)",
total.as_millis(),
name,
dt.as_millis()
);
tprev = now;
};
step("0/9 START");
let window = event_loop
.create_window(WindowAttributes::default().with_title("llimphi · vello-text"))
.map_err(|e| format!("create_window: {e}"))?;
let window = Arc::new(window);
let size = window.inner_size();
step(&format!("1/9 Window {}x{}", size.width, size.height));
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
..Default::default()
});
step("2/9 wgpu::Instance");
let surface = instance
.create_surface(window.clone())
.map_err(|e| format!("create_surface: {e}"))?;
step("3/9 Surface");
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
}))
.ok_or_else(|| "request_adapter → None".to_string())?;
let info = adapter.get_info();
step(&format!("4/9 Adapter {:?} {}", info.backend, info.name));
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("vello-text-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
))
.map_err(|e| format!("request_device: {e}"))?;
step("5/9 Device + Queue");
let hal = Hal {
instance,
adapter,
device,
queue,
};
step("6/9 Hal armado");
let surface = WinitSurface::from_surface(&hal, window.clone(), surface)
.map_err(|e| format!("WinitSurface: {e}"))?;
step("7/9 WinitSurface::from_surface");
// Sospechoso #1: vello compila ~20 shaders WGSL + crea pipelines
// de compute. En desktop ~150ms; en Adreno entry-level estimamos
// 1-3s. Si es esto, la solución es pipeline_cache persistente.
let renderer =
llimphi_raster::Renderer::new(&hal).map_err(|e| format!("Renderer: {e}"))?;
step("8/9 vello Renderer (shaders + pipelines)");
// Sospechoso #2: fontique escanea /system/fonts y parsea cada
// TTF/OTF para indexar metadata (family, style, scripts).
// Android tiene ~50-80 fuentes Noto + Roboto.
let typesetter = Typesetter::new();
step("9/9 Typesetter (fontique scan /system/fonts)");
log::info!(
"[boot ✓ total {}ms] stack texto listo",
t0.elapsed().as_millis()
);
window.request_redraw();
Ok(State {
window,
hal,
surface,
renderer,
scene: vello::Scene::new(),
typesetter,
})
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
log::info!("Resumed");
match self.boot(event_loop) {
Ok(s) => self.state = Some(s),
Err(e) => log::error!("BOOT FAILED: {e}"),
}
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
log::info!("Suspended");
self.state = None;
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(e) => {
log::warn!("acquire {e}");
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let (w, h) = frame.size();
state.scene.reset();
paint_page(&mut state.scene, &mut state.typesetter, w, h);
if let Err(e) = state.renderer.render(
&state.hal,
&state.scene,
&frame,
COSMOS_NIGHT,
) {
log::error!("render: {e}");
}
state.surface.present(frame, &state.hal);
if self.first_paint.is_none() {
let elapsed = self.started.elapsed();
log::info!(
"[FIRST PAINT] {}ms desde android_main START",
elapsed.as_millis()
);
self.first_paint = Some(Instant::now());
}
self.frames += 1;
if self.last_report.elapsed().as_secs() >= 2 {
let fps = self.frames as f64 / self.last_report.elapsed().as_secs_f64();
log::info!("{fps:.1} fps · {w}x{h}");
self.frames = 0;
self.last_report = Instant::now();
}
// No request_redraw: el texto es estático, evita drenar batería.
}
_ => {}
}
}
}
/// Pinta la página completa de texto. Escala las fuentes proporcionales al
/// ancho del viewport: en mobile (1080+ px) el texto queda ~1.4× más
/// grande que en desktop (960 px) — lectura cómoda con device a 30 cm.
fn paint_page(scene: &mut vello::Scene, ts: &mut Typesetter, w: u32, h: u32) {
// Escala lineal sobre el ancho del viewport. base = 1080 px → factor 1.0.
let scale = (w as f32 / 1080.0).clamp(0.6, 2.4);
let margin_x = (w as f64 * 0.06).max(24.0);
let margin_y = (h as f64 * 0.08).max(32.0);
let inner_w = (w as f32 - 2.0 * margin_x as f32).max(160.0);
// Título grande
draw_block(
scene,
ts,
&TextBlock {
text: "Llimphi",
size_px: 96.0 * scale,
color: FG_TEXT,
origin: (margin_x, margin_y),
max_width: Some(inner_w),
alignment: Alignment::Center,
line_height: 1.0,
italic: false,
font_family: None,
},
);
// Subtítulo en accent
draw_block(
scene,
ts,
&TextBlock {
text: "texto multi-script sobre Android",
size_px: 22.0 * scale,
color: ACCENT,
origin: (margin_x, margin_y + (110.0 * scale as f64)),
max_width: Some(inner_w),
alignment: Alignment::Center,
line_height: 1.0,
italic: false,
font_family: None,
},
);
// Línea separadora dorada (un guion largo en amber)
draw_block(
scene,
ts,
&TextBlock {
text: "",
size_px: 32.0 * scale,
color: AMBER,
origin: (margin_x, margin_y + (155.0 * scale as f64)),
max_width: Some(inner_w),
alignment: Alignment::Center,
line_height: 1.0,
italic: false,
font_family: None,
},
);
// Párrafo justificado con scripts mixtos
draw_block(
scene,
ts,
&TextBlock {
text: PARRAFO,
size_px: 22.0 * scale,
color: FG_TEXT,
origin: (margin_x, margin_y + (220.0 * scale as f64)),
max_width: Some(inner_w),
alignment: Alignment::Justify,
line_height: 1.5,
italic: false,
font_family: None,
},
);
// Pie técnico mute
draw_block(
scene,
ts,
&TextBlock {
text: TECNICO,
size_px: 16.0 * scale,
color: FG_MUTED,
origin: (margin_x, h as f64 - margin_y - (50.0 * scale as f64)),
max_width: Some(inner_w),
alignment: Alignment::Start,
line_height: 1.3,
italic: false,
font_family: None,
},
);
}
#[cfg(target_os = "android")]
fn install_panic_logger() {
std::panic::set_hook(Box::new(|info| {
let payload = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("<unknown>");
let loc = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "<?>".into());
log::error!("PANIC at {loc} — {payload}");
}));
}
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: android_activity::AndroidApp) {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag(TAG),
);
install_panic_logger();
log::info!("android_main START");
use llimphi_hal::winit::event_loop::EventLoopBuilder;
use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid;
let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build()
{
Ok(el) => el,
Err(e) => {
log::error!("EventLoop: {e}");
return;
}
};
// Wait (no Poll): el texto es estático, el redraw lo dispara
// Resized/Resumed. Ahorra batería vs vello-hello que anima.
event_loop.set_control_flow(ControlFlow::Wait);
let mut handler = App::new();
if let Err(e) = event_loop.run_app(&mut handler) {
log::error!("run_app: {e}");
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "llimphi-compositor"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-compositor — el núcleo declarativo de Llimphi sin winit: el árbol `View<Msg>`, el mount sobre taffy, el paint a `vello::Scene` y el hit-test. No depende de llimphi-hal ni de una surface concreta, así que la misma composición sirve sobre winit (llimphi-ui) o, a futuro, sobre el framebuffer del kernel wawa. `wgpu` entra sólo por la firma de `GpuPaintFn` (tipos, no windowing)."
[dependencies]
llimphi-layout = { path = "../llimphi-layout" }
llimphi-text = { path = "../llimphi-text" }
vello = { workspace = true }
# Sólo para los tipos de la firma de GpuPaintFn (Device/Queue/Encoder/View).
# wgpu NO depende de winit — el compositor sigue libre de windowing.
wgpu = { workspace = true }
+348
View File
@@ -0,0 +1,348 @@
//! llimphi-compositor — el núcleo declarativo de Llimphi, sin winit.
//!
//! Aquí vive el árbol de vista `View<Msg>` (DSL declarativo), su instalación
//! sobre taffy (`mount`), el pintado a `vello::Scene` (`paint`/`paint_gpu`) y
//! el hit-test. Nada de esto necesita una ventana ni `llimphi-hal`: la
//! composición `view → layout → scene` es pura y reutilizable.
//!
//! El runtime que la maneja vive aparte:
//! - `llimphi-ui` la corre sobre winit (`run<A: App>()`).
//! - a futuro, un runtime sobre el framebuffer del kernel `wawa` puede
//! reusar exactamente este compositor sin arrastrar winit.
//!
//! `wgpu` entra sólo por la firma de [`GpuPaintFn`] (tipos de Device/Queue/
//! Encoder/TextureView); `wgpu` no depende de winit, así que el compositor
//! sigue libre de windowing.
use std::collections::HashMap;
use std::sync::Arc;
use llimphi_layout::taffy::NodeId;
use llimphi_layout::{ComputedLayout, LayoutTree, Style};
use vello::kurbo::{Affine, Point, Rect as KurboRect, RoundedRect};
use vello::peniko::{Color, Fill, Image, Mix};
mod render;
mod view;
pub use render::*;
/// Texto a pintar dentro de un nodo. Alineación por defecto `Center`
/// (horizontal y vertical), apta para labels de botón. Para layouts tipo
/// editor o párrafo, usar `.text_aligned(...)` con `Alignment::Start`.
pub struct TextSpec {
pub content: String,
pub size_px: f32,
pub color: Color,
pub alignment: llimphi_text::Alignment,
/// `true` = forzar variante italic en la fuente activa. Default false.
pub italic: bool,
/// CSS-style font-family string (acepta lista con fallbacks). `None`
/// = la fuente default de parley.
pub font_family: Option<String>,
/// Múltiplo de interlínea (`line-height` / `font-size`). 1.2 es el
/// default que usaban todos los callers; puriy lo sobreescribe con el
/// valor computado de CSS. Se usa tanto al **medir** (para que taffy
/// reserve el alto correcto) como al **pintar**, así medida y dibujo
/// coinciden.
pub line_height: f32,
/// Colores por rango de **bytes** sobre `content`, para texto multicolor
/// (syntax highlighting) en una sola pasada de shaping. `None` = color
/// uniforme (`color`). Cuando es `Some`, el runtime usa
/// `Typesetter::layout_runs` + `draw_layout_runs`, y `color` actúa como
/// color por defecto de lo no cubierto por ningún run.
pub runs: Option<Vec<(usize, usize, Color)>>,
}
/// Fase de un drag activo. `Move` se emite por cada `CursorMoved` con el
/// delta desde el evento anterior; `End` se emite al soltar el botón.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DragPhase {
Move,
End,
}
/// Handler de drag. Recibe la fase + delta (`dx`, `dy`) **desde el evento
/// anterior** (no acumulado desde el press). Devolver `None` deja el drag
/// activo sin disparar Msg. `Arc<dyn Fn>` para que el runtime pueda
/// clonarlo barato al iniciar el drag y mantenerlo vivo aunque el cache
/// de la vista se regenere mientras tanto.
pub type DragFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
/// Handler de drop. El runtime lo invoca cuando un drag activo se suelta
/// sobre este nodo. Recibe el `payload` `u64` que el origen del drag
/// declaró vía [`View::drag_payload`]. Devolver `None` ignora el drop.
///
/// Los IDs `u64` son opacos para el runtime: el widget elige una
/// convención (índice de tile, hash del item, etc.) y el handler decide
/// qué Msg emitir en función de ese ID.
pub type DropFn<Msg> = Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>;
/// Handler de click con posición. Recibe `(x_local, y_local, rect_w,
/// rect_h)`: las dos primeras son la posición del cursor **relativa a
/// la esquina superior-izquierda del nodo** y las dos últimas son el
/// ancho/alto actual del nodo en pixels — útil cuando el caller
/// necesita centrar o normalizar. Devolver `None` no dispara update.
pub type ClickAtFn<Msg> = Arc<dyn Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;
/// Handler de rueda **local a un nodo**. Recibe el delta `(dx, dy)` en
/// líneas lógicas (misma normalización que `App::on_wheel`: `dy` positivo
/// = scroll hacia abajo). El runtime lo invoca cuando la rueda gira con el
/// cursor sobre este nodo, ANTES de caer al `App::on_wheel` global: si el
/// handler devuelve `Some(Msg)`, el evento se consume acá. Permite áreas
/// de scroll autocontenidas (el widget `scroll` lo usa) sin que cada app
/// rutee la rueda a mano por su `Model`. Devolver `None` deja pasar el
/// evento al `on_wheel` global.
pub type ScrollFn<Msg> = Arc<dyn Fn(f32, f32) -> Option<Msg> + Send + Sync>;
/// Variante de [`DragFn`] que **conoce la posición inicial del press**
/// relativa al rect del nodo. Útil cuando el caller necesita identificar
/// qué entidad (Concepto, lemming, etc.) bajo el cursor agarró el drag.
/// Recibe `(phase, dx, dy, initial_lx, initial_ly)`.
pub type DragAtFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;
/// Rect absoluto del nodo (en coordenadas físicas del frame). Lo
/// recibe el callback de [`View::paint_with`] para que pueda
/// posicionar sus primitivas custom dentro del nodo.
#[derive(Debug, Clone, Copy, Default)]
pub struct PaintRect {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
/// Callback de pintura custom. El runtime lo invoca durante el paint
/// del nodo (entre el `fill`/`image` y el `text`) con el `Scene` vivo
/// + el `Typesetter` cacheado del runtime + el rect absoluto del nodo.
/// Pensado para "canvas elements" tipo `dominium-canvas`,
/// `pluma-editor` (osciloscopio de coherencia), `cosmos` (charts).
///
/// El `Typesetter` se pasa porque crearlo por frame es caro
/// (`FontContext::new` enumera las fontes del sistema vía fontique).
/// Los callers que no necesiten texto pueden ignorar el argumento.
///
/// El callback no debe llamar a `scene.push_layer` sin un `pop_layer`
/// correspondiente, ni reset el scene — sólo agregar primitivas que
/// pertenezcan al rect del nodo.
pub type PaintFn = Arc<
dyn Fn(&mut vello::Scene, &mut llimphi_text::Typesetter, PaintRect) + Send + Sync,
>;
/// Callback de pintura GPU directo, sin vello intermedio. Recibe el
/// `device`/`queue` ya construidos por el runtime más un
/// `CommandEncoder` y la `TextureView` del frame (la intermediate
/// `Rgba8Unorm` de `WinitSurface`), todo durante el paint del nodo.
///
/// El caller abre su propio `begin_render_pass` con `LoadOp::Load` para
/// no sobrescribir lo que ya pintó vello, dibuja sus primitivas y
/// cierra el pass. El runtime se encarga de dispatchear (`queue.submit`)
/// el encoder ya con todas las pasadas de todos los nodos acumuladas —
/// es un solo submit por frame.
///
/// **Orden de pintura en Fase 1**: todos los `gpu_painter` corren
/// DESPUÉS de la pasada completa de vello (fill, image, painter,
/// text) sobre el `mounted` tree. Entre sí mantienen el orden DFS
/// pre-orden. Si una app necesita pintar texto **encima** del render
/// GPU directo, la forma idiomática es ponerlo en `App::view_overlay`,
/// que se renderiza como una segunda Scene de vello encima de todo.
///
/// Pensado para apps con volumen masivo de primitivos (cosmos
/// starfield Gaia, tinkuy particle viewer, nakui viewport, pineal
/// denso) — el hook que paga el costo de mantener pipelines WGSL
/// propias en `llimphi-raster` (ver `02_ruway/llimphi/SDD.md`
/// §"Roadmap — GPU directo wgpu").
pub type GpuPaintFn = Arc<
dyn Fn(
&wgpu::Device,
&wgpu::Queue,
&mut wgpu::CommandEncoder,
&wgpu::TextureView,
PaintRect,
(u32, u32),
) + Send
+ Sync,
>;
/// Nodo de la vista declarativa. Estilo de layout (taffy) + relleno opcional
/// (vello) + texto opcional (skrifa+vello) + Msg al click opcional + hijos.
pub struct View<Msg> {
pub style: Style,
pub fill: Option<Color>,
/// Relleno cuando el cursor está sobre este nodo. Sin valor (`None`)
/// = no se reacciona al hover.
pub hover_fill: Option<Color>,
pub radius: f64,
pub text: Option<TextSpec>,
/// Imagen a pintar dentro del rect del nodo. Se centra y escala
/// preservando aspect ratio (`min(rect.w/img.w, rect.h/img.h)`).
/// El alfa por píxel de la imagen y el `Image::alpha` global se
/// respetan; el `fill` (si lo hay) se pinta debajo como background.
pub image: Option<Image>,
/// Callback de pintura custom. Si está presente, el runtime lo
/// invoca durante el paint del nodo con el `Scene` vivo + el rect
/// absoluto. Pensado para "canvas elements" (dominium, pluma,
/// cosmos) que pintan primitivas custom no expresables como una
/// composición de Views.
pub painter: Option<PaintFn>,
/// Pintor GPU directo. Se invoca DESPUÉS de la pasada vello del
/// frame; comparte tree y orden DFS con los demás. Ver
/// [`GpuPaintFn`].
pub gpu_painter: Option<GpuPaintFn>,
pub on_click: Option<Msg>,
/// Handler de click que recibe la posición **relativa al rect del
/// nodo** (esquina superior-izquierda del nodo = `(0, 0)`). Útil
/// para canvas elements que quieren mapear el click a coordenadas
/// de mundo. Si está presente, gana sobre `on_click`. Devolver
/// `None` no dispara update.
pub on_click_at: Option<ClickAtFn<Msg>>,
/// Equivalente a `on_click` pero para el botón derecho del ratón.
/// Pensado para menús contextuales: el nodo declara qué `Msg`
/// emitir cuando se le hace right-click, y la app abre el overlay
/// con el menú.
pub on_right_click: Option<Msg>,
/// Variante posicional de [`Self::on_right_click`]. Útil para
/// grillas que necesitan saber *qué celda* del rect recibió el
/// click derecho (la celda no es un nodo aparte, sino una región
/// dentro del nodo). Si está presente, gana sobre `on_right_click`.
pub on_right_click_at: Option<ClickAtFn<Msg>>,
/// Equivalente a `on_click` pero para el botón del medio del ratón
/// (rueda presionada). Pensado para abrir en pestaña nueva — los
/// browsers usan middle-click como atajo equivalente a Ctrl+Click.
pub on_middle_click: Option<Msg>,
/// Handler de drag. Si está presente, este nodo arrastra (y NO emite
/// `on_click` al presionar — un nodo es uno u otro).
pub drag: Option<DragFn<Msg>>,
/// Variante de drag que recibe la posición inicial del press relativa
/// al rect del nodo. Gana sobre `drag` si ambos están presentes.
pub drag_at: Option<DragAtFn<Msg>>,
/// Payload `u64` que viaja con el drag iniciado sobre este nodo. Lo
/// recibe el handler [`Self::on_drop`] del drop target. Sin payload,
/// el drag funciona igual pero ningún drop target reacciona.
pub drag_payload: Option<u64>,
/// Handler invocado al soltar un drag sobre este nodo (drop target).
pub on_drop: Option<DropFn<Msg>>,
/// Color a pintar mientras un drag activo está hovereando este drop
/// target. Sobrepone a `fill`/`hover_fill` cuando aplica.
pub drop_hover_fill: Option<Color>,
/// Si `true`, los descendientes se recortan al rect del nodo (vía
/// `scene.push_layer` con `Mix::Clip`). El hit-test también respeta
/// el recorte: clicks fuera del rect ignoran a los hijos.
pub clip: bool,
/// Msg a emitir cuando el cursor entra al rect del nodo (transición
/// no-hover → hover). Útil para previews tipo "URL del link al
/// pasar el mouse".
pub on_pointer_enter: Option<Msg>,
/// Msg a emitir cuando el cursor sale del rect del nodo.
pub on_pointer_leave: Option<Msg>,
/// Handler de rueda local. Si está presente y el cursor cae sobre este
/// nodo, el runtime lo invoca antes del `App::on_wheel` global; un
/// `Some(Msg)` consume el evento. Base de las áreas de scroll
/// autocontenidas. Ver [`ScrollFn`].
pub on_scroll: Option<ScrollFn<Msg>>,
/// Marca este nodo como **enfocable** con el id opaco `u64`. El runtime
/// mantiene el foco (uno por ventana) y lo mueve con Tab/Shift+Tab en
/// orden de árbol (pre-orden) y al clickear un nodo enfocable; notifica
/// a la app vía `App::on_focus` para que pinte el ring y rutee el
/// teclado. El id lo elige el caller (índice de campo, hash, etc.).
pub focusable: Option<u64>,
/// Opacidad multiplicada sobre TODO el subtree (este nodo + hijos),
/// en `[0.0, 1.0]`. Se realiza con `scene.push_layer(Mix::Normal, a, …)`
/// alrededor del rect del nodo: el subárbol se rasteriza en una capa
/// intermedia y se compone al alfa indicado contra lo que ya hay
/// detrás. `None` = sin capa (caso de la abrumadora mayoría de
/// nodos). Útil para fade-in/out de overlays, ghosts mientras se
/// arrastra, modales que aparecen, panels "vidrio". Note que la
/// composición tiene costo (allocate + blit), por lo que sólo
/// poblar este slot cuando hace falta — no es un atributo gratis.
pub alpha: Option<f32>,
/// Transformación afín 2D aplicada a este nodo y todo su subtree
/// **alrededor del centro de su propio rect** (convención CSS
/// `transform-origin: 50% 50%`). El runtime resuelve el centro en
/// `paint` (sólo entonces conoce el layout computado) y compone
/// `T(centro) · transform · T(-centro)` sobre la transformación
/// acumulada del padre, así nodos anidados transforman en el espacio
/// ya transformado de su ancestro — igual que CSS. `None` = identidad
/// (la abrumadora mayoría de nodos). Pensado para `transform`/
/// `@keyframes` CSS de puriy (rotate/scale/translate). El hit-test
/// **respeta** el afín (un nodo transformado recibe clicks donde se ve
/// pintado). Limitación restante: los `painter`/`runs` custom no heredan
/// el afín, y la posición local que reciben los handlers `*_at` se
/// reporta en espacio de pantalla, no en el espacio local del nodo.
pub transform: Option<Affine>,
/// Texto de **tooltip**: si está, el runtime/cliente puede mostrar un
/// rótulo flotante cuando el cursor se posa sobre este nodo. Llimphi sólo
/// transporta el dato hasta el [`MountedNode`]; *quién* lo pinta (un overlay
/// del runtime, una surface popup del cliente) lo decide el consumidor. El
/// hit-test de hover ya localiza el nodo bajo el cursor. `None` = sin tip.
pub tooltip: Option<String>,
pub children: Vec<View<Msg>>,
}
/// Versión "instalada" del árbol: cada nodo tiene su NodeId de taffy, color
/// y handler. Se mantiene en orden de inserción (recorrido pre-orden), así
/// el hit-test puede iterar al revés para honrar el orden de pintado.
///
/// `pub` (con campos `pub`) porque el runtime (llimphi-ui) lee el árbol
/// montado para hit-test y para la pasada GPU directa, pero vive en otro
/// crate. No se construye fuera de [`mount`].
pub struct Mounted<Msg> {
pub root: NodeId,
pub nodes: Vec<MountedNode<Msg>>,
/// Contenido de texto por nodo-hoja, para que el runtime lo mida con
/// parley durante `compute_with_measure` y taffy reserve el alto real
/// del texto envuelto (varias líneas) en vez de una sola. Sin esto un
/// párrafo que envuelve a N líneas se aplastaría en la altura de una
/// (el bug clásico de "textos aplastados"). Sólo se pueblan hojas con
/// texto uniforme (sin `runs` multicolor, que el caller dimensiona).
pub text_measures: HashMap<NodeId, TextMeasure>,
}
/// Datos de un nodo-hoja de texto necesarios para medirlo (shaping +
/// line-break) sin volver a tocar el `View`. Lo consume el runtime en la
/// función de medición que le pasa a [`LayoutTree::compute_with_measure`].
#[derive(Clone)]
pub struct TextMeasure {
pub content: String,
pub size_px: f32,
pub alignment: llimphi_text::Alignment,
pub italic: bool,
pub font_family: Option<String>,
pub line_height: f32,
}
pub struct MountedNode<Msg> {
pub id: NodeId,
pub fill: Option<Color>,
pub hover_fill: Option<Color>,
pub radius: f64,
pub text: Option<TextSpec>,
pub image: Option<Image>,
pub painter: Option<PaintFn>,
pub gpu_painter: Option<GpuPaintFn>,
pub on_click: Option<Msg>,
pub on_click_at: Option<ClickAtFn<Msg>>,
pub on_right_click: Option<Msg>,
pub on_right_click_at: Option<ClickAtFn<Msg>>,
pub on_middle_click: Option<Msg>,
pub drag: Option<DragFn<Msg>>,
pub drag_at: Option<DragAtFn<Msg>>,
pub drag_payload: Option<u64>,
pub on_drop: Option<DropFn<Msg>>,
pub drop_hover_fill: Option<Color>,
pub clip: bool,
pub on_pointer_enter: Option<Msg>,
pub on_pointer_leave: Option<Msg>,
pub on_scroll: Option<ScrollFn<Msg>>,
pub focusable: Option<u64>,
pub alpha: Option<f32>,
/// Transformación afín 2D del nodo (alrededor del centro de su rect).
/// Ver [`View::transform`]. `paint` la compone con la del padre.
pub transform: Option<Affine>,
/// Texto de tooltip de este nodo (ver [`View::tooltip`]). El consumidor lo
/// lee tras un hit-test de hover para pintar el rótulo flotante.
pub tooltip: Option<String>,
/// Índice (exclusivo) del fin del subárbol en `Mounted::nodes`. Los
/// descendientes ocupan `[idx + 1, subtree_end)`. Hace de "barrera" en
/// paint/hit_test para `pop_layer` y para saltar subárboles enteros.
pub subtree_end: usize,
}
+705
View File
@@ -0,0 +1,705 @@
use super::*;
pub fn mount<Msg: Clone>(layout: &mut LayoutTree, v: View<Msg>) -> Mounted<Msg> {
let mut nodes = Vec::new();
let mut text_measures = std::collections::HashMap::new();
let root = mount_recursive(layout, v, &mut nodes, &mut text_measures);
Mounted { root, nodes, text_measures }
}
/// Mount en pre-orden directo sobre `out`: pusheamos el padre como
/// placeholder (id real desconocido hasta crear el taffy node), recursamos
/// hijos sobre el mismo `out`, y al volver completamos `id` + `subtree_end`.
pub fn mount_recursive<Msg: Clone>(
layout: &mut LayoutTree,
v: View<Msg>,
out: &mut Vec<MountedNode<Msg>>,
text_measures: &mut std::collections::HashMap<NodeId, TextMeasure>,
) -> NodeId {
let View {
style,
fill,
hover_fill,
radius,
text,
image,
painter,
gpu_painter,
on_click,
on_click_at,
on_right_click,
on_right_click_at,
on_middle_click,
drag,
drag_at,
drag_payload,
on_drop,
drop_hover_fill,
clip,
on_pointer_enter,
on_pointer_leave,
on_scroll,
focusable,
alpha,
transform,
tooltip,
children,
} = v;
let parent_idx = out.len();
out.push(MountedNode {
id: NodeId::new(0), // placeholder, lo sobreescribimos abajo
fill,
hover_fill,
radius,
text,
image,
painter,
gpu_painter,
on_click,
on_click_at,
on_right_click,
on_right_click_at,
on_middle_click,
drag,
drag_at,
drag_payload,
on_drop,
drop_hover_fill,
clip,
on_pointer_enter,
on_pointer_leave,
on_scroll,
focusable,
alpha,
transform,
tooltip,
subtree_end: 0,
});
let mut child_ids = Vec::with_capacity(children.len());
for child in children {
child_ids.push(mount_recursive(layout, child, out, text_measures));
}
let id = if child_ids.is_empty() {
layout.leaf(style).expect("layout leaf")
} else {
layout.node(style, &child_ids).expect("layout node")
};
out[parent_idx].id = id;
out[parent_idx].subtree_end = out.len();
// Hoja de texto uniforme: registrá su contenido para que el runtime lo
// mida con parley. El texto multicolor (`runs`) lo dimensiona el caller
// (editor: un nodo por línea), así que no lo medimos acá.
if child_ids.is_empty() {
if let Some(text) = out[parent_idx].text.as_ref() {
if text.runs.is_none() {
text_measures.insert(
id,
TextMeasure {
content: text.content.clone(),
size_px: text.size_px,
alignment: text.alignment,
italic: text.italic,
font_family: text.font_family.clone(),
line_height: text.line_height,
},
);
}
}
}
id
}
/// Mide una hoja de texto para taffy: shaping + line-break con parley contra
/// el ancho disponible, devolviendo el bounding box. Si el ancho ya está
/// resuelto (`known.width`) se usa ese; si no, se deriva del `available`
/// (Definite → ese ancho; MaxContent → sin límite = una línea; MinContent →
/// 0 = envuelve a la palabra más ancha). El `line_height` sale del propio
/// `TextMeasure`, el mismo que usa `paint`, así medida y pintado coinciden.
pub fn measure_text_node(
ts: &mut llimphi_text::Typesetter,
tm: &TextMeasure,
known: llimphi_layout::taffy::Size<Option<f32>>,
available: llimphi_layout::taffy::Size<llimphi_layout::taffy::AvailableSpace>,
) -> llimphi_layout::taffy::Size<f32> {
use llimphi_layout::taffy::AvailableSpace;
let max_width: Option<f32> = known.width.or(match available.width {
AvailableSpace::Definite(w) => Some(w),
AvailableSpace::MaxContent => None,
AvailableSpace::MinContent => Some(0.0),
});
let block = llimphi_text::TextBlock {
text: &tm.content,
size_px: tm.size_px,
color: Color::BLACK,
origin: (0.0, 0.0),
max_width,
alignment: tm.alignment,
line_height: tm.line_height,
italic: tm.italic,
font_family: tm.font_family.clone(),
};
let m = llimphi_text::measure(ts, &block);
llimphi_layout::taffy::Size { width: m.width, height: m.height }
}
pub fn paint<Msg>(
scene: &mut vello::Scene,
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
typesetter: &mut llimphi_text::Typesetter,
hover_idx: Option<usize>,
drop_hover_idx: Option<usize>,
) {
// Stack de subtree_end de los `push_layer` activos (clip y/o alpha).
// Vello requiere pop_layer en orden LIFO estricto, así que mantenemos
// un único stack común y popeamos en el orden en que se pushearon.
// Dos entradas con el mismo `subtree_end` (alpha + clip sobre el
// mismo nodo) se cierran en el orden inverso al push.
let mut layer_stack: Vec<usize> = Vec::new();
// Stack de transformaciones afines de subtree. Cada entrada guarda el
// `subtree_end` y la `cur_xf` previa para restaurarla al salir del
// subárbol. `cur_xf` es el producto acumulado de todos los `transform`
// de los ancestros activos — se multiplica en cada draw call. Cuando
// ningún nodo transforma, queda en `IDENTITY` y el paint es idéntico
// al previo (cero regresión).
let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
let mut cur_xf = Affine::IDENTITY;
for (idx, node) in mounted.nodes.iter().enumerate() {
// Cierre de capas que ya quedaron atrás (idx ≥ subtree_end).
while let Some(&end) = layer_stack.last() {
if idx >= end {
scene.pop_layer();
layer_stack.pop();
} else {
break;
}
}
// Restaurá la transformación al salir de subárboles transformados.
while let Some(&(end, prev)) = xf_stack.last() {
if idx >= end {
cur_xf = prev;
xf_stack.pop();
} else {
break;
}
}
let Some(r) = computed.get(node.id) else {
continue;
};
// Transform CSS del nodo: se aplica alrededor del centro de su rect
// (`transform-origin: 50% 50%`) y se compone sobre la del padre. Se
// empuja ANTES del alpha/fill para que toda la pintura del subtree
// (incl. la capa de alpha y el clip) caiga en el espacio transformado.
if let Some(local) = node.transform {
let cx = (r.x + r.w * 0.5) as f64;
let cy = (r.y + r.h * 0.5) as f64;
let centered =
Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy));
xf_stack.push((node.subtree_end, cur_xf));
cur_xf *= centered;
}
// Alpha de subtree: push ANTES de cualquier paint de este nodo
// para que fill/text/image/painter/children entren en la misma
// capa y se compongan juntos al alfa indicado. Si el nodo tiene
// hijos, su `subtree_end > idx + 1` y la capa permanece abierta
// hasta que el loop alcance el primer índice fuera del subárbol.
// Para nodos hoja con alpha el push y el pop son consecutivos —
// funcionalmente equivalente a multiplicar el alpha del fill,
// pero permite usar el mismo API sin distinguir hoja vs rama.
if let Some(a) = node.alpha {
let rect = KurboRect::new(
r.x as f64,
r.y as f64,
(r.x + r.w) as f64,
(r.y + r.h) as f64,
);
scene.push_layer(Mix::Normal, a, cur_xf, &rect);
layer_stack.push(node.subtree_end);
}
// Prioridad de pintura: drop-hover (drag activo) > hover normal >
// fill base. Solo aplica el override si el slot correspondiente
// está poblado; el siguiente cae como fallback.
let effective_fill = if Some(idx) == drop_hover_idx {
node.drop_hover_fill.or(node.hover_fill).or(node.fill)
} else if Some(idx) == hover_idx {
node.hover_fill.or(node.fill)
} else {
node.fill
};
if let Some(color) = effective_fill {
let rr = RoundedRect::new(
r.x as f64,
r.y as f64,
(r.x + r.w) as f64,
(r.y + r.h) as f64,
node.radius,
);
scene.fill(Fill::NonZero, cur_xf, color, None, &rr);
}
if let Some(image) = node.image.as_ref() {
// Aspect-fit centrado: el min de las dos escalas ocupa
// todo el rect en el eje más restrictivo y deja banda en
// el otro. Defensivo: envolvemos en push_layer/pop_layer
// con el rect del nodo para que, aunque el caller pida
// un layout mal-dimensionado, la imagen nunca pinte fuera
// del nodo (visualmente preferible a un overflow opaco).
if image.width > 0 && image.height > 0 && r.w > 0.0 && r.h > 0.0 {
let sx = r.w as f64 / image.width as f64;
let sy = r.h as f64 / image.height as f64;
let s = sx.min(sy);
let disp_w = image.width as f64 * s;
let disp_h = image.height as f64 * s;
let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5;
let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5;
let transform = Affine::translate((tx, ty)) * Affine::scale(s);
let node_rect = KurboRect::new(
r.x as f64,
r.y as f64,
(r.x + r.w) as f64,
(r.y + r.h) as f64,
);
scene.push_layer(Mix::Clip, 1.0, cur_xf, &node_rect);
scene.draw_image(image, cur_xf * transform);
scene.pop_layer();
}
}
if let Some(painter) = node.painter.as_ref() {
(painter)(
scene,
typesetter,
PaintRect {
x: r.x,
y: r.y,
w: r.w,
h: r.h,
},
);
}
if let Some(text) = node.text.as_ref() {
if let Some(runs) = text.runs.as_ref() {
// Texto multicolor (syntax highlighting): una sola pasada de
// shaping con color por rango, anclado arriba-izquierda. Cae
// por el flujo normal (clip/alpha se cierran como siempre).
let layout = typesetter.layout_runs(
&text.content,
text.size_px,
text.color,
runs,
text.alignment,
text.line_height,
);
llimphi_text::draw_layout_runs(scene, &layout, (r.x as f64, r.y as f64));
} else {
// Parley resuelve la alineación horizontal vía max_width +
// alignment. Para Center también centramos verticalmente; para
// Start/End/Justify anclamos arriba (párrafo/editor).
let block = llimphi_text::TextBlock {
text: &text.content,
size_px: text.size_px,
color: text.color,
origin: (r.x as f64, r.y as f64),
max_width: Some(r.w),
alignment: text.alignment,
line_height: text.line_height,
italic: text.italic,
font_family: text.font_family.clone(),
};
// Shaping una sola vez: el `Layout` retornado se reusa para
// medir (cuando hay centrado vertical) y para pintar.
let layout = llimphi_text::layout_block(typesetter, &block);
let origin =
if matches!(text.alignment, llimphi_text::Alignment::Center) {
let m = llimphi_text::measurement(&layout);
(
r.x as f64,
r.y as f64 + ((r.h - m.height) as f64 * 0.5).max(0.0),
)
} else {
block.origin
};
llimphi_text::draw_layout_xf(
scene,
&layout,
text.color,
cur_xf * Affine::translate(origin),
);
}
}
if node.clip {
let clip_rect = KurboRect::new(
r.x as f64,
r.y as f64,
(r.x + r.w) as f64,
(r.y + r.h) as f64,
);
scene.push_layer(Mix::Clip, 1.0, cur_xf, &clip_rect);
layer_stack.push(node.subtree_end);
}
}
// Cerrá capas (clip + alpha) que llegaron al final sin pop intermedio.
while layer_stack.pop().is_some() {
scene.pop_layer();
}
}
/// Pasada GPU directo: recorre el `Mounted` en pre-orden DFS (mismo orden
/// que [`paint`]) e invoca cada `gpu_painter` con el encoder y la
/// `TextureView` del frame. Se ejecuta DESPUÉS de la pasada vello — la
/// intermediate ya tiene fill/image/painter/text encima cuando los
/// callbacks corren, así que su `LoadOp` debe ser `Load`. Devuelve si
/// se invocó al menos un painter (para que el caller decida si vale la
/// pena finalizar y submitir el encoder).
/// `true` si algún nodo del árbol registró un `gpu_painter` (p. ej. el video
/// de media vía `gpu_paint_with`). El eventloop lo usa para decidir si la
/// capa de overlay necesita componerse aparte (sobre el contenido gpu) en vez
/// de pintarse en la escena principal.
pub fn has_gpu_painter<Msg>(mounted: &Mounted<Msg>) -> bool {
mounted.nodes.iter().any(|n| n.gpu_painter.is_some())
}
pub fn paint_gpu<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
viewport: (u32, u32),
) -> bool {
let mut any = false;
for node in &mounted.nodes {
let Some(painter) = node.gpu_painter.as_ref() else {
continue;
};
let Some(r) = computed.get(node.id) else {
continue;
};
(painter)(
device,
queue,
encoder,
view,
PaintRect {
x: r.x,
y: r.y,
w: r.w,
h: r.h,
},
viewport,
);
any = true;
}
any
}
/// Hit-test parametrizado por elegibilidad. Devuelve el índice del nodo
/// más al frente (último en pre-orden) cuyo rect contiene `(x, y)` y para
/// el cual `pred` devuelve `true`, respetando `clip`: si el punto cae
/// afuera de un nodo con clip, el subárbol entero es invisible.
///
/// **Respeta `transform`**: igual que [`paint`], compone el afín acumulado
/// de los ancestros (cada `transform` alrededor del centro del rect del
/// nodo, convención CSS `transform-origin: 50% 50%`). El punto de pantalla
/// `(x, y)` se lleva al espacio local del nodo invirtiendo ese afín, y se
/// testea contra el rect sin transformar. Así un nodo rotado/escalado/
/// trasladado recibe los clicks donde realmente se ve pintado (recorrido
/// tipo Prezi, lienzos de tullpu, `@keyframes` de puriy). Un subárbol con
/// afín singular (escala 0) es inalcanzable, igual que es invisible.
pub fn hit_test_pred<Msg, F>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
x: f32,
y: f32,
pred: F,
) -> Option<usize>
where
F: Fn(&MountedNode<Msg>) -> bool,
{
let mut hit: Option<usize> = None;
let mut clip_stack: Vec<usize> = Vec::new();
// Espejo del stack de transformaciones de `paint`: `cur_xf` es el
// producto acumulado de los `transform` de los ancestros activos
// (local → pantalla). Vacío ⇒ identidad ⇒ camino directo sin invertir
// (cero costo para la abrumadora mayoría de árboles sin transform).
let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
let mut cur_xf = Affine::IDENTITY;
let mut idx = 0;
while idx < mounted.nodes.len() {
while let Some(&end) = clip_stack.last() {
if idx >= end {
clip_stack.pop();
} else {
break;
}
}
while let Some(&(end, prev)) = xf_stack.last() {
if idx >= end {
cur_xf = prev;
xf_stack.pop();
} else {
break;
}
}
let node = &mounted.nodes[idx];
let Some(r) = computed.get(node.id) else {
idx += 1;
continue;
};
// Componé el transform de este nodo igual que `paint`, ANTES de
// resolver el punto local (su propio rect ya cae en el espacio
// transformado).
if let Some(local) = node.transform {
let cx = (r.x + r.w * 0.5) as f64;
let cy = (r.y + r.h * 0.5) as f64;
let centered =
Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy));
xf_stack.push((node.subtree_end, cur_xf));
cur_xf *= centered;
}
// Punto en el espacio local del nodo. Sin transform activo, es el
// punto de pantalla tal cual. Con transform, se invierte el afín;
// si es singular (no invertible) el subárbol es inalcanzable.
let (lx, ly) = if xf_stack.is_empty() {
(x as f64, y as f64)
} else if cur_xf.determinant().abs() < 1e-9 {
idx = node.subtree_end;
continue;
} else {
let p = cur_xf.inverse() * Point::new(x as f64, y as f64);
(p.x, p.y)
};
let inside = lx >= r.x as f64
&& lx < (r.x + r.w) as f64
&& ly >= r.y as f64
&& ly < (r.y + r.h) as f64;
if node.clip {
if !inside {
idx = node.subtree_end;
continue;
}
clip_stack.push(node.subtree_end);
}
if inside && pred(node) {
hit = Some(idx);
}
idx += 1;
}
hit
}
/// Hit-test específico para clicks (incluye nodos draggables).
pub fn hit_test_click<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
x: f32,
y: f32,
) -> Option<usize> {
hit_test_pred(mounted, computed, x, y, |n| {
n.on_click.is_some()
|| n.on_click_at.is_some()
|| n.drag.is_some()
|| n.drag_at.is_some()
})
}
/// Hit-test específico para right-click. Sólo considera nodos que
/// declararon `on_right_click` o `on_right_click_at` — un right-click
/// sobre un nodo sin handler no hace nada (no se "filtra" al click
/// izquierdo).
pub fn hit_test_right_click<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
x: f32,
y: f32,
) -> Option<usize> {
hit_test_pred(mounted, computed, x, y, |n| {
n.on_right_click.is_some() || n.on_right_click_at.is_some()
})
}
/// Hit-test específico para middle-click. Mismo modelo que right-click:
/// sólo nodos que declararon `on_middle_click` reaccionan.
pub fn hit_test_middle_click<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
x: f32,
y: f32,
) -> Option<usize> {
hit_test_pred(mounted, computed, x, y, |n| n.on_middle_click.is_some())
}
/// Hit-test específico para hover (nodos con `hover_fill`).
pub fn hit_test_hover<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
x: f32,
y: f32,
) -> Option<usize> {
hit_test_pred(mounted, computed, x, y, |n| n.hover_fill.is_some())
}
/// Hit-test específico para drop targets (nodos con `on_drop`). Usado
/// durante un drag activo para resaltar el destino y para invocar el
/// handler al soltar.
pub fn hit_test_drop<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
x: f32,
y: f32,
) -> Option<usize> {
hit_test_pred(mounted, computed, x, y, |n| n.on_drop.is_some())
}
/// Hit-test específico para áreas de scroll (nodos con `on_scroll`). El
/// runtime lo usa al recibir la rueda: el nodo más al frente bajo el
/// cursor con handler de scroll consume el evento antes del `on_wheel`
/// global.
pub fn hit_test_scroll<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
x: f32,
y: f32,
) -> Option<usize> {
hit_test_pred(mounted, computed, x, y, |n| n.on_scroll.is_some())
}
/// Hit-test para foco: el id `focusable` del nodo más al frente bajo el
/// cursor (click-to-focus). `None` si no se clickeó nada enfocable.
pub fn hit_test_focusable<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
x: f32,
y: f32,
) -> Option<u64> {
hit_test_pred(mounted, computed, x, y, |n| n.focusable.is_some())
.and_then(|i| mounted.nodes[i].focusable)
}
/// Ids enfocables en orden de Tab (pre-orden del árbol = orden de
/// inserción de `Mounted::nodes`). Sólo nodos con rect computado
/// (presentes en el layout). Es el orden DOM-like de tabulación.
pub fn focus_order<Msg>(mounted: &Mounted<Msg>, computed: &ComputedLayout) -> Vec<u64> {
mounted
.nodes
.iter()
.filter_map(|n| {
n.focusable
.filter(|_| computed.get(n.id).is_some())
})
.collect()
}
/// Próximo id de foco al pulsar Tab (o Shift+Tab si `reverse`), dado el
/// `order` (de [`focus_order`]) y el `current`. Envuelve en los extremos.
/// Si no hay enfocables devuelve `None`; si `current` ya no existe en el
/// orden, arranca por el primero (Tab) o el último (Shift+Tab).
pub fn next_focus(order: &[u64], current: Option<u64>, reverse: bool) -> Option<u64> {
if order.is_empty() {
return None;
}
let n = order.len();
let pos = current.and_then(|c| order.iter().position(|&id| id == c));
let next_idx = match pos {
Some(i) => {
if reverse {
(i + n - 1) % n
} else {
(i + 1) % n
}
}
None => {
if reverse {
n - 1
} else {
0
}
}
};
Some(order[next_idx])
}
#[cfg(test)]
mod tests {
use crate::{hit_test_click, mount, View};
use llimphi_layout::taffy::prelude::*;
use llimphi_layout::{LayoutTree, Style};
use vello::kurbo::Affine;
/// Un hijo clickeable de 100×100 anclado arriba-izquierda. Devuelve
/// `(mounted, computed)` ya layouteados sobre un viewport 400×400.
fn fixture(
transform: Option<Affine>,
) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
let mut child = View::<()>::new(Style {
size: Size {
width: length(100.0),
height: length(100.0),
},
..Default::default()
})
.on_click(());
if let Some(xf) = transform {
child = child.transform(xf);
}
let root = View::<()>::new(Style {
align_items: Some(AlignItems::FlexStart),
justify_content: Some(JustifyContent::FlexStart),
..Default::default()
})
.children(vec![child]);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
(mounted, computed)
}
#[test]
fn sin_transform_el_hit_cae_en_el_rect() {
let (m, c) = fixture(None);
assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1)); // dentro
assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), None); // fuera
}
#[test]
fn traslacion_mueve_el_area_clickeable() {
// El nodo se ve corrido +200px en x; el click debe seguirlo.
let (m, c) = fixture(Some(Affine::translate((200.0, 0.0))));
assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), Some(1)); // donde se ve
assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); // ya no donde estaba
}
#[test]
fn rotacion_180_grados_alrededor_del_centro() {
// Rotar 180° alrededor del centro (50,50) deja el rect en su sitio:
// una esquina mapea a la opuesta, pero el cuadrado cubre lo mismo.
let (m, c) = fixture(Some(Affine::rotate(std::f64::consts::PI)));
assert_eq!(hit_test_click(&m, &c, 10.0, 10.0), Some(1));
assert_eq!(hit_test_click(&m, &c, 90.0, 90.0), Some(1));
assert_eq!(hit_test_click(&m, &c, 150.0, 150.0), None);
}
#[test]
fn escala_cero_es_inalcanzable() {
let (m, c) = fixture(Some(Affine::scale(0.0)));
assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None);
}
#[test]
fn tab_traversal_envuelve_en_los_extremos() {
use crate::next_focus;
let order = [10u64, 20, 30];
// Avanza.
assert_eq!(next_focus(&order, Some(10), false), Some(20));
assert_eq!(next_focus(&order, Some(30), false), Some(10)); // wrap
// Retrocede (Shift+Tab).
assert_eq!(next_focus(&order, Some(20), true), Some(10));
assert_eq!(next_focus(&order, Some(10), true), Some(30)); // wrap
// Sin foco previo: Tab → primero, Shift+Tab → último.
assert_eq!(next_focus(&order, None, false), Some(10));
assert_eq!(next_focus(&order, None, true), Some(30));
// Foco obsoleto (id que ya no está) → arranca por el extremo.
assert_eq!(next_focus(&order, Some(99), false), Some(10));
// Lista vacía.
assert_eq!(next_focus(&[], Some(10), false), None);
}
}
+408
View File
@@ -0,0 +1,408 @@
use super::*;
impl<Msg> View<Msg> {
pub fn new(style: Style) -> Self {
Self {
style,
fill: None,
hover_fill: None,
radius: 0.0,
text: None,
image: None,
painter: None,
gpu_painter: None,
on_pointer_enter: None,
on_pointer_leave: None,
on_click: None,
on_click_at: None,
on_right_click: None,
on_right_click_at: None,
on_middle_click: None,
drag: None,
drag_at: None,
drag_payload: None,
on_drop: None,
drop_hover_fill: None,
clip: false,
on_scroll: None,
focusable: None,
alpha: None,
transform: None,
tooltip: None,
children: Vec::new(),
}
}
/// Asocia un texto de **tooltip** a este nodo. Llimphi sólo lo transporta
/// hasta el [`MountedNode`](crate::MountedNode); el consumidor decide cómo
/// mostrarlo (un overlay del runtime, una surface popup del cliente) tras
/// localizar el nodo bajo el cursor con el hit-test de hover.
pub fn tooltip(mut self, text: impl Into<String>) -> Self {
self.tooltip = Some(text.into());
self
}
/// Registra un handler de rueda local: si el cursor está sobre este
/// nodo cuando la rueda gira, el runtime lo invoca con el delta
/// `(dx, dy)` en líneas lógicas ANTES de caer al `App::on_wheel`
/// global. Devolver `Some(Msg)` consume el evento. Es la base de las
/// áreas de scroll autocontenidas (`llimphi-widget-scroll`).
pub fn on_scroll<F>(mut self, handler: F) -> Self
where
F: Fn(f32, f32) -> Option<Msg> + Send + Sync + 'static,
{
self.on_scroll = Some(Arc::new(handler));
self
}
/// Marca este nodo como enfocable con el id opaco `id`. El runtime lo
/// incluye en el orden de Tab (pre-orden del árbol) y le da foco al
/// clickearlo; cada cambio de foco se notifica vía `App::on_focus`.
/// El caller pinta el focus-ring comparando el id contra el foco que
/// guardó en su `Model`.
pub fn focusable(mut self, id: u64) -> Self {
self.focusable = Some(id);
self
}
/// Aplica una transformación afín 2D a este nodo y todo su subtree,
/// **alrededor del centro de su rect** (CSS `transform-origin: 50%
/// 50%`). El centro se resuelve en `paint` contra el layout computado;
/// el caller sólo provee el afín "local" (producto de sus
/// `rotate`/`scale`/`translate`). Nodos anidados componen en el
/// espacio ya transformado del padre. Pensado para `transform` y
/// `@keyframes` CSS de puriy. `Affine::IDENTITY` equivale a no setear.
pub fn transform(mut self, xf: Affine) -> Self {
self.transform = Some(xf);
self
}
pub fn fill(mut self, color: Color) -> Self {
self.fill = Some(color);
self
}
/// Opacidad uniforme aplicada a este nodo y todos sus descendientes
/// vía `scene.push_layer(Mix::Normal, a, …)`. Pensado para fade-in/out
/// de overlays, toasts y modales sin tener que tunear el alpha de
/// cada color del subtree. Valores fuera de `[0.0, 1.0]` se clampean.
/// Hace que el subtree se componga en una capa intermedia — usar sólo
/// cuando sea necesario (no es gratuito).
pub fn alpha(mut self, a: f32) -> Self {
self.alpha = Some(a.clamp(0.0, 1.0));
self
}
/// Color a usar cuando el cursor está sobre este nodo. Habilita
/// el hit-test de hover sobre el nodo.
pub fn hover_fill(mut self, color: Color) -> Self {
self.hover_fill = Some(color);
self
}
/// Marca este nodo como draggable. Mientras el usuario sostenga el
/// botón izquierdo sobre él, el runtime llama `handler(Move, dx, dy)`
/// por cada `CursorMoved` (dx/dy = delta desde el evento anterior) y
/// `handler(End, 0, 0)` al soltar. Sobreescribe `on_click` para este
/// nodo: un nodo es draggable **o** clickable.
pub fn draggable<F>(mut self, handler: F) -> Self
where
F: Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
{
self.drag = Some(Arc::new(handler));
self
}
/// Como `draggable`, pero el handler también recibe la posición
/// inicial del press relativa al rect del nodo `(initial_lx,
/// initial_ly)`. Útil cuando el caller necesita resolver qué
/// entidad bajo el cursor inició el drag (Conceptos, lemmings,
/// nodos de un grafo, etc.). Gana sobre `draggable` si ambos están.
pub fn draggable_at<F>(mut self, handler: F) -> Self
where
F: Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
{
self.drag_at = Some(Arc::new(handler));
self
}
/// Declara el payload `u64` que viaja con el drag de este nodo. Los
/// drop targets bajo cursor al soltar reciben este valor en su
/// `on_drop`. Sin payload, los drop targets no reaccionan (útil para
/// drags de "resize/scroll" que no representan transferencia).
pub fn drag_payload(mut self, payload: u64) -> Self {
self.drag_payload = Some(payload);
self
}
/// Marca este nodo como drop target. El runtime invoca `handler(payload)`
/// cuando un drag termina sobre el rect de este nodo y el origen del
/// drag declaró un payload. Si devuelve `Some(Msg)`, se dispatchea al
/// `update` antes del `DragPhase::End` del origen.
pub fn on_drop<F>(mut self, handler: F) -> Self
where
F: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
{
self.on_drop = Some(Arc::new(handler));
self
}
/// Color de relleno cuando un drag activo está hovereando este drop
/// target. Análogo a `hover_fill` pero solo aplica mientras dura un
/// drag. Útil para resaltar el destino válido.
pub fn drop_hover_fill(mut self, color: Color) -> Self {
self.drop_hover_fill = Some(color);
self
}
pub fn radius(mut self, r: f64) -> Self {
self.radius = r;
self
}
pub fn text(mut self, content: impl Into<String>, size_px: f32, color: Color) -> Self {
self.text = Some(TextSpec {
content: content.into(),
size_px,
color,
alignment: llimphi_text::Alignment::Center,
italic: false,
font_family: None,
line_height: 1.2,
runs: None,
});
self
}
pub fn text_aligned(
mut self,
content: impl Into<String>,
size_px: f32,
color: Color,
alignment: llimphi_text::Alignment,
) -> Self {
self.text = Some(TextSpec {
content: content.into(),
size_px,
color,
alignment,
italic: false,
font_family: None,
line_height: 1.2,
runs: None,
});
self
}
/// Como `text_aligned` pero con un flag `italic`. Si la fuente activa
/// no tiene variante italic, parley aplica synthesizing.
pub fn text_aligned_italic(
mut self,
content: impl Into<String>,
size_px: f32,
color: Color,
alignment: llimphi_text::Alignment,
italic: bool,
) -> Self {
self.text = Some(TextSpec {
content: content.into(),
size_px,
color,
alignment,
italic,
font_family: None,
line_height: 1.2,
runs: None,
});
self
}
/// Como `text_aligned_italic` pero con font-family explícito.
/// La cadena se pasa como `parley::FontStack::Source` (acepta listas
/// CSS con fallbacks).
pub fn text_aligned_full(
mut self,
content: impl Into<String>,
size_px: f32,
color: Color,
alignment: llimphi_text::Alignment,
italic: bool,
font_family: Option<String>,
) -> Self {
self.text = Some(TextSpec {
content: content.into(),
size_px,
color,
alignment,
italic,
font_family,
line_height: 1.2,
runs: None,
});
self
}
/// Texto **multicolor** en una sola pasada de shaping: `content` se pinta
/// con `default_color` y cada `(start_byte, end_byte, color)` de `runs`
/// sobreescribe su rango (offsets en bytes). Pensado para syntax
/// highlighting — un nodo por línea en vez de uno por token. Anclado
/// arriba-izquierda (sin centrado vertical); el caller dimensiona el rect.
pub fn text_runs(
mut self,
content: impl Into<String>,
size_px: f32,
default_color: Color,
runs: Vec<(usize, usize, Color)>,
alignment: llimphi_text::Alignment,
) -> Self {
self.text = Some(TextSpec {
content: content.into(),
size_px,
color: default_color,
alignment,
italic: false,
font_family: None,
line_height: 1.2,
runs: Some(runs),
});
self
}
/// Sobreescribe el múltiplo de interlínea del texto ya seteado (default
/// 1.2). No-op si el nodo no tiene texto. Pensado para puriy, que pasa
/// el `line-height` computado de CSS para que medición y pintado usen
/// el mismo valor.
pub fn line_height(mut self, mult: f32) -> Self {
if let Some(t) = self.text.as_mut() {
t.line_height = mult;
}
self
}
pub fn on_click(mut self, msg: Msg) -> Self {
self.on_click = Some(msg);
self
}
/// Dispatch `msg` cuando el cursor entra al rect del nodo
/// (transición no-hover → hover). Sólo emite una vez por entrada —
/// el runtime no repite el msg si el cursor se mueve dentro del rect.
pub fn on_pointer_enter(mut self, msg: Msg) -> Self {
self.on_pointer_enter = Some(msg);
self
}
/// Dispatch `msg` cuando el cursor sale del rect del nodo.
pub fn on_pointer_leave(mut self, msg: Msg) -> Self {
self.on_pointer_leave = Some(msg);
self
}
/// Como `on_click`, pero el handler recibe `(local_x, local_y,
/// rect_w, rect_h)` — la posición del cursor relativa al rect del
/// nodo más las dimensiones actuales del nodo. Útil para canvas
/// elements que necesitan saber dónde fue el click para convertirlo
/// a coordenadas de mundo. Sobrescribe `on_click` para este nodo
/// si ambos están presentes.
pub fn on_click_at<F>(mut self, handler: F) -> Self
where
F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
{
self.on_click_at = Some(Arc::new(handler));
self
}
/// Declara el `Msg` a emitir cuando el usuario hace click derecho
/// sobre este nodo. Para menús contextuales, conviene pasar un
/// `Msg::OpenMenu { ... }` y dejar que el modelo guarde la
/// posición; el overlay se abre vía [`App::view_overlay`].
pub fn on_right_click(mut self, msg: Msg) -> Self {
self.on_right_click = Some(msg);
self
}
/// Variante posicional de [`Self::on_right_click`]. El handler recibe
/// `(local_x, local_y, rect_w, rect_h)` para que un nodo "grilla"
/// pueda resolver internamente qué subcelda recibió el click. La
/// posición está relativa al rect del nodo.
pub fn on_right_click_at<F>(mut self, handler: F) -> Self
where
F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
{
self.on_right_click_at = Some(Arc::new(handler));
self
}
/// Declara el `Msg` a emitir cuando el usuario hace click con el
/// botón del medio (rueda presionada). Usado típicamente para abrir
/// links en pestaña nueva — igual que Ctrl+Click pero más rápido.
pub fn on_middle_click(mut self, msg: Msg) -> Self {
self.on_middle_click = Some(msg);
self
}
/// Pinta `image` dentro del rect del nodo, centrada y escalada
/// preservando aspect ratio. Re-exporta `peniko::Image` vía
/// `llimphi_raster::peniko::Image` — el caller decodifica los
/// bytes con el crate `image` (u otro) y construye el `Image`
/// con `Blob<u8>` + `ImageFormat::Rgba8`.
pub fn image(mut self, image: Image) -> Self {
self.image = Some(image);
self
}
/// Registra una closure de pintura custom. El runtime la invoca
/// con `(&mut vello::Scene, &mut Typesetter, PaintRect)` durante
/// el paint del nodo. La closure es responsable de pintar
/// primitivas custom dentro del rect; no debe dejar `push_layer`
/// sin par. Soporte para canvas elements estilo
/// dominium/pluma/cosmos.
pub fn paint_with<F>(mut self, painter: F) -> Self
where
F: Fn(&mut vello::Scene, &mut llimphi_text::Typesetter, PaintRect)
+ Send
+ Sync
+ 'static,
{
self.painter = Some(Arc::new(painter));
self
}
/// Registra una closure de pintura GPU directo. La closure recibe
/// `(&Device, &Queue, &mut CommandEncoder, &TextureView, PaintRect, (viewport_w, viewport_h))`
/// y debe escribir sobre el `TextureView` con `LoadOp::Load` (no
/// clear) para preservar la pasada vello previa. El último
/// argumento es el tamaño en pixels de la `TextureView` destino
/// (la intermedia del frame) — necesario para calcular NDC sin
/// asumir un viewport fijo. Ver [`GpuPaintFn`] para semántica
/// completa, contexto y orden de pintura.
pub fn gpu_paint_with<F>(mut self, painter: F) -> Self
where
F: Fn(
&wgpu::Device,
&wgpu::Queue,
&mut wgpu::CommandEncoder,
&wgpu::TextureView,
PaintRect,
(u32, u32),
) + Send
+ Sync
+ 'static,
{
self.gpu_painter = Some(Arc::new(painter));
self
}
/// Recorta los hijos al rect de este nodo (paint y hit-test). Útil
/// para paneles con contenido virtualizado que no debe sangrar a
/// vecinos (listas, scrollers, viewers).
pub fn clip(mut self, enabled: bool) -> Self {
self.clip = enabled;
self
}
pub fn children(mut self, children: Vec<View<Msg>>) -> Self {
self.children = children;
self
}
}
+87
View File
@@ -0,0 +1,87 @@
//! Verifica que un párrafo largo, dentro de un bloque angosto, reserva el
//! alto de **varias líneas** (no se aplasta en una). Es el regresor del bug
//! "textos aplastados" de puriy: sin medición con parley, taffy le daba a la
//! hoja de texto una sola línea de alto y las líneas envueltas se solapaban.
use llimphi_compositor::{measure_text_node, mount, View};
use llimphi_layout::taffy::prelude::*;
use llimphi_layout::taffy::Size as TSize;
use llimphi_layout::LayoutTree;
#[derive(Clone)]
enum Msg {}
#[test]
fn parrafo_largo_reserva_varias_lineas() {
// Bloque de 200px de ancho con un párrafo que claramente excede una línea.
let texto = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do \
eiusmod tempor incididunt ut labore et dolore magna aliqua ut \
enim ad minim veniam quis nostrud exercitation ullamco laboris.";
let block: View<Msg> = View::new(Style {
size: TSize { width: length(200.0_f32), height: auto() },
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
..Default::default()
})
.children(vec![View::new(Style {
size: TSize { width: auto(), height: auto() },
flex_shrink: 1.0,
..Default::default()
})
.text_aligned(texto, 16.0_f32, vello::peniko::Color::BLACK, llimphi_text::Alignment::Start)]);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, block);
let mut ts = llimphi_text::Typesetter::new();
let tmap = &mounted.text_measures;
assert_eq!(tmap.len(), 1, "debería haber exactamente una hoja de texto");
let computed = layout
.compute_with_measure(mounted.root, (800.0, 600.0), |nid, known, avail| match tmap.get(&nid)
{
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => TSize::ZERO,
})
.expect("layout");
// El nodo de texto es el segundo en orden DFS (root, luego la hoja).
let leaf_id = mounted.nodes[1].id;
let rect = computed.get(leaf_id).expect("rect de la hoja");
// A 16px y ~1.2 de interlínea, una línea ≈ 19px. Con ~150px de texto en
// 200px de ancho deberían ser >= 4 líneas → bastante más de una.
assert!(
rect.h > 40.0,
"el párrafo se aplastó: alto={} (esperaba varias líneas)",
rect.h
);
assert!(rect.w <= 200.0 + 1.0, "no debería exceder el ancho del bloque");
}
#[test]
fn line_height_mayor_reserva_mas_alto() {
let texto = "una línea de texto que envuelve en dos o tres renglones según \
el ancho disponible para el bloque contenedor angosto";
let medir = |lh: f32| -> f32 {
let mut ts = llimphi_text::Typesetter::new();
let tm = llimphi_compositor::TextMeasure {
content: texto.to_string(),
size_px: 16.0,
alignment: llimphi_text::Alignment::Start,
italic: false,
font_family: None,
line_height: lh,
};
let known = TSize { width: Some(180.0_f32), height: None };
let avail = TSize {
width: AvailableSpace::Definite(180.0),
height: AvailableSpace::MaxContent,
};
measure_text_node(&mut ts, &tm, known, avail).height
};
let compacto = medir(1.0);
let comodo = medir(2.0);
assert!(
comodo > compacto * 1.5,
"line-height: 2 debería reservar bastante más alto que 1.0 (got {compacto} vs {comodo})"
);
}
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "llimphi-gallery"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-gallery — demo único que prueba el kit transversal de elegancia. Binario standalone; `cargo run -p llimphi-gallery --release`."
[[bin]]
name = "llimphi-gallery"
path = "src/main.rs"
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-motion = { workspace = true }
llimphi-icons = { workspace = true }
llimphi-widget-wawa-mark = { workspace = true }
llimphi-widget-tooltip = { workspace = true }
llimphi-widget-spinner = { workspace = true }
llimphi-widget-progress = { workspace = true }
llimphi-widget-toast = { workspace = true }
llimphi-widget-modal = { workspace = true }
llimphi-widget-empty = { workspace = true }
llimphi-widget-status-bar = { workspace = true }
llimphi-widget-shortcuts-help = { workspace = true }
llimphi-widget-splash = { workspace = true }
llimphi-widget-switch = { workspace = true }
llimphi-widget-segmented = { workspace = true }
llimphi-widget-breadcrumb = { workspace = true }
llimphi-widget-badge = { workspace = true }
llimphi-widget-avatar = { workspace = true }
llimphi-widget-skeleton = { workspace = true }
llimphi-widget-field = { workspace = true }
llimphi-widget-panel = { workspace = true }
llimphi-widget-card = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-widget-menubar = { workspace = true }
app-bus = { workspace = true }
+966
View File
@@ -0,0 +1,966 @@
//! `llimphi-gallery` — demo único del kit transversal de elegancia.
//!
//! Una sola ventana que muestra cómo se ven los widgets del kit
//! juntos sobre el theme dark. Útil para verificar paleta, escala,
//! cinética y consistencia visual de un vistazo.
//!
//! `cargo run -p llimphi-gallery --release`
//!
//! Controles:
//! - Click en switches/segments/breadcrumb: dispatchea Msg
//! - Click en "Mostrar toast": apila un toast en bottom-right
//! - Click en "Abrir modal": muestra el modal
//! - `?`: abre/cierra el overlay de atajos
//! - Esc: cierra overlay activo
use std::sync::Arc;
use std::time::{Duration, Instant};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_icons::{icon_view, Icon};
use llimphi_theme::Theme;
use app_bus::{AppMenu, Menu, MenuItem};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use llimphi_widget_avatar::avatar_view;
use llimphi_widget_badge::{count_badge_view, dot_badge_view, BadgeKind};
use llimphi_widget_breadcrumb::{breadcrumb_view, BreadcrumbPalette};
use llimphi_widget_card::{card_view, CardOptions, CardPalette};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_empty::{empty_view, EmptyPalette};
use llimphi_widget_field::{field_view, FieldPalette, FieldSpec};
use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec};
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
use llimphi_widget_progress::{linear_progress_view, radial_progress_view};
use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
use llimphi_widget_shortcuts_help::{
shortcuts_help_view, ShortcutEntry, ShortcutGroup, ShortcutsHelpPalette, ShortcutsHelpSpec,
};
use llimphi_widget_skeleton::{skeleton_box_view, skeleton_line_view, SkeletonPalette};
use llimphi_widget_spinner::spinner_view;
use llimphi_widget_splash::splash_view;
use llimphi_widget_status_bar::{status_bar_view, StatusBarPalette, StatusSegment};
use llimphi_widget_switch::{switch_view, SwitchPalette};
use llimphi_widget_toast::{toast_stack_view, Toast};
use llimphi_widget_tooltip::{tooltip_view, Side, TooltipPalette, TooltipSpec};
use llimphi_widget_wawa_mark::{wawa_mark_view, WawaMarkPalette};
#[derive(Clone)]
enum Msg {
/// Tick para forzar repaint (animaciones por reloj absoluto).
Tick,
ToggleA,
ToggleB,
SelectSeg(usize),
#[allow(dead_code)]
BreadcrumbJump(usize),
PushToast,
DismissToast(u64),
OpenModal,
CloseModal,
ConfirmModal,
ToggleShortcuts,
OpenContextMenu,
CloseContextMenu,
ContextMenuPick(usize),
/// Abrir/cerrar un menú raíz de la barra principal (`None` = cerrar).
MenuOpen(Option<usize>),
/// Comando elegido en la barra principal (id `menu.<verbo>`).
MenuCommand(String),
}
struct Model {
started_at: Instant,
switch_a: bool,
switch_b: bool,
seg: usize,
toasts: Vec<Toast>,
next_toast_id: u64,
modal_open: bool,
shortcuts_open: bool,
viewport: (f32, f32),
/// Anchor del context-menu si está abierto. None = cerrado.
menu_open: Option<(f32, f32)>,
/// Item resaltado del menú (`usize::MAX` = ninguno, estado inicial).
menu_active: usize,
/// Última opción elegida del menú — se muestra como toast.
menu_last_pick: Option<String>,
/// Índice del menú raíz de la barra principal abierto. `None` = ninguno.
menubar_open: Option<usize>,
}
struct Gallery;
impl App for Gallery {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · gallery"
}
fn initial_size() -> (u32, u32) {
(1280, 800)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
// Loop infinito de ticks para animar spinner/skeleton/splash.
// En una app real esto se gateaba según haya animaciones vivas.
handle.spawn_periodic(Duration::from_millis(50), || Msg::Tick);
Model {
started_at: Instant::now(),
switch_a: true,
switch_b: false,
seg: 1,
toasts: Vec::new(),
next_toast_id: 0,
modal_open: false,
shortcuts_open: false,
viewport: (1280.0, 800.0),
menu_open: None,
menu_active: usize::MAX,
menu_last_pick: None,
menubar_open: None,
}
}
fn update(model: Self::Model, msg: Self::Msg, _handle: &Handle<Self::Msg>) -> Self::Model {
let mut m = model;
// Filtrar toasts expirados oportunamente.
let now = Instant::now();
m.toasts.retain(|t| t.is_alive(now));
match msg {
Msg::Tick => {}
Msg::ToggleA => m.switch_a = !m.switch_a,
Msg::ToggleB => m.switch_b = !m.switch_b,
Msg::SelectSeg(i) => m.seg = i,
Msg::BreadcrumbJump(_) => {} // sólo demo
Msg::PushToast => {
let kinds = [
(BadgeKind::Info, "guardado en disco"),
(BadgeKind::Success, "publicado correctamente"),
(BadgeKind::Warning, "espacio bajo en cache"),
(BadgeKind::Error, "no se pudo conectar"),
];
let (kind, text) = kinds[(m.next_toast_id as usize) % kinds.len()];
let id = m.next_toast_id;
m.next_toast_id += 1;
let toast = match kind {
BadgeKind::Info => Toast::info(id, text, Duration::from_secs(4)),
BadgeKind::Success => Toast::success(id, text, Duration::from_secs(4)),
BadgeKind::Warning => Toast::warning(id, text, Duration::from_secs(4)),
BadgeKind::Error => Toast::error(id, text, Duration::from_secs(4)),
BadgeKind::Neutral => Toast::info(id, text, Duration::from_secs(4)),
};
m.toasts.push(toast);
}
Msg::DismissToast(id) => m.toasts.retain(|t| t.id != id),
Msg::OpenModal => m.modal_open = true,
Msg::CloseModal => m.modal_open = false,
Msg::ConfirmModal => m.modal_open = false,
Msg::ToggleShortcuts => m.shortcuts_open = !m.shortcuts_open,
Msg::OpenContextMenu => {
// Posición fija razonable — el botón está en la columna
// derecha; abrir el menú con anchor relativo al
// viewport mantiene la demo predecible aunque la
// ventana cambie de tamaño.
m.menu_open = Some((m.viewport.0 * 0.72, m.viewport.1 * 0.55));
m.menu_active = usize::MAX;
m.menubar_open = None;
}
Msg::CloseContextMenu => {
m.menu_open = None;
m.menu_active = usize::MAX;
}
Msg::ContextMenuPick(idx) => {
let labels = ["Copiar", "Cortar", "Pegar", "", "Eliminar"];
let label = labels.get(idx).copied().unwrap_or("?");
m.menu_last_pick = Some(label.to_string());
m.menu_open = None;
m.menu_active = usize::MAX;
// Confirmación visible.
let id = m.next_toast_id;
m.next_toast_id += 1;
m.toasts.push(Toast::info(
id,
format!("Menú → {label}"),
Duration::from_secs(3),
));
}
Msg::MenuOpen(idx) => {
m.menubar_open = idx;
// El dropdown de la barra y el contextual son mutuamente
// excluyentes.
m.menu_open = None;
}
Msg::MenuCommand(cmd) => {
m.menubar_open = None;
match cmd.as_str() {
"app.quit" => std::process::exit(0),
"view.toast" => return Self::update(m, Msg::PushToast, _handle),
"view.modal" => m.modal_open = true,
"view.context" => {
m.menu_open = Some((m.viewport.0 * 0.5, m.viewport.1 * 0.45));
m.menu_active = usize::MAX;
}
"help.shortcuts" => m.shortcuts_open = true,
"help.about" => {
let id = m.next_toast_id;
m.next_toast_id += 1;
m.toasts.push(Toast::info(
id,
"llimphi · gallery — vitrina del kit de elegancia",
Duration::from_secs(4),
));
}
_ => {}
}
}
}
m
}
fn on_key(_model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
if ev.state != KeyState::Pressed {
return None;
}
match &ev.key {
Key::Named(NamedKey::Escape) => Some(Msg::CloseModal),
Key::Character(s) if s == "?" => Some(Msg::ToggleShortcuts),
_ => None,
}
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let theme = Theme::dark();
// Tres columnas equilibradas + status bar inferior.
let left = column_left(model, &theme);
let center = column_center(model, &theme);
let right = column_right(model, &theme);
let cols = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
gap: Size {
width: length(16.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.children(vec![left, center, right]);
let status = status_bar_view(
vec![
StatusSegment::text("llimphi-gallery").with_icon(Icon::Home),
StatusSegment::text(if model.switch_a { "modo: pleno" } else { "modo: simple" })
.emphasized(),
],
vec![],
vec![
StatusSegment::text("Ln 1, Col 1"),
StatusSegment::text("UTF-8"),
StatusSegment::text("? atajos")
.clickable(Msg::ToggleShortcuts)
.with_icon(Icon::Info),
],
&StatusBarPalette::from_theme(&theme),
);
let menu = app_menu();
let bar = menubar_view(&menubar_spec(&menu, model, &theme));
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.children(vec![bar, cols, status])
}
fn view_overlay(model: &Self::Model) -> Option<View<Self::Msg>> {
let theme = Theme::dark();
// Prioridad: modal > shortcuts > toasts.
if model.modal_open {
return Some(modal_view(ModalSpec {
title: "Confirmar acción".to_string(),
body: modal_body_view(&theme),
buttons: vec![
ModalButton::cancel("Cancelar", Msg::CloseModal),
ModalButton::primary("Aplicar", Msg::ConfirmModal),
],
size: (440.0, 220.0),
viewport: model.viewport,
on_dismiss: Msg::CloseModal,
palette: ModalPalette::from_theme(&theme),
}));
}
if model.shortcuts_open {
return Some(shortcuts_help_view(ShortcutsHelpSpec {
title: "Atajos de teclado".to_string(),
groups: vec![
ShortcutGroup::new(
"General",
vec![
ShortcutEntry::new("?", "Mostrar/ocultar esta ayuda"),
ShortcutEntry::new("Esc", "Cerrar overlay activo"),
],
),
ShortcutGroup::new(
"Demo",
vec![
ShortcutEntry::new("Click", "Toasts, modal y switches"),
ShortcutEntry::new("Hover", "Tooltips sobre los avatares"),
],
),
],
viewport: model.viewport,
on_dismiss: Msg::ToggleShortcuts,
palette: ShortcutsHelpPalette::from_theme(&theme),
}));
}
if let Some(anchor) = model.menu_open {
return Some(context_menu_view(ContextMenuSpec {
anchor,
viewport: model.viewport,
header: Some("Lienzo".into()),
items: vec![
ContextMenuItem::action("Copiar").with_shortcut("Ctrl+C"),
ContextMenuItem::action("Cortar").with_shortcut("Ctrl+X"),
ContextMenuItem::action("Pegar").with_shortcut("Ctrl+V").disabled(),
ContextMenuItem::separator(),
ContextMenuItem::action("Eliminar")
.with_shortcut("Del")
.destructive(),
],
active: model.menu_active,
on_pick: Arc::new(Msg::ContextMenuPick),
on_dismiss: Msg::CloseContextMenu,
palette: ContextMenuPalette::from_theme(&theme),
}));
}
// Dropdown de la barra de menú principal.
let menu = app_menu();
if let Some(v) = menubar_overlay(&menubar_spec(&menu, model, &theme)) {
return Some(v);
}
if !model.toasts.is_empty() {
return Some(toast_stack_view(
&model.toasts,
model.viewport,
Msg::DismissToast,
));
}
None
}
}
// ---------------------------------------------------------------------
// Barra de menú principal
// ---------------------------------------------------------------------
/// Menú principal de la vitrina. Sólo comandos que mapean a `Msg` reales.
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Ctrl+Q")))
.menu(
Menu::new("Ver")
.item(MenuItem::new("Mostrar toast", "view.toast"))
.item(MenuItem::new("Abrir modal", "view.modal"))
.item(MenuItem::new("Menú contextual", "view.context").separated()),
)
.menu(
Menu::new("Ayuda")
.item(MenuItem::new("Atajos", "help.shortcuts").shortcut("?"))
.item(MenuItem::new("Acerca de", "help.about")),
)
}
/// Arma el `MenuBarSpec` compartido entre `view` y `view_overlay`.
fn menubar_spec<'a>(menu: &'a AppMenu, model: &Model, theme: &'a Theme) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menubar_open,
theme,
viewport: model.viewport,
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|cmd: &str| Msg::MenuCommand(cmd.to_string())),
}
}
// ---------------------------------------------------------------------
// Columnas
// ---------------------------------------------------------------------
fn column_left(model: &Model, theme: &Theme) -> View<Msg> {
let mut children: Vec<View<Msg>> = Vec::new();
children.push(section_title("Identidad"));
// Sello wawa en chico + grande.
children.push(
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(128.0_f32),
},
gap: Size {
width: length(16.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![
wawa_frame(48.0),
wawa_frame(96.0),
wawa_frame(128.0),
]),
);
children.push(section_title("Splash"));
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(220.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(theme.bg_panel)
.radius(llimphi_theme::radius::MD)
.children(vec![splash_view(model.started_at, theme.bg_panel, theme.fg_text)]),
);
children.push(section_title("Empty state"));
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(200.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(theme.bg_panel)
.radius(llimphi_theme::radius::MD)
.children(vec![empty_view(
Icon::Folder,
"Sin documentos abiertos",
Some("Abrí uno con Ctrl+O o creá un nuevo lienzo para empezar."),
&EmptyPalette::from_theme(theme),
)]),
);
panel_view(children, theme)
}
fn column_center(model: &Model, theme: &Theme) -> View<Msg> {
let mut children: Vec<View<Msg>> = Vec::new();
children.push(section_title("Navegación"));
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: auto(),
},
..Default::default()
})
.children(vec![breadcrumb_view(
&["home", "docs", "2026", "elegancia.md"],
Msg::BreadcrumbJump,
&BreadcrumbPalette::from_theme(theme),
)]),
);
children.push(section_title("Controles"));
children.push(switch_row("Modo pleno", model.switch_a, Msg::ToggleA, theme));
children.push(switch_row("Telemetría", model.switch_b, Msg::ToggleB, theme));
children.push(spacer_v(8.0));
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
..Default::default()
})
.children(vec![segmented_view(
&["lista", "grilla", "kanban"],
model.seg,
Msg::SelectSeg,
&SegmentedPalette::from_theme(theme),
)]),
);
children.push(section_title("Formulario"));
children.push(field_view(FieldSpec {
label: "Nombre del lienzo".to_string(),
control: fake_text_input("introducción a wawa", theme),
required: true,
helper: Some("Aparece como título en la pestaña.".to_string()),
error: None,
palette: FieldPalette::from_theme(theme),
}));
children.push(spacer_v(12.0));
children.push(field_view(FieldSpec {
label: "Slug".to_string(),
control: fake_text_input("intro-wawa-x@123", theme),
required: false,
helper: None,
error: Some("Sólo letras, números y guiones.".to_string()),
palette: FieldPalette::from_theme(theme),
}));
children.push(section_title("Acciones"));
children.push(button_row(theme));
panel_view(children, theme)
}
fn column_right(_model: &Model, theme: &Theme) -> View<Msg> {
let mut children: Vec<View<Msg>> = Vec::new();
children.push(section_title("Identidades"));
// Avatares en línea con badge encima.
children.push(
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(48.0_f32),
},
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![
avatar_view("sergio", 40.0),
avatar_view("calcetín", 40.0),
avatar_view("amaru", 40.0),
avatar_view("pacha", 40.0),
avatar_view("inti", 40.0),
]),
);
children.push(section_title("Badges"));
children.push(
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(24.0_f32),
},
gap: Size {
width: length(10.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![
count_badge_view(3, BadgeKind::Info),
count_badge_view(12, BadgeKind::Success),
count_badge_view(99, BadgeKind::Warning),
count_badge_view(120, BadgeKind::Error),
dot_badge_view(BadgeKind::Success),
dot_badge_view(BadgeKind::Warning),
dot_badge_view(BadgeKind::Error),
]),
);
children.push(section_title("Carga"));
children.push(
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(48.0_f32),
},
gap: Size {
width: length(16.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![
View::new(Style {
size: Size {
width: length(40.0_f32),
height: length(40.0_f32),
},
..Default::default()
})
.children(vec![spinner_view(theme.accent, 0.12, 1.0)]),
View::new(Style {
size: Size {
width: length(40.0_f32),
height: length(40.0_f32),
},
..Default::default()
})
.children(vec![radial_progress_view(
0.66,
theme.bg_button,
theme.accent,
0.14,
)]),
linear_progress_view(0.42, theme.bg_button, theme.accent, 8.0),
]),
);
children.push(section_title("Skeleton"));
let palette = SkeletonPalette::from_theme(theme);
children.push(skeleton_line_view::<Msg>(200.0, &palette));
children.push(spacer_v(6.0));
children.push(skeleton_line_view::<Msg>(280.0, &palette));
children.push(spacer_v(6.0));
children.push(skeleton_line_view::<Msg>(160.0, &palette));
children.push(spacer_v(10.0));
children.push(skeleton_box_view::<Msg>(percent_to_px(0.9, 360.0), 60.0, &palette));
children.push(section_title("Cards"));
// Dos cards apilados: el primero con la firma (gradient sutil +
// hairline en el top), el segundo con `accent` lateral y fill plano.
// Para apreciar la firma hay que mirar de cerca: el ojo registra
// "tallado" sin saber por qué.
let card_palette = CardPalette::from_theme(theme);
children.push(card_view(
vec![
text_line("Documento — multilienzo", 13.0, theme.fg_text),
text_line("3 cuerpos · 412 átomos · BLAKE3 verificado", 11.0, theme.fg_muted),
],
CardOptions::with_signature(theme),
&card_palette,
));
children.push(spacer_v(8.0));
children.push(card_view(
vec![
text_line("Build pasó — wawa-kernel", 13.0, theme.fg_text),
text_line("x86_64-unknown-none · 1.42s · 0 warnings", 11.0, theme.fg_muted),
],
CardOptions {
accent: Some(Color::from_rgba8(110, 200, 130, 255)),
..Default::default()
},
&card_palette,
));
children.push(section_title("Menú contextual"));
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(32.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(theme.bg_button)
.hover_fill(theme.bg_button_hover)
.radius(llimphi_theme::radius::SM)
.text_aligned(
"Mostrar menú".to_string(),
12.0,
theme.fg_text,
Alignment::Center,
)
.on_click(Msg::OpenContextMenu),
);
children.push(section_title("Iconografía"));
children.push(icon_grid(theme));
panel_view(children, theme)
}
// ---------------------------------------------------------------------
// Helpers de composición
// ---------------------------------------------------------------------
fn text_line(text: &str, size: f32, color: Color) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(size + 6.0),
},
..Default::default()
})
.text_aligned(text.to_string(), size, color, Alignment::Start)
}
fn section_title(text: &str) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(
text.to_uppercase(),
10.0,
Color::from_rgba8(140, 160, 200, 255),
Alignment::Start,
)
}
fn panel_view(children: Vec<View<Msg>>, theme: &Theme) -> View<Msg> {
let style = PanelStyle::from_theme(theme);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(14.0_f32),
bottom: length(14.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(10.0_f32),
},
..Default::default()
})
.paint_with(panel_signature_painter(style))
.radius(style.radius)
.clip(true)
.children(children)
}
fn switch_row(label: &str, value: bool, msg: Msg, theme: &Theme) -> View<Msg> {
let progress = if value { 1.0 } else { 0.0 };
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
..Default::default()
})
.children(vec![
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(label.to_string(), 12.0, theme.fg_text, Alignment::Start),
switch_view(progress, msg, &SwitchPalette::from_theme(theme)),
])
}
fn fake_text_input(text: &str, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(theme.bg_input)
.radius(llimphi_theme::radius::SM)
.text_aligned(text.to_string(), 12.0, theme.fg_text, Alignment::Start)
}
fn button_row(theme: &Theme) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(32.0_f32),
},
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![
btn("Mostrar toast", theme.accent, theme.bg_app, Msg::PushToast),
btn("Abrir modal", theme.bg_button, theme.fg_text, Msg::OpenModal),
btn("Atajos (?)", theme.bg_button, theme.fg_text, Msg::ToggleShortcuts),
])
}
fn btn(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
let w = label.chars().count() as f32 * 7.5 + 24.0;
View::new(Style {
size: Size {
width: length(w),
height: length(32.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.radius(llimphi_theme::radius::SM)
.text_aligned(label.to_string(), 12.0, fg, Alignment::Center)
.on_click(msg)
}
fn icon_grid(theme: &Theme) -> View<Msg> {
let icons = [
Icon::File, Icon::Folder, Icon::Save, Icon::Open, Icon::Search,
Icon::Plus, Icon::Minus, Icon::X, Icon::Check, Icon::Edit,
Icon::Trash, Icon::Home, Icon::Settings, Icon::Bell, Icon::More,
Icon::Info, Icon::Warning, Icon::Error, Icon::ChevronUp,
Icon::ChevronDown, Icon::ChevronLeft, Icon::ChevronRight,
Icon::FolderOpen,
];
let cells: Vec<View<Msg>> = icons
.iter()
.map(|i| {
View::new(Style {
size: Size {
width: length(28.0_f32),
height: length(28.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(theme.bg_panel_alt)
.radius(llimphi_theme::radius::XS)
.children(vec![icon_view(*i, theme.fg_text, 1.6)])
})
.collect();
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: auto(),
},
gap: Size {
width: length(6.0_f32),
height: length(6.0_f32),
},
flex_wrap: llimphi_ui::llimphi_layout::taffy::FlexWrap::Wrap,
..Default::default()
})
.children(cells)
}
fn modal_body_view(theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.text_aligned(
"Esta acción reescribirá la configuración local. \
Sólo dura mientras no salgas — al guardar quedará persistida en disco."
.to_string(),
12.0,
theme.fg_muted,
Alignment::Start,
)
}
fn wawa_frame(side: f32) -> View<Msg> {
View::new(Style {
size: Size {
width: length(side),
height: length(side),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
flex_shrink: 0.0,
..Default::default()
})
.children(vec![wawa_mark_view(&WawaMarkPalette::default())])
}
fn spacer_v(h: f32) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(h),
},
flex_shrink: 0.0,
..Default::default()
})
}
fn percent_to_px(p: f32, base: f32) -> f32 {
p * base
}
// Tooltip placeholder — la demo no instrumenta hover-to-show porque
// requeriría más Msgs; queda como código de referencia para apps reales.
#[allow(dead_code)]
fn demo_tooltip(viewport: (f32, f32), text: &str, theme: &Theme) -> View<Msg> {
tooltip_view::<Msg>(TooltipSpec {
anchor: (viewport.0 * 0.5, viewport.1 * 0.5),
viewport,
side: Side::Bottom,
text: text.to_string(),
palette: TooltipPalette::from_theme(theme),
})
}
fn main() {
llimphi_ui::run::<Gallery>();
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "llimphi-gpu-bench"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Binario standalone que valida el SDD §'GPU directo wgpu' en una máquina con GPU real: imprime info del adapter, corre vello vs GPU directo a varios N, evalúa el criterio (≥5× a 500K, ≥60 fps @ 1M) y exporta PNGs de verificación."
[dependencies]
llimphi-hal = { path = "../llimphi-hal" }
llimphi-raster = { path = "../llimphi-raster" }
vello = { workspace = true }
pollster = { workspace = true }
png = { workspace = true }
+941
View File
@@ -0,0 +1,941 @@
//! `llimphi-gpu-bench` — binario standalone para validar el SDD
//! `02_ruway/llimphi/SDD.md` §"GPU directo wgpu" en una máquina con GPU
//! real.
//!
//! Hace cuatro cosas en orden y lo imprime todo a stdout en formato
//! markdown / tabla copy-paste friendly:
//!
//! 1. **Header del sistema** — versión, hora, OS, GPU detectado.
//! 2. **Info del adapter wgpu** — backend (Vulkan/Metal/DX12/GL),
//! device name, vendor, limits relevantes.
//! 3. **Spike vello vs GPU directo** — para N ∈ {25K, 50K, 100K, 200K,
//! 500K, 1M}. Mide ms/frame de cada uno y el factor. Evalúa el
//! criterio del SDD: ≥5× a 500K → PASA; < → ABORTAR.
//! 4. **Escalado GPU directo solo** — para N ∈ {100K, 500K, 1M, 2M,
//! 5M, 10M}. Mide ms/frame, fps equivalente, Mprim/s. Evalúa el
//! objetivo de 60 fps @ 1M.
//! 5. **PNGs de verificación visual** — exporta 2 archivos al cwd:
//! `bench_vello_100k.png` y `bench_directo_100k.png`. La forma del
//! cielo de puntos debe coincidir entre los dos (LCG determinista).
//!
//! Pegar el output completo en chat para la verificación.
//!
//! Corre con: `cargo run -p llimphi-gpu-bench --release`.
use std::fs::File;
use std::io::{BufWriter, Write};
use std::time::Instant;
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::kurbo::{Affine, Rect};
use llimphi_raster::peniko::{color::palette, Color, Fill};
use llimphi_raster::{vello, GpuBatch, GpuPipelines};
const W: u32 = 1024;
const H: u32 = 1024;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const WARMUP: usize = 5;
const MEASURED: usize = 15;
const SPIKE_SIZES: &[u32] = &[25_000, 50_000, 100_000, 200_000, 500_000, 1_000_000];
const SCALE_SIZES: &[u32] = &[100_000, 500_000, 1_000_000, 2_000_000, 5_000_000, 10_000_000];
/// Overrides via env vars (para correr en hosts limitados sin tumbar el
/// binario). En GPU real ignorarlos y dejar los defaults.
///
/// - `LLIMPHI_BENCH_SPIKE_MAX=N` — recorta SPIKE_SIZES a los ≤ N.
/// - `LLIMPHI_BENCH_SCALE_MAX=N` — idem SCALE_SIZES.
/// - `LLIMPHI_BENCH_SKIP_VELLO=1` — saltea totalmente la columna vello
/// (útil si vello revienta con SIGSEGV en este host).
fn spike_sizes() -> Vec<u32> {
let max = std::env::var("LLIMPHI_BENCH_SPIKE_MAX")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(u32::MAX);
SPIKE_SIZES.iter().copied().filter(|&n| n <= max).collect()
}
fn scale_sizes() -> Vec<u32> {
let max = std::env::var("LLIMPHI_BENCH_SCALE_MAX")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(u32::MAX);
SCALE_SIZES.iter().copied().filter(|&n| n <= max).collect()
}
fn skip_vello() -> bool {
std::env::var("LLIMPHI_BENCH_SKIP_VELLO").ok().as_deref() == Some("1")
}
fn main() {
print_header();
let hal = pollster::block_on(Hal::new(None)).expect("hal");
print_adapter(&hal);
let (target, view) = make_target(&hal.device);
let pipelines = GpuPipelines::new(&hal.device, FMT);
let mut vello_renderer = vello::Renderer::new(
&hal.device,
vello::RendererOptions {
use_cpu: false,
antialiasing_support: vello::AaSupport {
area: true,
msaa8: false,
msaa16: false,
},
num_init_threads: None,
pipeline_cache: None,
},
)
.expect("vello renderer");
println!("## Spike vello vs GPU directo");
println!();
println!("Target: {W}×{H} Rgba8Unorm, headless. Cada N corre {WARMUP} warmup + {MEASURED} medidos, reporta mediana.");
println!();
println!("| N | vello ms | directo ms | factor | nota |");
println!("|---:|---:|---:|---:|---|");
let mut spike_rows: Vec<SpikeRow> = Vec::new();
let skip_v = skip_vello();
for n in spike_sizes() {
let row = bench_spike(&hal, &mut vello_renderer, &pipelines, &view, n, skip_v);
let note = if row.vello_crashed {
"vello SIGSEGV/error"
} else if let Some(f) = row.factor {
if f >= 5.0 { "≥5×" } else { "<5×" }
} else {
"-"
};
let vello_str = if row.vello_crashed {
"".to_string()
} else {
format!("{:.2}", row.vello_ms.unwrap_or(0.0))
};
let factor_str = match row.factor {
Some(f) => format!("{:.2}×", f),
None => "".to_string(),
};
println!(
"| {} | {} | {:.2} | {} | {} |",
fmt_int(n),
vello_str,
row.directo_ms,
factor_str,
note
);
let _ = std::io::stdout().flush();
spike_rows.push(row);
}
println!();
print_spike_verdict(&spike_rows);
println!("## Escalado GPU directo");
println!();
println!("API real (`GpuPipelines` + `GpuBatch::add_rect`). Sólo se mide el lado GPU directo — vello no llega acá.");
println!();
println!("| N | ms / frame | fps (1000/ms) | Mprim/s |");
println!("|---:|---:|---:|---:|");
let mut scale_rows: Vec<ScaleRow> = Vec::new();
for n in scale_sizes() {
let ms = bench_directo(&hal, &pipelines, &view, n);
let fps = 1000.0 / ms;
let mps = (n as f64 / 1_000_000.0) / (ms / 1000.0);
println!(
"| {} | {:.2} | {:.1} | {:.2} |",
fmt_int(n),
ms,
fps,
mps
);
let _ = std::io::stdout().flush();
scale_rows.push(ScaleRow { n, ms, fps, mps });
}
println!();
print_scale_verdict(&scale_rows);
// ----------------------------------------------------------------
// Variantes persistentes: el rebuild del batch/scene por frame es
// el peor caso. En apps reales (cosmos starfield Gaia, tinkuy
// particles iniciales, nakui viewport estático) los datos no
// cambian por frame — se uploadean UNA vez y el bucle solo redraw.
// Estos benches lo miden.
// ----------------------------------------------------------------
println!("## Persistente — datos fijos, sólo redraw por frame");
println!();
println!("Setup (LCG + write_buffer / Scene fill) fuera de la medición; el bucle medido sólo emite render_pass + draw + submit + wait.");
println!();
println!("### vello (Scene reutilizada sin reset)");
println!();
println!("| N | ms / frame | fps (1000/ms) |");
println!("|---:|---:|---:|");
let mut vello_persist_rows: Vec<(u32, f64)> = Vec::new();
let skip_v = skip_vello();
for n in scale_sizes() {
if skip_v {
println!("| {} | skipped | — |", fmt_int(n));
continue;
}
let attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
bench_vello_persistent(&hal, &mut vello_renderer, &view, n)
}));
match attempt {
Ok(ms) => {
let fps = 1000.0 / ms;
println!("| {} | {:.2} | {:.1} |", fmt_int(n), ms, fps);
let _ = std::io::stdout().flush();
vello_persist_rows.push((n, ms));
}
Err(_) => {
println!("| {} | crash | — |", fmt_int(n));
}
}
}
println!();
println!("### GPU directo (buffer + bind group persistentes)");
println!();
println!("| N | ms / frame | fps (1000/ms) | Mprim/s |");
println!("|---:|---:|---:|---:|");
let mut directo_persist_rows: Vec<ScaleRow> = Vec::new();
for n in scale_sizes() {
let ms = bench_directo_persistent(&hal, &pipelines, &view, n);
let fps = 1000.0 / ms;
let mps = (n as f64 / 1_000_000.0) / (ms / 1000.0);
println!("| {} | {:.2} | {:.1} | {:.2} |", fmt_int(n), ms, fps, mps);
let _ = std::io::stdout().flush();
directo_persist_rows.push(ScaleRow { n, ms, fps, mps });
}
println!();
print_persistent_verdict(&directo_persist_rows, &vello_persist_rows);
println!("## Validación visual");
println!();
let png_vello = "bench_vello_100k.png";
let png_directo = "bench_directo_100k.png";
if let Err(e) = export_vello_png(&hal, &mut vello_renderer, &target, &view, 100_000, png_vello)
{
println!("vello PNG fallo: {e}");
} else {
println!("- vello 100K → `{}` ({W}×{H})", png_vello);
}
if let Err(e) =
export_directo_png(&hal, &pipelines, &target, &view, 100_000, png_directo)
{
println!("directo PNG fallo: {e}");
} else {
println!("- directo 100K → `{}` ({W}×{H})", png_directo);
}
println!();
println!("Las dos imágenes deben mostrar la misma constelación de puntos (LCG determinista).");
println!("Mirar en visor: si vello tiene halo AA suave y directo tiene pixeles hard-edged, todo bien.");
println!();
println!("## Resumen");
println!();
print_summary(
&spike_rows,
&scale_rows,
&directo_persist_rows,
&vello_persist_rows,
);
}
// ============================================================
// IO / header
// ============================================================
fn print_header() {
println!("# llimphi-gpu-bench");
println!();
println!("Validación de Fase 0 del SDD `02_ruway/llimphi/SDD.md` §\"GPU directo wgpu\".");
println!("Criterio: factor ≥ 5× a 500K Y ≥ 60 fps @ 1M en GPU mid (Radeon 5500M, Iris Xe).");
println!();
println!("- crate version: {}", env!("CARGO_PKG_VERSION"));
println!("- host OS: {}", std::env::consts::OS);
println!("- host arch: {}", std::env::consts::ARCH);
println!();
}
fn print_adapter(hal: &Hal) {
let info = hal.adapter.get_info();
let limits = hal.adapter.limits();
println!("## Adapter wgpu");
println!();
println!("- backend: `{:?}`", info.backend);
println!("- device name: `{}`", info.name);
println!("- vendor: `0x{:04x}`", info.vendor);
println!("- device id: `0x{:04x}`", info.device);
println!("- device type: `{:?}`", info.device_type);
println!("- driver: `{}`", info.driver);
println!("- driver info: `{}`", info.driver_info);
println!();
println!("Limits relevantes:");
println!();
println!("- max texture 2D: {}", limits.max_texture_dimension_2d);
println!("- max buffer size: {} MB", limits.max_buffer_size / (1024 * 1024));
println!("- max storage buffer binding: {} MB", limits.max_storage_buffer_binding_size / (1024 * 1024));
println!();
let is_software = matches!(
info.device_type,
wgpu::DeviceType::Cpu
) || info.driver.to_lowercase().contains("llvmpipe")
|| info.driver.to_lowercase().contains("software")
|| info.name.to_lowercase().contains("llvmpipe")
|| info.name.to_lowercase().contains("swiftshader");
if is_software {
println!("⚠️ Adapter parece software (`{}`). Los números no reflejan GPU real.", info.name);
println!();
}
}
fn fmt_int(n: u32) -> String {
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
out.push('_');
}
out.push(c);
}
out.chars().rev().collect()
}
// ============================================================
// Benches
// ============================================================
struct SpikeRow {
n: u32,
vello_ms: Option<f64>,
vello_crashed: bool,
directo_ms: f64,
factor: Option<f64>,
}
struct ScaleRow {
n: u32,
ms: f64,
fps: f64,
mps: f64,
}
fn bench_spike(
hal: &Hal,
vello_renderer: &mut vello::Renderer,
pipelines: &GpuPipelines,
view: &wgpu::TextureView,
n: u32,
skip_vello: bool,
) -> SpikeRow {
let directo_ms = bench_directo(hal, pipelines, view, n);
if skip_vello {
return SpikeRow {
n,
vello_ms: None,
vello_crashed: true, // tratamos "skipped" como "no llegó"
directo_ms,
factor: None,
};
}
// catch_unwind sólo atrapa panics, no SIGSEGV. En vello pre-200K
// este path debería ser suficiente; si el binario muere igual,
// re-correr con `LLIMPHI_BENCH_SKIP_VELLO=1`.
let vello_attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
bench_vello(hal, vello_renderer, view, n)
}));
match vello_attempt {
Ok(ms) => {
let factor = ms / directo_ms;
SpikeRow {
n,
vello_ms: Some(ms),
vello_crashed: false,
directo_ms,
factor: Some(factor),
}
}
Err(_) => SpikeRow {
n,
vello_ms: None,
vello_crashed: true,
directo_ms,
factor: None,
},
}
}
fn bench_vello(
hal: &Hal,
renderer: &mut vello::Renderer,
view: &wgpu::TextureView,
n: u32,
) -> f64 {
let mut scene = vello::Scene::new();
let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
for frame in 0..(WARMUP + MEASURED) {
let t0 = Instant::now();
scene.reset();
let mut state: u32 = 0x1234_5678;
for _ in 0..n {
let (x, y, rgba) = lcg_point(&mut state);
let r = (rgba & 0xFF) as u8;
let g = ((rgba >> 8) & 0xFF) as u8;
let b = ((rgba >> 16) & 0xFF) as u8;
let a = ((rgba >> 24) & 0xFF) as u8;
let xf = x as f64;
let yf = y as f64;
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
Color::from_rgba8(r, g, b, a),
None,
&Rect::new(xf, yf, xf + POINT_PX as f64, yf + POINT_PX as f64),
);
}
renderer
.render_to_texture(
&hal.device,
&hal.queue,
&scene,
view,
&vello::RenderParams {
base_color: palette::css::BLACK,
width: W,
height: H,
antialiasing_method: vello::AaConfig::Area,
},
)
.expect("vello render");
hal.device.poll(wgpu::Maintain::Wait);
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
}
}
median(&mut samples)
}
fn bench_directo(
hal: &Hal,
pipelines: &GpuPipelines,
view: &wgpu::TextureView,
n: u32,
) -> f64 {
let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
for frame in 0..(WARMUP + MEASURED) {
let t0 = Instant::now();
let mut batch = GpuBatch::new(pipelines);
let mut state: u32 = 0x1234_5678;
for _ in 0..n {
let (x, y, rgba) = lcg_point(&mut state);
let r = (rgba & 0xFF) as u8;
let g = ((rgba >> 8) & 0xFF) as u8;
let b = ((rgba >> 16) & 0xFF) as u8;
let a = ((rgba >> 24) & 0xFF) as u8;
batch.add_rect(x, y, POINT_PX, POINT_PX, Color::from_rgba8(r, g, b, a));
}
let mut encoder = hal.device.create_command_encoder(
&wgpu::CommandEncoderDescriptor {
label: Some("bench-directo-enc"),
},
);
batch.flush(
&hal.device,
&hal.queue,
&mut encoder,
view,
(W as f32, H as f32),
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
}
}
median(&mut samples)
}
/// Vello persistente: la Scene se construye UNA vez (fill N rects) y
/// el bucle medido sólo invoca `render_to_texture`. Sin `scene.reset()`.
fn bench_vello_persistent(
hal: &Hal,
renderer: &mut vello::Renderer,
view: &wgpu::TextureView,
n: u32,
) -> f64 {
let mut scene = vello::Scene::new();
scene.reset();
let mut state: u32 = 0x1234_5678;
for _ in 0..n {
let (x, y, rgba) = lcg_point(&mut state);
let r = (rgba & 0xFF) as u8;
let g = ((rgba >> 8) & 0xFF) as u8;
let b = ((rgba >> 16) & 0xFF) as u8;
let a = ((rgba >> 24) & 0xFF) as u8;
let xf = x as f64;
let yf = y as f64;
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
Color::from_rgba8(r, g, b, a),
None,
&Rect::new(xf, yf, xf + POINT_PX as f64, yf + POINT_PX as f64),
);
}
let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
for frame in 0..(WARMUP + MEASURED) {
let t0 = Instant::now();
renderer
.render_to_texture(
&hal.device,
&hal.queue,
&scene,
view,
&vello::RenderParams {
base_color: palette::css::BLACK,
width: W,
height: H,
antialiasing_method: vello::AaConfig::Area,
},
)
.expect("vello render");
hal.device.poll(wgpu::Maintain::Wait);
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
}
}
median(&mut samples)
}
/// GPU directo persistente: instance buffer + uniform buffer + bind
/// group se construyen UNA vez. Bucle medido sólo abre render_pass,
/// hace `draw(0..6, 0..n)` y submit.
///
/// Replica el layout que pinta `GpuBatch::add_rect` por debajo
/// (instance stride 20 B = [x:f32, y:f32, w:f32, h:f32, rgba:u32]),
/// usando el `rects` pipeline + `bind_layout` expuestos por
/// `GpuPipelines`.
fn bench_directo_persistent(
hal: &Hal,
pipelines: &GpuPipelines,
view: &wgpu::TextureView,
n: u32,
) -> f64 {
// Empaquetar instancias UNA vez.
let mut bytes = Vec::with_capacity(n as usize * 20);
let mut state: u32 = 0x1234_5678;
for _ in 0..n {
let (x, y, rgba) = lcg_point(&mut state);
bytes.extend_from_slice(&x.to_ne_bytes());
bytes.extend_from_slice(&y.to_ne_bytes());
bytes.extend_from_slice(&POINT_PX.to_ne_bytes());
bytes.extend_from_slice(&POINT_PX.to_ne_bytes());
bytes.extend_from_slice(&rgba.to_ne_bytes());
}
let inst_buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("persist-rects"),
size: bytes.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
hal.queue.write_buffer(&inst_buf, 0, &bytes);
// Uniforms (viewport + line_width).
let u_data: [f32; 4] = [W as f32, H as f32, 1.0, 0.0];
let mut u_bytes = Vec::with_capacity(16);
for v in u_data {
u_bytes.extend_from_slice(&v.to_ne_bytes());
}
let uniforms = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("persist-uniforms"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
hal.queue.write_buffer(&uniforms, 0, &u_bytes);
let bind_group = hal.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("persist-bg"),
layout: &pipelines.bind_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniforms.as_entire_binding(),
}],
});
// Asegurar que toda la escritura previa esté en la GPU antes de
// empezar a medir frames — si no, el primer frame paga el upload.
hal.queue.submit(std::iter::empty::<wgpu::CommandBuffer>());
hal.device.poll(wgpu::Maintain::Wait);
let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
for frame in 0..(WARMUP + MEASURED) {
let t0 = Instant::now();
let mut encoder = hal.device.create_command_encoder(
&wgpu::CommandEncoderDescriptor {
label: Some("persist-enc"),
},
);
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("persist-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&pipelines.rects);
pass.set_bind_group(0, &bind_group, &[]);
pass.set_vertex_buffer(0, inst_buf.slice(..));
pass.draw(0..6, 0..n);
}
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
}
}
median(&mut samples)
}
fn lcg_point(state: &mut u32) -> (f32, f32, u32) {
*state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let x = (*state % W) as f32;
*state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let y = (*state % H) as f32;
*state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
// Colores: piso 128 por canal para que las PNGs de verificación
// se vean (sin esto el LCG produce muchos negros casi puros, y
// los puntos quedan invisibles en pantalla aunque estén pintados).
let r = 128 | ((*state >> 0) & 0x7F) as u8;
let g = 128 | ((*state >> 8) & 0x7F) as u8;
let b = 128 | ((*state >> 16) & 0x7F) as u8;
let rgba = (r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | 0xFF00_0000;
(x, y, rgba)
}
const POINT_PX: f32 = 2.5;
fn median(samples: &mut [f64]) -> f64 {
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
samples[samples.len() / 2]
}
// ============================================================
// Veredictos
// ============================================================
fn print_spike_verdict(rows: &[SpikeRow]) {
let at_500k = rows.iter().find(|r| r.n == 500_000);
match at_500k {
Some(r) if r.vello_crashed => {
println!("**Veredicto Fase 0:** Vello revienta antes de 500K → directo es el único path posible en ese régimen. PASA cualitativo.");
}
Some(r) => match r.factor {
Some(f) if f >= 5.0 => {
println!("**Veredicto Fase 0:** factor a 500K = {:.2}× ≥ 5 → **PASA** (criterio SDD cumplido).", f);
}
Some(f) => {
println!("**Veredicto Fase 0:** factor a 500K = {:.2}× < 5 → **ABORTAR** según criterio literal del SDD.", f);
println!("Pero ver si vello revienta a tamaños mayores — eso cambia el veredicto cualitativamente.");
}
None => {
println!("**Veredicto Fase 0:** sin datos para 500K (vello crashed o N no medido). Revisar tabla arriba.");
}
},
None => {
println!("**Veredicto Fase 0:** no se midió 500K en este run. Revisar tabla arriba.");
}
}
println!();
}
fn print_persistent_verdict(
directo: &[ScaleRow],
vello: &[(u32, f64)],
) {
let d_1m = directo.iter().find(|r| r.n == 1_000_000);
let v_1m = vello.iter().find(|(n, _)| *n == 1_000_000);
match d_1m {
Some(r) if r.fps >= 60.0 => {
println!(
"**Veredicto persistente @ 1M:** directo {:.1} fps ≥ 60 → **PASA**.",
r.fps
);
}
Some(r) => {
println!(
"**Veredicto persistente @ 1M:** directo {:.1} fps < 60 → falla incluso sin rebuild.",
r.fps
);
}
None => println!("**Veredicto:** sin datos a 1M."),
}
if let (Some(d), Some((_, v_ms))) = (d_1m, v_1m) {
let factor = v_ms / d.ms;
println!(
"**Factor persistente @ 1M:** vello {:.1} ms / directo {:.1} ms = {:.2}× ({})",
v_ms,
d.ms,
factor,
if factor >= 5.0 { "≥5×" } else { "<5×" }
);
}
println!();
}
fn print_scale_verdict(rows: &[ScaleRow]) {
let at_1m = rows.iter().find(|r| r.n == 1_000_000);
match at_1m {
Some(r) if r.fps >= 60.0 => {
println!("**Veredicto Fase 0 (objetivo 60 fps @ 1M):** {:.1} fps ≥ 60 → **PASA**.", r.fps);
}
Some(r) => {
println!("**Veredicto Fase 0 (objetivo 60 fps @ 1M):** {:.1} fps < 60 → marginal. ¿Es CPU-bound el bench (write_buffer de 12-20 MB por frame)? Probar también con `mapped_at_creation` para sacar el camino más rápido.", r.fps);
}
None => {
println!("**Veredicto:** sin datos para 1M.");
}
}
println!();
}
fn print_summary(
spike: &[SpikeRow],
scale: &[ScaleRow],
persist_directo: &[ScaleRow],
persist_vello: &[(u32, f64)],
) {
println!("Copiar lo que sigue al chat:");
println!();
println!("```");
println!("rebuild por frame — vello vs directo:");
for r in spike {
let v = match (r.vello_crashed, r.vello_ms) {
(true, _) => "crash".to_string(),
(_, Some(ms)) => format!("{:.1}ms", ms),
_ => "-".to_string(),
};
let f = r
.factor
.map(|x| format!("{:.2}x", x))
.unwrap_or_else(|| "-".to_string());
println!(" {:>10} vello={:>10} directo={:>7.1}ms factor={}", fmt_int(r.n), v, r.directo_ms, f);
}
println!();
println!("rebuild por frame — escalado directo:");
for r in scale {
println!(" {:>10} {:>7.1}ms {:>5.1}fps {:>5.2}Mprim/s", fmt_int(r.n), r.ms, r.fps, r.mps);
}
println!();
println!("persistente (datos fijos, sólo redraw):");
for r in persist_directo {
let v_ms = persist_vello
.iter()
.find(|(n, _)| *n == r.n)
.map(|(_, ms)| format!("{:>7.1}ms", ms))
.unwrap_or_else(|| "".to_string());
let factor = persist_vello
.iter()
.find(|(n, _)| *n == r.n)
.map(|(_, vms)| format!("factor={:.2}x", vms / r.ms))
.unwrap_or_else(|| "factor= — ".to_string());
println!(
" {:>10} vello={} directo={:>7.1}ms {} {:>5.1}fps {:>5.2}Mprim/s",
fmt_int(r.n),
v_ms,
r.ms,
factor,
r.fps,
r.mps,
);
}
println!("```");
}
// ============================================================
// Textura destino + PNG export
// ============================================================
fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("bench-target"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
// RENDER_ATTACHMENT para el directo, STORAGE_BINDING para vello,
// TEXTURE_BINDING + COPY_SRC para poder leer (PNG export).
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
(tex, view)
}
fn export_vello_png(
hal: &Hal,
renderer: &mut vello::Renderer,
target: &wgpu::Texture,
view: &wgpu::TextureView,
n: u32,
path: &str,
) -> Result<(), String> {
let mut scene = vello::Scene::new();
let mut state: u32 = 0x1234_5678;
for _ in 0..n {
let (x, y, rgba) = lcg_point(&mut state);
let r = (rgba & 0xFF) as u8;
let g = ((rgba >> 8) & 0xFF) as u8;
let b = ((rgba >> 16) & 0xFF) as u8;
let a = ((rgba >> 24) & 0xFF) as u8;
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
Color::from_rgba8(r, g, b, a),
None,
&Rect::new(x as f64, y as f64, x as f64 + POINT_PX as f64, y as f64 + POINT_PX as f64),
);
}
renderer
.render_to_texture(
&hal.device,
&hal.queue,
&scene,
view,
&vello::RenderParams {
base_color: palette::css::BLACK,
width: W,
height: H,
antialiasing_method: vello::AaConfig::Area,
},
)
.map_err(|e| format!("{e:?}"))?;
write_texture_png(hal, target, path)
}
fn export_directo_png(
hal: &Hal,
pipelines: &GpuPipelines,
target: &wgpu::Texture,
view: &wgpu::TextureView,
n: u32,
path: &str,
) -> Result<(), String> {
let mut batch = GpuBatch::new(pipelines);
let mut state: u32 = 0x1234_5678;
for _ in 0..n {
let (x, y, rgba) = lcg_point(&mut state);
let r = (rgba & 0xFF) as u8;
let g = ((rgba >> 8) & 0xFF) as u8;
let b = ((rgba >> 16) & 0xFF) as u8;
let a = ((rgba >> 24) & 0xFF) as u8;
batch.add_rect(x, y, POINT_PX, POINT_PX, Color::from_rgba8(r, g, b, a));
}
let mut encoder = hal.device.create_command_encoder(
&wgpu::CommandEncoderDescriptor {
label: Some("png-directo-enc"),
},
);
batch.flush(
&hal.device,
&hal.queue,
&mut encoder,
view,
(W as f32, H as f32),
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
write_texture_png(hal, target, path)
}
/// Copia la textura a un buffer mapeable + lee + escribe PNG.
fn write_texture_png(hal: &Hal, target: &wgpu::Texture, path: &str) -> Result<(), String> {
// wgpu pide stride alineado a 256 B en COPY_TEXTURE_TO_BUFFER.
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = ((unpadded + align - 1) / align) * align;
let buf_size = (padded * H as usize) as u64;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("png-readback"),
size: buf_size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = hal.device.create_command_encoder(
&wgpu::CommandEncoderDescriptor {
label: Some("png-copy-enc"),
},
);
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(encoder.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::Maintain::Wait);
rx.recv().map_err(|e| e.to_string())?.map_err(|e| e.to_string())?;
let data = slice.get_mapped_range();
// Desempaquetar las filas (skip padding) y escribir PNG.
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H {
let start = row as usize * padded;
let end = start + unpadded;
pixels.extend_from_slice(&data[start..end]);
}
drop(data);
buf.unmap();
let file = File::create(path).map_err(|e| e.to_string())?;
let writer = BufWriter::new(file);
let mut encoder = png::Encoder::new(writer, W, H);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut w = encoder.write_header().map_err(|e| e.to_string())?;
w.write_image_data(&pixels).map_err(|e| e.to_string())?;
Ok(())
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "llimphi-hal"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
wgpu = { workspace = true }
raw-window-handle = { workspace = true }
winit = { workspace = true }
pollster = { workspace = true }
[[example]]
name = "clear_screen"
path = "examples/clear_screen.rs"
+10
View File
@@ -0,0 +1,10 @@
# llimphi-hal
> Abstracción de superficie de [llimphi](../README.md). Multi-plataforma.
Trait `Surface` que abstrae window/framebuffer/canvas. Implementaciones: `winit` (Linux/macOS/Windows desktop), `android` (NDK), `wawa` (framebuffer del kernel). El resto del stack llimphi habla `Surface`; mover Wayland → Wawa es cambiar el HAL, no el árbol gráfico.
## Deps
- `winit`, `raw-window-handle`
- `serde`, `wgpu` (re-export para que widgets puedan paint_with)
+10
View File
@@ -0,0 +1,10 @@
# llimphi-hal
> Surface abstraction of [llimphi](../README.md). Multi-platform.
`Surface` trait that abstracts window/framebuffer/canvas. Implementations: `winit` (Linux/macOS/Windows desktop), `android` (NDK), `wawa` (kernel framebuffer). The rest of the llimphi stack talks to `Surface`; moving Wayland → Wawa is swapping the HAL, not the scene tree.
## Deps
- `winit`, `raw-window-handle`
- `serde`, `wgpu` (re-export so widgets can paint_with)
+135
View File
@@ -0,0 +1,135 @@
//! Fase 1 de Llimphi: ventana gris plomo a la frecuencia máxima del display.
//!
//! Corre con: `cargo run -p llimphi-hal --example clear_screen --release`.
//!
//! Imprime fps por stderr cada segundo. En un panel de 144 Hz con AutoVsync
//! debe estabilizarse cerca de 144; en uno de 60 Hz, cerca de 60.
use std::sync::Arc;
use std::time::Instant;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::dpi::LogicalSize;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{wgpu, Hal, Surface, WinitSurface};
const LEAD_GRAY: wgpu::Color = wgpu::Color {
r: 0.235,
g: 0.239,
b: 0.247,
a: 1.0,
};
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
}
struct App {
state: Option<State>,
frames: u64,
last_report: Instant,
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let window = event_loop
.create_window(
WindowAttributes::default()
.with_title("llimphi · clear_screen")
.with_inner_size(LogicalSize::new(960u32, 540u32)),
)
.expect("create window");
let window = Arc::new(window);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let surface = WinitSurface::new(&hal, window.clone()).expect("surface");
window.request_redraw();
self.state = Some(State {
window,
hal,
surface,
});
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(_) => {
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let mut encoder =
state
.hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("clear_screen-encoder"),
});
{
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("clear_screen-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: frame.view(),
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(LEAD_GRAY),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
}
state.hal.queue.submit(std::iter::once(encoder.finish()));
state.surface.present(frame, &state.hal);
self.frames += 1;
let elapsed = self.last_report.elapsed();
if elapsed.as_secs() >= 1 {
let fps = self.frames as f64 / elapsed.as_secs_f64();
eprintln!("llimphi · clear_screen — {fps:.1} fps");
self.frames = 0;
self.last_report = Instant::now();
}
state.window.request_redraw();
}
_ => {}
}
}
}
fn main() {
let event_loop = EventLoop::new().expect("event loop");
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = App {
state: None,
frames: 0,
last_report: Instant::now(),
};
event_loop.run_app(&mut app).expect("run app");
}
+823
View File
@@ -0,0 +1,823 @@
//! llimphi-hal — Puente al Silicio.
//!
//! Aísla el motor del sistema operativo. Pinta en ventana Wayland/X11
//! (vía `mirada` en producción, vía `winit` en dev) o framebuffer directo
//! del kernel `wawa` (TODO). Trait `Surface` abstracto + struct `Hal`
//! que posee Instance/Adapter/Device/Queue de wgpu.
use std::sync::Arc;
pub use raw_window_handle;
pub use wgpu;
pub use winit;
use winit::window::Window;
/// Errores al adquirir un frame de la superficie.
#[derive(Debug)]
pub enum SurfaceError {
Lost,
Outdated,
OutOfMemory,
Timeout,
Other(String),
}
impl std::fmt::Display for SurfaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Lost => write!(f, "surface lost"),
Self::Outdated => write!(f, "surface outdated"),
Self::OutOfMemory => write!(f, "surface out of memory"),
Self::Timeout => write!(f, "surface timeout"),
Self::Other(s) => write!(f, "surface error: {s}"),
}
}
}
impl std::error::Error for SurfaceError {}
/// Errores al construir Hal o crear una Surface.
#[derive(Debug)]
pub enum HalError {
NoAdapter,
RequestDevice(String),
CreateSurface(String),
}
impl std::fmt::Display for HalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoAdapter => write!(f, "no GPU adapter available"),
Self::RequestDevice(s) => write!(f, "request_device failed: {s}"),
Self::CreateSurface(s) => write!(f, "create_surface failed: {s}"),
}
}
}
impl std::error::Error for HalError {}
/// Superficie gráfica donde llimphi pinta.
///
/// Vello (rasterizador) emite a una textura intermedia con storage binding
/// (la única forma portable: los formatos de swapchain no aceptan writes
/// de compute shader en muchos adapters). En `present` se blittea la
/// intermedia al swapchain real y se hace el flip.
///
/// Implementaciones:
/// - [`WinitSurface`]: ventana Wayland/X11 (dev + producción vía mirada).
/// - `WawaFramebufferSurface` (TODO): framebuffer directo del kernel wawa.
pub trait Surface {
fn size(&self) -> (u32, u32);
fn resize(&mut self, width: u32, height: u32);
/// Adquiere la textura intermedia donde el raster pinta este frame.
fn acquire(&mut self) -> Result<Frame, SurfaceError>;
/// Blittea la intermedia al swapchain y la presenta.
fn present(&mut self, frame: Frame, hal: &Hal);
}
/// Frame en curso. `view()` devuelve la textura intermedia (Rgba8Unorm,
/// STORAGE_BINDING) lista para que vello escriba sobre ella.
pub struct Frame {
surface_texture: wgpu::SurfaceTexture,
surface_view: wgpu::TextureView,
intermediate_view: wgpu::TextureView,
/// Textura secundaria para la capa de overlay (menús/paleta/modal)
/// cuando hay contenido `gpu_paint` que la taparía. El overlay se
/// rasteriza acá con fondo transparente y luego se compone con
/// alpha SOBRE la intermedia (que ya tiene UI + video). Ver
/// [`OverlayCompositor`] y el eventloop de `llimphi-ui`.
overlay_view: wgpu::TextureView,
width: u32,
height: u32,
}
impl Frame {
pub fn view(&self) -> &wgpu::TextureView {
&self.intermediate_view
}
/// Vista de la textura de overlay (mismo tamaño y formato que la
/// intermedia). Sólo se usa en el camino de compositing del overlay.
pub fn overlay_view(&self) -> &wgpu::TextureView {
&self.overlay_view
}
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
}
/// Estado wgpu compartido. Una instancia por proceso. `Device` y `Queue`
/// son `Arc` internamente, así que clonar es barato.
pub struct Hal {
pub instance: wgpu::Instance,
pub adapter: wgpu::Adapter,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
}
impl Hal {
/// Construye Hal pidiendo un adapter compatible con una surface dada
/// (recomendado: pasar `Some(&surface)` para garantizar que el adapter
/// elegido sabe presentar a esa surface).
pub async fn new(
compatible_surface: Option<&wgpu::Surface<'static>>,
) -> Result<Self, HalError> {
let opts = wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface,
};
// Preferimos backends PRIMARY (Vulkan/Metal/DX12). El backend GL de
// wgpu sobre Mesa/Wayland tiene un bug de teardown: al soltar la
// instancia, `eglTerminate` marshalea sobre una conexión Wayland ya
// muerta (`wl_proxy_marshal`) y revienta con SIGSEGV. Con
// `Backends::all()` (el default), wgpu puede elegir GL aun habiendo
// Vulkan, y la app crashea al cerrar/teardown. Forzamos PRIMARY; si la
// máquina no tiene Vulkan/Metal/DX12 (VM vieja, etc.) caemos a todos
// los backends —incluido GL— para no dejarla sin gráficos. En el
// camino de escritorio `compatible_surface` es `None` (la surface se
// crea después contra esta misma instancia), así que cambiar de
// instancia aquí es seguro.
let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
let (instance, adapter) = match primary.request_adapter(&opts).await {
Some(a) => (primary, a),
None => {
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
let a = all.request_adapter(&opts).await.ok_or(HalError::NoAdapter)?;
(all, a)
}
};
// `Limits::default()` cubre los 5 storage buffers/stage que vello
// necesita. `downlevel_defaults()` solo expone 4 y rompe el raster.
// Si el adapter no lo aguanta, `using_resolution` recorta lo recortable
// (texturas/buffers grandes) preservando los conteos mínimos.
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("llimphi-hal-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
.await
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
Ok(Self {
instance,
adapter,
device,
queue,
})
}
/// Construye el `Hal` **y** una [`RawSurface`] a la vez, eligiendo el adaptador
/// **compatible con esa surface** — el dispositivo que el compositor sabe
/// presentar. Es el camino correcto para el backend layer-shell de `pata`.
///
/// El problema que resuelve: en sistemas multi-GPU (Optimus), pedir el
/// adaptador sin pista de surface (`new(None)` con `HighPerformance`) puede
/// elegir la dGPU mientras el compositor compone en la iGPU → los dmabuf
/// cruzan dispositivos y `get_capabilities` devuelve 0 formatos (la surface
/// "no expone formatos"). Pasar `compatible_surface` ata el adaptador al
/// dispositivo del compositor. Como la surface hace falta ANTES de pedir el
/// adaptador, y `new` crea la instancia internamente, este constructor une los
/// dos pasos.
///
/// `make_target` reconstruye el `SurfaceTargetUnsafe` cada vez que se llama
/// (los `RawHandle` son `Copy`): `create_surface_unsafe` consume el target y
/// puede que probemos dos instancias (PRIMARY y, si no hay adaptador, todos
/// los backends — el GL de Mesa/Wayland revienta en teardown, por eso PRIMARY
/// primero, igual que [`Hal::new`]).
///
/// # Safety
/// Los handles que produce `make_target` deben apuntar a objetos Wayland/…
/// vivos durante toda la vida de la `RawSurface` devuelta.
pub async unsafe fn new_for_raw_surface(
make_target: impl Fn() -> wgpu::SurfaceTargetUnsafe,
width: u32,
height: u32,
) -> Result<(Self, RawSurface), HalError> {
// PRIMARY (Vulkan/Metal/DX12) primero; si no hay adaptador compatible, a
// todos los backends recreando instancia y surface.
let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
let prim_surface = unsafe { primary.create_surface_unsafe(make_target()) }
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
let prim_adapter = primary
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&prim_surface),
})
.await;
let (instance, adapter, wgpu_surface) = match prim_adapter {
Some(a) => (primary, a, prim_surface),
None => {
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
let surface = unsafe { all.create_surface_unsafe(make_target()) }
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
let a = all
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
})
.await
.ok_or(HalError::NoAdapter)?;
(all, a, surface)
}
};
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("llimphi-hal-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
.await
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
let hal = Self {
instance,
adapter,
device,
queue,
};
let surface = RawSurface::from_surface(&hal, wgpu_surface, width, height)?;
Ok((hal, surface))
}
}
/// Surface basada en `winit::window::Window`. Mantiene una textura
/// intermedia `Rgba8Unorm` con storage binding (donde pinta vello) y
/// un `TextureBlitter` que la copia al swapchain al presentar.
pub struct WinitSurface {
_window: Arc<Window>,
surface: wgpu::Surface<'static>,
config: wgpu::SurfaceConfiguration,
device: wgpu::Device,
intermediate: wgpu::Texture,
intermediate_view: wgpu::TextureView,
/// Textura de la capa de overlay (ver [`Frame::overlay_view`]).
overlay: wgpu::Texture,
overlay_view: wgpu::TextureView,
blitter: wgpu::util::TextureBlitter,
}
const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
impl WinitSurface {
/// Constructor "feliz": crea la `wgpu::Surface` internamente.
/// Conveniente en desktop donde la secuencia normal es
/// `Hal::new(None)` → `WinitSurface::new(hal, window)`. **En Android
/// usar [`WinitSurface::from_surface`]** — allí la surface debe
/// existir antes del `request_adapter(compatible_surface=Some(...))`,
/// y crearla dos veces sobre la misma `ANativeWindow` falla con
/// `ERROR_NATIVE_WINDOW_IN_USE_KHR`.
pub fn new(hal: &Hal, window: Arc<Window>) -> Result<Self, HalError> {
let surface = hal
.instance
.create_surface(window.clone())
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
Self::from_surface(hal, window, surface)
}
/// Constructor reutilizable: arma el `WinitSurface` envolviendo una
/// `wgpu::Surface` ya creada por el caller. Necesario en Android
/// porque el orden allí es:
///
/// 1. `instance.create_surface(window)`
/// 2. `instance.request_adapter(compatible_surface=Some(&surface))`
/// 3. `adapter.request_device(...)`
/// 4. `WinitSurface::from_surface(hal, window, surface)`
///
/// — no se puede dropear la surface entre 2 y 4 ni recrearla, porque
/// Android reserva la `ANativeWindow` por VkSurface y rechaza un
/// segundo `vkCreateAndroidSurfaceKHR` sobre la misma ventana.
pub fn from_surface(
hal: &Hal,
window: Arc<Window>,
surface: wgpu::Surface<'static>,
) -> Result<Self, HalError> {
let size = window.inner_size();
let caps = surface.get_capabilities(&hal.adapter);
// Preferimos Bgra8Unorm o Rgba8Unorm (no sRGB) para que el blit
// desde la intermedia lineal preserve los valores tal cual.
let format = caps
.formats
.iter()
.copied()
.find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm))
.unwrap_or(caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
// El swapchain solo necesita render-attachment: vello no escribe
// directo, escribe a la intermedia y luego se blittea.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: choose_present_mode(&caps),
desired_maximum_frame_latency: 2,
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
};
surface.configure(&hal.device, &config);
let (intermediate, intermediate_view) =
create_intermediate(&hal.device, config.width, config.height);
let (overlay, overlay_view) =
create_intermediate(&hal.device, config.width, config.height);
let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
Ok(Self {
_window: window,
surface,
config,
device: hal.device.clone(),
intermediate,
intermediate_view,
overlay,
overlay_view,
blitter,
})
}
pub fn format(&self) -> wgpu::TextureFormat {
self.config.format
}
}
/// Surface sobre una `wgpu::Surface` creada desde **handles raw** (sin
/// `winit::Window`): la usa el backend `wlr-layer-shell` de `pata` para pintar
/// en una *layer surface* de Wayland (barras/paneles al nivel de eww/waybar).
/// Misma mecánica que [`WinitSurface`] —intermedia `Rgba8Unorm` + blit al
/// swapchain— pero el tamaño se pasa explícito porque no hay ventana que
/// consultar. La `wgpu::Surface` la crea el caller (típicamente con
/// `instance.create_surface_unsafe` desde los punteros `wl_display`/`wl_surface`).
pub struct RawSurface {
surface: wgpu::Surface<'static>,
config: wgpu::SurfaceConfiguration,
device: wgpu::Device,
intermediate: wgpu::Texture,
intermediate_view: wgpu::TextureView,
overlay: wgpu::Texture,
overlay_view: wgpu::TextureView,
blitter: wgpu::util::TextureBlitter,
}
impl RawSurface {
/// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial.
pub fn from_surface(
hal: &Hal,
surface: wgpu::Surface<'static>,
width: u32,
height: u32,
) -> Result<Self, HalError> {
let caps = surface.get_capabilities(&hal.adapter);
let info = hal.adapter.get_info();
// Si la superficie no expone formatos, el compositor no la soporta por
// este backend (Vulkan/GL WSI): error claro en vez de un panic por
// indexar `formats[0]` sobre una lista vacía.
let format = match caps
.formats
.iter()
.copied()
.find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm))
.or_else(|| caps.formats.first().copied())
{
Some(f) => f,
None => {
return Err(HalError::CreateSurface(format!(
"la superficie no expone formatos (adapter {:?}/{:?}): el compositor no la soporta por {:?} WSI",
info.backend, info.device_type, info.backend
)))
}
};
let alpha_mode = caps
.alpha_modes
.first()
.copied()
.unwrap_or(wgpu::CompositeAlphaMode::Auto);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: width.max(1),
height: height.max(1),
present_mode: choose_present_mode(&caps),
desired_maximum_frame_latency: 2,
alpha_mode,
view_formats: vec![],
};
surface.configure(&hal.device, &config);
let (intermediate, intermediate_view) =
create_intermediate(&hal.device, config.width, config.height);
let (overlay, overlay_view) =
create_intermediate(&hal.device, config.width, config.height);
let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
Ok(Self {
surface,
config,
device: hal.device.clone(),
intermediate,
intermediate_view,
overlay,
overlay_view,
blitter,
})
}
pub fn format(&self) -> wgpu::TextureFormat {
self.config.format
}
}
impl Surface for RawSurface {
fn size(&self) -> (u32, u32) {
(self.config.width, self.config.height)
}
fn resize(&mut self, width: u32, height: u32) {
let (w, h) = (width.max(1), height.max(1));
// Sin cambio de tamaño NO reconfiguramos. El backend layer-shell de `pata`
// llama a `resize` en cada cuadro (no tiene eventos de resize como winit);
// reconfigurar el swapchain por cuadro lo reconstruye una y otra vez, y en
// Vulkan WSI eso **destruye el `wl_buffer` recién presentado antes de que el
// compositor lo componga** — wlroots lo tolera, smithay (mirada) no, y la
// superficie queda en negro (el compositor ve `buffer=None`).
if self.config.width == w && self.config.height == h {
return;
}
self.config.width = w;
self.config.height = h;
self.surface.configure(&self.device, &self.config);
let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
self.intermediate = tex;
self.intermediate_view = view;
let (otex, oview) =
create_intermediate(&self.device, self.config.width, self.config.height);
self.overlay = otex;
self.overlay_view = oview;
}
fn acquire(&mut self) -> Result<Frame, SurfaceError> {
let texture = match self.surface.get_current_texture() {
Ok(t) => t,
// El backend layer-shell no tiene un evento de resize que reconfigure
// el swapchain; si quedó obsoleto/perdido, lo reconstruimos aquí mismo
// y reintentamos una vez. Sin esto el panel quedaría en negro para
// siempre tras el primer `Outdated`.
Err(e @ (wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost)) => {
self.surface.configure(&self.device, &self.config);
self.surface.get_current_texture().map_err(|_| match e {
wgpu::SurfaceError::Lost => SurfaceError::Lost,
_ => SurfaceError::Outdated,
})?
}
Err(wgpu::SurfaceError::OutOfMemory) => return Err(SurfaceError::OutOfMemory),
Err(wgpu::SurfaceError::Timeout) => return Err(SurfaceError::Timeout),
Err(other) => return Err(SurfaceError::Other(format!("{other:?}"))),
};
let surface_view = texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
Ok(Frame {
surface_texture: texture,
surface_view,
intermediate_view: self.intermediate_view.clone(),
overlay_view: self.overlay_view.clone(),
width: self.config.width,
height: self.config.height,
})
}
fn present(&mut self, frame: Frame, hal: &Hal) {
let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("llimphi-blit-raw"),
});
self.blitter.copy(
&hal.device,
&mut encoder,
&frame.intermediate_view,
&frame.surface_view,
);
hal.queue.submit(std::iter::once(encoder.finish()));
frame.surface_texture.present();
}
}
/// Elige el modo de presentación del swapchain.
///
/// Default: **Mailbox** si el driver lo expone, sino **Fifo**. La razón es
/// el cuelgue observado en las apps Llimphi (investigación 2026-05-30): con
/// `Fifo`/`AutoVsync`, `surface.get_current_texture()` **bloquea** esperando
/// el frame-callback del compositor Wayland — si el compositor no suelta un
/// buffer, el hilo del UI queda dormido (CPU baja, deadlock aparente).
/// `Mailbox` no bloquea (triple-buffer, descarta frames viejos), así que el
/// loop nunca se queda esperando al compositor. `Fifo` está garantizado por
/// spec como fallback.
///
/// Override por entorno para A/B sin recompilar (útil en la laptop con
/// display real): `LLIMPHI_PRESENT_MODE = fifo | mailbox | immediate |
/// fifo_relaxed`. Si el modo pedido no está soportado, se ignora y se aplica
/// el default.
fn choose_present_mode(caps: &wgpu::SurfaceCapabilities) -> wgpu::PresentMode {
use wgpu::PresentMode::{Fifo, FifoRelaxed, Immediate, Mailbox};
if let Ok(v) = std::env::var("LLIMPHI_PRESENT_MODE") {
let want = match v.trim().to_ascii_lowercase().as_str() {
"fifo" | "vsync" => Some(Fifo),
"fifo_relaxed" | "fiforelaxed" => Some(FifoRelaxed),
"mailbox" => Some(Mailbox),
"immediate" | "novsync" => Some(Immediate),
_ => None,
};
if let Some(m) = want {
if caps.present_modes.contains(&m) {
return m;
}
}
}
if caps.present_modes.contains(&Mailbox) {
Mailbox
} else {
Fifo
}
}
fn create_intermediate(
device: &wgpu::Device,
width: u32,
height: u32,
) -> (wgpu::Texture, wgpu::TextureView) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-intermediate"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE_FORMAT,
// STORAGE_BINDING: vello escribe via compute shader.
// TEXTURE_BINDING: el blitter la lee como sampler source.
// RENDER_ATTACHMENT: render passes con clear-only (sin vello)
// también escriben acá — desktop drivers lo tolerían sin este
// flag, Adreno con validación estricta rechaza el frame.
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
(texture, view)
}
/// Compositor de la capa de overlay: alpha-blittea una textura source (el
/// overlay rasterizado por vello sobre fondo transparente) SOBRE una textura
/// target (la intermedia, que ya tiene la UI principal + el video pintado por
/// `gpu_paint`). Resuelve el z-order: sin esto, el blit de `gpu_paint` (video)
/// queda encima de la capa vello del overlay y los menús se ven por debajo del
/// video.
///
/// Es un pase de pantalla completa (triángulo) que samplea el source y lo
/// emite con alpha-over. El factor de blend asume alpha **premultiplicado**
/// (lo que produce vello); si en pantalla los menús se ven con halos oscuros o
/// transparencia rara, exportar `LLIMPHI_OVERLAY_BLEND=straight` para usar
/// alpha recto sin recompilar.
pub struct OverlayCompositor {
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
bind_layout: wgpu::BindGroupLayout,
}
impl OverlayCompositor {
pub fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-overlay-composite"),
source: wgpu::ShaderSource::Wgsl(OVERLAY_COMPOSITE_WGSL.into()),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-overlay-bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-overlay-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
// Alpha-over. `src_factor` distingue premultiplicado (One) de recto
// (SrcAlpha); el resto es siempre OneMinusSrcAlpha.
let straight = std::env::var("LLIMPHI_OVERLAY_BLEND")
.map(|v| v.trim().eq_ignore_ascii_case("straight"))
.unwrap_or(false);
let color_src = if straight {
wgpu::BlendFactor::SrcAlpha
} else {
wgpu::BlendFactor::One
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-overlay-pipe"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
targets: &[Some(wgpu::ColorTargetState {
format: INTERMEDIATE_FORMAT,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: color_src,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("llimphi-overlay-sampler"),
..Default::default()
});
OverlayCompositor {
pipeline,
sampler,
bind_layout,
}
}
/// Compone `source` (overlay con fondo transparente) sobre `target` (la
/// intermedia), preservando el contenido previo del target (LoadOp::Load)
/// y mezclando con alpha. Graba un render pass en `encoder`.
pub fn composite(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
source: &wgpu::TextureView,
) {
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-overlay-bg"),
layout: &self.bind_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-overlay-composite-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1);
}
}
/// Pase de pantalla completa que samplea la textura de overlay y la emite
/// para alpha-over. Triángulo grande que cubre el viewport; UV mapea clip
/// → texel 1:1 (Y invertida, igual que un blit estándar).
const OVERLAY_COMPOSITE_WGSL: &str = r#"
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
var corners = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let xy = corners[vi];
var out: VsOut;
out.pos = vec4<f32>(xy, 0.0, 1.0);
out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
return out;
}
@group(0) @binding(0) var src_tex: texture_2d<f32>;
@group(0) @binding(1) var src_samp: sampler;
@fragment
fn fs(in: VsOut) -> @location(0) vec4<f32> {
return textureSample(src_tex, src_samp, in.uv);
}
"#;
impl Surface for WinitSurface {
fn size(&self) -> (u32, u32) {
(self.config.width, self.config.height)
}
fn resize(&mut self, width: u32, height: u32) {
self.config.width = width.max(1);
self.config.height = height.max(1);
self.surface.configure(&self.device, &self.config);
let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
self.intermediate = tex;
self.intermediate_view = view;
let (otex, oview) =
create_intermediate(&self.device, self.config.width, self.config.height);
self.overlay = otex;
self.overlay_view = oview;
}
fn acquire(&mut self) -> Result<Frame, SurfaceError> {
let texture = self.surface.get_current_texture().map_err(|e| match e {
wgpu::SurfaceError::Lost => SurfaceError::Lost,
wgpu::SurfaceError::Outdated => SurfaceError::Outdated,
wgpu::SurfaceError::OutOfMemory => SurfaceError::OutOfMemory,
wgpu::SurfaceError::Timeout => SurfaceError::Timeout,
other => SurfaceError::Other(format!("{other:?}")),
})?;
let surface_view = texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
// `TextureView` envuelve un Arc — clonar es atomic-incref, no
// recrea la vista. La intermedia sólo cambia en `resize`.
Ok(Frame {
surface_texture: texture,
surface_view,
intermediate_view: self.intermediate_view.clone(),
overlay_view: self.overlay_view.clone(),
width: self.config.width,
height: self.config.height,
})
}
fn present(&mut self, frame: Frame, hal: &Hal) {
let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("llimphi-blit"),
});
self.blitter.copy(
&hal.device,
&mut encoder,
&frame.intermediate_view,
&frame.surface_view,
);
hal.queue.submit(std::iter::once(encoder.finish()));
frame.surface_texture.present();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "llimphi-icons"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-icons — set mínimo de iconos vectoriales (BezPath en grid 24×24) renderizables vía paint_with. Stroke-based, escalables. Cubre las acciones canónicas de cualquier UI gioser."
[dependencies]
llimphi-ui = { workspace = true }
+136
View File
@@ -0,0 +1,136 @@
//! Galería de los iconos de marca de todas las apps de gioser.
//!
//! Pinta los 29 [`AppIcon`] en una grilla, cada uno en su color de marca
//! con su nombre debajo. Sirve para eyeballear de un vistazo que el set
//! es coherente (mismo peso de trazo, mismo aire) y que cada glifo es
//! reconocible.
//!
//! `cargo run -p llimphi-icons --example app_icons_gallery --release`
use llimphi_icons::app_icons::{app_icon_view, AppIcon, ALL};
use llimphi_ui::llimphi_layout::taffy::prelude::{
auto, length, percent, AlignItems, FlexDirection, JustifyContent, Size, Style,
};
use llimphi_ui::llimphi_layout::taffy::Rect;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
const COLS: usize = 6;
const BG: Color = Color::from_rgb8(18, 20, 24);
const CELL: Color = Color::from_rgb8(28, 31, 38);
const LABEL: Color = Color::from_rgb8(196, 202, 212);
struct Model;
#[derive(Clone)]
enum Msg {}
fn cell(icon: AppIcon) -> View<Msg> {
// Recuadro del glifo (cuadrado, el icono se escala al lado menor).
let icon_box = View::new(Style {
size: Size {
width: length(52.0_f32),
height: length(52.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![app_icon_view(icon, 2.0)]);
let label = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
..Default::default()
})
.text_aligned(icon.name().to_string(), 11.0, LABEL, Alignment::Center);
View::new(Style {
size: Size {
width: length(118.0_f32),
height: length(96.0_f32),
},
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
..Default::default()
})
.fill(CELL)
.radius(12.0)
.children(vec![icon_box, label])
}
fn row(icons: &[AppIcon]) -> View<Msg> {
View::new(Style {
size: Size {
width: auto(),
height: auto(),
},
flex_direction: FlexDirection::Row,
gap: Size {
width: length(14.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(icons.iter().copied().map(cell).collect())
}
struct Gallery;
impl App for Gallery {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi-icons · galería de apps"
}
fn initial_size() -> (u32, u32) {
(820, 620)
}
fn init(_: &Handle<Msg>) -> Model {
Model
}
fn update(_model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
match msg {}
}
fn view(_: &Model) -> View<Msg> {
let rows: Vec<View<Msg>> = ALL.chunks(COLS).map(row).collect();
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size {
width: length(0.0_f32),
height: length(14.0_f32),
},
padding: Rect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(20.0_f32),
bottom: length(20.0_f32),
},
..Default::default()
})
.fill(BG)
.children(rows)
}
}
fn main() {
llimphi_ui::run::<Gallery>();
}
+824
View File
@@ -0,0 +1,824 @@
//! `app_icons` — iconos de marca, uno por dominio/app de gioser.
//!
//! A diferencia del set canónico de [`crate::Icon`] (glifos genéricos de
//! acción: file, save, search…), acá vive **un glifo distintivo por app**.
//! Cada app tiene su símbolo y su **color de marca** propios, pero todos
//! comparten el mismo lenguaje visual:
//!
//! - **Mismo grid lógico 24×24**, origen top-left, eje Y hacia abajo.
//! - **Stroke-based, sin fill**: trazos con `Join::Round` + `Cap::Round`.
//! - **Geometría minimal**: reconocible al primer vistazo aún en 16×16.
//! - **Aire de ~3 unidades** en los bordes para que respire dentro de un chip.
//!
//! La idea es que un dock/spotlight/menú pinte `app_icon_view(AppIcon::Pluma)`
//! y obtenga el glifo de la pluma en su color de tinta, sin que la app tenga
//! que cargar un PNG ni declarar su propia geometría.
//!
//! ```ignore
//! use llimphi_icons::app_icons::{AppIcon, app_icon_view};
//!
//! // Resuelve desde el id del registro de apps:
//! if let Some(icon) = AppIcon::from_app_id("cosmos") {
//! let chip = View::new(style).children(vec![app_icon_view(icon, 1.8)]);
//! }
//! ```
use llimphi_ui::llimphi_layout::taffy::{
prelude::{percent, Size, Style},
Position,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Cap, Join, Stroke};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
/// Una app de gioser con icono de marca. El identificador (`name`) coincide
/// con el `id` del `AppEntry` en `app-bus`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppIcon {
// --- 00_unanchay · PERCIBIR ---
Chaka,
Khipu,
Pineal,
Pluma,
Puriy,
Rimay,
// --- 01_yachay · CONOCER ---
Cosmos,
Dominium,
Iniy,
Nakui,
Tinkuy,
// --- 02_ruway · HACER ---
Ayni,
Cards,
Chasqui,
Llimphi,
Media,
Mirada,
Nada,
Nahual,
Shuma,
Supay,
Takiy,
Tullpu,
Wawa,
// --- 03_ukupacha · RAÍZ ---
Agora,
Arje,
Minga,
Sandokan,
WawaExplorer,
}
/// Las 29 apps, en orden de cuadrante. Útil para iterar (galerías, tests).
pub const ALL: [AppIcon; 29] = [
AppIcon::Chaka,
AppIcon::Khipu,
AppIcon::Pineal,
AppIcon::Pluma,
AppIcon::Puriy,
AppIcon::Rimay,
AppIcon::Cosmos,
AppIcon::Dominium,
AppIcon::Iniy,
AppIcon::Nakui,
AppIcon::Tinkuy,
AppIcon::Ayni,
AppIcon::Cards,
AppIcon::Chasqui,
AppIcon::Llimphi,
AppIcon::Media,
AppIcon::Mirada,
AppIcon::Nada,
AppIcon::Nahual,
AppIcon::Shuma,
AppIcon::Supay,
AppIcon::Takiy,
AppIcon::Tullpu,
AppIcon::Wawa,
AppIcon::Agora,
AppIcon::Arje,
AppIcon::Minga,
AppIcon::Sandokan,
AppIcon::WawaExplorer,
];
impl AppIcon {
/// Id estable de la app (coincide con `AppEntry.id` / nombre del dominio).
pub const fn name(self) -> &'static str {
match self {
AppIcon::Chaka => "chaka",
AppIcon::Khipu => "khipu",
AppIcon::Pineal => "pineal",
AppIcon::Pluma => "pluma",
AppIcon::Puriy => "puriy",
AppIcon::Rimay => "rimay",
AppIcon::Cosmos => "cosmos",
AppIcon::Dominium => "dominium",
AppIcon::Iniy => "iniy",
AppIcon::Nakui => "nakui",
AppIcon::Tinkuy => "tinkuy",
AppIcon::Ayni => "ayni",
AppIcon::Cards => "cards",
AppIcon::Chasqui => "chasqui",
AppIcon::Llimphi => "llimphi",
AppIcon::Media => "media",
AppIcon::Mirada => "mirada",
AppIcon::Nada => "nada",
AppIcon::Nahual => "nahual",
AppIcon::Shuma => "shuma",
AppIcon::Supay => "supay",
AppIcon::Takiy => "takiy",
AppIcon::Tullpu => "tullpu",
AppIcon::Wawa => "wawa",
AppIcon::Agora => "agora",
AppIcon::Arje => "arje",
AppIcon::Minga => "minga",
AppIcon::Sandokan => "sandokan",
AppIcon::WawaExplorer => "wawa-explorer",
}
}
/// Resuelve una app desde su `id` del registro. Acepta tanto
/// `"wawa-explorer"` como `"wawa_explorer"`.
pub fn from_app_id(id: &str) -> Option<AppIcon> {
let id = id.trim().to_ascii_lowercase();
let id = id.replace('_', "-");
ALL.into_iter().find(|a| a.name() == id)
}
/// Color de marca de la app — el que el dock/menú debería usar para
/// pintar el glifo por default.
pub const fn brand(self) -> Color {
let (r, g, b) = match self {
AppIcon::Chaka => (43, 166, 164),
AppIcon::Khipu => (181, 101, 29),
AppIcon::Pineal => (108, 79, 216),
AppIcon::Pluma => (61, 59, 142),
AppIcon::Puriy => (63, 163, 77),
AppIcon::Rimay => (232, 131, 58),
AppIcon::Cosmos => (230, 184, 0),
AppIcon::Dominium => (74, 111, 165),
AppIcon::Iniy => (124, 179, 66),
AppIcon::Nakui => (194, 84, 157),
AppIcon::Tinkuy => (217, 83, 79),
AppIcon::Ayni => (42, 168, 196),
AppIcon::Cards => (142, 99, 206),
AppIcon::Chasqui => (52, 179, 106),
AppIcon::Llimphi => (229, 91, 122),
AppIcon::Media => (226, 62, 87),
AppIcon::Mirada => (45, 125, 210),
AppIcon::Nada => (136, 147, 160),
AppIcon::Nahual => (124, 77, 191),
AppIcon::Shuma => (224, 165, 38),
AppIcon::Supay => (155, 63, 181),
AppIcon::Takiy => (229, 99, 155),
AppIcon::Tullpu => (224, 96, 58),
AppIcon::Wawa => (91, 141, 239),
AppIcon::Agora => (47, 158, 143),
AppIcon::Arje => (176, 141, 87),
AppIcon::Minga => (224, 123, 57),
AppIcon::Sandokan => (192, 57, 43),
AppIcon::WawaExplorer => (110, 160, 240),
};
Color::from_rgb8(r, g, b)
}
/// `BezPath` del glifo en coords del grid 24×24.
pub fn path(self) -> BezPath {
match self {
AppIcon::Chaka => path_chaka(),
AppIcon::Khipu => path_khipu(),
AppIcon::Pineal => path_pineal(),
AppIcon::Pluma => path_pluma(),
AppIcon::Puriy => path_puriy(),
AppIcon::Rimay => path_rimay(),
AppIcon::Cosmos => path_cosmos(),
AppIcon::Dominium => path_dominium(),
AppIcon::Iniy => path_iniy(),
AppIcon::Nakui => path_nakui(),
AppIcon::Tinkuy => path_tinkuy(),
AppIcon::Ayni => path_ayni(),
AppIcon::Cards => path_cards(),
AppIcon::Chasqui => path_chasqui(),
AppIcon::Llimphi => path_llimphi(),
AppIcon::Media => path_media(),
AppIcon::Mirada => path_mirada(),
AppIcon::Nada => path_nada(),
AppIcon::Nahual => path_nahual(),
AppIcon::Shuma => path_shuma(),
AppIcon::Supay => path_supay(),
AppIcon::Takiy => path_takiy(),
AppIcon::Tullpu => path_tullpu(),
AppIcon::Wawa => path_wawa(),
AppIcon::Agora => path_agora(),
AppIcon::Arje => path_arje(),
AppIcon::Minga => path_minga(),
AppIcon::Sandokan => path_sandokan(),
AppIcon::WawaExplorer => path_wawa_explorer(),
}
}
}
/// `View` que pinta el icono de app en su **color de marca**, ocupando todo
/// el rect del padre, escalado uniforme y centrado.
///
/// - `stroke_width` en unidades del grid 24×24 (típico de marca: `1.8`).
pub fn app_icon_view<Msg: Clone + 'static>(icon: AppIcon, stroke_width: f32) -> View<Msg> {
app_icon_view_colored(icon, icon.brand(), stroke_width)
}
/// Igual que [`app_icon_view`] pero forzando un color (p.ej. monocromo
/// `theme.fg_text` para un menú denso donde el color distrae).
pub fn app_icon_view_colored<Msg: Clone + 'static>(
icon: AppIcon,
color: Color,
stroke_width: f32,
) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
paint_app_icon(scene, rect, icon, color, stroke_width);
})
}
/// Pintor crudo — para stampear varios iconos de app dentro del mismo
/// `paint_with` (una grilla de launcher, por ejemplo).
pub fn paint_app_icon(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
rect: llimphi_ui::PaintRect,
icon: AppIcon,
color: Color,
stroke_width: f32,
) {
let side = rect.w.min(rect.h) as f64;
if side <= 0.0 {
return;
}
let scale = side / 24.0;
let tx = rect.x as f64 + (rect.w as f64 - side) * 0.5;
let ty = rect.y as f64 + (rect.h as f64 - side) * 0.5;
let xform = Affine::translate((tx, ty)) * Affine::scale(scale);
let stroke = Stroke::new(stroke_width as f64)
.with_join(Join::Round)
.with_caps(Cap::Round);
let path = icon.path();
scene.stroke(&stroke, xform, color, None, &path);
}
// =====================================================================
// Helpers
// =====================================================================
/// Círculo aproximado con `segments` lados rectos (liso por el Cap::Round).
fn circle(cx: f64, cy: f64, r: f64, segments: usize) -> BezPath {
let mut p = BezPath::new();
for i in 0..=segments {
let theta = std::f64::consts::TAU * (i as f64) / (segments as f64);
let x = cx + r * theta.cos();
let y = cy + r * theta.sin();
if i == 0 {
p.move_to((x, y));
} else {
p.line_to((x, y));
}
}
p
}
/// Empuja todos los elementos de `src` dentro de `dst` (para componer
/// glifos hechos de varias subformas).
fn push_all(dst: &mut BezPath, src: BezPath) {
for el in src.elements() {
dst.push(*el);
}
}
// =====================================================================
// Glifos — uno por app. Grid 24×24, margen ~3.
// =====================================================================
// --- 00_unanchay · PERCIBIR ---
fn path_chaka() -> BezPath {
// chaka = puente: tablero recto + arco + dos pilotes.
let mut p = BezPath::new();
// Tablero.
p.move_to((3.0, 9.0));
p.line_to((21.0, 9.0));
// Arco bajo el tablero.
p.move_to((5.0, 18.0));
p.curve_to((5.0, 11.0), (19.0, 11.0), (19.0, 18.0));
// Pilotes que conectan tablero y arco.
p.move_to((9.0, 9.0));
p.line_to((9.0, 12.5));
p.move_to((15.0, 9.0));
p.line_to((15.0, 12.5));
p
}
fn path_khipu() -> BezPath {
// khipu: cordón principal + tres ramales con nudos (puntos).
let mut p = BezPath::new();
// Cordón superior.
p.move_to((4.0, 6.0));
p.line_to((20.0, 6.0));
// Ramales.
p.move_to((7.0, 6.0));
p.line_to((7.0, 19.0));
p.move_to((12.0, 6.0));
p.line_to((12.0, 20.0));
p.move_to((17.0, 6.0));
p.line_to((17.0, 18.0));
// Nudos.
push_all(&mut p, circle(7.0, 12.0, 1.3, 10));
push_all(&mut p, circle(12.0, 10.0, 1.3, 10));
push_all(&mut p, circle(12.0, 16.0, 1.3, 10));
push_all(&mut p, circle(17.0, 11.0, 1.3, 10));
p
}
fn path_pineal() -> BezPath {
// pineal = tercer ojo: párpado almendrado + iris + antena/rayo arriba.
let mut p = BezPath::new();
p.move_to((4.0, 12.0));
p.curve_to((8.0, 7.0), (16.0, 7.0), (20.0, 12.0));
p.curve_to((16.0, 17.0), (8.0, 17.0), (4.0, 12.0));
push_all(&mut p, circle(12.0, 12.0, 2.6, 14));
p.move_to((12.0, 3.0));
p.line_to((12.0, 5.5));
p
}
fn path_pluma() -> BezPath {
// pluma = plumín: rombo apuntando abajo + ranura + ojal.
let mut p = BezPath::new();
p.move_to((12.0, 3.0));
p.line_to((16.0, 9.0));
p.line_to((13.5, 20.0));
p.line_to((10.5, 20.0));
p.line_to((8.0, 9.0));
p.close_path();
// Ranura.
p.move_to((12.0, 11.5));
p.line_to((12.0, 19.0));
// Ojal.
push_all(&mut p, circle(12.0, 9.5, 1.2, 10));
p
}
fn path_puriy() -> BezPath {
// puriy = caminar/recorrido: senda curva ascendente con flecha.
let mut p = BezPath::new();
p.move_to((6.0, 20.0));
p.curve_to((6.0, 12.0), (18.0, 12.0), (18.0, 4.0));
// Cabeza de flecha.
p.move_to((15.0, 6.0));
p.line_to((18.0, 4.0));
p.line_to((20.5, 6.5));
p
}
fn path_rimay() -> BezPath {
// rimay = palabra/habla: globo de diálogo con cola + dos renglones.
let mut p = BezPath::new();
p.move_to((4.0, 6.0));
p.line_to((20.0, 6.0));
p.line_to((20.0, 15.0));
p.line_to((11.0, 15.0));
p.line_to((8.0, 19.0));
p.line_to((8.0, 15.0));
p.line_to((4.0, 15.0));
p.close_path();
// Renglones.
p.move_to((8.0, 9.5));
p.line_to((16.0, 9.5));
p.move_to((8.0, 12.0));
p.line_to((13.0, 12.0));
p
}
// --- 01_yachay · CONOCER ---
fn path_cosmos() -> BezPath {
// cosmos = destello de 4 puntas + dos estrellas pequeñas.
let mut p = BezPath::new();
p.move_to((12.0, 4.0));
p.line_to((13.4, 10.6));
p.line_to((20.0, 12.0));
p.line_to((13.4, 13.4));
p.line_to((12.0, 20.0));
p.line_to((10.6, 13.4));
p.line_to((4.0, 12.0));
p.line_to((10.6, 10.6));
p.close_path();
// Estrellas chicas.
push_all(&mut p, circle(19.0, 6.0, 0.8, 8));
push_all(&mut p, circle(5.5, 18.0, 0.8, 8));
p
}
fn path_dominium() -> BezPath {
// dominium = ERP/libro mayor: barras de distinta altura sobre una base.
let mut p = BezPath::new();
// Base.
p.move_to((3.0, 20.0));
p.line_to((21.0, 20.0));
// Columnas.
p.move_to((6.0, 14.0));
p.line_to((9.0, 14.0));
p.line_to((9.0, 20.0));
p.line_to((6.0, 20.0));
p.close_path();
p.move_to((10.5, 8.0));
p.line_to((13.5, 8.0));
p.line_to((13.5, 20.0));
p.line_to((10.5, 20.0));
p.close_path();
p.move_to((15.0, 11.0));
p.line_to((18.0, 11.0));
p.line_to((18.0, 20.0));
p.line_to((15.0, 20.0));
p.close_path();
p
}
fn path_iniy() -> BezPath {
// iniy = aliento/creer: brote con tallo y dos hojas.
let mut p = BezPath::new();
// Tallo.
p.move_to((12.0, 20.0));
p.line_to((12.0, 10.0));
// Hoja izquierda.
p.move_to((12.0, 14.0));
p.curve_to((8.0, 14.0), (6.0, 11.0), (7.0, 8.0));
p.curve_to((10.0, 9.0), (12.0, 11.0), (12.0, 14.0));
// Hoja derecha.
p.move_to((12.0, 12.0));
p.curve_to((15.5, 12.0), (17.0, 9.0), (16.5, 6.0));
p.curve_to((14.0, 7.0), (12.0, 9.0), (12.0, 12.0));
p
}
fn path_nakui() -> BezPath {
// nakui = grafo de morfismos: tres nodos + aristas.
let mut p = BezPath::new();
// Aristas (primero, para que queden bajo los nodos).
p.move_to((7.5, 9.0));
p.line_to((16.5, 9.0));
p.move_to((7.5, 9.8));
p.line_to((10.8, 16.0));
p.move_to((16.5, 9.8));
p.line_to((13.2, 16.0));
// Nodos.
push_all(&mut p, circle(6.0, 8.0, 2.2, 14));
push_all(&mut p, circle(18.0, 8.0, 2.2, 14));
push_all(&mut p, circle(12.0, 18.0, 2.2, 14));
p
}
fn path_tinkuy() -> BezPath {
// tinkuy = encuentro/choque: dos flechas que convergen + chispa.
let mut p = BezPath::new();
// Flecha izquierda →
p.move_to((3.0, 12.0));
p.line_to((9.5, 12.0));
p.move_to((7.5, 10.0));
p.line_to((9.5, 12.0));
p.line_to((7.5, 14.0));
// Flecha derecha ←
p.move_to((21.0, 12.0));
p.line_to((14.5, 12.0));
p.move_to((16.5, 10.0));
p.line_to((14.5, 12.0));
p.line_to((16.5, 14.0));
// Chispa central.
push_all(&mut p, circle(12.0, 12.0, 1.6, 10));
p
}
// --- 02_ruway · HACER ---
fn path_ayni() -> BezPath {
// ayni = reciprocidad: dos flechas curvas en ciclo.
let mut p = BezPath::new();
// Arco superior, flecha hacia la derecha-abajo.
p.move_to((6.0, 8.0));
p.curve_to((9.0, 4.0), (15.0, 4.0), (18.0, 8.5));
p.move_to((15.5, 8.0));
p.line_to((18.0, 8.5));
p.line_to((18.5, 5.8));
// Arco inferior, flecha hacia la izquierda-arriba.
p.move_to((18.0, 16.0));
p.curve_to((15.0, 20.0), (9.0, 20.0), (6.0, 15.5));
p.move_to((8.5, 16.0));
p.line_to((6.0, 15.5));
p.line_to((5.5, 18.2));
p
}
fn path_cards() -> BezPath {
// cards = naipes apilados: carta frontal + borde de la de atrás.
let mut p = BezPath::new();
// Carta de atrás (asoma arriba y a la derecha).
p.move_to((8.0, 5.0));
p.line_to((19.0, 5.0));
p.line_to((19.0, 16.0));
// Carta frontal.
p.move_to((5.0, 9.0));
p.line_to((15.0, 9.0));
p.line_to((15.0, 20.0));
p.line_to((5.0, 20.0));
p.close_path();
p
}
fn path_chasqui() -> BezPath {
// chasqui = mensajero: avión de papel.
let mut p = BezPath::new();
p.move_to((4.0, 11.0));
p.line_to((20.0, 4.0));
p.line_to((13.0, 20.0));
p.line_to((11.0, 13.0));
p.close_path();
// Pliegue central.
p.move_to((11.0, 13.0));
p.line_to((20.0, 4.0));
p
}
fn path_llimphi() -> BezPath {
// llimphi = pintura/color: paleta con apoyo para el pulgar + 3 gotas.
let mut p = BezPath::new();
p.move_to((4.0, 12.0));
p.curve_to((4.0, 6.0), (11.0, 4.0), (15.0, 5.0));
p.curve_to((20.0, 6.5), (21.0, 12.0), (18.0, 15.0));
p.curve_to((16.0, 16.5), (16.5, 13.5), (14.0, 14.0));
p.curve_to((11.5, 14.5), (12.5, 18.0), (9.0, 18.0));
p.curve_to((5.5, 18.0), (4.0, 15.0), (4.0, 12.0));
p.close_path();
// Gotas de pintura.
push_all(&mut p, circle(8.0, 9.0, 1.1, 10));
push_all(&mut p, circle(12.0, 8.0, 1.1, 10));
push_all(&mut p, circle(15.5, 10.0, 1.1, 10));
p
}
fn path_media() -> BezPath {
// media = reproducción: marco + triángulo de play.
let mut p = BezPath::new();
p.move_to((4.0, 6.0));
p.line_to((20.0, 6.0));
p.line_to((20.0, 18.0));
p.line_to((4.0, 18.0));
p.close_path();
// Play.
p.move_to((10.0, 9.0));
p.line_to((10.0, 15.0));
p.line_to((16.0, 12.0));
p.close_path();
p
}
fn path_mirada() -> BezPath {
// mirada = ojo: párpado + iris + pupila.
let mut p = BezPath::new();
p.move_to((3.0, 12.0));
p.curve_to((8.0, 6.0), (16.0, 6.0), (21.0, 12.0));
p.curve_to((16.0, 18.0), (8.0, 18.0), (3.0, 12.0));
p.close_path();
push_all(&mut p, circle(12.0, 12.0, 3.4, 18));
push_all(&mut p, circle(12.0, 12.0, 1.0, 8));
p
}
fn path_nada() -> BezPath {
// nada = vacío: conjunto vacío ∅ (anillo + diagonal).
let mut p = circle(12.0, 12.0, 8.0, 28);
p.move_to((6.5, 17.5));
p.line_to((17.5, 6.5));
p
}
fn path_nahual() -> BezPath {
// nahual = máscara/mutación de forma: antifaz con dos ojos.
let mut p = BezPath::new();
p.move_to((4.0, 9.0));
p.curve_to((4.0, 6.5), (8.0, 6.0), (10.0, 7.5));
p.curve_to((11.0, 8.2), (13.0, 8.2), (14.0, 7.5));
p.curve_to((16.0, 6.0), (20.0, 6.5), (20.0, 9.0));
p.curve_to((20.0, 13.5), (16.0, 16.5), (12.0, 15.5));
p.curve_to((8.0, 16.5), (4.0, 13.5), (4.0, 9.0));
p.close_path();
push_all(&mut p, circle(9.0, 10.0, 1.3, 10));
push_all(&mut p, circle(15.0, 10.0, 1.3, 10));
p
}
fn path_shuma() -> BezPath {
// shuma = discernir: embudo/filtro.
let mut p = BezPath::new();
p.move_to((4.0, 6.0));
p.line_to((20.0, 6.0));
p.line_to((13.0, 14.0));
p.line_to((13.0, 19.0));
p.line_to((11.0, 20.0));
p.line_to((11.0, 14.0));
p.close_path();
p
}
fn path_supay() -> BezPath {
// supay = espíritu del ukhupacha: llama doble.
let mut p = BezPath::new();
// Llama exterior.
p.move_to((12.0, 3.0));
p.curve_to((17.0, 9.0), (16.0, 14.0), (12.0, 21.0));
p.curve_to((8.0, 14.0), (7.0, 9.0), (12.0, 3.0));
p.close_path();
// Llama interior.
p.move_to((12.0, 9.0));
p.curve_to((14.0, 12.0), (13.0, 16.0), (12.0, 18.0));
p.curve_to((11.0, 16.0), (10.0, 12.0), (12.0, 9.0));
p.close_path();
p
}
fn path_takiy() -> BezPath {
// takiy = cantar: corchea + ondas de sonido.
let mut p = BezPath::new();
// Cabeza de nota.
push_all(&mut p, circle(8.0, 18.0, 2.4, 16));
// Plica.
p.move_to((10.4, 18.0));
p.line_to((10.4, 6.0));
// Banderola.
p.move_to((10.4, 6.0));
p.curve_to((13.5, 7.0), (14.5, 9.0), (13.5, 11.0));
// Ondas.
p.move_to((16.0, 9.0));
p.curve_to((18.0, 11.0), (18.0, 13.0), (16.0, 15.0));
p
}
fn path_tullpu() -> BezPath {
// tullpu = tinte/color: tres gotas.
let mut p = BezPath::new();
// Gota 1.
p.move_to((8.0, 5.0));
p.curve_to((11.0, 9.0), (11.0, 11.0), (8.0, 12.0));
p.curve_to((5.0, 11.0), (5.0, 9.0), (8.0, 5.0));
p.close_path();
// Gota 2.
p.move_to((16.0, 6.0));
p.curve_to((19.0, 10.0), (19.0, 12.0), (16.0, 13.0));
p.curve_to((13.0, 12.0), (13.0, 10.0), (16.0, 6.0));
p.close_path();
// Gota 3.
p.move_to((12.0, 13.0));
p.curve_to((15.0, 17.0), (15.0, 19.0), (12.0, 20.0));
p.curve_to((9.0, 19.0), (9.0, 17.0), (12.0, 13.0));
p.close_path();
p
}
fn path_wawa() -> BezPath {
// wawa = célula/semilla (el SO en gestación): membrana + núcleo.
let mut p = circle(12.0, 12.0, 8.0, 28);
push_all(&mut p, circle(12.0, 12.0, 3.0, 16));
p
}
// --- 03_ukupacha · RAÍZ ---
fn path_agora() -> BezPath {
// agora = firma/confianza: escudo con check.
let mut p = BezPath::new();
p.move_to((12.0, 3.0));
p.line_to((20.0, 6.0));
p.line_to((20.0, 12.0));
p.curve_to((20.0, 17.0), (16.0, 20.0), (12.0, 21.0));
p.curve_to((8.0, 20.0), (4.0, 17.0), (4.0, 12.0));
p.line_to((4.0, 6.0));
p.close_path();
// Check.
p.move_to((8.5, 12.0));
p.line_to((11.0, 14.5));
p.line_to((16.0, 8.5));
p
}
fn path_arje() -> BezPath {
// arje = arché/raíz de confianza: ancla.
let mut p = BezPath::new();
// Anillo.
push_all(&mut p, circle(12.0, 5.0, 2.2, 14));
// Caña.
p.move_to((12.0, 7.2));
p.line_to((12.0, 19.0));
// Travesaño.
p.move_to((8.0, 10.0));
p.line_to((16.0, 10.0));
// Uñas/brazos.
p.move_to((6.0, 14.0));
p.curve_to((6.0, 18.5), (9.0, 20.0), (12.0, 20.0));
p.move_to((18.0, 14.0));
p.curve_to((18.0, 18.5), (15.0, 20.0), (12.0, 20.0));
p
}
fn path_minga() -> BezPath {
// minga = trabajo comunal: tres figuras.
let mut p = BezPath::new();
// Figura central.
push_all(&mut p, circle(12.0, 7.0, 2.2, 14));
p.move_to((8.0, 18.0));
p.curve_to((8.0, 13.0), (16.0, 13.0), (16.0, 18.0));
// Figura izquierda.
push_all(&mut p, circle(5.5, 10.0, 1.6, 12));
p.move_to((2.5, 18.0));
p.curve_to((2.5, 14.5), (6.0, 13.5), (7.5, 15.0));
// Figura derecha.
push_all(&mut p, circle(18.5, 10.0, 1.6, 12));
p.move_to((21.5, 18.0));
p.curve_to((21.5, 14.5), (18.0, 13.5), (16.5, 15.0));
p
}
fn path_sandokan() -> BezPath {
// sandokan = caja/contenedor aislado: cubo isométrico.
let mut p = BezPath::new();
// Cara frontal.
p.move_to((5.0, 8.0));
p.line_to((14.0, 8.0));
p.line_to((14.0, 18.0));
p.line_to((5.0, 18.0));
p.close_path();
// Tapa.
p.move_to((5.0, 8.0));
p.line_to((9.0, 4.0));
p.line_to((18.0, 4.0));
p.line_to((14.0, 8.0));
// Cara lateral.
p.move_to((14.0, 8.0));
p.line_to((18.0, 4.0));
p.line_to((18.0, 14.0));
p.line_to((14.0, 18.0));
p
}
fn path_wawa_explorer() -> BezPath {
// wawa-explorer = launchpad: grilla 2×2.
let mut p = BezPath::new();
for (x, y) in &[(5.0, 5.0), (13.0, 5.0), (5.0, 13.0), (13.0, 13.0)] {
p.move_to((*x, *y));
p.line_to((*x + 6.0, *y));
p.line_to((*x + 6.0, *y + 6.0));
p.line_to((*x, *y + 6.0));
p.close_path();
}
p
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_app_icons_have_nonempty_path() {
for icon in ALL {
let p = icon.path();
assert!(
p.elements().len() > 0,
"icono de app {} produjo path vacío",
icon.name()
);
}
}
#[test]
fn app_names_are_unique() {
let mut names: Vec<&str> = ALL.iter().map(|i| i.name()).collect();
let n = names.len();
names.sort();
names.dedup();
assert_eq!(names.len(), n, "nombres duplicados en AppIcon::name()");
}
#[test]
fn from_app_id_roundtrips() {
for icon in ALL {
assert_eq!(AppIcon::from_app_id(icon.name()), Some(icon));
}
// Tolera underscores y mayúsculas.
assert_eq!(AppIcon::from_app_id("WAWA_EXPLORER"), Some(AppIcon::WawaExplorer));
assert_eq!(AppIcon::from_app_id("desconocida"), None);
}
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "llimphi-layout"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
taffy = { workspace = true }
[dev-dependencies]
llimphi-hal = { path = "../llimphi-hal" }
llimphi-raster = { path = "../llimphi-raster" }
pollster = { workspace = true }
[[example]]
name = "layout_panels"
path = "examples/layout_panels.rs"
+10
View File
@@ -0,0 +1,10 @@
# llimphi-layout
> Layout taffy + extensiones de [llimphi](../README.md).
Wrapper sobre `taffy` (Flexbox + Grid) con tipos ergonómicos para `View<Msg>`. Cache del layout calculado entre frames; invalidación dirigida cuando el árbol cambia.
## Deps
- `taffy`, `glam`
- `serde`
+10
View File
@@ -0,0 +1,10 @@
# llimphi-layout
> Taffy layout + extensions of [llimphi](../README.md).
Wrapper over `taffy` (Flexbox + Grid) with ergonomic types for `View<Msg>`. Cache of computed layout between frames; directed invalidation when the tree changes.
## Deps
- `taffy`, `glam`
- `serde`
+250
View File
@@ -0,0 +1,250 @@
//! Fase 3 de Llimphi: 3 paneles (sidebar + header/body/footer) que se
//! reorganizan al redimensionar la ventana. Pintados por vello a través
//! de llimphi-raster.
//!
//! Corre con: `cargo run -p llimphi-layout --example layout_panels --release`.
use std::sync::Arc;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::dpi::LogicalSize;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{Hal, Surface, WinitSurface};
use llimphi_layout::{
taffy::{prelude::*, Style},
ComputedLayout, LayoutTree, Rect,
};
use llimphi_raster::kurbo::{Affine, RoundedRect};
use llimphi_raster::peniko::{color::palette, Color, Fill};
use llimphi_raster::{vello, Renderer};
struct Panels {
sidebar: NodeId,
header: NodeId,
body: NodeId,
footer: NodeId,
root: NodeId,
}
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
renderer: Renderer,
scene: vello::Scene,
layout: LayoutTree,
panels: Panels,
}
struct App {
state: Option<State>,
}
fn build_tree(layout: &mut LayoutTree) -> Panels {
let sidebar = layout
.leaf(Style {
size: Size {
width: length(220.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.unwrap();
let header = layout
.leaf(Style {
size: Size {
width: percent(1.0_f32),
height: length(64.0_f32),
},
..Default::default()
})
.unwrap();
let body = layout
.leaf(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
..Default::default()
})
.unwrap();
let footer = layout
.leaf(Style {
size: Size {
width: percent(1.0_f32),
height: length(40.0_f32),
},
..Default::default()
})
.unwrap();
let content = layout
.node(
Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
padding: Rect_(length(8.0_f32)),
..Default::default()
},
&[header, body, footer],
)
.unwrap();
let root = layout
.node(
Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
},
&[sidebar, content],
)
.unwrap();
Panels {
sidebar,
header,
body,
footer,
root,
}
}
/// Helper para pasar el mismo length a todos los lados de un Rect.
#[allow(non_snake_case)]
fn Rect_(v: LengthPercentage) -> taffy::Rect<LengthPercentage> {
taffy::Rect {
left: v,
right: v,
top: v,
bottom: v,
}
}
fn paint(scene: &mut vello::Scene, computed: &ComputedLayout, panels: &Panels) {
fn rect(scene: &mut vello::Scene, r: Rect, color: Color, radius: f64) {
let rr = RoundedRect::new(
r.x as f64,
r.y as f64,
(r.x + r.w) as f64,
(r.y + r.h) as f64,
radius,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &rr);
}
if let Some(r) = computed.get(panels.sidebar) {
rect(scene, r, Color::from_rgba8(36, 44, 60, 255), 0.0);
}
if let Some(r) = computed.get(panels.header) {
rect(scene, r, Color::from_rgba8(60, 80, 110, 255), 8.0);
}
if let Some(r) = computed.get(panels.body) {
rect(scene, r, Color::from_rgba8(80, 110, 150, 255), 8.0);
}
if let Some(r) = computed.get(panels.footer) {
rect(scene, r, Color::from_rgba8(60, 80, 110, 255), 8.0);
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let window = event_loop
.create_window(
WindowAttributes::default()
.with_title("llimphi · layout_panels")
.with_inner_size(LogicalSize::new(960u32, 540u32)),
)
.expect("create window");
let window = Arc::new(window);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let surface = WinitSurface::new(&hal, window.clone()).expect("surface");
let renderer = Renderer::new(&hal).expect("renderer");
let mut layout = LayoutTree::new();
let panels = build_tree(&mut layout);
window.request_redraw();
self.state = Some(State {
window,
hal,
surface,
renderer,
scene: vello::Scene::new(),
layout,
panels,
});
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(_) => {
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let (w, h) = frame.size();
let computed = state
.layout
.compute(state.panels.root, (w as f32, h as f32))
.expect("compute layout");
state.scene.reset();
paint(&mut state.scene, &computed, &state.panels);
if let Err(e) = state.renderer.render(
&state.hal,
&state.scene,
&frame,
palette::css::BLACK,
) {
eprintln!("render error: {e}");
}
state.surface.present(frame, &state.hal);
state.window.request_redraw();
}
_ => {}
}
}
}
fn main() {
let event_loop = EventLoop::new().expect("event loop");
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = App { state: None };
event_loop.run_app(&mut app).expect("run app");
}
+184
View File
@@ -0,0 +1,184 @@
//! llimphi-layout — Física del Espacio.
//!
//! Wrapper sobre `taffy` que resuelve árboles flex/grid y devuelve
//! coordenadas absolutas (no relativas al padre). El consumidor pasa el
//! árbol a `compute(root, viewport)` y obtiene un [`ComputedLayout`] con
//! un rect absoluto por nodo, listo para `llimphi-raster`.
use std::collections::HashMap;
pub use taffy;
pub use taffy::prelude::*;
/// Errores del motor de layout.
#[derive(Debug)]
pub enum LayoutError {
Taffy(String),
}
impl std::fmt::Display for LayoutError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Taffy(s) => write!(f, "taffy: {s}"),
}
}
}
impl std::error::Error for LayoutError {}
/// Caja absoluta de un nodo (origen en la esquina superior izquierda del viewport).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
/// Resultado de [`LayoutTree::compute`]: rect absoluto por nodo del árbol.
#[derive(Debug, Default)]
pub struct ComputedLayout {
pub rects: HashMap<NodeId, Rect>,
}
impl ComputedLayout {
pub fn get(&self, node: NodeId) -> Option<Rect> {
self.rects.get(&node).copied()
}
}
/// Árbol de layout. Encapsula la `TaffyTree` y la lógica de absolutización.
pub struct LayoutTree {
inner: TaffyTree<()>,
}
impl Default for LayoutTree {
fn default() -> Self {
Self::new()
}
}
impl LayoutTree {
pub fn new() -> Self {
Self {
inner: TaffyTree::new(),
}
}
/// Vacía el árbol conservando la capacidad ya asignada. Permite
/// reusar la misma `LayoutTree` entre frames sin re-allocar el
/// slotmap interno de taffy: `clear()` + `mount` en vez de
/// `LayoutTree::new()` por frame. Los `NodeId` emitidos antes de
/// `clear()` quedan inválidos (el caller ya volcó lo que necesitaba
/// a un `ComputedLayout`, que es dueño de sus rects).
pub fn clear(&mut self) {
self.inner.clear();
}
/// Crea una hoja (nodo sin hijos).
pub fn leaf(&mut self, style: Style) -> Result<NodeId, LayoutError> {
self.inner
.new_leaf(style)
.map_err(|e| LayoutError::Taffy(e.to_string()))
}
/// Crea un nodo contenedor con hijos.
pub fn node(&mut self, style: Style, children: &[NodeId]) -> Result<NodeId, LayoutError> {
self.inner
.new_with_children(style, children)
.map_err(|e| LayoutError::Taffy(e.to_string()))
}
/// Calcula el layout para `root` con viewport `(w, h)` y devuelve rects absolutos.
pub fn compute(
&mut self,
root: NodeId,
viewport: (f32, f32),
) -> Result<ComputedLayout, LayoutError> {
self.inner
.compute_layout(
root,
taffy::Size {
width: AvailableSpace::Definite(viewport.0),
height: AvailableSpace::Definite(viewport.1),
},
)
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
let mut out = ComputedLayout::default();
flatten(&self.inner, root, 0.0, 0.0, &mut out.rects)?;
Ok(out)
}
/// Como [`Self::compute`] pero pasando una función de medición por
/// nodo. Taffy la invoca sobre las **hojas** que necesita dimensionar
/// (texto que envuelve, contenido intrínseco) con el `NodeId`, las
/// dimensiones ya conocidas y el espacio disponible; el caller devuelve
/// el tamaño en px. Devolver `Size::ZERO` deja que el estilo decida (el
/// comportamiento de [`Self::compute`] para hojas sin contenido). El
/// `NodeId` permite al caller mantener su propio mapa nodo→contenido
/// (p. ej. texto a shapear con parley) sin acoplar este crate a la capa
/// de tipografía.
pub fn compute_with_measure<F>(
&mut self,
root: NodeId,
viewport: (f32, f32),
mut measure: F,
) -> Result<ComputedLayout, LayoutError>
where
F: FnMut(NodeId, taffy::Size<Option<f32>>, taffy::Size<AvailableSpace>) -> taffy::Size<f32>,
{
self.inner
.compute_layout_with_measure(
root,
taffy::Size {
width: AvailableSpace::Definite(viewport.0),
height: AvailableSpace::Definite(viewport.1),
},
|known, available, node_id, _ctx, _style| {
measure(node_id, known, available)
},
)
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
let mut out = ComputedLayout::default();
flatten(&self.inner, root, 0.0, 0.0, &mut out.rects)?;
Ok(out)
}
pub fn inner(&self) -> &TaffyTree<()> {
&self.inner
}
pub fn inner_mut(&mut self) -> &mut TaffyTree<()> {
&mut self.inner
}
}
fn flatten(
tree: &TaffyTree<()>,
node: NodeId,
ox: f32,
oy: f32,
out: &mut HashMap<NodeId, Rect>,
) -> Result<(), LayoutError> {
let layout = tree
.layout(node)
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
let x = ox + layout.location.x;
let y = oy + layout.location.y;
out.insert(
node,
Rect {
x,
y,
w: layout.size.width,
h: layout.size.height,
},
);
let children = tree
.children(node)
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
for child in children {
flatten(tree, child, x, y, out)?;
}
Ok(())
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-motion"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-motion — Tween<T> + helpers de animación integrados al bucle Elm de llimphi-ui (Handle::spawn_periodic). Lerp para f32, Color, (f32,f32). Easings comparten convenciones de llimphi-theme::motion."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+259
View File
@@ -0,0 +1,259 @@
//! `llimphi-motion` — animaciones simples sobre el bucle Elm de Llimphi.
//!
//! Llimphi es Elm puro: `update(msg) -> model`. Para animar un valor en
//! el tiempo (un alpha que sube de 0 a 1, una posición que se desliza)
//! la app guarda un [`Tween`] en su modelo y pide al `Handle` que le
//! dispatchee un `Msg::Tick` periódicamente (cada ~16 ms) hasta que la
//! animación termine. Cada `update` lee `tween.value()` y la `view` la
//! pinta.
//!
//! Esta crate es deliberadamente chiquita:
//! - [`Lerp`] — interpolación lineal genérica (impls para `f32`,
//! `(f32, f32)` y `Color`).
//! - [`Tween`] — interpolación temporizada con easing entre dos valores.
//! - [`animate`] — helper que arranca un loop de ticks autosuficiente
//! sobre un `Handle`.
//!
//! Las duraciones y easings canónicos viven en [`llimphi_theme::motion`].
//!
//! ## Patrón típico
//!
//! ```ignore
//! use llimphi_motion::{Tween, animate};
//! use llimphi_theme::motion;
//!
//! enum Msg { ToastShow, Tick, ToastHidden }
//! struct Model { toast_alpha: Tween<f32> }
//!
//! // update:
//! Msg::ToastShow => {
//! model.toast_alpha = Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic);
//! animate(handle, motion::NORMAL, || Msg::Tick);
//! model
//! }
//! Msg::Tick => {
//! // El loop interno terminará solo cuando el tween esté done;
//! // la `view` ya lee el alpha actual sin más.
//! model
//! }
//!
//! // view:
//! toast_view().alpha(model.toast_alpha.value())
//! ```
#![forbid(unsafe_code)]
use std::time::{Duration, Instant};
pub use llimphi_theme::motion;
pub use llimphi_theme::Color;
use llimphi_ui::Handle;
/// Interpolación lineal genérica entre `self` y `other` con factor `t`
/// en `[0.0, 1.0]`. Cada impl decide cómo combinar componentes; los
/// callers pasan `t` ya con el easing aplicado.
pub trait Lerp: Copy {
fn lerp(self, other: Self, t: f32) -> Self;
}
impl Lerp for f32 {
#[inline]
fn lerp(self, other: Self, t: f32) -> Self {
self + (other - self) * t
}
}
impl Lerp for f64 {
#[inline]
fn lerp(self, other: Self, t: f32) -> Self {
self + (other - self) * t as f64
}
}
impl Lerp for (f32, f32) {
#[inline]
fn lerp(self, other: Self, t: f32) -> Self {
(self.0.lerp(other.0, t), self.1.lerp(other.1, t))
}
}
impl Lerp for (f64, f64) {
#[inline]
fn lerp(self, other: Self, t: f32) -> Self {
(self.0.lerp(other.0, t), self.1.lerp(other.1, t))
}
}
impl Lerp for Color {
/// Interpolación componente a componente sobre los 4 canales RGBA
/// en espacio sRGB lineal-asumido. No es colorimetricamente correcto
/// (debería ser oklab), pero para fades de alpha/tinte de UI es
/// indistinguible y mucho más barato.
#[inline]
fn lerp(self, other: Self, t: f32) -> Self {
let a = self.components;
let b = other.components;
Color {
components: [
a[0].lerp(b[0], t),
a[1].lerp(b[1], t),
a[2].lerp(b[2], t),
a[3].lerp(b[3], t),
],
..self
}
}
}
/// Animación temporizada de un valor `T: Lerp` entre `from` y `to`.
///
/// El tween es **observable**: la app llama [`Tween::value`] desde su
/// `view` y obtiene el valor interpolado para el frame actual. No tiene
/// estado mutable: el tiempo se mide contra un `Instant` de inicio, así
/// que el mismo `Tween` puede ser leído desde múltiples lugares sin
/// que se desincronice.
#[derive(Debug, Clone, Copy)]
pub struct Tween<T: Lerp> {
pub from: T,
pub to: T,
started: Instant,
pub duration: Duration,
/// Función de easing aplicada a `t ∈ [0, 1]` antes de interpolar.
/// Las canónicas viven en [`llimphi_theme::motion`].
pub easing: fn(f32) -> f32,
}
impl<T: Lerp> Tween<T> {
/// Arranca el tween *ahora*. La primera lectura siguiente devuelve
/// `from`; cuando hayan pasado `duration` segundos, devuelve `to`.
pub fn new(from: T, to: T, duration: Duration, easing: fn(f32) -> f32) -> Self {
Self {
from,
to,
started: Instant::now(),
duration,
easing,
}
}
/// Tween que ya está terminado y siempre devuelve el mismo valor.
/// Útil para inicializar un campo de modelo antes de cualquier animación.
pub fn idle(value: T) -> Self {
Self {
from: value,
to: value,
started: Instant::now() - Duration::from_secs(1),
duration: Duration::from_millis(1),
easing: motion::linear,
}
}
/// Progreso normalizado en `[0.0, 1.0]`, ya con easing aplicado.
pub fn progress(&self) -> f32 {
if self.duration.is_zero() {
return 1.0;
}
let elapsed = self.started.elapsed().as_secs_f32();
let t = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
(self.easing)(t)
}
/// Valor actual interpolado.
pub fn value(&self) -> T {
self.from.lerp(self.to, self.progress())
}
/// `true` si la animación ya completó su `duration`.
pub fn done(&self) -> bool {
self.started.elapsed() >= self.duration
}
}
/// Lanza un loop de ticks de animación que dispara `make_msg()` a ~60 Hz
/// durante `duration`, y se autodetiene cuando termina. El callback no
/// hace falta que verifique el tiempo: la app lee `tween.value()` y el
/// hilo interno se encarga de los frames.
///
/// Cada tick dispatcha un `Msg` al `update` — la app no tiene que hacer
/// nada en ese update salvo, eventualmente, leer el `Tween` cuya
/// `progress()` cambió desde la última lectura. La `view` luego se
/// repinta con el valor interpolado del frame.
///
/// **Detención**: el hilo de ticks vive `duration + 32ms` (un frame
/// extra de gracia para que el último tick caiga *después* del tope
/// del tween y la `view` final pinte el valor `to`). No hace falta
/// cancelar manualmente. Para tweens encadenados (A → B → C) la app
/// llama `animate()` de nuevo desde el `update` cuando el tween anterior
/// termina.
///
/// Internamente usa un hilo dedicado (no `spawn_periodic`, que es
/// infinito) y dispatcha vía `Handle::dispatch` clonado.
pub fn animate<F, Msg>(handle: &Handle<Msg>, duration: Duration, make_msg: F)
where
F: Fn() -> Msg + Send + Sync + 'static,
Msg: Clone + Send + 'static,
{
let frame = Duration::from_millis(16);
let total = duration + Duration::from_millis(32);
let handle = handle.clone();
std::thread::spawn(move || {
let start = Instant::now();
while start.elapsed() <= total {
handle.dispatch(make_msg());
std::thread::sleep(frame);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lerp_f32_endpoints() {
assert!((0.0_f32.lerp(10.0, 0.0) - 0.0).abs() < 1e-6);
assert!((0.0_f32.lerp(10.0, 1.0) - 10.0).abs() < 1e-6);
assert!((0.0_f32.lerp(10.0, 0.5) - 5.0).abs() < 1e-6);
}
#[test]
fn lerp_tuple_componentwise() {
let p = (0.0_f32, 100.0).lerp((10.0, 0.0), 0.5);
assert!((p.0 - 5.0).abs() < 1e-6);
assert!((p.1 - 50.0).abs() < 1e-6);
}
#[test]
fn lerp_color_endpoints() {
let a = Color::from_rgba8(0, 0, 0, 0);
let b = Color::from_rgba8(255, 255, 255, 255);
let mid = a.lerp(b, 0.5);
let [r, g, bl, al] = mid.components;
assert!((r - 0.5).abs() < 1e-3);
assert!((g - 0.5).abs() < 1e-3);
assert!((bl - 0.5).abs() < 1e-3);
assert!((al - 0.5).abs() < 1e-3);
}
#[test]
fn tween_idle_returns_constant_value() {
let t = Tween::idle(42.0_f32);
assert!((t.value() - 42.0).abs() < 1e-6);
assert!(t.done());
}
#[test]
fn tween_zero_duration_immediately_done() {
let t = Tween::new(0.0_f32, 1.0, Duration::ZERO, motion::linear);
assert!((t.progress() - 1.0).abs() < 1e-6);
assert!((t.value() - 1.0).abs() < 1e-6);
}
#[test]
fn tween_progress_clamps_after_duration() {
let t = Tween::new(0.0_f32, 10.0, Duration::from_millis(1), motion::linear);
std::thread::sleep(Duration::from_millis(10));
assert!((t.progress() - 1.0).abs() < 1e-6);
assert!((t.value() - 10.0).abs() < 1e-6);
}
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "llimphi-raster"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
llimphi-hal = { path = "../llimphi-hal" }
vello = { workspace = true }
pollster = { workspace = true }
[[example]]
name = "render_node"
path = "examples/render_node.rs"
[[example]]
name = "spike_gpu_directo"
path = "examples/spike_gpu_directo.rs"
[[example]]
name = "gpu_million_points"
path = "examples/gpu_million_points.rs"
+10
View File
@@ -0,0 +1,10 @@
# llimphi-raster
> Rasterizer vello + cache de scenes de [llimphi](../README.md).
Wrapper sobre `vello`/`wgpu` con cache LRU de `Scene`s pre-renderizadas (para layouts estáticos que no cambian frame a frame). Manejo de antialiasing, clipping, blend modes. Trabaja contra `Surface` del HAL.
## Deps
- [`llimphi-hal`](../llimphi-hal/README.md)
- `vello`, `wgpu`, `peniko`, `kurbo`
+10
View File
@@ -0,0 +1,10 @@
# llimphi-raster
> Vello rasterizer + scene cache of [llimphi](../README.md).
Wrapper over `vello`/`wgpu` with LRU cache of pre-rendered `Scene`s (for static layouts that don't change frame to frame). Antialiasing, clipping, blend modes. Works against the HAL's `Surface`.
## Deps
- [`llimphi-hal`](../llimphi-hal/README.md)
- `vello`, `wgpu`, `peniko`, `kurbo`
@@ -0,0 +1,111 @@
//! Demo headless del HAL GPU directo — Fase 6 del SDD
//! `02_ruway/llimphi/SDD.md` §"GPU directo wgpu".
//!
//! A diferencia de `spike_gpu_directo` (que compara vello vs un pipeline
//! mock para tomar la decisión arquitectónica), este ejemplo usa
//! directamente la API pública `GpuPipelines` + `GpuBatch` sobre N
//! puntos (rects 1.2×1.2 px) sintéticos. Su rol es:
//!
//! - Documentar el uso mínimo: 8 líneas de código + uso de Color.
//! - Ejercitar el HAL sin ninguna app (sin winit, sin runtime Elm).
//! - Servir de benchmark de referencia post-implementación: tiempo
//! total CPU+GPU para 100K / 500K / 1M / 5M rects.
//!
//! Corre con: `cargo run -p llimphi-raster --example gpu_million_points --release`.
use std::io::Write;
use std::time::Instant;
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{GpuBatch, GpuPipelines};
const W: u32 = 1024;
const H: u32 = 1024;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const WARMUP: usize = 5;
const MEASURED: usize = 15;
const SIZES: &[u32] = &[100_000, 500_000, 1_000_000, 5_000_000];
fn main() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let pipelines = GpuPipelines::new(&hal.device, FMT);
let (_tex, view) = make_target(&hal.device);
println!();
println!("gpu_million_points — GpuBatch + 3 pipelines · target {W}×{H} Rgba8Unorm");
println!("warmup {WARMUP}, measured {MEASURED}");
println!(" {:>10} | {:>14} | {:>14}", "N", "ms / frame", "Mprim/s");
println!(" {:->10} + {:->14} + {:->14}", "", "", "");
for &n in SIZES {
let ms = bench(&hal, &pipelines, &view, n);
let throughput = (n as f64 / 1_000_000.0) / (ms / 1000.0);
println!(" {:>10} | {:>14.3} | {:>14.2}", n, ms, throughput);
let _ = std::io::stdout().flush();
}
println!();
println!("(en llvmpipe estos números son CPU-bound — ver Fase 0 del SDD)");
println!();
}
fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("gpu_million_points-target"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
(tex, view)
}
fn bench(hal: &Hal, pipelines: &GpuPipelines, view: &wgpu::TextureView, n: u32) -> f64 {
let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
for frame in 0..(WARMUP + MEASURED) {
let t0 = Instant::now();
let mut batch = GpuBatch::new(pipelines);
let mut state: u32 = 0x1234_5678;
for _ in 0..n {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let x = (state % W) as f32;
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let y = (state % H) as f32;
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let r = ((state >> 0) & 0xFF) as f32 / 255.0;
let g = ((state >> 8) & 0xFF) as f32 / 255.0;
let b = ((state >> 16) & 0xFF) as f32 / 255.0;
batch.add_rect(x, y, 1.2, 1.2, Color::new([r, g, b, 1.0]));
}
let mut encoder = hal.device.create_command_encoder(
&wgpu::CommandEncoderDescriptor {
label: Some("gpu_million_points-enc"),
},
);
batch.flush(
&hal.device,
&hal.queue,
&mut encoder,
view,
(W as f32, H as f32),
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
}
}
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
samples[samples.len() / 2]
}
+143
View File
@@ -0,0 +1,143 @@
//! Fase 2 de Llimphi: un nodo (círculo + halo) renderizado por vello con AA
//! perfecto sobre el swapchain de llimphi-hal.
//!
//! Corre con: `cargo run -p llimphi-raster --example render_node --release`.
use std::sync::Arc;
use std::time::Instant;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::dpi::LogicalSize;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{Hal, Surface, WinitSurface};
use llimphi_raster::kurbo::{Affine, Circle, Stroke};
use llimphi_raster::peniko::{color::palette, Color, Fill};
use llimphi_raster::{vello, Renderer};
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
renderer: Renderer,
scene: vello::Scene,
}
struct App {
state: Option<State>,
started: Instant,
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let window = event_loop
.create_window(
WindowAttributes::default()
.with_title("llimphi · render_node")
.with_inner_size(LogicalSize::new(960u32, 540u32)),
)
.expect("create window");
let window = Arc::new(window);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let surface = WinitSurface::new(&hal, window.clone()).expect("surface");
let renderer = Renderer::new(&hal).expect("renderer");
window.request_redraw();
self.state = Some(State {
window,
hal,
surface,
renderer,
scene: vello::Scene::new(),
});
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(_) => {
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let (w, h) = frame.size();
state.scene.reset();
build_node(&mut state.scene, w as f64, h as f64, self.started.elapsed().as_secs_f64());
if let Err(e) = state.renderer.render(
&state.hal,
&state.scene,
&frame,
palette::css::BLACK,
) {
eprintln!("render error: {e}");
}
state.surface.present(frame, &state.hal);
state.window.request_redraw();
}
_ => {}
}
}
}
/// Pinta un nodo centrado (círculo lleno + halo) que respira con `t`.
fn build_node(scene: &mut vello::Scene, w: f64, h: f64, t: f64) {
let cx = w * 0.5;
let cy = h * 0.5;
let pulse = 1.0 + 0.06 * (t * 1.6).sin();
let r = (h.min(w) * 0.18) * pulse;
// Halo
scene.stroke(
&Stroke::new(2.0),
Affine::IDENTITY,
Color::from_rgba8(60, 120, 200, 180),
None,
&Circle::new((cx, cy), r * 1.35),
);
// Cuerpo
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
Color::from_rgba8(90, 160, 230, 255),
None,
&Circle::new((cx, cy), r),
);
// Borde
scene.stroke(
&Stroke::new(3.0),
Affine::IDENTITY,
Color::from_rgba8(20, 50, 100, 255),
None,
&Circle::new((cx, cy), r),
);
}
fn main() {
let event_loop = EventLoop::new().expect("event loop");
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = App {
state: None,
started: Instant::now(),
};
event_loop.run_app(&mut app).expect("run app");
}
@@ -0,0 +1,390 @@
//! Spike Fase 0 — GPU directo vs vello.
//!
//! Compara el tiempo total CPU+GPU por frame para pintar N puntos en una
//! textura `Rgba8Unorm` 1024×1024 con dos estrategias:
//!
//! - **Vello**: una llamada `Scene::fill(Rect 1×1)` por punto, luego
//! `vello::Renderer::render_to_texture`.
//! - **GPU directo**: un pipeline `wgpu` con instanced quad. Cada punto es
//! una instancia `[x:f32, y:f32, rgba:u32]`. Una sola draw call.
//!
//! Tamaños: 100K, 500K, 1M puntos. 10 frames de warmup + 20 medidos por
//! tamaño. Reporta mediana y factor de aceleración.
//!
//! Criterio de aceptación del SDD (`llimphi/SDD.md` §"GPU directo wgpu"):
//! factor ≥ 5× a 500K → seguir con Fase 1. Si no, abortar.
//!
//! Corre con: `cargo run -p llimphi-raster --example spike_gpu_directo --release`.
use std::io::Write;
use std::time::Instant;
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::{
kurbo::{Affine, Rect},
peniko::{color::palette, Color, Fill},
vello,
};
const W: u32 = 1024;
const H: u32 = 1024;
const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const WARMUP_FRAMES: usize = 5;
const MEASURED_FRAMES: usize = 15;
// Vello revienta (SIGSEGV en `vello_encoding::path::flatten`) cuando la
// escena pasa de ~200K paths con los `Limits::default()` que pide el HAL.
// Es exactamente el techo del SDD §"GPU directo wgpu". Lo medimos hasta
// donde vello aguanta; el lado directo se mide a sizes mucho mayores para
// confirmar el régimen post-techo.
const VELLO_SIZES: &[usize] = &[25_000, 50_000, 100_000, 200_000];
const DIRECTO_SIZES: &[usize] = &[100_000, 500_000, 1_000_000, 5_000_000];
fn main() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
// Textura destino compartida por ambos backends. STORAGE_BINDING para
// vello (compute), RENDER_ATTACHMENT para el pipeline directo. Idéntica
// al `intermediate` de `WinitSurface` (HAL real).
let (target, target_view) = create_target(&hal.device);
let mut vello_renderer = vello::Renderer::new(
&hal.device,
vello::RendererOptions {
use_cpu: false,
antialiasing_support: vello::AaSupport {
area: true,
msaa8: false,
msaa16: false,
},
num_init_threads: None,
pipeline_cache: None,
},
)
.expect("vello renderer");
let directo = DirectoPipeline::new(&hal.device);
println!();
println!("spike GPU directo — target {W}×{H} Rgba8Unorm, headless");
println!("warmup {WARMUP_FRAMES}, measured {MEASURED_FRAMES}");
println!();
println!("vello (scene.fill por punto):");
println!(" {:>10} | {:>14}", "N", "ms / frame");
println!(" {:->10} + {:->14}", "", "");
let mut vello_100k_ms: Option<f64> = None;
for &n in VELLO_SIZES {
let points = gen_points(n);
let ms = bench_vello(&hal, &mut vello_renderer, &target_view, &points);
println!(" {:>10} | {:>14.3}", n, ms);
let _ = std::io::stdout().flush();
if n == 100_000 {
vello_100k_ms = Some(ms);
}
}
println!();
println!("GPU directo (instanced quad, 1 draw call):");
println!(" {:>10} | {:>14}", "N", "ms / frame");
println!(" {:->10} + {:->14}", "", "");
let mut directo_100k_ms: Option<f64> = None;
for &n in DIRECTO_SIZES {
let points = gen_points(n);
let ms = bench_directo(&hal, &directo, &target_view, &points);
println!(" {:>10} | {:>14.3}", n, ms);
let _ = std::io::stdout().flush();
if n == 100_000 {
directo_100k_ms = Some(ms);
}
}
println!();
if let (Some(v), Some(d)) = (vello_100k_ms, directo_100k_ms) {
let factor = v / d;
let verdict = if factor >= 5.0 { "PASA" } else { "ABORTAR" };
println!(
"veredicto Fase 0 @ 100K: vello {:.2} ms / directo {:.2} ms = {:.2}×{}",
v, d, factor, verdict
);
println!("(SDD pide ≥5× a 500K, pero vello no llega a 500K — techo medido <300K)");
}
println!();
// Mantener vivo el texture para evitar warnings.
drop(target);
}
fn create_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("spike-target"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: TARGET_FORMAT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
(tex, view)
}
/// LCG numerical recipes — determinista, sin dependencias.
fn gen_points(n: usize) -> Vec<(f32, f32, u32)> {
let mut state: u32 = 0x1234_5678;
let mut out = Vec::with_capacity(n);
for _ in 0..n {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let x = (state % W) as f32;
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let y = (state % H) as f32;
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
// RGBA packed little-endian: R en byte bajo (queda igual a como lo
// lee el shader: `rgba & 0xFF` → R).
let rgba = (state & 0x00FF_FFFF) | 0xFF00_0000;
out.push((x, y, rgba));
}
out
}
fn bench_vello(
hal: &Hal,
renderer: &mut vello::Renderer,
target: &wgpu::TextureView,
points: &[(f32, f32, u32)],
) -> f64 {
let mut scene = vello::Scene::new();
let mut samples: Vec<f64> = Vec::with_capacity(MEASURED_FRAMES);
for frame in 0..(WARMUP_FRAMES + MEASURED_FRAMES) {
let t0 = Instant::now();
scene.reset();
for &(x, y, rgba) in points {
let r = (rgba & 0xFF) as u8;
let g = ((rgba >> 8) & 0xFF) as u8;
let b = ((rgba >> 16) & 0xFF) as u8;
let a = ((rgba >> 24) & 0xFF) as u8;
let xf = x as f64;
let yf = y as f64;
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
Color::from_rgba8(r, g, b, a),
None,
&Rect::new(xf, yf, xf + 1.0, yf + 1.0),
);
}
renderer
.render_to_texture(
&hal.device,
&hal.queue,
&scene,
target,
&vello::RenderParams {
base_color: palette::css::BLACK,
width: W,
height: H,
antialiasing_method: vello::AaConfig::Area,
},
)
.expect("vello render");
// Bloquear hasta que la GPU termine este frame. Sin esto medimos
// sólo el submit + queue building, no el trabajo real.
hal.device.poll(wgpu::Maintain::Wait);
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP_FRAMES {
samples.push(dt);
}
}
median(&mut samples)
}
fn bench_directo(
hal: &Hal,
pipe: &DirectoPipeline,
target: &wgpu::TextureView,
points: &[(f32, f32, u32)],
) -> f64 {
// Buffer de instancias dimensionado para el peor caso.
let bytes_per_inst = std::mem::size_of::<[u32; 3]>(); // [x:f32, y:f32, rgba:u32] = 12B
let inst_buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("spike-directo-inst"),
size: (points.len() * bytes_per_inst) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut samples: Vec<f64> = Vec::with_capacity(MEASURED_FRAMES);
for frame in 0..(WARMUP_FRAMES + MEASURED_FRAMES) {
let t0 = Instant::now();
// Empaquetar instancias: igual a la "scene build" del lado vello,
// para que la comparación sea fair (ambos parten de los mismos
// puntos crudos).
let bytes = pack_instances(points);
hal.queue.write_buffer(&inst_buf, 0, &bytes);
let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("spike-directo-enc"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("spike-directo-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&pipe.pipeline);
pass.set_vertex_buffer(0, inst_buf.slice(..));
// 6 vértices por instancia (2 tris = quad), N instancias.
pass.draw(0..6, 0..points.len() as u32);
}
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP_FRAMES {
samples.push(dt);
}
}
median(&mut samples)
}
fn pack_instances(points: &[(f32, f32, u32)]) -> Vec<u8> {
let mut v = Vec::with_capacity(points.len() * 12);
for &(x, y, rgba) in points {
v.extend_from_slice(&x.to_ne_bytes());
v.extend_from_slice(&y.to_ne_bytes());
v.extend_from_slice(&rgba.to_ne_bytes());
}
v
}
fn median(samples: &mut [f64]) -> f64 {
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
samples[samples.len() / 2]
}
/// Pipeline trivial para el bench: instanced quad sin texturas, color
/// per-instance. No es código de producción — es el "mock GPU directo"
/// que pide la Fase 0 del SDD para medir el techo alcanzable.
struct DirectoPipeline {
pipeline: wgpu::RenderPipeline,
}
impl DirectoPipeline {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("spike-directo-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("spike-directo-layout"),
bind_group_layouts: &[],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("spike-directo-pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
offset: 8,
shader_location: 1,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: TARGET_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
Self { pipeline }
}
}
const WGSL: &str = r#"
struct Inst {
@location(0) xy: vec2<f32>,
@location(1) rgba: u32,
};
struct V2F {
@builtin(position) pos: vec4<f32>,
@location(0) color: vec4<f32>,
};
const W: f32 = 1024.0;
const H: f32 = 1024.0;
@vertex
fn vs(@builtin(vertex_index) vid: u32, inst: Inst) -> V2F {
// Quad 1.5px alrededor de (inst.xy + 0.5). Pixel-centered.
var corners = array<vec2<f32>, 6>(
vec2<f32>(-0.75, -0.75),
vec2<f32>( 0.75, -0.75),
vec2<f32>( 0.75, 0.75),
vec2<f32>(-0.75, -0.75),
vec2<f32>( 0.75, 0.75),
vec2<f32>(-0.75, 0.75),
);
let off = corners[vid];
let px = inst.xy + vec2<f32>(0.5, 0.5) + off;
// pixel → NDC, Y invertido (vello / textura framebuffer).
let ndc = vec2<f32>(px.x / W * 2.0 - 1.0, 1.0 - px.y / H * 2.0);
let r = f32( inst.rgba & 0xFFu) / 255.0;
let g = f32((inst.rgba >> 8u) & 0xFFu) / 255.0;
let b = f32((inst.rgba >> 16u) & 0xFFu) / 255.0;
let a = f32((inst.rgba >> 24u) & 0xFFu) / 255.0;
var out: V2F;
out.pos = vec4<f32>(ndc, 0.0, 1.0);
out.color = vec4<f32>(r, g, b, a);
return out;
}
@fragment
fn fs(in: V2F) -> @location(0) vec4<f32> {
return in.color;
}
"#;
+553
View File
@@ -0,0 +1,553 @@
//! Backend GPU directo (Fases 2 + 3 del SDD §"GPU directo wgpu").
//!
//! Tres pipelines `wgpu` cacheadas en [`GpuPipelines`] (lines / tris /
//! rects) + un acumulador [`GpuBatch`] que las apps usan por frame para
//! emitir centenares de miles a millones de primitivos en una draw call
//! por tipo, sin pasar por vello.
//!
//! Diseño minimal Fase 2/3:
//!
//! - Vertex format triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert).
//! - Instance format líneas: `[x0, y0, x1, y1, rgba]` (20 B/seg).
//! - Instance format rects: `[x, y, w, h, rgba]` (20 B/rect).
//! - Sin texturas. Sin AA por shader — quien necesite AA fino sigue por
//! vello. Para puntos densos el "popping" no se nota.
//! - Blending alfa habilitado: el alpha del color es respetado.
//! - El viewport `(width, height)` se pasa al flush y va en un uniform —
//! los shaders convierten pixel → NDC ahí.
//!
//! Cache de pipelines: una sola instancia de `GpuPipelines` por
//! `(device, color_format)`. Construirla compila los 3 pipelines en
//! caliente (~ms en hardware moderno). Los callers la mantienen viva
//! entre frames (en su Model o vía `OnceLock`).
//!
//! Grow strategy: `flush` crea un buffer por tipo no vacío en el
//! mismo frame. Sin reuso entre frames — Fase 4 (`GpuSceneCanvas`)
//! introducirá el `GpuBuffers` persistente que dobla capacidad si
//! aparece la necesidad.
use llimphi_hal::wgpu;
use vello::peniko::Color;
/// Pipelines cacheadas. Crear uno por proceso (o por surface format).
///
/// Para uso típico via [`GpuBatch`] los campos no se tocan directo. La
/// API pública existe para callers avanzados que quieran montar su propio
/// buffer persistente (datos que no cambian por frame: starfield Gaia,
/// particles iniciales, viewport estático) y emitir draw calls
/// manualmente reusando estas pipelines.
///
/// Layouts:
/// - Vertex buffer triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert).
/// - Instance buffer rects: `[x, y, w, h, rgba]` (20 B/inst).
/// - Instance buffer líneas: `[x0, y0, x1, y1, rgba]` (20 B/inst).
/// - Bind group 0 binding 0: uniform `{viewport: vec2<f32>, line_width: f32, _pad: f32}` (16 B).
pub struct GpuPipelines {
pub lines: wgpu::RenderPipeline,
pub tris: wgpu::RenderPipeline,
pub rects: wgpu::RenderPipeline,
pub bind_layout: wgpu::BindGroupLayout,
}
impl GpuPipelines {
/// Compila los 3 pipelines apuntando al `color_format` del target
/// que recibirán en `flush` (el de la intermediate de `WinitSurface`,
/// normalmente `Rgba8Unorm`).
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-raster-gpu-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-raster-gpu-bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-raster-gpu-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
let color_targets = [Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})];
// Triángulos (vertex buffer plano, color per-vertex).
let tris = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-raster-gpu-tris"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_tris"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
offset: 8,
shader_location: 1,
},
],
}],
},
primitive: tri_primitive(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &color_targets,
}),
multiview: None,
cache: None,
});
// Rects (instanced quad).
let rects = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-raster-gpu-rects"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_rects"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 20,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 8,
shader_location: 1,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
offset: 16,
shader_location: 2,
},
],
}],
},
primitive: tri_primitive(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &color_targets,
}),
multiview: None,
cache: None,
});
// Líneas con grosor: cada segmento es una instancia de 20 B; el
// VS expande a un quad de 6 vértices perpendicular al segmento
// usando un grosor uniforme en píxeles (vienen del uniform).
let lines = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-raster-gpu-lines"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_lines"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 20,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
offset: 16,
shader_location: 1,
},
],
}],
},
primitive: tri_primitive(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &color_targets,
}),
multiview: None,
cache: None,
});
Self {
lines,
tris,
rects,
bind_layout,
}
}
}
fn tri_primitive() -> wgpu::PrimitiveState {
wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
}
}
/// Acumulador de primitivas por frame. Construir → `add_*` → `flush`.
pub struct GpuBatch<'a> {
pipelines: &'a GpuPipelines,
line_verts: Vec<u8>,
tri_verts: Vec<u8>,
rect_insts: Vec<u8>,
line_width: f32,
line_count: u32,
tri_vert_count: u32,
rect_count: u32,
}
impl<'a> GpuBatch<'a> {
pub fn new(pipelines: &'a GpuPipelines) -> Self {
Self {
pipelines,
line_verts: Vec::new(),
tri_verts: Vec::new(),
rect_insts: Vec::new(),
line_width: 1.0,
line_count: 0,
tri_vert_count: 0,
rect_count: 0,
}
}
/// Grosor de las próximas líneas (en pixels del frame, sin AA).
/// Se aplica a todas las líneas del batch — el lado bueno de una
/// sola draw call es que sólo hay un grosor "vivo" por flush.
pub fn line_width(&mut self, w: f32) {
self.line_width = w;
}
/// Añade un segmento de línea como instancia.
pub fn add_line(&mut self, p0: (f32, f32), p1: (f32, f32), color: Color) {
let rgba = pack_rgba(color);
self.line_verts.extend_from_slice(&p0.0.to_ne_bytes());
self.line_verts.extend_from_slice(&p0.1.to_ne_bytes());
self.line_verts.extend_from_slice(&p1.0.to_ne_bytes());
self.line_verts.extend_from_slice(&p1.1.to_ne_bytes());
self.line_verts.extend_from_slice(&rgba.to_ne_bytes());
self.line_count += 1;
}
/// Añade una polilínea como secuencia de segmentos individuales
/// (line-list). Para N puntos emite N-1 instancias.
pub fn add_polyline(&mut self, points: &[(f32, f32)], color: Color) {
if points.len() < 2 {
return;
}
for w in points.windows(2) {
self.add_line(w[0], w[1], color);
}
}
/// Añade un triángulo con color por vértice.
pub fn add_tri(
&mut self,
a: (f32, f32),
b: (f32, f32),
c: (f32, f32),
ca: Color,
cb: Color,
cc: Color,
) {
self.push_tri_vert(a, ca);
self.push_tri_vert(b, cb);
self.push_tri_vert(c, cc);
}
fn push_tri_vert(&mut self, p: (f32, f32), color: Color) {
let rgba = pack_rgba(color);
self.tri_verts.extend_from_slice(&p.0.to_ne_bytes());
self.tri_verts.extend_from_slice(&p.1.to_ne_bytes());
self.tri_verts.extend_from_slice(&rgba.to_ne_bytes());
self.tri_vert_count += 1;
}
/// Añade un triangle list crudo `[(x, y); 3*N]` con un mismo color
/// uniforme por vértice. Útil para teselaciones precomputadas
/// (contornos, polígonos rellenos).
pub fn add_tri_list(&mut self, verts: &[(f32, f32)], color: Color) {
for &p in verts {
self.push_tri_vert(p, color);
}
}
/// Añade un rectángulo lleno como instancia (sin radio — para
/// rounded rects sigue por vello).
pub fn add_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) {
let rgba = pack_rgba(color);
self.rect_insts.extend_from_slice(&x.to_ne_bytes());
self.rect_insts.extend_from_slice(&y.to_ne_bytes());
self.rect_insts.extend_from_slice(&w.to_ne_bytes());
self.rect_insts.extend_from_slice(&h.to_ne_bytes());
self.rect_insts.extend_from_slice(&rgba.to_ne_bytes());
self.rect_count += 1;
}
/// Cuenta total de primitivas pendientes (útil para benches).
pub fn primitive_count(&self) -> u32 {
self.line_count + self.rect_count + self.tri_vert_count / 3
}
/// Despacha las primitivas acumuladas como 1 draw call por tipo
/// no vacío contra `view`. `viewport` es el tamaño en pixels del
/// target (lo usa el VS para mapear pixel → NDC).
///
/// `load_op` decide si la pasada conserva el contenido previo
/// (`Load`, lo normal cuando vello ya pintó algo) o limpia
/// (`Clear(color)`). Apps que llamen a `GpuBatch` desde
/// `gpu_paint_with` quieren `Load`.
pub fn flush(
self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
viewport: (f32, f32),
load_op: wgpu::LoadOp<wgpu::Color>,
) {
let total = self.line_count + self.tri_vert_count + self.rect_count;
if total == 0 {
return;
}
// Uniforms: [viewport.w, viewport.h, line_width, _pad].
let u_data = [viewport.0, viewport.1, self.line_width, 0.0];
let mut u_bytes = Vec::with_capacity(16);
for v in u_data {
u_bytes.extend_from_slice(&v.to_ne_bytes());
}
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-raster-gpu-u"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&uniforms, 0, &u_bytes);
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-raster-gpu-bg"),
layout: &self.pipelines.bind_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniforms.as_entire_binding(),
}],
});
// Buffers por tipo (sólo si hay datos).
let lines_buf = (!self.line_verts.is_empty()).then(|| {
let b = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-raster-gpu-lines-buf"),
size: self.line_verts.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&b, 0, &self.line_verts);
b
});
let tris_buf = (!self.tri_verts.is_empty()).then(|| {
let b = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-raster-gpu-tris-buf"),
size: self.tri_verts.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&b, 0, &self.tri_verts);
b
});
let rects_buf = (!self.rect_insts.is_empty()).then(|| {
let b = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-raster-gpu-rects-buf"),
size: self.rect_insts.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&b, 0, &self.rect_insts);
b
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-raster-gpu-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
ops: wgpu::Operations {
load: load_op,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_bind_group(0, &bind_group, &[]);
// Orden de draws: rects (fondo) → tris → lines (encima). Match
// de la convención usual "fill abajo, stroke arriba".
if let Some(buf) = rects_buf.as_ref() {
pass.set_pipeline(&self.pipelines.rects);
pass.set_vertex_buffer(0, buf.slice(..));
pass.draw(0..6, 0..self.rect_count);
}
if let Some(buf) = tris_buf.as_ref() {
pass.set_pipeline(&self.pipelines.tris);
pass.set_vertex_buffer(0, buf.slice(..));
pass.draw(0..self.tri_vert_count, 0..1);
}
if let Some(buf) = lines_buf.as_ref() {
pass.set_pipeline(&self.pipelines.lines);
pass.set_vertex_buffer(0, buf.slice(..));
pass.draw(0..6, 0..self.line_count);
}
}
}
/// Empaqueta un `peniko::Color` a u32 little-endian RGBA8.
/// El shader lo lee como `inst.rgba` y separa bytes — debe coincidir
/// con la convención del WGSL (`r = rgba & 0xFF`, etc.).
fn pack_rgba(c: Color) -> u32 {
let [r, g, b, a] = c.to_rgba8().to_u8_array();
(r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | ((a as u32) << 24)
}
const WGSL: &str = r#"
struct Uniforms {
viewport: vec2<f32>,
line_width: f32,
_pad: f32,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
struct V2F {
@builtin(position) pos: vec4<f32>,
@location(0) color: vec4<f32>,
};
fn unpack_rgba(c: u32) -> vec4<f32> {
let r = f32( c & 0xFFu) / 255.0;
let g = f32((c >> 8u) & 0xFFu) / 255.0;
let b = f32((c >> 16u) & 0xFFu) / 255.0;
let a = f32((c >> 24u) & 0xFFu) / 255.0;
return vec4<f32>(r, g, b, a);
}
fn px_to_ndc(p: vec2<f32>) -> vec2<f32> {
return vec2<f32>(p.x / u.viewport.x * 2.0 - 1.0, 1.0 - p.y / u.viewport.y * 2.0);
}
// -------- triángulos: 1 vértice = (xy, rgba) --------
@vertex
fn vs_tris(@location(0) xy: vec2<f32>, @location(1) rgba: u32) -> V2F {
var out: V2F;
out.pos = vec4<f32>(px_to_ndc(xy), 0.0, 1.0);
out.color = unpack_rgba(rgba);
return out;
}
// -------- rects: 1 instancia = (xy, wh, rgba), 6 vértices/quad --------
@vertex
fn vs_rects(
@builtin(vertex_index) vid: u32,
@location(0) inst_xy: vec2<f32>,
@location(1) inst_wh: vec2<f32>,
@location(2) inst_rgba: u32,
) -> V2F {
var corners = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 1.0),
);
let local = corners[vid];
let px = inst_xy + local * inst_wh;
var out: V2F;
out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
out.color = unpack_rgba(inst_rgba);
return out;
}
// -------- líneas: 1 instancia = (p0xy, p1xy, rgba), expandida a quad ----
@vertex
fn vs_lines(
@builtin(vertex_index) vid: u32,
@location(0) seg: vec4<f32>,
@location(1) rgba: u32,
) -> V2F {
// Quad perpendicular al segmento, grosor uniforme `u.line_width` px.
// vid 0..5 mapea a los 6 vértices del quad (2 tris).
let p0 = seg.xy;
let p1 = seg.zw;
let dir = normalize(p1 - p0);
let n = vec2<f32>(-dir.y, dir.x);
let half_w = u.line_width * 0.5;
let offsets = array<vec2<f32>, 6>(
vec2<f32>(0.0, -half_w), // p0 -n
vec2<f32>(0.0, half_w), // p0 +n
vec2<f32>(1.0, half_w), // p1 +n
vec2<f32>(0.0, -half_w), // p0 -n
vec2<f32>(1.0, half_w), // p1 +n
vec2<f32>(1.0, -half_w), // p1 -n
);
let o = offsets[vid];
let along = mix(p0, p1, o.x);
let across = n * o.y;
let px = along + across;
var out: V2F;
out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
out.color = unpack_rgba(rgba);
return out;
}
@fragment
fn fs(in: V2F) -> @location(0) vec4<f32> {
return in.color;
}
"#;
+120
View File
@@ -0,0 +1,120 @@
//! llimphi-raster — Brocha Matemática.
//!
//! Traduce primitivas vectoriales (líneas, curvas de Bézier, texto) a
//! píxeles via Compute Shaders. Backend: `vello`.
//!
//! Punto de entrada: [`Renderer`]. Recibe una [`vello::Scene`] y la pinta
//! sobre un [`llimphi_hal::Frame`].
use llimphi_hal::{Frame, Hal};
pub use vello;
pub use vello::kurbo;
pub use vello::peniko;
pub mod gpu;
pub use gpu::{GpuBatch, GpuPipelines};
/// Errores del rasterizador.
#[derive(Debug)]
pub enum RasterError {
Init(String),
Render(String),
}
impl std::fmt::Display for RasterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Init(s) => write!(f, "vello init: {s}"),
Self::Render(s) => write!(f, "vello render: {s}"),
}
}
}
impl std::error::Error for RasterError {}
/// Rasterizador vectorial. Una instancia por surface (porque vello cachea
/// resources contra un `surface_format` específico).
pub struct Renderer {
inner: vello::Renderer,
}
impl Renderer {
/// Inicializa el rasterizador. Vello acepta cualquier textura compatible
/// (Rgba8Unorm / Bgra8Unorm) en `render`, así que no se fija un formato
/// en construcción.
///
/// **`antialiasing_support`**: pedimos `area` solamente, no `all()`.
/// `area` es el único método que `render()` usa (`AaConfig::Area`
/// fijo). Pedir `all()` haría a vello compilar también pipelines
/// para `msaa8` y `msaa16` que nunca se invocan — en Mali-G57 eso
/// triplica el cold-start (medido: 3.7s vs ~1.2s). Si alguna app
/// futura necesita MSAA, agregamos un constructor explícito.
///
/// **`num_init_threads: None`**: vello paraleliza la compilación
/// de shaders en `None` → todos los CPU cores. Mali-G57 viene en
/// SoCs octa-core ARM; con 1 thread tardamos 2.0s, con 8 esperamos
/// ~400-600ms. La compilación de shaders es 100% CPU (Rust →
/// SPIR-V), el GPU no participa, así que multi-thread escala
/// casi linealmente hasta saturar el queue del Naga compiler.
pub fn new(hal: &Hal) -> Result<Self, RasterError> {
let inner = vello::Renderer::new(
&hal.device,
vello::RendererOptions {
use_cpu: false,
antialiasing_support: vello::AaSupport {
area: true,
msaa8: false,
msaa16: false,
},
num_init_threads: None,
pipeline_cache: None,
},
)
.map_err(|e| RasterError::Init(e.to_string()))?;
Ok(Self { inner })
}
/// Renderiza `scene` sobre `frame` limpiando con `base_color`. AA fija
/// en area-sampling (precisión Δ < 10⁻⁹ rad del SDD).
pub fn render(
&mut self,
hal: &Hal,
scene: &vello::Scene,
frame: &Frame,
base_color: peniko::Color,
) -> Result<(), RasterError> {
let (width, height) = frame.size();
self.render_to_view(hal, scene, frame.view(), width, height, base_color)
}
/// Como [`render`](Self::render) pero contra una vista de textura
/// explícita (mismo formato/tamaño que la intermedia). Lo usa el
/// compositor de overlay de `llimphi-ui` para rasterizar la capa de
/// overlay sobre fondo transparente en su propia textura. Ojo:
/// `render_to_texture` **limpia** el target con `base_color` y escribe
/// todos los píxeles — no compone sobre contenido previo.
pub fn render_to_view(
&mut self,
hal: &Hal,
scene: &vello::Scene,
view: &llimphi_hal::wgpu::TextureView,
width: u32,
height: u32,
base_color: peniko::Color,
) -> Result<(), RasterError> {
self.inner
.render_to_texture(
&hal.device,
&hal.queue,
scene,
view,
&vello::RenderParams {
base_color,
width,
height,
antialiasing_method: vello::AaConfig::Area,
},
)
.map_err(|e| RasterError::Render(e.to_string()))
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Smoke test del backend GPU directo (`llimphi_raster::gpu`).
//!
//! No verifica píxeles — eso requiere AA y un patrón conocido, y por
//! ahora el módulo no garantiza pixel-exactness. Sí verifica que:
//!
//! - `GpuPipelines::new` compila los 3 shaders WGSL sin errores de naga.
//! - `GpuBatch` acepta líneas, triángulos y rects mezclados sin pánico.
//! - `flush` ejecuta sin errores wgpu y la `Maintain::Wait` retorna
//! (= la GPU/llvmpipe terminó las pasadas).
//!
//! Corre en cualquier adapter wgpu disponible — en CI sin GPU usa
//! llvmpipe, donde igual valida el ensamblado y la sintaxis WGSL.
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::gpu::{GpuBatch, GpuPipelines};
use llimphi_raster::peniko::Color;
const W: u32 = 256;
const H: u32 = 256;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("smoke-target"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
(tex, view)
}
#[test]
fn batch_with_rects_lines_tris_does_not_panic() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let pipelines = GpuPipelines::new(&hal.device, FMT);
let (_tex, view) = make_target(&hal.device);
let mut batch = GpuBatch::new(&pipelines);
batch.line_width(2.0);
// Cuadrícula 8×8 de rects con color que varía.
for j in 0..8 {
for i in 0..8 {
let x = 8.0 + i as f32 * 30.0;
let y = 8.0 + j as f32 * 30.0;
let c = Color::from_rgba8(
(i * 32) as u8,
(j * 32) as u8,
100,
255,
);
batch.add_rect(x, y, 24.0, 24.0, c);
}
}
// Diagonal de líneas.
for k in 0..16 {
batch.add_line(
(0.0, k as f32 * 16.0),
(W as f32, (k + 1) as f32 * 16.0),
Color::from_rgba8(220, 220, 250, 180),
);
}
// Triángulo grande con color por vértice.
batch.add_tri(
(128.0, 32.0),
(64.0, 220.0),
(220.0, 220.0),
Color::from_rgba8(255, 80, 80, 200),
Color::from_rgba8(80, 255, 80, 200),
Color::from_rgba8(80, 80, 255, 200),
);
assert!(batch.primitive_count() > 0, "batch debería tener primitivas");
let mut encoder = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("smoke-enc"),
});
batch.flush(
&hal.device,
&hal.queue,
&mut encoder,
&view,
(W as f32, H as f32),
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
}
#[test]
fn empty_batch_flush_is_no_op() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let pipelines = GpuPipelines::new(&hal.device, FMT);
let (_tex, view) = make_target(&hal.device);
let batch = GpuBatch::new(&pipelines);
assert_eq!(batch.primitive_count(), 0);
let mut encoder = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("smoke-empty-enc"),
});
// Con batch vacío, flush no debe crear render pass ni buffers.
batch.flush(
&hal.device,
&hal.queue,
&mut encoder,
&view,
(W as f32, H as f32),
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-surface"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
llimphi-hal = { path = "../llimphi-hal" }
llimphi-ui = { path = "../llimphi-ui" }
parking_lot = { workspace = true }
+404
View File
@@ -0,0 +1,404 @@
//! llimphi-surface — superficies externas dentro del bucle Elm.
//!
//! Un `ExternalSurface` es una textura RGBA8 que vive en GPU y se pinta
//! sobre un rect del frame Llimphi cada vez que la app lo expone vía
//! `View::gpu_paint_with`. La fuente de bytes corre afuera del bucle
//! Elm: un decoder de video, un capture de cámara, un raster de PDF,
//! una textura raw producida por otro motor — cualquier productor que
//! genere RGBA puede empujar frames con [`ExternalSurface::upload`] y
//! ver el resultado en la próxima pasada de raster.
//!
//! El crate provee:
//!
//! - [`ExternalSurface`]: dueño de la textura + render pipeline + bind
//! group. `upload(rgba, w, h)` sube bytes y recrea la textura si
//! `w`/`h` cambiaron.
//! - [`ExternalSurface::view`]: helper que construye un [`View`] con
//! `gpu_paint_with` ya conectado. La app sólo elige el `Style` del
//! nodo (qué porción del layout ocupa).
//!
//! ## Diseño
//!
//! El pipeline es un textured-quad clásico: dos triángulos cubren el
//! rect destino, el fragment shader samplea la textura externa con
//! sampler bilineal. Las coordenadas NDC del quad se computan en GPU
//! a partir de `(rect, viewport)` que viajan por uniform — por eso
//! el callback necesita el `viewport` que `llimphi-ui` empezó a
//! propagar en `GpuPaintFn`.
//!
//! La textura intermedia donde Llimphi pinta vello es `Rgba8Unorm`
//! (ver `llimphi-hal::INTERMEDIATE_FORMAT`). El pipeline emite
//! `Rgba8Unorm` también — el target del render pass es esa misma
//! intermedia con `LoadOp::Load`, así el fondo vello queda preservado.
use std::sync::Arc;
use llimphi_hal::wgpu;
use llimphi_ui::{PaintRect, View};
use parking_lot::Mutex;
const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const SOURCE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
struct Inner {
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::RenderPipeline,
bgl: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
uniforms: wgpu::Buffer,
// Textura + bind group recreados cuando cambia (w, h) del frame de
// entrada. Empieza en (1, 1) con un pixel transparente para que el
// pipeline funcione antes del primer `upload`.
tex: wgpu::Texture,
bind_group: wgpu::BindGroup,
tex_size: (u32, u32),
}
/// Superficie externa: textura GPU + pipeline que la blittea al rect
/// que ocupe en el árbol Llimphi. Clonar es barato (Arc interno).
#[derive(Clone)]
pub struct ExternalSurface {
inner: Arc<Mutex<Inner>>,
}
impl ExternalSurface {
/// Construye la surface usando el `Device`/`Queue` del Hal de la app.
/// La textura arranca en 1×1 transparente; el primer
/// [`Self::upload`] la redimensiona al tamaño real del frame.
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-surface-bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-surface-pl"),
bind_group_layouts: &[&bgl],
push_constant_ranges: &[],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-surface-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-surface-pipe"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: TARGET_FORMAT,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("llimphi-surface-sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
// Uniforms: 8 floats — rect (x, y, w, h) + viewport (vw, vh, _, _).
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-surface-uniforms"),
size: 32,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let (tex, bind_group) =
make_texture_and_bg(device, queue, &bgl, &uniforms, &sampler, 1, 1, &[0, 0, 0, 0]);
Self {
inner: Arc::new(Mutex::new(Inner {
device: device.clone(),
queue: queue.clone(),
pipeline,
bgl,
sampler,
uniforms,
tex,
bind_group,
tex_size: (1, 1),
})),
}
}
/// Sube `rgba` (8 bits por canal, premultiplicado o no — el blend
/// usa straight alpha) como nuevo contenido de la surface. Si
/// `(width, height)` difiere del tamaño actual, recrea la textura
/// y el bind group. `rgba.len()` debe ser exactamente
/// `width * height * 4`.
pub fn upload(&self, rgba: &[u8], width: u32, height: u32) {
let mut inner = self.inner.lock();
debug_assert_eq!(rgba.len(), (width as usize) * (height as usize) * 4);
if inner.tex_size != (width, height) {
let (tex, bg) = make_texture_and_bg(
&inner.device,
&inner.queue,
&inner.bgl,
&inner.uniforms,
&inner.sampler,
width,
height,
rgba,
);
inner.tex = tex;
inner.bind_group = bg;
inner.tex_size = (width, height);
} else {
inner.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &inner.tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
rgba,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(width * 4),
rows_per_image: Some(height),
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
}
}
/// Tamaño actual de la textura interna (último upload o (1,1) si
/// nunca se subió nada).
pub fn size(&self) -> (u32, u32) {
self.inner.lock().tex_size
}
/// Encola el draw del quad que pinta la surface en `dst_view` dentro
/// de `rect`, escalando la textura para cubrir el rect entero.
/// Llamado típicamente desde el callback de `View::gpu_paint_with`.
pub fn blit(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
dst_view: &wgpu::TextureView,
rect: PaintRect,
viewport: (u32, u32),
) {
let inner = self.inner.lock();
let uniforms = [
rect.x,
rect.y,
rect.w,
rect.h,
viewport.0 as f32,
viewport.1 as f32,
0.0,
0.0,
];
let mut bytes = [0u8; 32];
for (i, v) in uniforms.iter().enumerate() {
bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes());
}
queue.write_buffer(&inner.uniforms, 0, &bytes);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-surface-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: dst_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&inner.pipeline);
pass.set_bind_group(0, &inner.bind_group, &[]);
pass.draw(0..6, 0..1);
}
/// Construye un `View` cuyo `gpu_paint_with` blittea la surface al
/// rect que le asigne el layout. La app sólo escoge el `Style`
/// (tamaño, flex_grow…). El `Msg` está libre — la View no emite
/// eventos por sí sola.
pub fn view<Msg>(&self, style: llimphi_ui::llimphi_layout::taffy::Style) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
let this = self.clone();
View::new(style).gpu_paint_with(move |_device, queue, encoder, view, rect, viewport| {
this.blit(queue, encoder, view, rect, viewport);
})
}
}
fn make_texture_and_bg(
device: &wgpu::Device,
queue: &wgpu::Queue,
bgl: &wgpu::BindGroupLayout,
uniforms: &wgpu::Buffer,
sampler: &wgpu::Sampler,
width: u32,
height: u32,
initial_rgba: &[u8],
) -> (wgpu::Texture, wgpu::BindGroup) {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-surface-tex"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: SOURCE_FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
initial_rgba,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(width * 4),
rows_per_image: Some(height),
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-surface-bg"),
layout: bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniforms.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
});
(tex, bind_group)
}
const WGSL: &str = r#"
struct Uniforms {
rect: vec4<f32>, // x, y, w, h en pixels del frame
viewport: vec4<f32>, // vw, vh, _, _
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var tex: texture_2d<f32>;
@group(0) @binding(2) var samp: sampler;
struct V2F {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vid: u32) -> V2F {
// Dos triángulos en UV-space, recorridos CCW.
var uvs = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 1.0),
);
let uv = uvs[vid];
let px = u.rect.x + uv.x * u.rect.z;
let py = u.rect.y + uv.y * u.rect.w;
// NDC: x ∈ [-1, 1] sin flip, y flipeado (en pantalla y-down).
let ndc = vec2<f32>(
px / u.viewport.x * 2.0 - 1.0,
1.0 - py / u.viewport.y * 2.0,
);
var out: V2F;
out.pos = vec4<f32>(ndc, 0.0, 1.0);
out.uv = uv;
return out;
}
@fragment
fn fs(in: V2F) -> @location(0) vec4<f32> {
return textureSample(tex, samp, in.uv);
}
"#;
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "llimphi-text"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
# vello directo (no llimphi-raster): el motor de texto sólo necesita
# Scene/peniko/kurbo para construir y pintar layouts — nada del Renderer ni
# de llimphi-hal. Eso mantiene llimphi-text (y quien lo use: el compositor)
# libre de winit, condición para correr sobre el framebuffer de wawa.
[dependencies]
vello = { workspace = true }
parley = { workspace = true }
[dev-dependencies]
llimphi-raster = { path = "../llimphi-raster" }
llimphi-hal = { path = "../llimphi-hal" }
pollster = { workspace = true }
[[example]]
name = "hello_text"
path = "examples/hello_text.rs"
+9
View File
@@ -0,0 +1,9 @@
# llimphi-text
> Shaping + fonts de [llimphi](../README.md).
Capa de tipografía. Fontdue para subset minimal; HarfBuzz cuando se requiere shaping complejo (árabe, devanagari, ligaduras). Cache de glyphs rasterizados; medición precisa para layout (`measure(text, font, size) → (w, h)`).
## Deps
- `fontdue`, `harfbuzz_rs` (feature)
+9
View File
@@ -0,0 +1,9 @@
# llimphi-text
> Shaping + fonts of [llimphi](../README.md).
Typography layer. Fontdue for minimal subset; HarfBuzz when complex shaping is required (Arabic, Devanagari, ligatures). Cache of rasterized glyphs; precise measurement for layout (`measure(text, font, size) → (w, h)`).
## Deps
- `fontdue`, `harfbuzz_rs` (feature)
Binary file not shown.
+167
View File
@@ -0,0 +1,167 @@
//! Texto via parley sobre vello: párrafo wrappeable + shaping (kerning,
//! ligatures, bidi, fallback CJK/emoji).
//!
//! Corre con: `cargo run -p llimphi-text --example hello_text --release`.
use std::sync::Arc;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::dpi::LogicalSize;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{Hal, Surface, WinitSurface};
use llimphi_text::peniko::{color::palette, Color};
use llimphi_text::{draw_block, Alignment, TextBlock, Typesetter};
const PARRAFO: &str = "Llimphi pinta vector preciso sobre el silicio: \
geometrías exactas, sin cajas negras. شكراً 你好 — el shaping de parley \
maneja kerning, ligaduras y fallback CJK/Arabic en la misma línea.";
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
renderer: llimphi_raster::Renderer,
scene: llimphi_raster::vello::Scene,
typesetter: Typesetter,
}
struct App {
state: Option<State>,
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let window = event_loop
.create_window(
WindowAttributes::default()
.with_title("llimphi · hello_text")
.with_inner_size(LogicalSize::new(960u32, 540u32)),
)
.expect("create window");
let window = Arc::new(window);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let surface = WinitSurface::new(&hal, window.clone()).expect("surface");
let renderer = llimphi_raster::Renderer::new(&hal).expect("renderer");
let typesetter = Typesetter::new();
window.request_redraw();
self.state = Some(State {
window,
hal,
surface,
renderer,
scene: llimphi_raster::vello::Scene::new(),
typesetter,
});
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(_) => {
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let (w, _h) = frame.size();
let margin_x = 64.0_f64;
let margin_y = 64.0_f64;
let inner_w = (w as f32 - 2.0 * margin_x as f32).max(100.0);
state.scene.reset();
// Título centrado
draw_block(
&mut state.scene,
&mut state.typesetter,
&TextBlock {
text: "Llimphi",
size_px: 96.0,
color: Color::from_rgba8(220, 230, 240, 255),
origin: (margin_x, margin_y),
max_width: Some(inner_w),
alignment: Alignment::Center,
line_height: 1.0,
italic: false,
font_family: None,
},
);
// Subtítulo centrado
draw_block(
&mut state.scene,
&mut state.typesetter,
&TextBlock {
text: "motor gráfico soberano · parley + vello",
size_px: 20.0,
color: Color::from_rgba8(140, 160, 180, 255),
origin: (margin_x, margin_y + 110.0),
max_width: Some(inner_w),
alignment: Alignment::Center,
line_height: 1.0,
italic: false,
font_family: None,
},
);
// Párrafo justificado con wrap
draw_block(
&mut state.scene,
&mut state.typesetter,
&TextBlock {
text: PARRAFO,
size_px: 22.0,
color: Color::from_rgba8(200, 210, 220, 255),
origin: (margin_x, margin_y + 170.0),
max_width: Some(inner_w),
alignment: Alignment::Justify,
line_height: 1.4,
italic: false,
font_family: None,
},
);
if let Err(e) = state.renderer.render(
&state.hal,
&state.scene,
&frame,
palette::css::BLACK,
) {
eprintln!("render error: {e}");
}
state.surface.present(frame, &state.hal);
}
_ => {}
}
}
}
fn main() {
let event_loop = EventLoop::new().expect("event loop");
event_loop.set_control_flow(ControlFlow::Wait);
let mut app = App { state: None };
event_loop.run_app(&mut app).expect("run app");
}
+359
View File
@@ -0,0 +1,359 @@
//! llimphi-text — Texto sobre vello vía parley.
//!
//! parley hace shaping completo (bidi, ligatures, kerning), line break y
//! alineación; fontique resuelve fuentes del sistema con fallback CJK/emoji.
//! Aquí lo envolvemos en una API mínima centrada en el caso común: un
//! bloque de texto con color uniforme, ancho máximo opcional y alineación.
use vello::peniko::{Brush, Color};
pub use parley;
pub use vello;
pub use vello::peniko;
/// Estado compartido del motor de texto. Una instancia por proceso es lo
/// recomendado: `FontContext` cachea la base de fuentes y `LayoutContext`
/// reutiliza allocaciones entre layouts.
pub struct Typesetter {
font_cx: parley::FontContext,
layout_cx: parley::LayoutContext<()>,
/// Contexto separado para layouts multicolor (`Brush` por rango). El
/// brush genérico de parley no puede ser `()` y `RunBrush` a la vez en
/// el mismo `LayoutContext`, así que mantenemos uno por sabor.
runs_cx: parley::LayoutContext<RunBrush>,
}
impl Default for Typesetter {
fn default() -> Self {
Self::new()
}
}
/// DejaVu Sans embebida como **fallback universal de símbolos**. El motor
/// confía en las fuentes del sistema vía fontique, pero muchas instalaciones
/// (p. ej. solo Liberation/Adwaita) carecen de glyphs para flechas (`→`),
/// formas geométricas (`● ▶`), dingbats (`✓ ✗ ✎`), avisos (`⚠`) o astro
/// (`♈ ☉ ☽`) — y entonces parley pinta el "tofu" (□). DejaVu cubre todo ese
/// rango; la registramos y la enganchamos al fallback del script `Common`
/// (`Zyyy`), que es donde Unicode clasifica esos símbolos. Así cualquier app
/// Llimphi deja de mostrar cuadrados sin tocar una línea de su código.
/// Licencia: Bitstream Vera + Arev (libre, redistribuible).
const DEJAVU_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf");
impl Typesetter {
pub fn new() -> Self {
let mut font_cx = parley::FontContext::new();
Self::install_symbol_fallback(&mut font_cx);
Self {
font_cx,
layout_cx: parley::LayoutContext::new(),
runs_cx: parley::LayoutContext::new(),
}
}
/// Registra DejaVu Sans y la apila como último recurso para los símbolos
/// del script `Common` (flechas, geométricos, dingbats, astro…). Ver la
/// nota de [`DEJAVU_SANS`]. Best-effort: si algo falla, el texto sigue
/// funcionando con las fuentes del sistema (solo reaparecería el tofu).
fn install_symbol_fallback(font_cx: &mut parley::FontContext) {
use parley::fontique::Blob;
let blob = Blob::new(std::sync::Arc::new(DEJAVU_SANS));
let registered = font_cx.collection.register_fonts(blob, None);
if let Some((family_id, _)) = registered.first() {
// `Zyyy` (Common) es el script de la inmensa mayoría de los
// símbolos que daban tofu; lo apilamos al final del fallback.
font_cx
.collection
.append_fallbacks("Zyyy", std::iter::once(*family_id));
}
}
/// Acceso al `FontContext` por si se necesita registrar fuentes extra
/// o cambiar la stack de fallback.
pub fn font_context_mut(&mut self) -> &mut parley::FontContext {
&mut self.font_cx
}
/// Construye y resuelve un `parley::Layout`. Aplica `font_size`,
/// `line_height` (multiplicador del font_size), `max_width` (line
/// break), y `alignment`. `italic`=true selecciona la variante
/// italic/oblique de la fuente activa (vía `parley::FontStyle`).
pub fn layout(
&mut self,
text: &str,
size_px: f32,
max_width: Option<f32>,
alignment: Alignment,
line_height: f32,
italic: bool,
font_family: Option<&str>,
) -> parley::Layout<()> {
let mut builder =
self.layout_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(parley::StyleProperty::FontSize(size_px));
builder.push_default(parley::StyleProperty::LineHeight(line_height));
if italic {
builder.push_default(parley::StyleProperty::FontStyle(
parley::FontStyle::Italic,
));
}
if let Some(ff) = font_family {
// parley::FontStack::Source acepta CSS-like syntax
// (`"Helvetica", sans-serif`).
builder.push_default(parley::StyleProperty::FontStack(
parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)),
));
}
let mut layout = builder.build(text);
layout.break_all_lines(max_width);
layout.align(
max_width,
alignment.into(),
parley::AlignmentOptions::default(),
);
layout
}
/// Construye un layout **multicolor** en una sola pasada de shaping:
/// `default_color` cubre todo el texto y cada `(start_byte, end_byte,
/// color)` lo sobreescribe en su rango (offsets en **bytes**, no chars —
/// la convención de parley). Pensado para syntax highlighting: shapear
/// la línea entera una vez con un color por token, en vez de un layout
/// por token. Sin wrap (`max_width = None`); el caller posiciona la línea.
pub fn layout_runs(
&mut self,
text: &str,
size_px: f32,
default_color: Color,
runs: &[(usize, usize, Color)],
alignment: Alignment,
line_height: f32,
) -> parley::Layout<RunBrush> {
let mut builder = self
.runs_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(parley::StyleProperty::FontSize(size_px));
builder.push_default(parley::StyleProperty::LineHeight(line_height));
builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color)));
let len = text.len();
for &(start, end, color) in runs {
if start < end && end <= len {
builder.push(parley::StyleProperty::Brush(RunBrush(color)), start..end);
}
}
let mut layout = builder.build(text);
layout.break_all_lines(None);
layout.align(None, alignment.into(), parley::AlignmentOptions::default());
layout
}
}
/// Brush por-run para texto multicolor. Newtype sobre [`Color`] porque
/// parley exige que el brush genérico implemente `Default` (que `Color` no
/// garantiza); aquí proveemos uno explícito (negro opaco) que nunca se ve
/// en la práctica: todo run lleva su color o el `default_color` del bloque.
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct RunBrush(pub Color);
impl Default for RunBrush {
fn default() -> Self {
RunBrush(Color::from_rgba8(0, 0, 0, 255))
}
}
/// Alineación horizontal del bloque dentro de su ancho máximo.
#[derive(Debug, Clone, Copy)]
pub enum Alignment {
Start,
Center,
End,
Justify,
}
impl From<Alignment> for parley::Alignment {
fn from(a: Alignment) -> Self {
match a {
Alignment::Start => parley::Alignment::Start,
Alignment::Center => parley::Alignment::Middle,
Alignment::End => parley::Alignment::End,
Alignment::Justify => parley::Alignment::Justified,
}
}
}
/// Especificación de un bloque de texto a rasterizar.
pub struct TextBlock<'a> {
pub text: &'a str,
pub size_px: f32,
pub color: Color,
/// Esquina superior-izquierda del bloque (no el baseline — parley se
/// encarga del baseline internamente).
pub origin: (f64, f64),
pub max_width: Option<f32>,
pub alignment: Alignment,
/// Múltiplo del font_size (1.0 = compacto, 1.3 = cómodo).
pub line_height: f32,
/// `true` → fuerza variante italic/oblique en la fuente activa.
pub italic: bool,
/// CSS-style `font-family` string. `None` = sans-serif default.
pub font_family: Option<String>,
}
impl<'a> TextBlock<'a> {
/// Constructor simple para una línea sin wrap.
pub fn simple(text: &'a str, size_px: f32, color: Color, origin: (f64, f64)) -> Self {
Self {
text,
size_px,
color,
origin,
max_width: None,
alignment: Alignment::Start,
line_height: 1.0,
italic: false,
font_family: None,
}
}
}
/// Medidas resultantes de un layout.
#[derive(Debug, Clone, Copy)]
pub struct Measurement {
pub width: f32,
pub height: f32,
}
/// Construye el layout (shaping + line break + alineación) listo para medir
/// y/o pintar. Usá esta API cuando necesitás el alto **antes** de elegir el
/// origen (p. ej. centrado vertical) y no querés repetir el shaping en el
/// `draw`: medís sobre el layout retornado y luego lo pasás a
/// [`draw_layout`].
pub fn layout_block(ts: &mut Typesetter, block: &TextBlock<'_>) -> parley::Layout<()> {
ts.layout(
block.text,
block.size_px,
block.max_width,
block.alignment,
block.line_height,
block.italic,
block.font_family.as_deref(),
)
}
/// Devuelve las medidas de un layout ya resuelto. Equivalente conceptual a
/// `(layout.width(), layout.height())` pero envuelto en [`Measurement`].
pub fn measurement(layout: &parley::Layout<()>) -> Measurement {
Measurement {
width: layout.width(),
height: layout.height(),
}
}
/// Pinta un layout ya resuelto en `scene` con `color` y un offset `origin`
/// (esquina superior-izquierda del bloque). No alloca: los glifos van
/// directo del iterador de parley al builder de vello.
pub fn draw_layout(
scene: &mut vello::Scene,
layout: &parley::Layout<()>,
color: Color,
origin: (f64, f64),
) {
draw_layout_xf(scene, layout, color, vello::kurbo::Affine::translate(origin));
}
/// Igual que [`draw_layout`] pero con una **afín completa** en vez de sólo un
/// desplazamiento: permite pintar texto girado/escalado (p. ej. dentro de un
/// marco rotado en una presentación espacial). El origen del layout (0,0) es el
/// que mapea `transform`; las posiciones de glifo se aplican en ese espacio.
pub fn draw_layout_xf(
scene: &mut vello::Scene,
layout: &parley::Layout<()>,
color: Color,
transform: vello::kurbo::Affine,
) {
draw_layout_brush_xf(scene, layout, &Brush::Solid(color), transform);
}
/// Igual que [`draw_layout_xf`] pero con un [`Brush`] arbitrario en vez de un
/// color sólido: permite rellenar los glifos con un gradiente o una imagen
/// (p. ej. CSS `background-clip: text`). El brush se interpreta en el espacio
/// **local** del layout (origen 0,0) y `transform` lo lleva al lugar final —
/// así un gradiente construido en coords (0,0)-(w,h) queda alineado con los
/// glifos. Para texto normal usá [`draw_layout_xf`] (solid = máxima compat).
pub fn draw_layout_brush_xf(
scene: &mut vello::Scene,
layout: &parley::Layout<()>,
brush: &Brush,
transform: vello::kurbo::Affine,
) {
for line in layout.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
let run = glyph_run.run();
let font = run.font().clone();
let font_size = run.font_size();
scene
.draw_glyphs(&font)
.font_size(font_size)
.brush(brush)
.transform(transform)
.draw(
peniko::Fill::NonZero,
glyph_run.positioned_glyphs().map(|g| vello::Glyph {
id: g.id as u32,
x: g.x,
y: g.y,
}),
);
}
}
}
}
/// Pinta un layout **multicolor** ([`Typesetter::layout_runs`]): cada
/// `glyph_run` usa el color de su propio brush ([`RunBrush`]) en vez de un
/// color uniforme. `origin` es la esquina superior-izquierda del bloque.
pub fn draw_layout_runs(
scene: &mut vello::Scene,
layout: &parley::Layout<RunBrush>,
origin: (f64, f64),
) {
let transform = vello::kurbo::Affine::translate(origin);
for line in layout.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
let brush = Brush::Solid(glyph_run.style().brush.0);
let run = glyph_run.run();
let font = run.font().clone();
let font_size = run.font_size();
scene
.draw_glyphs(&font)
.font_size(font_size)
.brush(&brush)
.transform(transform)
.draw(
peniko::Fill::NonZero,
glyph_run.positioned_glyphs().map(|g| vello::Glyph {
id: g.id as u32,
x: g.x,
y: g.y,
}),
);
}
}
}
}
/// Mide sin pintar. Atajo de [`layout_block`] + [`measurement`] para
/// llamadores que sólo necesitan el bounding box.
pub fn measure(ts: &mut Typesetter, block: &TextBlock<'_>) -> Measurement {
measurement(&layout_block(ts, block))
}
/// Rasteriza el bloque en `scene` haciendo shaping una sola vez. Equivale a
/// `layout_block` + `draw_layout` con `block.origin`.
pub fn draw_block(scene: &mut vello::Scene, ts: &mut Typesetter, block: &TextBlock<'_>) {
let layout = layout_block(ts, block);
draw_layout(scene, &layout, block.color, block.origin);
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-theme"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-theme — paleta compartida entre apps Llimphi. Define los slots semánticos (bg_app, fg_text, accent, etc.) en `peniko::Color`; cada widget toma su paleta del Theme vía `Palette::from_theme(&theme)`."
[dependencies]
# Reexporta peniko::Color para que las apps consuman sin pull-in directo.
llimphi-raster = { path = "../llimphi-raster" }
+9
View File
@@ -0,0 +1,9 @@
# llimphi-theme
> Themes Dark/Light/Aurora/Sunset + paleta de [llimphi](../README.md).
`Theme { bg_app, bg_panel, bg_input, bg_button, fg_text, fg_muted, accent, border, ... }`. Cuatro variantes built-in; cualquier app puede definir las suyas. Tema reactivo: el cambio se propaga sin re-mount del árbol.
## Deps
- `serde`
+9
View File
@@ -0,0 +1,9 @@
# llimphi-theme
> Dark/Light/Aurora/Sunset themes + palette of [llimphi](../README.md).
`Theme { bg_app, bg_panel, bg_input, bg_button, fg_text, fg_muted, accent, border, ... }`. Four built-in variants; any app can define its own. Reactive theme: changes propagate without re-mounting the tree.
## Deps
- `serde`
+361
View File
@@ -0,0 +1,361 @@
//! `llimphi-theme` — paleta compartida entre apps Llimphi.
//!
//! Define un set de slots semánticos (`bg_app`, `fg_text`, `accent`, etc.)
//! que cada widget mapea a su propio `Palette` específico vía
//! `Palette::from_theme(&theme)`. El analógo Llimphi al `nahual-theme`
//! GPUI, pero con colores `peniko::Color` y sin macros de Background /
//! gradiente — Llimphi pinta colores sólidos por ahora.
//!
//! Disponer del Theme en un crate aparte permite:
//! 1. **Consistencia visual**: las apps comparten paleta sin redefinirla.
//! 2. **Temas intercambiables**: `Theme::dark()` vs `Theme::light()` (o
//! más adelante, sobreescritos por config del usuario).
//! 3. **Widgets desacoplados**: cada widget acepta su `Palette` (no el
//! Theme entero), así un consumidor que sólo necesita un botón con
//! colores no-temáticos puede construir su `ButtonPalette` a mano.
#![forbid(unsafe_code)]
pub use llimphi_raster::peniko::Color;
use std::time::Duration;
// =====================================================================
// Tokens transversales — motion, alpha, radius
// =====================================================================
//
// Los widgets de elegancia (tooltip, toast, modal, spinner, splash, …)
// comparten **duraciones**, **alphas** y **radios** para que el sistema
// se sienta uno solo. Cada token es `const`: las apps pueden referenciar
// `motion::NORMAL`/`alpha::SCRIM` directamente, o tomarlos del `Theme`
// vía `theme.motion()` / `theme.alpha()` / `theme.radius()` cuando una
// future variante por preset lo requiera.
/// Duraciones canónicas (segundo nivel: rítmico, no nervioso, no
/// soporífero). Los widgets eligen `FAST` para microinteracciones
/// (hover, focus), `NORMAL` para transiciones principales (toast entrar,
/// modal abrir) y `SLOW` para énfasis o entradas dramáticas (splash de
/// boot).
pub mod motion {
use super::Duration;
pub const FAST: Duration = Duration::from_millis(80);
pub const NORMAL: Duration = Duration::from_millis(160);
pub const SLOW: Duration = Duration::from_millis(320);
/// Easing estándar — cubic-out. Energía inicial, asentamiento suave.
/// La gran mayoría de transiciones de salida / aparición.
#[inline]
pub fn ease_out_cubic(t: f32) -> f32 {
let inv = 1.0 - t.clamp(0.0, 1.0);
1.0 - inv * inv * inv
}
/// Easing énfasis — cubic-in-out. Para movimientos que cruzan la
/// pantalla y necesitan acentuar el centro (modales, splashes).
#[inline]
pub fn ease_in_out_cubic(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t < 0.5 {
4.0 * t * t * t
} else {
let f = -2.0 * t + 2.0;
1.0 - f * f * f / 2.0
}
}
/// Lineal — no es elegante pero a veces es lo correcto (barra de
/// progreso, valores numéricos crudos).
#[inline]
pub fn linear(t: f32) -> f32 {
t.clamp(0.0, 1.0)
}
}
/// Valores de opacidad alfa (0255) para capas semánticas. Usar siempre
/// que se quiera *transparencia coherente*. El widget que improvisa su
/// propio alpha rompe la firma visual.
pub mod alpha {
/// Scrim que cubre la app cuando hay overlay (menú/modal/picker).
/// Apaga el fondo lo justo para que el overlay tenga jerarquía,
/// sin ocultar contexto.
pub const SCRIM: u8 = 64;
/// Tinte aplicado a un panel "vidrio" sobre fondo activo (tooltip,
/// status hint). Casi opaco pero deja respirar.
pub const GLASS_PANEL: u8 = 232;
/// Elementos deshabilitados — visibles pero con menos peso.
pub const DISABLED: u8 = 140;
/// Hint sutil (text watermark, ghost) — apenas legible.
pub const HINT: u8 = 96;
}
/// Radios de esquina canónicos. La elegancia se construye en escalera:
/// `XS` para chips e inputs, `SM` para botones, `MD` para paneles,
/// `LG` para superficies grandes (toast, modal, card destacada).
pub mod radius {
pub const XS: f64 = 2.0;
pub const SM: f64 = 4.0;
pub const MD: f64 = 8.0;
pub const LG: f64 = 12.0;
pub const XL: f64 = 20.0;
}
/// Paleta de la app. Slots semánticos que cubren los casos comunes
/// (fondo, texto, hover, foco, acento). Los widgets reusables toman su
/// `Palette` específico desde acá vía `Palette::from_theme(&theme)`.
#[derive(Debug, Clone, Copy)]
pub struct Theme {
/// Nombre legible del preset — alimenta `Theme::by_name`,
/// `next_after`, y los UIs que ciclan presets (theme-switcher).
pub name: &'static str,
// --- Fondos ---
/// Fondo de la ventana / superficie raíz.
pub bg_app: Color,
/// Fondo de paneles (sidebars, cards).
pub bg_panel: Color,
/// Fondo alternativo para barras / strips (tab bar, status bar).
pub bg_panel_alt: Color,
/// Fondo de campos de input (texto editable).
pub bg_input: Color,
/// Fondo de input cuando tiene foco.
pub bg_input_focus: Color,
/// Fondo de botón (chip).
pub bg_button: Color,
/// Fondo de botón al hover.
pub bg_button_hover: Color,
/// Fondo de la fila/item seleccionado (lista, tree).
pub bg_selected: Color,
/// Fondo de fila al hover (sin selección).
pub bg_row_hover: Color,
// --- Foregrounds (texto) ---
pub fg_text: Color,
pub fg_muted: Color,
pub fg_placeholder: Color,
pub fg_destructive: Color,
// --- Bordes y acento ---
pub border: Color,
pub border_focus: Color,
/// Acento primario — divisores activos, borde de input focado,
/// underline del tab activo, etc. Tono único de la app.
pub accent: Color,
}
impl Default for Theme {
fn default() -> Self {
Self::dark()
}
}
impl Theme {
/// Tema oscuro — el default. Análogo al `nahual-theme` dark en su
/// versión Llimphi: tonos azulados profundos, acento azul claro.
pub const fn dark() -> Self {
Self {
name: "Dark",
bg_app: Color::from_rgba8(14, 16, 22, 255),
bg_panel: Color::from_rgba8(22, 26, 36, 255),
bg_panel_alt: Color::from_rgba8(18, 22, 30, 255),
bg_input: Color::from_rgba8(16, 20, 28, 255),
bg_input_focus: Color::from_rgba8(20, 26, 38, 255),
bg_button: Color::from_rgba8(36, 42, 56, 255),
bg_button_hover: Color::from_rgba8(54, 64, 86, 255),
bg_selected: Color::from_rgba8(58, 78, 128, 255),
bg_row_hover: Color::from_rgba8(36, 44, 60, 255),
fg_text: Color::from_rgba8(214, 222, 232, 255),
fg_muted: Color::from_rgba8(140, 152, 170, 255),
fg_placeholder: Color::from_rgba8(95, 105, 122, 255),
fg_destructive: Color::from_rgba8(220, 110, 110, 255),
border: Color::from_rgba8(46, 54, 70, 255),
border_focus: Color::from_rgba8(110, 140, 220, 255),
accent: Color::from_rgba8(110, 140, 220, 255),
}
}
/// Tema claro — contraste revisado para WCAG AA sobre `bg_app`:
/// `fg_text` ~12:1, `fg_muted` ~5.4:1 (texto secundario legible),
/// `fg_destructive` y `accent` oscurecidos para superar 4.5:1 sobre
/// fondos claros. `fg_placeholder` queda deliberadamente tenue
/// (hint, no contenido).
pub const fn light() -> Self {
Self {
name: "Light",
bg_app: Color::from_rgba8(244, 246, 250, 255),
bg_panel: Color::from_rgba8(232, 236, 242, 255),
bg_panel_alt: Color::from_rgba8(224, 230, 240, 255),
bg_input: Color::from_rgba8(255, 255, 255, 255),
bg_input_focus: Color::from_rgba8(250, 252, 255, 255),
bg_button: Color::from_rgba8(220, 226, 236, 255),
bg_button_hover: Color::from_rgba8(200, 210, 226, 255),
bg_selected: Color::from_rgba8(160, 180, 220, 255),
bg_row_hover: Color::from_rgba8(214, 222, 236, 255),
fg_text: Color::from_rgba8(24, 32, 45, 255),
fg_muted: Color::from_rgba8(86, 98, 116, 255),
fg_placeholder: Color::from_rgba8(140, 150, 168, 255),
fg_destructive: Color::from_rgba8(168, 48, 48, 255),
border: Color::from_rgba8(190, 199, 214, 255),
border_focus: Color::from_rgba8(48, 92, 196, 255),
accent: Color::from_rgba8(48, 92, 196, 255),
}
}
/// Tema "Aurora" — verdes nocturnos con acento aqua. Análogo al
/// preset del nahual-theme.
pub const fn aurora() -> Self {
Self {
name: "Aurora",
bg_app: Color::from_rgba8(8, 18, 22, 255),
bg_panel: Color::from_rgba8(14, 28, 34, 255),
bg_panel_alt: Color::from_rgba8(12, 24, 30, 255),
bg_input: Color::from_rgba8(10, 22, 28, 255),
bg_input_focus: Color::from_rgba8(14, 30, 38, 255),
bg_button: Color::from_rgba8(20, 44, 52, 255),
bg_button_hover: Color::from_rgba8(30, 66, 78, 255),
bg_selected: Color::from_rgba8(30, 90, 100, 255),
bg_row_hover: Color::from_rgba8(20, 46, 56, 255),
fg_text: Color::from_rgba8(214, 232, 232, 255),
fg_muted: Color::from_rgba8(130, 168, 168, 255),
fg_placeholder: Color::from_rgba8(90, 120, 120, 255),
fg_destructive: Color::from_rgba8(220, 110, 110, 255),
border: Color::from_rgba8(38, 70, 78, 255),
border_focus: Color::from_rgba8(80, 200, 200, 255),
accent: Color::from_rgba8(80, 200, 200, 255),
}
}
/// Tema "Sunset" — cálidos con acento naranja, sobre base oscura.
pub const fn sunset() -> Self {
Self {
name: "Sunset",
bg_app: Color::from_rgba8(22, 14, 14, 255),
bg_panel: Color::from_rgba8(34, 22, 22, 255),
bg_panel_alt: Color::from_rgba8(28, 18, 18, 255),
bg_input: Color::from_rgba8(28, 18, 18, 255),
bg_input_focus: Color::from_rgba8(36, 24, 22, 255),
bg_button: Color::from_rgba8(54, 34, 28, 255),
bg_button_hover: Color::from_rgba8(78, 50, 38, 255),
bg_selected: Color::from_rgba8(120, 64, 38, 255),
bg_row_hover: Color::from_rgba8(56, 36, 28, 255),
fg_text: Color::from_rgba8(238, 220, 200, 255),
fg_muted: Color::from_rgba8(174, 142, 120, 255),
fg_placeholder: Color::from_rgba8(120, 96, 80, 255),
fg_destructive: Color::from_rgba8(220, 100, 100, 255),
border: Color::from_rgba8(70, 46, 36, 255),
border_focus: Color::from_rgba8(232, 140, 70, 255),
accent: Color::from_rgba8(232, 140, 70, 255),
}
}
/// Tema "Print" — blanco y negro de alto contraste para impresión.
/// Fondo blanco papel, tinta negra, sin grises decorativos: todo lo
/// que se imprime tiene que leerse en una fotocopiadora. `fg_muted`
/// es un gris medio (3.5:1) reservado a metadatos; el cuerpo va en
/// negro puro. Acento y bordes negros — la tinta es una sola.
pub const fn print() -> Self {
Self {
name: "Print",
bg_app: Color::from_rgba8(255, 255, 255, 255),
bg_panel: Color::from_rgba8(255, 255, 255, 255),
bg_panel_alt: Color::from_rgba8(246, 246, 246, 255),
bg_input: Color::from_rgba8(255, 255, 255, 255),
bg_input_focus: Color::from_rgba8(248, 248, 248, 255),
bg_button: Color::from_rgba8(238, 238, 238, 255),
bg_button_hover: Color::from_rgba8(224, 224, 224, 255),
bg_selected: Color::from_rgba8(220, 220, 220, 255),
bg_row_hover: Color::from_rgba8(240, 240, 240, 255),
fg_text: Color::from_rgba8(0, 0, 0, 255),
fg_muted: Color::from_rgba8(90, 90, 90, 255),
fg_placeholder: Color::from_rgba8(140, 140, 140, 255),
fg_destructive: Color::from_rgba8(0, 0, 0, 255),
border: Color::from_rgba8(0, 0, 0, 255),
border_focus: Color::from_rgba8(0, 0, 0, 255),
accent: Color::from_rgba8(0, 0, 0, 255),
}
}
/// Todos los presets del repo, en el orden canónico de rotación
/// (Dark → Light → Aurora → Sunset → Dark…). El theme-switcher
/// los consume vía [`Theme::next_after`]. `print()` queda fuera de la
/// rotación a propósito — es un modo deliberado (imprimir), no un
/// gusto estético que se cicle por accidente.
pub fn all() -> Vec<Self> {
vec![Self::dark(), Self::light(), Self::aurora(), Self::sunset()]
}
/// Busca un preset por nombre exacto.
pub fn by_name(name: &str) -> Option<Self> {
Self::all().into_iter().find(|t| t.name == name)
}
/// Próximo preset en la rotación de [`Theme::all`]. Si `current` no
/// se encuentra, retorna el primero — el switcher nunca se traba.
pub fn next_after(current: &str) -> Self {
let all = Self::all();
let idx = all
.iter()
.position(|t| t.name == current)
.map(|i| (i + 1) % all.len())
.unwrap_or(0);
all[idx]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_have_unique_names() {
let all = Theme::all();
let mut names: Vec<&str> = all.iter().map(|t| t.name).collect();
let n_before = names.len();
names.sort();
names.dedup();
assert_eq!(names.len(), n_before, "nombres duplicados en Theme::all()");
}
#[test]
fn by_name_finds_each_preset() {
for t in Theme::all() {
let by = Theme::by_name(t.name).expect("preset registrado");
assert_eq!(by.name, t.name);
}
}
#[test]
fn by_name_returns_none_for_unknown() {
assert!(Theme::by_name("ThisDoesNotExist").is_none());
}
#[test]
fn next_after_cycles_through_all_presets() {
let all = Theme::all();
let mut current = all[0].name;
let mut visited = vec![current];
for _ in 0..all.len() - 1 {
current = Theme::next_after(current).name;
visited.push(current);
}
let names: Vec<&str> = all.iter().map(|t| t.name).collect();
assert_eq!(visited, names);
// El siguiente debe volver al primero.
let wrapped = Theme::next_after(current).name;
assert_eq!(wrapped, all[0].name);
}
#[test]
fn next_after_unknown_falls_back_to_first() {
let n = Theme::next_after("Nope").name;
assert_eq!(n, Theme::all()[0].name);
}
#[test]
fn dark_is_the_default() {
assert_eq!(Theme::default().name, "Dark");
}
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "llimphi-ui"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
llimphi-hal = { path = "../llimphi-hal" }
llimphi-layout = { path = "../llimphi-layout" }
llimphi-raster = { path = "../llimphi-raster" }
llimphi-text = { path = "../llimphi-text" }
# El compositor declarativo (winit-free): View, mount, paint, hit-test.
llimphi-compositor = { path = "../llimphi-compositor" }
pollster = { workspace = true }
[[example]]
name = "counter"
path = "examples/counter.rs"
[[example]]
name = "editor"
path = "examples/editor.rs"
[[example]]
name = "gpu_paint_demo"
path = "examples/gpu_paint_demo.rs"
+9
View File
@@ -0,0 +1,9 @@
# llimphi-ui
> `View<Msg>` retained-mode + Elm-arch de [llimphi](../README.md).
API pública del framework: `App { Model, Msg, init, update, view }`. Reactivo: `update` muta el `Model`, `view(&Model)` produce el árbol; el runtime difea contra el árbol anterior y aplica el mínimo. Hover/focus/click se traducen a `Msg`s tipados.
## Deps
- [`llimphi-hal`](../llimphi-hal/README.md), [`llimphi-raster`](../llimphi-raster/README.md), [`llimphi-layout`](../llimphi-layout/README.md), [`llimphi-text`](../llimphi-text/README.md), [`llimphi-theme`](../llimphi-theme/README.md)
+9
View File
@@ -0,0 +1,9 @@
# llimphi-ui
> Retained-mode `View<Msg>` + Elm-arch of [llimphi](../README.md).
Public API of the framework: `App { Model, Msg, init, update, view }`. Reactive: `update` mutates `Model`, `view(&Model)` produces the tree; the runtime diffs against the previous tree and applies the minimum. Hover/focus/click translate to typed `Msg`s.
## Deps
- [`llimphi-hal`](../llimphi-hal/README.md), [`llimphi-raster`](../llimphi-raster/README.md), [`llimphi-layout`](../llimphi-layout/README.md), [`llimphi-text`](../llimphi-text/README.md), [`llimphi-theme`](../llimphi-theme/README.md)
+124
View File
@@ -0,0 +1,124 @@
//! Fase 4 de Llimphi: contador Elm puro con texto real.
//!
//! Bucle completo input→update→view→layout→raster→present. El click sobre
//! el botón inferior incrementa el contador; el panel central muestra el
//! número actual rasterizado por skrifa+vello.
//!
//! Corre con: `cargo run -p llimphi-ui --example counter --release`.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
AlignItems, JustifyContent,
};
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 number = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(model.to_string(), 160.0, Color::from_rgba8(230, 240, 250, 255));
let increment = View::new(Style {
size: Size {
width: length(160.0_f32),
height: length(56.0_f32),
},
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);
let reset = View::new(Style {
size: Size {
width: length(120.0_f32),
height: length(56.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(Color::from_rgba8(220, 80, 80, 255))
.radius(12.0)
.text("reset", 22.0, Color::from_rgba8(30, 10, 10, 255))
.on_click(Msg::Reset);
let buttons = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(56.0_f32),
},
gap: Size {
width: length(16.0_f32),
height: length(0.0_f32),
},
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.children(vec![increment, reset]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(24.0_f32),
},
padding: llimphi_ui::llimphi_layout::taffy::Rect {
left: length(32.0_f32),
right: length(32.0_f32),
top: length(32.0_f32),
bottom: length(32.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(20, 24, 32, 255))
.children(vec![number, buttons])
}
}
fn main() {
llimphi_ui::run::<Counter>();
}
+132
View File
@@ -0,0 +1,132 @@
//! Editor mínimo: text field con char insertion, backspace, enter, ctrl+L
//! para limpiar. Valida que el bucle Elm absorbe input de teclado.
//!
//! Corre con: `cargo run -p llimphi-ui --example editor --release`.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
#[derive(Clone)]
enum Msg {
Insert(String),
Backspace,
Clear,
}
struct Editor;
impl App for Editor {
type Model = String;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · editor"
}
fn init(_: &Handle<Self::Msg>) -> Self::Model {
String::new()
}
fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Insert(s) => {
let mut m = model;
m.push_str(&s);
m
}
Msg::Backspace => {
let mut m = model;
m.pop();
m
}
Msg::Clear => String::new(),
}
}
fn on_key(_: &Self::Model, e: &KeyEvent) -> Option<Self::Msg> {
if e.state != KeyState::Pressed {
return None;
}
if e.modifiers.ctrl {
if let Key::Character(c) = &e.key {
if c.eq_ignore_ascii_case("l") {
return Some(Msg::Clear);
}
}
return None;
}
match &e.key {
Key::Named(NamedKey::Backspace) => Some(Msg::Backspace),
Key::Named(NamedKey::Enter) => Some(Msg::Insert("\n".into())),
Key::Named(NamedKey::Tab) => Some(Msg::Insert(" ".into())),
_ => e.text.clone().map(Msg::Insert),
}
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let body_text = if model.is_empty() {
"tipea algo · ctrl+L limpia · enter salto · backspace borra".to_string()
} else {
// Cursor visual al final del contenido.
format!("{model}\u{2588}")
};
let body_color = if model.is_empty() {
Color::from_rgba8(110, 130, 150, 255)
} else {
Color::from_rgba8(220, 230, 240, 255)
};
let body = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.text_aligned(body_text, 22.0, body_color, Alignment::Start);
let status = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(36.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(30, 36, 48, 255))
.text(
format!("{} chars", model.chars().count()),
16.0,
Color::from_rgba8(160, 180, 200, 255),
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
padding: llimphi_ui::llimphi_layout::taffy::Rect {
left: length(24.0_f32),
right: length(24.0_f32),
top: length(24.0_f32),
bottom: length(24.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(20, 24, 32, 255))
.children(vec![body, status])
}
}
fn main() {
llimphi_ui::run::<Editor>();
}
+393
View File
@@ -0,0 +1,393 @@
//! Demo del hook GPU directo (`View::gpu_paint_with`) — Fase 1 del SDD
//! `02_ruway/llimphi/SDD.md` §"GPU directo wgpu".
//!
//! Pinta una grilla de N puntos coloridos sobre un panel central usando
//! un pipeline `wgpu` propio (instanced quad), encima de un fondo y
//! títulos pintados por vello. Valida que:
//!
//! - El callback `gpu_paint_with` recibe `(device, queue, encoder,
//! view, rect)` con los recursos del runtime.
//! - El `LoadOp::Load` preserva la pasada vello (el fondo no se borra).
//! - El submit del encoder ocurre antes del `surface.present` (las
//! primitivas GPU son visibles).
//!
//! Corre con: `cargo run -p llimphi-ui --example gpu_paint_demo --release`.
use std::sync::{Arc, OnceLock};
use llimphi_ui::llimphi_hal::wgpu;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect as TaffyRect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{App, Handle, PaintRect, View};
const POINTS: u32 = 250_000;
const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
#[derive(Clone)]
enum Msg {
Bump,
}
struct GpuDemo;
impl App for GpuDemo {
type Model = u32;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · gpu_paint_demo"
}
fn init(_: &Handle<Self::Msg>) -> Self::Model {
0
}
fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Bump => model.wrapping_add(1),
}
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let title = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(48.0_f32),
},
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::Center),
..Default::default()
})
.text(
format!("gpu_paint_with — {POINTS} puntos GPU directo · seed {model}"),
22.0,
Color::from_rgba8(220, 230, 245, 255),
);
// Canvas central: vello pinta el fondo (fill + radius), GPU pinta
// la grilla de puntos encima vía gpu_paint_with. El seed del
// modelo se mete en el shader vía una rotación trivial — cada
// click cambia el patrón. El callback se invoca ya con el
// CommandEncoder del frame y la TextureView intermediate.
let seed = *model;
let canvas = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: auto(),
},
flex_grow: 1.0,
..Default::default()
})
.fill(Color::from_rgba8(14, 18, 28, 255))
.radius(8.0)
.gpu_paint_with(move |device, queue, encoder, view, rect, _viewport| {
draw_points(device, queue, encoder, view, rect, seed);
})
.on_click(Msg::Bump);
let footer = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::Center),
..Default::default()
})
.text(
"click sobre el canvas → rebobinar el seed",
14.0,
Color::from_rgba8(150, 165, 185, 255),
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(16.0_f32),
},
padding: TaffyRect {
left: length(24.0_f32),
right: length(24.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(24, 28, 38, 255))
.children(vec![title, canvas, footer])
}
}
fn main() {
llimphi_ui::run::<GpuDemo>();
}
// ============================================================
// Lado GPU del demo: pipeline + buffer + draw call.
// ============================================================
/// Estado compartido del demo a través de los frames. Se construye en
/// el primer `gpu_paint_with` (cuando ya tenemos device/queue) y se
/// reutiliza después. Sin esto pagaríamos creación de pipeline + write
/// del buffer por frame, que es lo que `GpuBatch` resolverá de raíz en
/// Fase 3.
struct DemoGpu {
pipeline: wgpu::RenderPipeline,
instances: wgpu::Buffer,
uniforms: wgpu::Buffer,
bind_group: wgpu::BindGroup,
}
fn shared() -> &'static OnceLock<Arc<DemoGpu>> {
static SLOT: OnceLock<Arc<DemoGpu>> = OnceLock::new();
&SLOT
}
fn draw_points(
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
rect: PaintRect,
seed: u32,
) {
let gpu = shared()
.get_or_init(|| Arc::new(DemoGpu::new(device)))
.clone();
// Uniforms: rect + seed → el VS los usa para colocar y colorear.
let uniforms = [rect.x, rect.y, rect.w, rect.h, f32::from_bits(seed), 0.0, 0.0, 0.0];
let mut bytes = Vec::with_capacity(32);
for v in uniforms {
bytes.extend_from_slice(&v.to_ne_bytes());
}
queue.write_buffer(&gpu.uniforms, 0, &bytes);
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("gpu_paint_demo-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
ops: wgpu::Operations {
// Load preserva el fondo vello ya pintado en este frame.
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&gpu.pipeline);
pass.set_bind_group(0, &gpu.bind_group, &[]);
pass.set_vertex_buffer(0, gpu.instances.slice(..));
pass.draw(0..6, 0..POINTS);
}
}
impl DemoGpu {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gpu_paint_demo-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu_paint_demo-bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("gpu_paint_demo-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("gpu_paint_demo-pipe"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 4,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
offset: 0,
shader_location: 0,
}],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: TARGET_FORMAT,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
// Instance buffer: índice 0..POINTS empaquetado como u32.
let mut idx_bytes = Vec::with_capacity((POINTS as usize) * 4);
for i in 0..POINTS {
idx_bytes.extend_from_slice(&i.to_ne_bytes());
}
let instances = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("gpu_paint_demo-inst"),
size: idx_bytes.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// El buffer ya vive el resto del programa — escribimos una vez.
// Para esto necesitamos el queue, pero `new` no lo recibe. Lo
// mantenemos como "lazy escrito en draw_points la primera vez";
// por simplicidad lo escribimos en el primer queue.write_buffer
// del flujo de uniforms. Actualmente el shader no usa la
// instancia (sólo @builtin(vertex_index) + uniforms + builtin
// instance_index), así que el buffer es ignorado — lo dejamos
// para que el layout del pipeline siga válido y el día que
// queramos meter datos por instancia ya está el slot listo.
let _ = idx_bytes; // (no se sube — ver comentario arriba)
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("gpu_paint_demo-u"),
size: 32,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu_paint_demo-bg"),
layout: &bind_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniforms.as_entire_binding(),
}],
});
Self {
pipeline,
instances,
uniforms,
bind_group,
}
}
}
// Hash 32-bit barato (PCG-like) implementado en WGSL para mapear
// `instance_index + seed` → posición/color sin tocar buffers. Mantiene
// el demo en una sola draw call con cero CPU work por frame (salvo
// 32 bytes de uniforms).
const WGSL: &str = r#"
struct Uniforms {
rect: vec4<f32>, // x, y, w, h en pixels del frame
seed: u32,
_pad0: u32,
_pad1: u32,
_pad2: u32,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
struct V2F {
@builtin(position) pos: vec4<f32>,
@location(0) color: vec4<f32>,
};
fn hash(x: u32) -> u32 {
var v = x ^ 2747636419u;
v = v * 2654435769u;
v = v ^ (v >> 16u);
v = v * 2654435769u;
v = v ^ (v >> 16u);
v = v * 2654435769u;
return v;
}
// La resolución real del frame no la conoce el shader sin un uniform
// adicional. Como aproximación robusta, asumimos que el callback se
// llama sobre un viewport "default" 960×540 (tamaño inicial del demo)
// y dejamos que rect.x/y/w/h centren los puntos dentro del canvas.
// El tamaño real del frame se debería pasar por uniforms en una versión
// no-demo — Fase 2/3 del SDD lo formaliza vía `GpuBatch`.
const FRAME_W: f32 = 960.0;
const FRAME_H: f32 = 540.0;
@vertex
fn vs(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> V2F {
var corners = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, 1.0),
);
let off = corners[vid] * 1.5; // quad de 3 pixels lado
let h1 = hash(iid ^ u.seed);
let h2 = hash(h1);
let h3 = hash(h2);
let fx = f32(h1 & 0xFFFFu) / 65535.0;
let fy = f32(h2 & 0xFFFFu) / 65535.0;
let px = u.rect.x + fx * u.rect.z + off.x;
let py = u.rect.y + fy * u.rect.w + off.y;
let ndc = vec2<f32>(
px / FRAME_W * 2.0 - 1.0,
1.0 - py / FRAME_H * 2.0,
);
let r = f32( h3 & 0xFFu) / 255.0;
let g = f32((h3 >> 8u) & 0xFFu) / 255.0;
let b = f32((h3 >> 16u) & 0xFFu) / 255.0;
var out: V2F;
out.pos = vec4<f32>(ndc, 0.0, 1.0);
out.color = vec4<f32>(r, g, b, 0.85);
return out;
}
@fragment
fn fs(in: V2F) -> @location(0) vec4<f32> {
return in.color;
}
"#;
File diff suppressed because it is too large Load Diff
+604
View File
@@ -0,0 +1,604 @@
//! llimphi-ui — Runtime Elm sobre winit.
//!
//! Maneja el bucle `input → update(model, msg) → view(model) → layout →
//! raster → present` sobre una ventana winit + GPU (`llimphi-hal` +
//! `llimphi-raster`). La parte declarativa y winit-agnóstica (el árbol
//! `View<Msg>`, `mount`, `paint`, hit-test) vive en `llimphi-compositor` y
//! se re-exporta tal cual, así los consumidores siguen escribiendo
//! `llimphi_ui::View` sin enterarse del split.
//!
//! El estado del [`App`] es inmutable: cada evento produce un `Model`
//! nuevo. La vista (`view`) es una función pura `&Model -> View<Msg>`.
use std::sync::Arc;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition};
use llimphi_hal::winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
use llimphi_hal::winit::keyboard::ModifiersState;
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{Hal, Surface, WinitSurface};
pub use llimphi_hal::winit::keyboard::{Key, NamedKey};
use llimphi_layout::{ComputedLayout, LayoutTree};
use llimphi_raster::peniko::color::palette;
use llimphi_raster::{vello, Renderer};
pub use llimphi_hal;
pub use llimphi_layout;
pub use llimphi_raster;
pub use llimphi_text;
// El compositor declarativo (View, mount, paint, hit-test, tipos de
// handler) se re-exporta entero: `llimphi_ui::View`, `llimphi_ui::DragFn`,
// etc. siguen resolviendo igual que antes del split.
pub use llimphi_compositor;
pub use llimphi_compositor::*;
/// Aplicación Elm: estado inmutable, transición pura, vista pura.
///
/// `init` y `update` reciben un [`Handle`] que permite hablar con el runtime
/// desde dentro de la transición (cerrar la ventana, lanzar trabajo en otro
/// hilo y reentrar con un Msg al terminar). Mantener la transición pura del
/// modelo sigue siendo el contrato — `Handle` sólo escala efectos.
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>;
/// Maneja una pulsación de tecla. Devuelve `Some(Msg)` para disparar
/// una transición; `None` (default) ignora la tecla.
fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> {
None
}
/// El foco cambió: el runtime movió el foco a `id` (`None` = nada
/// enfocado). Pasa al pulsar Tab/Shift+Tab (recorre los nodos
/// `View::focusable` en orden de árbol, envolviendo) o al clickear un
/// nodo enfocable. La app guarda `id` en su `Model` para (a) pintar el
/// focus-ring (`if model.focus == Some(id) { … }` en `view`) y (b)
/// rutear el teclado al campo activo desde `on_key`. Devolver
/// `Some(Msg)` dispara una transición; `None` (default) ignora.
///
/// El foco lo administra el runtime (única fuente de verdad), así que
/// Tab y click-to-focus quedan consistentes sin que la app los cablee.
fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> {
None
}
/// ¿Habilitar IME (input method editor) en esta ventana? Default
/// `false`. Con IME activo, el texto compuesto (CJK, acentos muertos,
/// emoji picker) llega por [`App::on_ime`] como `Commit`, **no** por
/// `KeyEvent.text` — por eso es opt-in: las apps que sólo leen
/// `on_key` siguen funcionando igual. Las que editan texto
/// (`text-input`, `text-editor`) la activan e implementan `on_ime`.
fn ime_allowed() -> bool {
false
}
/// Maneja un evento de IME (sólo llega si [`App::ime_allowed`] es
/// `true`). El flujo típico: `Enabled` → uno o más `Preedit` (texto en
/// composición, a pintar subrayado en el caret) → `Commit(texto)` (el
/// texto final, a insertar como si se hubiera tecleado) o `Disabled`.
/// El `Preedit` no es definitivo: cada uno reemplaza al anterior, y un
/// `Commit` o `Preedit` vacío lo cierra. Devolver `Some(Msg)` dispara
/// una transición.
fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> {
None
}
/// Área del caret en **píxeles físicos** `(x, y, w, h)` para posicionar
/// la ventana de candidatos del IME (CJK) junto al cursor de texto. El
/// runtime la consulta por frame cuando [`App::ime_allowed`] es `true`.
/// `None` (default) deja que el sistema la ubique por defecto.
fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> {
None
}
/// Maneja una rueda del mouse. `delta` está normalizado a "líneas"
/// (positivo arriba/izquierda, negativo abajo/derecha). En backends
/// que reportan píxeles, llimphi-ui divide por 20 para aproximar.
fn on_wheel(
_model: &Self::Model,
_delta: WheelDelta,
_cursor: (f32, f32),
_modifiers: Modifiers,
) -> Option<Self::Msg> {
None
}
/// Capa de overlay opcional. Si devuelve `Some(view)`, el runtime
/// la pinta encima del árbol principal y los clicks/hover se
/// rutean exclusivamente a ella (el árbol de fondo queda "bajo
/// vidrio" hasta que se cierre el overlay). Pensado para menús
/// contextuales, diálogos modales, popovers — el patrón usual es
/// envolver los items en un scrim a pantalla completa con
/// `on_click = DismissOverlay` para que los clicks afuera lo
/// cierren.
///
/// La transición entre "con overlay" y "sin overlay" la maneja la
/// app vía su Model: cuando el state diga "menu abierto",
/// `view_overlay` devuelve `Some`; cuando se cierre, `None`.
fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> {
None
}
/// Maneja un drop de archivo desde el sistema operativo (drag&drop
/// desde el file manager hacia la ventana). El runtime invoca este
/// callback una vez por archivo soltado — si el usuario suelta varios,
/// llega un evento por path. Devolver `Some(Msg)` dispara un update;
/// `None` (default) ignora el drop.
///
/// Backend: mapea directamente `winit::WindowEvent::DroppedFile(PathBuf)`.
/// La posición del drop no se reporta porque winit no la expone hasta
/// que el compositor la propague — en Wayland depende del extension
/// `data_device_manager`, en X11 viene en el ClientMessage XDND.
fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> {
None
}
/// Maneja un redimensionado de la ventana. `width`/`height` son el
/// nuevo tamaño en **píxeles físicos** (lo que reporta
/// `winit::WindowEvent::Resized` y lo que recibe la surface). El
/// runtime ya reconfiguró la surface y pedirá redraw; este callback
/// es para que la app reaccione al nuevo viewport (recalcular layout
/// dependiente del tamaño, emitir un evento `resize`, etc.).
/// Devolver `Some(Msg)` dispara un update; `None` (default) lo ignora.
fn on_resize(_model: &Self::Model, _width: u32, _height: u32) -> Option<Self::Msg> {
None
}
/// Maneja un cambio del factor de escala de la ventana (`scale_factor`
/// de winit: 1.0 en pantallas normales, 2.0 en HiDPI/Retina, fraccional
/// con escalado del compositor). El runtime lo invoca una vez al arrancar
/// (con el factor inicial de la ventana, tras `init`) y luego en cada
/// `WindowEvent::ScaleFactorChanged` (mover la ventana entre monitores,
/// cambiar el escalado del sistema). Es lo que permite, p. ej., que
/// `window.devicePixelRatio` refleje el DPI real. Devolver `Some(Msg)`
/// dispara un update; `None` (default) lo ignora.
fn on_scale_factor(_model: &Self::Model, _scale: f64) -> Option<Self::Msg> {
None
}
/// Título de la ventana (sólo se lee al arrancar). Es el título inicial;
/// para uno que cambie en runtime, ver [`App::window_title`].
fn title() -> &'static str {
"llimphi"
}
/// Título **dinámico** de la ventana, derivado del modelo. El runtime lo
/// consulta tras cada render y, si cambió, lo aplica con `Window::set_title`
/// — así el título de la barra del SO puede reflejar el estado (p. ej. el
/// medio que se reproduce). `None` (default) deja el título fijo de
/// [`App::title`]; una app que no lo implemente no paga nada.
fn window_title(_model: &Self::Model) -> Option<String> {
None
}
/// Vista de una ventana OS **secundaria** identificada por `key` (la que
/// se pasó a [`Handle::open_window`]). El runtime la pinta en su propia
/// ventana y rutea sus eventos al mismo [`App::update`] — comparte modelo
/// con la primaria. `None` (default, o para una key desconocida) deja la
/// ventana en blanco. Las secundarias NO tienen capa de overlay
/// ([`App::view_overlay`] es sólo de la primaria); para diálogos dentro de
/// una secundaria, componerlos en su propio `secondary_view`.
fn secondary_view(_model: &Self::Model, _key: u64) -> Option<View<Self::Msg>> {
None
}
/// Título dinámico de una ventana secundaria (análogo a
/// [`App::window_title`] para la primaria). `None` deja el título con el
/// que se abrió.
fn secondary_title(_model: &Self::Model, _key: u64) -> Option<String> {
None
}
/// El usuario cerró una ventana secundaria con el botón del SO. El runtime
/// ya la destruyó; este callback es para que la app sincronice su modelo
/// (p. ej. marcar el panel como cerrado). Devolver `Some(Msg)` dispara un
/// `update`; `None` (default) no hace nada.
fn on_secondary_close(_model: &Self::Model, _key: u64) -> Option<Self::Msg> {
None
}
/// Identificador de aplicación. En Wayland se mapea al `app_id` del
/// xdg-toplevel (lo que el compositor usa para reconocer la ventana,
/// p. ej. `carmen.greeter`). `None` deja que el sistema asigne uno.
fn app_id() -> Option<&'static str> {
None
}
/// Tamaño lógico inicial de la ventana, en píxeles. El usuario puede
/// redimensionar después; sólo se lee al arrancar.
fn initial_size() -> (u32, u32) {
(960, 540)
}
}
/// Mensaje interno del event loop. `Msg` lo dispara la app desde un hilo de
/// fondo vía [`Handle::dispatch`] o [`Handle::spawn`]; `Quit` cierra la
/// ventana y termina el proceso.
pub enum UserEvent<Msg> {
Msg(Msg),
Quit,
/// Pide abrir una ventana OS **secundaria** con la `key` dada (la app la
/// usa para distinguir cuál es en [`App::secondary_view`]). Idempotente:
/// si ya existe una con esa key, se enfoca en vez de duplicar. La crea el
/// event loop (que tiene el `ActiveEventLoop`); por eso va por mensaje.
OpenWindow {
key: u64,
title: String,
width: u32,
height: u32,
},
/// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria.
CloseWindow { key: u64 },
}
/// Asa al runtime de Llimphi. Clonable y enviable entre hilos: la usás para
/// pedir cerrar la ventana o para lanzar trabajo (PAM, IO, etc.) que al
/// terminar reentra con un Msg al `update`.
///
/// Tests pueden construir un handle "muerto" con [`Handle::for_test`]: los
/// `dispatch`/`quit`/`spawn` siguen siendo seguros de llamar pero los
/// `Msg` que generan no van a ningún lado (no hay event loop detrás).
pub struct Handle<Msg: Send + 'static> {
inner: HandleInner<Msg>,
}
enum HandleInner<Msg: Send + 'static> {
Real(EventLoopProxy<UserEvent<Msg>>),
/// Handle de tests: drop silencioso de todos los dispatches. Permite
/// llamar funciones que toman `&Handle<Msg>` sin levantar un event
/// loop real (que en CI sin display tiraría).
Test,
}
impl<Msg: Send + 'static> Clone for Handle<Msg> {
fn clone(&self) -> Self {
Self {
inner: match &self.inner {
HandleInner::Real(p) => HandleInner::Real(p.clone()),
HandleInner::Test => HandleInner::Test,
},
}
}
}
impl<Msg: Send + 'static> Handle<Msg> {
/// Construye un handle desactivado para tests — todos los dispatch
/// se descartan silenciosamente. Útil para probar funciones que toman
/// `&Handle<Msg>` sin levantar un event loop real (que en CI sin
/// display tiraría).
pub fn for_test() -> Self {
Self {
inner: HandleInner::Test,
}
}
/// Cierra la ventana y termina el bucle. La transición en curso (si la
/// hay) se completa antes de salir.
pub fn quit(&self) {
match &self.inner {
HandleInner::Real(p) => {
let _ = p.send_event(UserEvent::Quit);
}
HandleInner::Test => {}
}
}
/// Abre una ventana OS **secundaria** (ver [`App::secondary_view`]). La
/// `key` la elige la app para reconocerla luego; abrir con una key que ya
/// existe sólo la enfoca (no duplica). El contenido lo pinta
/// `App::secondary_view(model, key)` y los eventos (click/tecla/…) reentran
/// al mismo `update`, así que la ventana comparte el modelo con la primaria.
/// Cerrala con [`Self::close_window`] o con el botón del SO.
pub fn open_window(&self, key: u64, title: impl Into<String>, width: u32, height: u32) {
if let HandleInner::Real(p) = &self.inner {
let _ = p.send_event(UserEvent::OpenWindow {
key,
title: title.into(),
width,
height,
});
}
}
/// Cierra la ventana secundaria con esa `key` (no-op si no existe). La
/// ventana primaria nunca se cierra por acá — para eso está [`Self::quit`].
pub fn close_window(&self, key: u64) {
if let HandleInner::Real(p) = &self.inner {
let _ = p.send_event(UserEvent::CloseWindow { key });
}
}
/// Encola un Msg para procesarse en el próximo turno del bucle. Útil
/// para que un callback externo reentre al update.
pub fn dispatch(&self, msg: Msg) {
match &self.inner {
HandleInner::Real(p) => {
let _ = p.send_event(UserEvent::Msg(msg));
}
HandleInner::Test => {}
}
}
/// Lanza una closure en un hilo aparte; cuando devuelve `Msg`, el
/// runtime la entrega al `update` en el hilo de UI. Pensado para
/// trabajo bloqueante (PAM tarda ~2 s ante un fallo, p. ej.).
pub fn spawn<F>(&self, f: F)
where
F: FnOnce() -> Msg + Send + 'static,
{
match &self.inner {
HandleInner::Real(p) => {
let proxy = p.clone();
std::thread::spawn(move || {
let msg = f();
let _ = proxy.send_event(UserEvent::Msg(msg));
});
}
HandleInner::Test => {
// Corremos la closure igual (para no perder side-effects de
// tests que dependan de su side) pero el msg se descarta.
std::thread::spawn(move || {
let _ = f();
});
}
}
}
/// Lanza un loop periódico en un hilo aparte: cada `period` invoca
/// `f()` y dispatcha el `Msg` resultante al `update`. El thread
/// queda corriendo hasta que el event loop se cierra (en ese
/// punto el `send_event` falla silenciosamente y el thread spinea
/// hasta el exit del proceso, costo despreciable).
///
/// Útil para ticks de simulación (~11 Hz en dominium), polling de
/// hardware, o cualquier feed que necesite Msgs a intervalos
/// regulares. Si `f` necesita state, capturalo en la closure por
/// move; la closure se ejecuta en un thread aparte así que el
/// state capturado debe ser `Send`.
pub fn spawn_periodic<F>(&self, period: std::time::Duration, f: F)
where
F: Fn() -> Msg + Send + 'static,
{
match &self.inner {
HandleInner::Real(p) => {
let proxy = p.clone();
std::thread::spawn(move || loop {
std::thread::sleep(period);
if proxy.send_event(UserEvent::Msg(f())).is_err() {
// Event loop cerrado — el thread puede morir.
break;
}
});
}
HandleInner::Test => {
// Un thread vivo eternamente sin sumidero ni manera de
// pararlo sería un leak — en for_test simplemente no
// arrancamos el loop. Los tests que necesiten verificar
// periodic behaviour deben usar el callback directo.
let _ = f;
}
}
}
}
/// Evento de teclado normalizado.
#[derive(Debug, Clone)]
pub struct KeyEvent {
pub key: Key,
pub state: KeyState,
/// Texto resultante (con modifiers e IME aplicados). Útil para inserción
/// directa; `None` para teclas que no producen texto (flechas, etc.).
pub text: Option<String>,
pub modifiers: Modifiers,
pub repeat: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyState {
Pressed,
Released,
}
/// Evento de IME normalizado (espeja `winit::event::Ime`). Ver
/// [`App::on_ime`] para el flujo Enabled → Preedit* → Commit/Disabled.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImeEvent {
/// El IME se activó para esta ventana.
Enabled,
/// Texto en composición (aún no confirmado). `cursor` es el rango
/// `(inicio, fin)` en bytes a resaltar dentro de `text`, si el IME lo
/// reporta. Cada `Preedit` reemplaza al anterior; uno con `text`
/// vacío cierra la preedición sin confirmar.
Preedit {
text: String,
cursor: Option<(usize, usize)>,
},
/// Texto confirmado: insertarlo como si se hubiera tecleado.
Commit(String),
/// El IME se desactivó (perder foco, cambiar de método).
Disabled,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Modifiers {
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
pub meta: bool,
}
/// Delta de rueda en "líneas" lógicas (normalizado a través de backends).
/// Convención CSS: positivo = scroll **hacia abajo** (contenido sube).
/// `x` similar para scroll horizontal (touchpads, ratones de 2 ejes).
#[derive(Debug, Clone, Copy, Default)]
pub struct WheelDelta {
pub x: f32,
pub y: f32,
}
impl From<ModifiersState> for Modifiers {
fn from(m: ModifiersState) -> Self {
Self {
shift: m.shift_key(),
ctrl: m.control_key(),
alt: m.alt_key(),
meta: m.super_key(),
}
}
}
// --- Runtime winit. El event loop (impl ApplicationHandler) vive en
// `eventloop` y accede los campos privados de estos structs vía
// `use super::*`. La composición declarativa (View, mount, paint,
// hit-test) la trae el re-export de `llimphi_compositor`. ---
mod eventloop;
struct Runtime<A: App> {
handle: Handle<A::Msg>,
state: Option<RuntimeState<A>>,
/// Ventanas OS secundarias abiertas (opt-in vía [`Handle::open_window`]).
/// Comparten el `Hal`/`Renderer` y el modelo de la primaria (`state`);
/// cada una lleva su propia surface + caches de interacción. Vacío en la
/// inmensa mayoría de las apps (monoventana) — coste cero.
secondaries: Vec<SecondaryState<A>>,
}
/// Estado por **ventana secundaria**. Espeja los campos de interacción de
/// [`RuntimeState`] pero SIN modelo (vive en la primaria), sin overlay y sin
/// `Hal`/`Renderer` propios (los toma prestados de la primaria al pintar).
struct SecondaryState<A: App> {
/// La key con la que la app la abrió (la pasa a `secondary_view`).
key: u64,
window: Arc<Window>,
surface: WinitSurface,
scene: vello::Scene,
typesetter: llimphi_text::Typesetter,
layout: LayoutTree,
cursor: PhysicalPosition<f64>,
modifiers: Modifiers,
last_render: Option<SecRenderCache<A::Msg>>,
hovered: Option<usize>,
drag: Option<DragState<A::Msg>>,
last_title: Option<String>,
}
/// Cache de render de una ventana secundaria (como [`RenderCache`] pero sin
/// capa de overlay). Sólo guarda el árbol montado + layout para hit-testear el
/// próximo click/hover; el `hover_idx` actual vive en `SecondaryState::hovered`.
struct SecRenderCache<Msg> {
mounted: Mounted<Msg>,
computed: ComputedLayout,
}
struct RuntimeState<A: App> {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
renderer: Renderer,
scene: vello::Scene,
/// Compositor de la capa de overlay sobre contenido `gpu_paint` (video).
/// Sólo entra en juego cuando el árbol principal tiene painters gpu y hay
/// un overlay activo; resuelve el z-order (menús por encima del video).
overlay_compositor: llimphi_hal::OverlayCompositor,
model: Option<A::Model>,
cursor: PhysicalPosition<f64>,
modifiers: Modifiers,
typesetter: llimphi_text::Typesetter,
/// Árboles de layout reusados entre frames: `clear()` + `mount` en
/// vez de re-allocar el slotmap de taffy en cada redraw. Uno para el
/// árbol principal, otro para el overlay (sus `NodeId` no deben
/// colisionar dentro del mismo frame).
layout: LayoutTree,
overlay_layout: LayoutTree,
/// Último frame renderizado: árbol montado + rects absolutos +
/// nodo con hover. Lo consume el handler de click para hit-testear
/// sin reconstruir `view` + layout, y CursorMoved para detectar si
/// el hover cambió y disparar redraw.
last_render: Option<RenderCache<A::Msg>>,
/// Nodo hovereado **persistente** entre frames, actualizado SÓLO en
/// `CursorMoved`. Es contra esto que se detecta el `on_pointer_enter`
/// (no contra `last_render.hover_idx`, que el render recomputa cada
/// cuadro): en una app que re-renderiza sin parar (visores `paint_with`)
/// el render "se comería" la transición de hover antes de que el handler
/// del mouse la detecte, y el hover-switch de menús no funcionaría.
hovered: Option<usize>,
/// Drag activo. Mantiene su propio handler clonado del MountedNode
/// — así el drag sobrevive aunque el cache se invalide entre
/// eventos.
drag: Option<DragState<A::Msg>>,
/// Foco actual (id de un nodo `View::focusable`). El runtime es la
/// única fuente de verdad: lo mueve con Tab/Shift+Tab y click-to-focus
/// y lo notifica vía `App::on_focus`. `None` = nada enfocado.
focused: Option<u64>,
/// Último título dinámico aplicado a la ventana (ver [`App::window_title`]).
/// Evita llamar `set_title` en cada frame cuando no cambió.
last_title: Option<String>,
}
struct RenderCache<Msg> {
mounted: Mounted<Msg>,
computed: ComputedLayout,
/// Índice del nodo en hover en el frame ya pintado. `None` si el
/// cursor no toca ningún `hover_fill`.
hover_idx: Option<usize>,
/// Índice del drop target hovereado en el frame ya pintado. Solo
/// se setea durante un drag activo con `payload` declarado.
drop_hover_idx: Option<usize>,
/// Capa de overlay (menú contextual, modal). Cuando está presente,
/// hover/click/right-click se rutean a ella exclusivamente — el
/// árbol principal queda "bajo vidrio" hasta que la app cierre el
/// overlay devolviendo `None` desde [`App::view_overlay`].
overlay: Option<OverlayCache<Msg>>,
}
struct OverlayCache<Msg> {
mounted: Mounted<Msg>,
computed: ComputedLayout,
hover_idx: Option<usize>,
}
/// Dos sabores de handler de drag activo: el simple `(phase, dx, dy)`
/// o la variante que conserva la posición local del press original
/// `(phase, dx, dy, lx0, ly0)`. El runtime elige uno al iniciar el drag.
enum DragHandlerKind<Msg> {
Delta(DragFn<Msg>),
DeltaAt(DragAtFn<Msg>, f32, f32),
}
struct DragState<Msg> {
handler: DragHandlerKind<Msg>,
/// Cursor en el último evento (Press o CursorMoved). El delta del
/// próximo Move se calcula contra este, no contra el inicio del
/// drag — el caller acumula los deltas en su modelo si los necesita.
last_cursor: PhysicalPosition<f64>,
/// Payload `u64` que viaja con el drag. `None` si el draggable
/// origen no declaró ninguno (drag de resize/scroll/etc.). Los drop
/// targets sólo reaccionan cuando hay payload.
payload: Option<u64>,
}
/// Punto de entrada: corre el bucle Elm hasta que el usuario cierre la
/// ventana (o la app llame [`Handle::quit`]).
pub fn run<A: App>() {
let event_loop = EventLoop::<UserEvent<A::Msg>>::with_user_event()
.build()
.expect("event loop");
event_loop.set_control_flow(ControlFlow::Wait);
let handle = Handle {
inner: HandleInner::Real(event_loop.create_proxy()),
};
let mut runtime: Runtime<A> = Runtime {
handle,
state: None,
secondaries: Vec::new(),
};
event_loop.run_app(&mut runtime).expect("run app");
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-workspace"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-workspace — chasis genérico estilo tmux: hospeda N paneles en un árbol BSP (llimphi-widget-panes) con la máquina de estados (split/close/focus/resize) + chrome estándar. La capa sobre la que cualquier app de gioser se monta en un layout intercambiable."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-panes = { path = "../widgets/panes" }
@@ -0,0 +1,212 @@
//! Demo del chasis `llimphi-workspace`.
//!
//! Mismo resultado que `panes_demo` pero la app ya no reimplementa la
//! máquina de estados: guarda un `Workspace` + un mapa de paneles, y deja
//! que el chasis maneje split/cerrar/foco/resize y el chrome. Esto es el
//! molde que después adopta cada app de gioser.
//!
//! Correr: `cargo run -p llimphi-workspace --example workspace_demo --release`
use std::collections::HashMap;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::{App, Handle, View};
use llimphi_theme::Theme;
use llimphi_workspace::{workspace_view, Axis, PaneId, Workspace, WorkspacePalette, WsEffect, WsMsg};
struct Demo;
#[derive(Clone)]
enum Msg {
Ws(WsMsg),
Panel(PaneId, PanelMsg),
}
#[derive(Clone)]
enum PanelMsg {
Inc,
Dec,
AddNote,
}
enum Kind {
Counter(i64),
Notes(Vec<String>),
}
struct Model {
ws: Workspace,
panes: HashMap<PaneId, Kind>,
theme: Theme,
}
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"workspace — chasis tmux de gioser"
}
fn init(_: &Handle<Msg>) -> Model {
let mut ws = Workspace::new(); // panel 0
let mut panes = HashMap::new();
panes.insert(0, Kind::Counter(0));
let id = ws.split(Axis::Horizontal);
panes.insert(id, Kind::Notes(vec!["arrastrá el divisor del medio →".into()]));
ws.focus(0);
Model {
ws,
panes,
theme: Theme::dark(),
}
}
fn update(mut model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
match msg {
Msg::Ws(m) => match model.ws.apply(m) {
WsEffect::Created(id) => {
// Alternamos tipo para ilustrar paneles heterogéneos.
let kind = if id % 2 == 0 {
Kind::Counter(0)
} else {
Kind::Notes(vec![])
};
model.panes.insert(id, kind);
}
WsEffect::Closed(id) => {
model.panes.remove(&id);
}
WsEffect::None => {}
},
Msg::Panel(id, pm) => {
if let Some(kind) = model.panes.get_mut(&id) {
match (kind, pm) {
(Kind::Counter(n), PanelMsg::Inc) => *n += 1,
(Kind::Counter(n), PanelMsg::Dec) => *n -= 1,
(Kind::Notes(v), PanelMsg::AddNote) => {
let n = v.len() + 1;
v.push(format!("nota #{n}"));
}
_ => {}
}
}
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let palette = WorkspacePalette::from_theme(&model.theme);
let panes = &model.panes;
let theme = &model.theme;
workspace_view(
&model.ws,
&palette,
move |id| render_pane(panes, theme, id),
Msg::Ws,
)
}
}
fn render_pane(panes: &HashMap<PaneId, Kind>, t: &Theme, id: PaneId) -> View<Msg> {
let Some(kind) = panes.get(&id) else {
return label("(vacío)".to_string(), 14.0, t.fg_muted);
};
let body = match kind {
Kind::Counter(n) => col(
8.0,
vec![
label(format!("{n}"), 44.0, t.accent),
row(
8.0,
vec![
button("", Msg::Panel(id, PanelMsg::Dec), t),
button("+", Msg::Panel(id, PanelMsg::Inc), t),
],
),
],
),
Kind::Notes(v) => {
let mut lines: Vec<View<Msg>> = v
.iter()
.map(|s| label(format!("{s}"), 14.0, t.fg_text))
.collect();
lines.push(button("+ nota", Msg::Panel(id, PanelMsg::AddNote), t));
col(6.0, lines)
}
};
View::new(Style {
flex_direction: FlexDirection::Column,
gap: Size {
width: length(10.0),
height: length(10.0),
},
padding: uniform(12.0),
flex_grow: 1.0,
..Default::default()
})
.children(vec![label(format!("panel #{id}"), 13.0, t.fg_muted), body])
}
fn button(text: &str, msg: Msg, t: &Theme) -> View<Msg> {
View::new(Style {
padding: Rect {
left: length(12.0),
right: length(12.0),
top: length(6.0),
bottom: length(6.0),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(t.bg_button)
.hover_fill(t.bg_button_hover)
.radius(6.0)
.on_click(msg)
.children(vec![label(text.to_string(), 14.0, t.fg_text)])
}
fn col(gap: f32, children: Vec<View<Msg>>) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Column,
gap: Size {
width: length(gap),
height: length(gap),
},
..Default::default()
})
.children(children)
}
fn row(gap: f32, children: Vec<View<Msg>>) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
gap: Size {
width: length(gap),
height: length(gap),
},
..Default::default()
})
.children(children)
}
fn label(text: String, size: f32, color: llimphi_ui::llimphi_raster::peniko::Color) -> View<Msg> {
View::new(Style::default()).text(text, size, color)
}
fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
Rect {
left: length(px),
right: length(px),
top: length(px),
bottom: length(px),
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
+378
View File
@@ -0,0 +1,378 @@
//! `llimphi-workspace` — chasis genérico estilo tmux.
//!
//! Paso 2 de la visión "montar cualquier componente de gioser en un layout
//! intercambiable con splits resizables". Donde [`llimphi_widget_panes`]
//! aporta el **árbol** (estructura + render + drag), este crate aporta la
//! **máquina de estados** (qué panel está enfocado, cómo se parte/cierra,
//! el contador de ids) + el **chrome estándar** (toolbar split/cerrar).
//!
//! ## Cómo lo usa una app
//!
//! La app guarda un [`Workspace`] en su `Model` y un `HashMap<PaneId, …>`
//! con el estado de cada panel. Su `Msg` envuelve dos cosas:
//!
//! ```ignore
//! enum Msg {
//! Ws(WsMsg), // mensajes del chasis (focus/split/…)
//! Panel(PaneId, PanelMsg), // mensajes de un panel concreto
//! }
//! ```
//!
//! En `update`, los `Ws` se aplican con [`Workspace::apply`], que devuelve
//! un [`WsEffect`] indicando si hay que **crear** el estado de un panel
//! nuevo o **borrar** el de uno cerrado. En `view`, [`workspace_view`] arma
//! el chrome + el árbol; la app sólo provee el contenido de cada hoja (ya
//! lifteado a su propio `Msg` — el chasis no toca los `PanelMsg`).
//!
//! El lift se hace al construir la vista (igual que `shuma-module`), así
//! sorteamos la falta de `View::map` sin `Box<dyn Any>`: el chasis es
//! genérico sobre el `Msg` del host y nunca downcastea.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_widget_panes::{panes_view, Layout, PanesPalette};
pub use llimphi_widget_panes::{Axis, PaneId, Side};
/// Estado del workspace: el árbol de paneles + cuál está enfocado + el
/// contador para asignar ids nuevos. Agnóstico del contenido — el host
/// guarda el estado real de cada panel por su `PaneId`.
#[derive(Debug, Clone)]
pub struct Workspace {
layout: Layout,
focused: PaneId,
next_id: PaneId,
}
impl Workspace {
/// Workspace con un único panel (id `0`).
pub fn new() -> Self {
Self {
layout: Layout::single(0),
focused: 0,
next_id: 1,
}
}
/// Id del panel enfocado.
pub fn focused(&self) -> PaneId {
self.focused
}
/// Cantidad de paneles.
pub fn count(&self) -> usize {
self.layout.count()
}
/// Ids de todos los paneles, en orden espacial.
pub fn leaves(&self) -> Vec<PaneId> {
self.layout.leaves()
}
/// El árbol crudo (para casos avanzados; lo normal es [`workspace_view`]).
pub fn layout(&self) -> &Layout {
&self.layout
}
/// Enfoca un panel (no-op si no existe).
pub fn focus(&mut self, id: PaneId) {
if self.layout.contains(id) {
self.focused = id;
}
}
/// Parte el panel enfocado en `axis`; el nuevo queda enfocado. Devuelve
/// el `PaneId` nuevo para que el host cree su estado.
pub fn split(&mut self, axis: Axis) -> PaneId {
let id = self.next_id;
self.next_id += 1;
self.layout.split(self.focused, id, axis);
self.focused = id;
id
}
/// Cierra el panel enfocado (no cierra el último). Devuelve el id
/// removido para que el host libere su estado, o `None` si no removió.
pub fn close(&mut self) -> Option<PaneId> {
if self.count() <= 1 {
return None;
}
let target = self.focused;
let (nl, removed) = self.layout.clone().without(target);
if removed {
self.layout = nl;
self.focused = self.layout.first_leaf();
Some(target)
} else {
None
}
}
/// Ajusta el ratio del split direccionado por `path`.
pub fn resize(&mut self, path: &[Side], delta: f32) {
self.layout.resize(path, delta);
}
/// Aplica un mensaje del chasis y reporta el efecto a atender.
pub fn apply(&mut self, msg: WsMsg) -> WsEffect {
match msg {
WsMsg::Focus(id) => {
self.focus(id);
WsEffect::None
}
WsMsg::Split(axis) => WsEffect::Created(self.split(axis)),
WsMsg::Close => match self.close() {
Some(id) => WsEffect::Closed(id),
None => WsEffect::None,
},
WsMsg::Resize(path, d) => {
self.resize(&path, d);
WsEffect::None
}
}
}
}
impl Default for Workspace {
fn default() -> Self {
Self::new()
}
}
/// Mensajes del chasis. El host los envuelve en su propio `Msg` y los rutea
/// a [`Workspace::apply`].
#[derive(Debug, Clone, PartialEq)]
pub enum WsMsg {
Focus(PaneId),
Split(Axis),
Close,
Resize(Vec<Side>, f32),
}
/// Resultado de [`Workspace::apply`] — qué tiene que hacer el host con su
/// mapa de estados de panel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WsEffect {
/// Nada que hacer.
None,
/// Se creó un panel nuevo con este id: inicializá su estado.
Created(PaneId),
/// Se cerró este panel: borrá su estado.
Closed(PaneId),
}
/// Paleta del chasis.
#[derive(Debug, Clone, Copy)]
pub struct WorkspacePalette {
pub panes: PanesPalette,
pub bar_bg: Color,
pub btn_bg: Color,
pub btn_hover: Color,
pub label: Color,
pub muted: Color,
}
impl Default for WorkspacePalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl WorkspacePalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
panes: PanesPalette::from_theme(t),
bar_bg: t.bg_panel,
btn_bg: t.bg_button,
btn_hover: t.bg_button_hover,
label: t.fg_text,
muted: t.fg_muted,
}
}
}
/// Arma el chasis completo: toolbar (Split →/↓, Cerrar, estado) + el árbol
/// de paneles.
///
/// - `leaf` materializa el contenido de cada panel — **ya lifteado al `Msg`
/// del host** (el host hace el lift internamente con su `Panel(id, …)`).
/// - `lift` mapea los [`WsMsg`] del chasis al `Msg` del host.
pub fn workspace_view<Host>(
ws: &Workspace,
palette: &WorkspacePalette,
mut leaf: impl FnMut(PaneId) -> View<Host>,
lift: impl Fn(WsMsg) -> Host + Clone + Send + Sync + 'static,
) -> View<Host>
where
Host: Clone + Send + Sync + 'static,
{
let toolbar = View::new(Style {
flex_direction: FlexDirection::Row,
gap: Size {
width: length(8.0),
height: length(8.0),
},
padding: uniform(8.0),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bar_bg)
.children(vec![
button("Split →", lift(WsMsg::Split(Axis::Horizontal)), palette),
button("Split ↓", lift(WsMsg::Split(Axis::Vertical)), palette),
button("Cerrar", lift(WsMsg::Close), palette),
View::new(Style {
flex_grow: 1.0,
..Default::default()
}),
text(
format!("foco #{} · {} paneles", ws.focused(), ws.count()),
13.0,
palette.muted,
),
]);
let lift_resize = lift.clone();
let lift_focus = lift.clone();
let area = panes_view(
ws.layout(),
ws.focused(),
|id| leaf(id),
move |path, phase, d| {
let _ = phase;
Some((lift_resize)(WsMsg::Resize(path, d)))
},
move |id| (lift_focus)(WsMsg::Focus(id)),
&palette.panes,
);
let area_wrap = View::new(Style {
flex_grow: 1.0,
size: full(),
min_size: zero(),
..Default::default()
})
.children(vec![area]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: full(),
..Default::default()
})
.children(vec![toolbar, area_wrap])
}
fn button<Host>(label: &str, msg: Host, palette: &WorkspacePalette) -> View<Host>
where
Host: Clone + Send + Sync + 'static,
{
View::new(Style {
padding: Rect {
left: length(12.0),
right: length(12.0),
top: length(6.0),
bottom: length(6.0),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.btn_bg)
.hover_fill(palette.btn_hover)
.radius(6.0)
.on_click(msg)
.children(vec![text(label.to_string(), 14.0, palette.label)])
}
fn text<Host>(content: String, size: f32, color: Color) -> View<Host>
where
Host: Clone + Send + Sync + 'static,
{
View::new(Style::default()).text(content, size, color)
}
fn full() -> Size<Dimension> {
Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
}
}
fn zero() -> Size<Dimension> {
Size {
width: length(0.0_f32),
height: length(0.0_f32),
}
}
fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
Rect {
left: length(px),
right: length(px),
top: length(px),
bottom: length(px),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn starts_with_one_pane() {
let ws = Workspace::new();
assert_eq!(ws.count(), 1);
assert_eq!(ws.focused(), 0);
}
#[test]
fn split_creates_and_focuses_new() {
let mut ws = Workspace::new();
let id = ws.split(Axis::Horizontal);
assert_eq!(ws.count(), 2);
assert_eq!(ws.focused(), id);
assert_ne!(id, 0);
}
#[test]
fn apply_split_reports_created() {
let mut ws = Workspace::new();
match ws.apply(WsMsg::Split(Axis::Vertical)) {
WsEffect::Created(id) => assert_eq!(id, ws.focused()),
other => panic!("esperaba Created, fue {other:?}"),
}
}
#[test]
fn close_reports_closed_and_refocuses() {
let mut ws = Workspace::new();
let id = ws.split(Axis::Horizontal); // foco en el nuevo
match ws.apply(WsMsg::Close) {
WsEffect::Closed(closed) => {
assert_eq!(closed, id);
assert_eq!(ws.count(), 1);
assert_eq!(ws.focused(), 0);
}
other => panic!("esperaba Closed, fue {other:?}"),
}
}
#[test]
fn cannot_close_last_pane() {
let mut ws = Workspace::new();
assert_eq!(ws.apply(WsMsg::Close), WsEffect::None);
assert_eq!(ws.count(), 1);
}
#[test]
fn focus_ignores_unknown() {
let mut ws = Workspace::new();
ws.focus(999);
assert_eq!(ws.focused(), 0);
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "llimphi-module-bookmarks"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-bookmarks - marcadores per-file persistentes en la sesion del editor. Modulo Llimphi: el host emite ToggleAt(path, line) al disparar Ctrl+Alt+B, JumpNext/JumpPrev para navegar (devuelve JumpTo accion), y OpenList para abrir un overlay tipo symbol-outline con fuzzy filter sobre los marks. No persiste a disco - el host puede serializar marks si quiere."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
nucleo-matcher = { workspace = true }
[dev-dependencies]
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-bookmarks
> Bookmarks por archivo de [llimphi](../../README.md).
Marca posiciones en un archivo (línea + columna + nombre); navegación rápida (`F2`/`Shift+F2`). Persiste por workspace.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-bookmarks
> Per-file bookmarks of [llimphi](../../README.md).
Marks positions in a file (line + column + name); quick navigation (`F2`/`Shift+F2`). Persists per workspace.
+424
View File
@@ -0,0 +1,424 @@
//! llimphi-module-bookmarks - marcadores per-file persistentes en sesion.
//!
//! El usuario marca lineas con Ctrl+Alt+B y luego salta con
//! Ctrl+Alt+N / Ctrl+Alt+P. Ctrl+Shift+B abre un overlay con la
//! lista filtrable.
//!
//! Los marks son tuplas (PathBuf, line). Viven en memoria del
//! proceso; el host puede serializar marks si quiere persistir.
//!
//! Sigue el contrato Llimphi de docs/MODULES.md.
#![forbid(unsafe_code)]
use std::path::{Path, PathBuf};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
/// Capabilities que aporta este modulo al host.
pub const CAPABILITIES: &[&str] = &["editor.bookmarks"];
pub const MAX_RESULTS: usize = 500;
const PANEL_H: f32 = 320.0;
const ROW_H: f32 = 20.0;
const MAX_VISIBLE: usize = 12;
/// Sub-state del overlay tipo lista (input + results + selected).
/// None cuando no hay panel abierto.
pub struct BookmarksOverlay {
pub input: TextInputState,
/// Indices a state.marks rankeados por fuzzy match. Cap MAX_RESULTS.
pub results: Vec<usize>,
pub selected: usize,
}
impl BookmarksOverlay {
pub fn new() -> Self {
Self { input: TextInputState::new(), results: Vec::new(), selected: 0 }
}
}
/// Estado interno. Persiste durante toda la sesion (no es Option en
/// el host como otros modulos): los marks viven siempre, el overlay si
/// es opcional. Hace de mini-registro de waypoints del usuario.
pub struct BookmarksState {
/// Marks en orden de creacion. Cada uno es (path, line).
/// Toggle quita uno existente o agrega uno nuevo al final.
pub marks: Vec<(PathBuf, usize)>,
/// Overlay-list abierto cuando Some.
pub overlay: Option<BookmarksOverlay>,
}
impl Default for BookmarksState {
fn default() -> Self { Self::new() }
}
impl BookmarksState {
pub fn new() -> Self {
Self { marks: Vec::new(), overlay: None }
}
/// True si existe un mark con la misma (path, line).
pub fn contains(&self, path: &Path, line: usize) -> bool {
self.marks.iter().any(|(p, l)| p == path && *l == line)
}
/// Toggle: si ya existe lo remueve; si no, lo agrega al final.
/// Devuelve true si quedo agregado.
pub fn toggle(&mut self, path: PathBuf, line: usize) -> bool {
if let Some(idx) = self.marks.iter().position(|(p, l)| p == &path && *l == line) {
self.marks.remove(idx);
false
} else {
self.marks.push((path, line));
true
}
}
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Debug, Clone)]
pub enum BookmarksMsg {
/// Toggle del mark en (path, line). El host emite esto cuando
/// detecta el shortcut (Ctrl+Alt+B) y conoce la posicion del caret.
ToggleAt { path: PathBuf, line: usize },
/// Saltar al proximo mark cronologicamente despues de
/// (current_path, current_line). Si no hay marks, no-op.
JumpNext { current_path: PathBuf, current_line: usize },
/// Saltar al previo. Misma semantica reversa.
JumpPrev { current_path: PathBuf, current_line: usize },
/// Abrir el overlay-list.
OpenList,
/// Cerrar el overlay.
CloseList,
/// Teclas para el input del overlay.
ListKey(KeyEvent),
/// Navegacion en la lista del overlay.
ListNav(i32),
/// Enter: salta al mark seleccionado.
ListApply,
/// Limpia todos los marks.
ClearAll,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BookmarksAction {
None,
/// El host deberia cerrar el overlay (limpiar la sub-state).
Close,
/// El host deberia abrir ese path (si no esta abierto) y
/// posicionar el caret. Cierra el overlay automaticamente cuando
/// llega vinculado a ListApply.
JumpTo { path: PathBuf, line: usize },
/// Mensaje informativo para la status bar (eg toggle feedback).
SetStatus(String),
}
/// Aplica un mensaje al estado.
pub fn apply(state: &mut BookmarksState, msg: BookmarksMsg) -> BookmarksAction {
match msg {
BookmarksMsg::ToggleAt { path, line } => {
let added = state.toggle(path.clone(), line);
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
let msg = if added {
format!("bookmark agregado en {} linea {}", name, line + 1)
} else {
format!("bookmark removido de {} linea {}", name, line + 1)
};
BookmarksAction::SetStatus(msg)
}
BookmarksMsg::JumpNext { current_path, current_line } => {
match next_after(state, &current_path, current_line) {
Some((p, l)) => BookmarksAction::JumpTo { path: p, line: l },
None => BookmarksAction::SetStatus("sin bookmarks".into()),
}
}
BookmarksMsg::JumpPrev { current_path, current_line } => {
match prev_before(state, &current_path, current_line) {
Some((p, l)) => BookmarksAction::JumpTo { path: p, line: l },
None => BookmarksAction::SetStatus("sin bookmarks".into()),
}
}
BookmarksMsg::OpenList => BookmarksAction::None,
BookmarksMsg::CloseList => BookmarksAction::Close,
BookmarksMsg::ListKey(ev) => {
if let Some(ov) = state.overlay.as_mut() {
ov.input.apply_key(&ev);
refilter_overlay(state);
}
BookmarksAction::None
}
BookmarksMsg::ListNav(d) => {
if let Some(ov) = state.overlay.as_mut() {
let n = ov.results.len() as i32;
if n > 0 {
ov.selected = (ov.selected as i32 + d).rem_euclid(n) as usize;
}
}
BookmarksAction::None
}
BookmarksMsg::ListApply => {
let Some(ov) = state.overlay.as_ref() else { return BookmarksAction::None };
let Some(&idx) = ov.results.get(ov.selected) else { return BookmarksAction::None };
let Some((p, l)) = state.marks.get(idx).cloned() else { return BookmarksAction::None };
BookmarksAction::JumpTo { path: p, line: l }
}
BookmarksMsg::ClearAll => {
let n = state.marks.len();
state.marks.clear();
if let Some(ov) = state.overlay.as_mut() {
ov.results.clear();
ov.selected = 0;
}
BookmarksAction::SetStatus(format!("bookmarks limpios ({} removidos)", n))
}
}
}
/// Devuelve el mark inmediatamente posterior a (path, line) en orden
/// de marks. Wraparound al final.
fn next_after(state: &BookmarksState, path: &Path, line: usize) -> Option<(PathBuf, usize)> {
if state.marks.is_empty() { return None; }
let n = state.marks.len();
let cur_idx = state.marks.iter().position(|(p, l)| p == path && *l == line);
let start = match cur_idx {
Some(i) => (i + 1) % n,
None => 0,
};
Some(state.marks[start].clone())
}
/// Devuelve el mark inmediatamente previo. Wraparound al inicio.
fn prev_before(state: &BookmarksState, path: &Path, line: usize) -> Option<(PathBuf, usize)> {
if state.marks.is_empty() { return None; }
let n = state.marks.len();
let cur_idx = state.marks.iter().position(|(p, l)| p == path && *l == line);
let start = match cur_idx {
Some(i) if i > 0 => i - 1,
Some(_) => n - 1,
None => n - 1,
};
Some(state.marks[start].clone())
}
/// Routing de teclas cuando el overlay esta abierto.
pub fn on_key(state: &BookmarksState, event: &KeyEvent) -> Option<BookmarksMsg> {
state.overlay.as_ref()?;
if event.state != KeyState::Pressed { return None; }
Some(match &event.key {
Key::Named(NamedKey::Escape) => BookmarksMsg::CloseList,
Key::Named(NamedKey::Enter) => BookmarksMsg::ListApply,
Key::Named(NamedKey::ArrowDown) => BookmarksMsg::ListNav(1),
Key::Named(NamedKey::ArrowUp) => BookmarksMsg::ListNav(-1),
_ => BookmarksMsg::ListKey(event.clone()),
})
}
/// Atajo de toggle: Ctrl+Alt+B.
pub fn toggle_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.alt
&& !event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("b"))
}
/// Atajo de open-list: Ctrl+Shift+B. Tambien sirve como toggle del
/// panel (cierra si ya estaba abierto). El host decide en base a su
/// state.
pub fn open_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("b"))
}
/// Atajo de next: Ctrl+Alt+N.
pub fn next_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.alt
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("n"))
}
/// Atajo de prev: Ctrl+Alt+P.
pub fn prev_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.alt
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("p"))
}
/// Recalcula overlay.results con fuzzy match contra path+line.
/// Query vacio = todos los marks en orden.
pub fn refilter_overlay(state: &mut BookmarksState) {
let Some(ov) = state.overlay.as_mut() else { return; };
let q = ov.input.text();
if q.trim().is_empty() {
ov.results = (0..state.marks.len().min(MAX_RESULTS)).collect();
ov.selected = 0;
return;
}
use nucleo_matcher::{pattern::{CaseMatching, Normalization, Pattern}, Config, Matcher, Utf32Str};
let mut matcher = Matcher::new(Config::DEFAULT);
let pat = Pattern::parse(&q, CaseMatching::Smart, Normalization::Smart);
let mut scored: Vec<(u32, usize)> = Vec::new();
let mut buf = Vec::new();
for (i, (p, l)) in state.marks.iter().enumerate() {
let hay_str = format!("{} {}", p.display(), l + 1);
buf.clear();
let hay = Utf32Str::new(&hay_str, &mut buf);
if let Some(score) = pat.score(hay, &mut matcher) {
scored.push((score, i));
}
}
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored.truncate(MAX_RESULTS);
ov.results = scored.into_iter().map(|(_, i)| i).collect();
ov.selected = 0;
}
/// Paleta visual.
#[derive(Debug, Clone)]
pub struct BookmarksPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_accent: Color,
theme: llimphi_theme::Theme,
}
impl BookmarksPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel,
bg_header: t.bg_panel_alt,
bg_selected: t.bg_selected,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_accent: t.accent,
theme: t.clone(),
}
}
}
/// Render del overlay. Solo se llama cuando state.overlay es Some.
/// El host pasa root para mostrar paths relativos en la lista.
pub fn view<HostMsg, F>(
state: &BookmarksState,
root: &Path,
palette: &BookmarksPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(BookmarksMsg) -> HostMsg + Copy + 'static,
{
let ov = match state.overlay.as_ref() {
Some(o) => o,
None => return View::new(Style::default()),
};
let header = if state.marks.is_empty() {
"bookmarks - sin marks - Ctrl+Alt+B agrega - Esc cierra".to_string()
} else if ov.results.is_empty() {
format!("bookmarks - sin matches - {} marks - Esc cierra", state.marks.len())
} else {
format!(
"bookmarks - {} / {} - flechas navegan - Enter salta - Esc cierra",
ov.selected + 1,
ov.results.len(),
)
};
let header_view = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
let tp = TextInputPalette::from_theme(&palette.theme);
let input_view = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(vec![text_input_view(
&ov.input,
"filtro: path o numero de linea",
true,
&tp,
to_host(BookmarksMsg::OpenList),
)]);
let visible_start = ov.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
let visible_end = (visible_start + MAX_VISIBLE).min(ov.results.len());
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
for i in visible_start..visible_end {
let Some(&idx) = ov.results.get(i) else { continue };
let Some((p, line)) = state.marks.get(idx) else { continue };
let rel: String = match p.strip_prefix(root) {
Ok(r) => r.display().to_string(),
Err(_) => p.display().to_string(),
};
let label = format!("{} : linea {}", rel, line + 1);
let selected = i == ov.selected;
let bg = if selected { palette.bg_selected } else { palette.bg_panel };
let fg = if selected { palette.fg_text } else { palette.fg_muted };
rows.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
padding: Rect {
left: length(10.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.text_aligned(label, 11.0, fg, Alignment::Start),
);
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(2 + rows.len());
children.push(header_view);
children.push(input_view);
children.extend(rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(PANEL_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
+94
View File
@@ -0,0 +1,94 @@
//! Smoke tests del modulo bookmarks: toggle, jump-next/prev,
//! shortcuts, fuzzy refilter del overlay.
use std::path::PathBuf;
use llimphi_module_bookmarks::{
self as bm, BookmarksAction, BookmarksMsg, BookmarksOverlay, BookmarksState,
};
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
fn key_with(ctrl: bool, alt: bool, shift: bool, ch: &str) -> KeyEvent {
KeyEvent {
key: Key::Character(ch.into()),
state: KeyState::Pressed,
text: Some(ch.into()),
modifiers: Modifiers { ctrl, alt, shift, ..Modifiers::default() },
repeat: false,
}
}
#[test]
fn toggle_agrega_y_remueve() {
let mut s = BookmarksState::new();
let p = PathBuf::from("/x/foo.rs");
let a1 = bm::apply(&mut s, BookmarksMsg::ToggleAt { path: p.clone(), line: 5 });
assert!(matches!(a1, BookmarksAction::SetStatus(_)));
assert!(s.contains(&p, 5));
let a2 = bm::apply(&mut s, BookmarksMsg::ToggleAt { path: p.clone(), line: 5 });
assert!(matches!(a2, BookmarksAction::SetStatus(_)));
assert!(!s.contains(&p, 5));
}
#[test]
fn jump_next_wraparound() {
let mut s = BookmarksState::new();
let a = PathBuf::from("/x/a.rs");
let b = PathBuf::from("/x/b.rs");
s.toggle(a.clone(), 10);
s.toggle(b.clone(), 20);
s.toggle(a.clone(), 30);
// Estamos en (a, 10) - next debe ser (b, 20).
let action = bm::apply(&mut s, BookmarksMsg::JumpNext { current_path: a.clone(), current_line: 10 });
assert_eq!(action, BookmarksAction::JumpTo { path: b.clone(), line: 20 });
// Estamos en (a, 30) - next wrappea a (a, 10).
let action = bm::apply(&mut s, BookmarksMsg::JumpNext { current_path: a.clone(), current_line: 30 });
assert_eq!(action, BookmarksAction::JumpTo { path: a.clone(), line: 10 });
}
#[test]
fn jump_prev_wraparound() {
let mut s = BookmarksState::new();
let a = PathBuf::from("/x/a.rs");
s.toggle(a.clone(), 10);
s.toggle(a.clone(), 20);
s.toggle(a.clone(), 30);
// Estamos en (a, 10) - prev wrappea a (a, 30).
let action = bm::apply(&mut s, BookmarksMsg::JumpPrev { current_path: a.clone(), current_line: 10 });
assert_eq!(action, BookmarksAction::JumpTo { path: a.clone(), line: 30 });
}
#[test]
fn jump_sin_marks_es_setstatus() {
let mut s = BookmarksState::new();
let action = bm::apply(&mut s, BookmarksMsg::JumpNext { current_path: PathBuf::from("/x"), current_line: 0 });
assert!(matches!(action, BookmarksAction::SetStatus(_)));
}
#[test]
fn shortcuts_distinguibles() {
assert!(bm::toggle_shortcut(&key_with(true, true, false, "b")));
assert!(!bm::toggle_shortcut(&key_with(true, true, true, "b"))); // ctrl+alt+shift+b no
assert!(bm::open_shortcut(&key_with(true, false, true, "b")));
assert!(bm::next_shortcut(&key_with(true, true, false, "n")));
assert!(bm::prev_shortcut(&key_with(true, true, false, "p")));
}
#[test]
fn refilter_con_query_vacio_lista_todos() {
let mut s = BookmarksState::new();
s.toggle(PathBuf::from("/x/a.rs"), 1);
s.toggle(PathBuf::from("/x/b.rs"), 2);
s.overlay = Some(BookmarksOverlay::new());
bm::refilter_overlay(&mut s);
assert_eq!(s.overlay.as_ref().unwrap().results.len(), 2);
}
#[test]
fn clear_all_vacia_marks() {
let mut s = BookmarksState::new();
s.toggle(PathBuf::from("/x"), 1);
s.toggle(PathBuf::from("/y"), 2);
let _ = bm::apply(&mut s, BookmarksMsg::ClearAll);
assert!(s.marks.is_empty());
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-module-command-palette"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-command-palette — paleta de comandos estilo Ctrl+Shift+P de VS Code. Módulo Llimphi reutilizable: state + Msg + Action + apply/on_key/view sobre un slice de Commands que provee el host. Fuzzy match con nucleo-matcher."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
nucleo-matcher = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-command-palette
> Paleta de comandos de [llimphi](../../README.md).
`Ctrl+Shift+P` abre un fuzzy-finder de comandos registrados (`Command { id, label, shortcut, action }`). Cada app declara sus comandos al iniciar.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-command-palette
> Command palette of [llimphi](../../README.md).
`Ctrl+Shift+P` opens a fuzzy-finder of registered commands (`Command { id, label, shortcut, action }`). Each app declares its commands on init.
+352
View File
@@ -0,0 +1,352 @@
//! `llimphi-module-command-palette` — paleta de comandos reutilizable.
//!
//! Equivalente a Ctrl+Shift+P de VS Code: el host declara una lista
//! plana de [`Command`]s (id opaco + título visible + grupo + hint del
//! atajo) y el módulo presenta un overlay con input + resultados
//! rankeados por fuzzy match. Cuando el user pica uno, el módulo emite
//! [`PaletteAction::Invoke`] con el `id` — el host hace match y
//! dispatcha lo que corresponda en su propio Msg.
//!
//! El módulo no sabe **qué** hacen los comandos. Eso es deliberado:
//! mantiene al palette agnóstico de la app, y permite que aplicaciones
//! muy distintas (un editor, un explorador de grafos, un viewer de
//! imágenes) lo enchufen con sus respectivas listas sin acoplarse.
//!
//! Sigue el contrato Llimphi de `docs/MODULES.md`:
//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
/// Capabilities que aporta este módulo al host.
pub const CAPABILITIES: &[&str] = &["editor.command-palette"];
/// Tope de resultados rankeados visibles.
pub const MAX_RESULTS: usize = 200;
const BAR_H: f32 = 280.0;
const ROW_H: f32 = 22.0;
const MAX_VISIBLE: usize = 10;
/// Una entrada del catálogo de comandos que el host arma.
///
/// Los campos son convencionales:
/// - `id`: identificador opaco, único dentro del catálogo del host.
/// El host lo recibe en [`PaletteAction::Invoke`] y hace match a su
/// propio Msg. Por convención, formato `"namespace.action"` (ej.
/// `"editor.save"`, `"terminal.open"`).
/// - `title`: lo que el user lee. Idealmente en lengua de la app.
/// - `group`: categoría visible a la derecha de la fila (ej. `"Editor"`,
/// `"Terminal"`, `"LSP"`). Sirve para escanear visualmente.
/// - `shortcut`: hint textual del atajo nativo del comando, si existe
/// (ej. `"Ctrl+S"`). Sólo decorativo — el módulo no captura nada
/// distinto a Enter/Esc/↑↓.
#[derive(Debug, Clone)]
pub struct Command {
pub id: String,
pub title: String,
pub group: String,
pub shortcut: Option<String>,
}
impl Command {
pub fn new(
id: impl Into<String>,
title: impl Into<String>,
group: impl Into<String>,
) -> Self {
Self { id: id.into(), title: title.into(), group: group.into(), shortcut: None }
}
pub fn with_shortcut(mut self, s: impl Into<String>) -> Self {
self.shortcut = Some(s.into());
self
}
}
/// Estado interno. `results` son índices al slice de commands que pasa
/// el host: el módulo no copia, sólo guarda índices.
pub struct PaletteState {
pub input: TextInputState,
pub results: Vec<usize>,
pub selected: usize,
}
impl Default for PaletteState {
fn default() -> Self {
Self::new_empty()
}
}
impl PaletteState {
pub fn new_empty() -> Self {
Self {
input: TextInputState::new(),
results: Vec::new(),
selected: 0,
}
}
/// Crea un palette pre-poblado con todos los comandos sin filtro,
/// listo para mostrar después del shortcut de apertura.
pub fn new(commands: &[Command]) -> Self {
let mut s = Self::new_empty();
refilter(&mut s, commands);
s
}
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Clone)]
pub enum PaletteMsg {
/// Símbolo conveniente para que el host dispatche al detectar el
/// shortcut. El módulo no construye el state él mismo — eso lo hace
/// el host con la lista canónica de commands.
Open,
Close,
KeyInput(KeyEvent),
Nav(i32),
/// Enter: invoca el comando seleccionado.
Apply,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaletteAction {
None,
/// El host debería remover el state del modelo.
Close,
/// El host debería ejecutar el comando con este `id`. El módulo NO
/// se cierra automáticamente — el host decide (típicamente sí, igual
/// que un menú).
Invoke(String),
}
/// Aplica un mensaje al estado.
pub fn apply(
state: &mut PaletteState,
msg: PaletteMsg,
commands: &[Command],
) -> PaletteAction {
match msg {
PaletteMsg::Open => PaletteAction::None,
PaletteMsg::Close => PaletteAction::Close,
PaletteMsg::KeyInput(ev) => {
state.input.apply_key(&ev);
refilter(state, commands);
PaletteAction::None
}
PaletteMsg::Nav(d) => {
let n = state.results.len() as i32;
if n > 0 {
state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
}
PaletteAction::None
}
PaletteMsg::Apply => {
let Some(&cmd_idx) = state.results.get(state.selected) else {
return PaletteAction::None;
};
let Some(cmd) = commands.get(cmd_idx) else {
return PaletteAction::None;
};
PaletteAction::Invoke(cmd.id.clone())
}
}
}
/// Routing de teclas cuando el palette está abierto.
pub fn on_key(_state: &PaletteState, event: &KeyEvent) -> Option<PaletteMsg> {
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => PaletteMsg::Close,
Key::Named(NamedKey::Enter) => PaletteMsg::Apply,
Key::Named(NamedKey::ArrowDown) => PaletteMsg::Nav(1),
Key::Named(NamedKey::ArrowUp) => PaletteMsg::Nav(-1),
_ => PaletteMsg::KeyInput(event.clone()),
})
}
/// El atajo recomendado: **Ctrl+Shift+P**, igual que VS Code.
pub fn open_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("p"))
}
/// Recalcula `state.results` según el query del input. Fuzzy match con
/// `nucleo-matcher` sobre `"title · group"` (mismo string para que el
/// usuario pueda buscar por grupo: "term" matchea "Open Terminal · Editor").
/// Query vacío = lista completa ordenada como vino del host.
/// Cap: [`MAX_RESULTS`].
pub fn refilter(state: &mut PaletteState, commands: &[Command]) {
let q = state.input.text();
if q.trim().is_empty() {
state.results = (0..commands.len().min(MAX_RESULTS)).collect();
state.selected = 0;
return;
}
use nucleo_matcher::{
pattern::{CaseMatching, Normalization, Pattern},
Config, Matcher, Utf32Str,
};
let mut matcher = Matcher::new(Config::DEFAULT);
let pat = Pattern::parse(&q, CaseMatching::Smart, Normalization::Smart);
let mut scored: Vec<(u32, usize)> = Vec::new();
let mut buf = Vec::new();
for (i, cmd) in commands.iter().enumerate() {
let hay_str = format!("{} {}", cmd.title, cmd.group);
buf.clear();
let hay = Utf32Str::new(&hay_str, &mut buf);
if let Some(score) = pat.score(hay, &mut matcher) {
scored.push((score, i));
}
}
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored.truncate(MAX_RESULTS);
state.results = scored.into_iter().map(|(_, i)| i).collect();
state.selected = 0;
}
/// Paleta visual.
#[derive(Debug, Clone)]
pub struct PalettePalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
theme: llimphi_theme::Theme,
}
impl PalettePalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel,
bg_header: t.bg_panel_alt,
bg_selected: t.bg_selected,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
theme: t.clone(),
}
}
}
/// Render del overlay. `to_host` mapea cada `PaletteMsg` interno al
/// `Msg` de la app.
pub fn view<HostMsg, F>(
state: &PaletteState,
commands: &[Command],
palette: &PalettePalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(PaletteMsg) -> HostMsg + Copy + 'static,
{
let header = if state.results.is_empty() {
format!("command palette · sin matches · {} comandos · Esc cierra", commands.len())
} else {
format!(
"command palette · {} / {} · ↓↑ navega · Enter ejecuta · Esc cierra",
state.selected + 1,
state.results.len(),
)
};
let header_view = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
let tp = TextInputPalette::from_theme(&palette.theme);
let input_view = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(vec![text_input_view(
&state.input,
"filtro: nombre del comando…",
true,
&tp,
to_host(PaletteMsg::Open),
)]);
let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
for i in visible_start..visible_end {
let Some(&cmd_idx) = state.results.get(i) else { continue };
let Some(cmd) = commands.get(cmd_idx) else { continue };
let label = match (&cmd.shortcut, cmd.group.as_str()) {
(Some(sc), grp) if !grp.is_empty() => {
format!("{} {} [{sc}]", cmd.title, cmd.group)
}
(Some(sc), _) => format!("{} [{sc}]", cmd.title),
(None, grp) if !grp.is_empty() => format!("{} {}", cmd.title, cmd.group),
(None, _) => cmd.title.clone(),
};
let selected = i == state.selected;
let bg = if selected { palette.bg_selected } else { palette.bg_panel };
let fg = if selected { palette.fg_text } else { palette.fg_muted };
rows.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
padding: Rect {
left: length(10.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.text_aligned(label, 12.0, fg, Alignment::Start),
);
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(2 + rows.len());
children.push(header_view);
children.push(input_view);
children.extend(rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(BAR_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
+125
View File
@@ -0,0 +1,125 @@
//! Smoke tests del fuzzy match y del flujo `Open → KeyInput → Apply`.
//! No requieren backend gráfico — sólo el reducer puro y `refilter`.
use llimphi_module_command_palette::{
self as palette, Command, PaletteAction, PaletteMsg, PaletteState,
};
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
fn seed() -> Vec<Command> {
vec![
Command::new("editor.save", "Save File", "Editor").with_shortcut("Ctrl+S"),
Command::new("editor.open", "Open File", "Editor").with_shortcut("Ctrl+P"),
Command::new("editor.findInFiles", "Find in Files", "Editor")
.with_shortcut("Ctrl+Shift+F"),
Command::new("terminal.open", "Open Terminal", "Terminal")
.with_shortcut("Ctrl+`"),
Command::new("lsp.format", "Format Document", "LSP")
.with_shortcut("Ctrl+Alt+L"),
Command::new("lsp.goto", "Go to Definition", "LSP").with_shortcut("F12"),
]
}
fn key_char(c: &str) -> KeyEvent {
KeyEvent {
key: Key::Character(c.into()),
state: KeyState::Pressed,
text: Some(c.into()),
modifiers: Modifiers::default(),
repeat: false,
}
}
#[test]
fn estado_vacio_lista_todos_los_comandos() {
let cmds = seed();
let s = PaletteState::new(&cmds);
assert_eq!(s.results.len(), cmds.len());
assert_eq!(s.selected, 0);
}
#[test]
fn fuzzy_match_acerca_el_comando_correcto_al_top() {
let cmds = seed();
let mut s = PaletteState::new(&cmds);
// Tipear "term" debería rankear "Open Terminal" o "Terminal" arriba.
for ch in ["t", "e", "r", "m"] {
let action = palette::apply(&mut s, PaletteMsg::KeyInput(key_char(ch)), &cmds);
assert_eq!(action, PaletteAction::None);
}
let top = s.results.first().expect("debe haber al menos un match");
assert_eq!(
cmds[*top].id, "terminal.open",
"esperaba terminal.open al top, vi {:?}",
cmds[*top].title
);
}
#[test]
fn enter_emite_invoke_con_el_id_seleccionado() {
let cmds = seed();
let mut s = PaletteState::new(&cmds);
for ch in ["s", "a", "v"] {
palette::apply(&mut s, PaletteMsg::KeyInput(key_char(ch)), &cmds);
}
let action = palette::apply(&mut s, PaletteMsg::Apply, &cmds);
assert_eq!(action, PaletteAction::Invoke("editor.save".into()));
}
#[test]
fn nav_circula_por_los_resultados() {
let cmds = seed();
let mut s = PaletteState::new(&cmds);
assert_eq!(s.selected, 0);
palette::apply(&mut s, PaletteMsg::Nav(1), &cmds);
assert_eq!(s.selected, 1);
// Saltar al final desde la cima con -1 (wrap-around).
let mut s = PaletteState::new(&cmds);
palette::apply(&mut s, PaletteMsg::Nav(-1), &cmds);
assert_eq!(s.selected, cmds.len() - 1);
}
#[test]
fn escape_emite_close() {
let cmds = seed();
let mut s = PaletteState::new(&cmds);
let action = palette::apply(&mut s, PaletteMsg::Close, &cmds);
assert_eq!(action, PaletteAction::Close);
}
#[test]
fn open_shortcut_es_ctrl_shift_p() {
use llimphi_ui::Modifiers;
let mk = |ctrl: bool, shift: bool, c: &str| KeyEvent {
key: Key::Character(c.into()),
state: KeyState::Pressed,
text: Some(c.into()),
modifiers: Modifiers { ctrl, shift, ..Modifiers::default() },
repeat: false,
};
assert!(palette::open_shortcut(&mk(true, true, "p")));
assert!(palette::open_shortcut(&mk(true, true, "P")));
// Sin shift no — ese es Ctrl+P del file-picker.
assert!(!palette::open_shortcut(&mk(true, false, "p")));
// Sin ctrl no.
assert!(!palette::open_shortcut(&mk(false, true, "p")));
// Otra letra no.
assert!(!palette::open_shortcut(&mk(true, true, "q")));
}
#[test]
fn busqueda_por_grupo_funciona() {
let cmds = seed();
let mut s = PaletteState::new(&cmds);
// "lsp" debería traer Format y Goto Definition (ambos del grupo LSP).
for ch in ["l", "s", "p"] {
palette::apply(&mut s, PaletteMsg::KeyInput(key_char(ch)), &cmds);
}
let ids: Vec<&str> = s.results.iter().map(|&i| cmds[i].id.as_str()).collect();
assert!(ids.contains(&"lsp.format"), "esperaba lsp.format en {ids:?}");
assert!(ids.contains(&"lsp.goto"), "esperaba lsp.goto en {ids:?}");
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-module-diff-viewer"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-diff-viewer — visualización side-by-side de cambios entre dos textos. Módulo Llimphi: el host provee before/after (typically HEAD vs working tree, o snapshot vs current buffer), el módulo computa el diff con `similar` y lo presenta en dos columnas con marcadores +/- y números de línea."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
similar = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-diff-viewer
> Diff side-by-side de [llimphi](../../README.md).
Toma dos textos y muestra diff por línea: inserciones, eliminaciones, modificaciones. Algoritmo Myers; resaltado intra-línea opcional.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-diff-viewer
> Side-by-side diff of [llimphi](../../README.md).
Takes two texts and shows line-by-line diff: insertions, deletions, modifications. Myers algorithm; optional intra-line highlight.
+398
View File
@@ -0,0 +1,398 @@
//! `llimphi-module-diff-viewer` — visualización side-by-side de cambios.
//!
//! Equivalente al "Compare with Saved" de VS Code o el panel "Compare"
//! de JetBrains, pero como módulo Llimphi enchufable. El host le pasa
//! dos textos (`before`/`after`) y dos etiquetas (`"HEAD"`, `"Working
//! Tree"`, `"Buffer"` — lo que tenga sentido en su contexto), y el
//! módulo computa el diff line-based con [`similar`] y lo renderiza
//! en dos columnas con marcadores `+`/`-` y números de línea.
//!
//! El módulo no abre archivos, no llama a `git`, no toca disco. Toda
//! la fuente del diff la decide el host: puede comparar el disco vs
//! el buffer dirty, dos branches, dos snapshots de history, etc.
//!
//! Sigue el contrato Llimphi de `docs/MODULES.md`:
//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use similar::{ChangeTag, TextDiff};
/// Capabilities que aporta este módulo al host.
pub const CAPABILITIES: &[&str] = &["editor.diff-viewer"];
const HEADER_H: f32 = 18.0;
const ROW_H: f32 = 15.0;
/// Una línea del diff alineada para render side-by-side.
///
/// El render usa dos celdas por fila (izquierda = `before`, derecha =
/// `after`). En una línea `Equal`, ambas celdas tienen el mismo
/// contenido. En `Delete`, sólo la izquierda; en `Insert`, sólo la
/// derecha. La struct cumple las dos roles para simplificar el render.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffRow {
pub kind: DiffKind,
/// Contenido de la celda izquierda (Equal o Delete) o vacío.
pub left: Option<DiffCell>,
/// Contenido de la celda derecha (Equal o Insert) o vacío.
pub right: Option<DiffCell>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffCell {
/// Número de línea 1-based en el lado correspondiente.
pub line_no: usize,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffKind {
Equal,
Delete,
Insert,
}
/// Estado del panel.
pub struct DiffState {
pub before_label: String,
pub after_label: String,
pub rows: Vec<DiffRow>,
pub scroll: usize,
/// Conteo agregado para mostrar en el header (`+12 / -3` etc.).
pub stats: DiffStats,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct DiffStats {
pub inserts: usize,
pub deletes: usize,
pub equals: usize,
}
impl DiffState {
/// Construye el state computando el diff entre `before` y `after`.
/// Líneas se separan por '\n'; el último '\n' se conserva como
/// separador (no aparece como línea extra vacía).
pub fn new(
before_label: impl Into<String>,
after_label: impl Into<String>,
before: &str,
after: &str,
) -> Self {
let (rows, stats) = compute_rows(before, after);
Self {
before_label: before_label.into(),
after_label: after_label.into(),
rows,
scroll: 0,
stats,
}
}
}
/// Computa las filas alineadas a partir de los dos textos. La salida
/// preserva el orden lineal del archivo: bloques `Equal` mantienen las
/// líneas pareadas; un `Delete` que no tiene contraparte en el otro
/// lado aparece con `right = None`, y viceversa para `Insert`. No se
/// emparejan visualmente delete con insert — siguen la convención de
/// VS Code, que los muestra como líneas separadas.
pub fn compute_rows(before: &str, after: &str) -> (Vec<DiffRow>, DiffStats) {
let diff = TextDiff::from_lines(before, after);
let mut rows: Vec<DiffRow> = Vec::new();
let mut stats = DiffStats::default();
let mut left_no = 0usize;
let mut right_no = 0usize;
for change in diff.iter_all_changes() {
let text = change.value().trim_end_matches('\n').to_string();
match change.tag() {
ChangeTag::Equal => {
left_no += 1;
right_no += 1;
stats.equals += 1;
rows.push(DiffRow {
kind: DiffKind::Equal,
left: Some(DiffCell { line_no: left_no, text: text.clone() }),
right: Some(DiffCell { line_no: right_no, text }),
});
}
ChangeTag::Delete => {
left_no += 1;
stats.deletes += 1;
rows.push(DiffRow {
kind: DiffKind::Delete,
left: Some(DiffCell { line_no: left_no, text }),
right: None,
});
}
ChangeTag::Insert => {
right_no += 1;
stats.inserts += 1;
rows.push(DiffRow {
kind: DiffKind::Insert,
left: None,
right: Some(DiffCell { line_no: right_no, text }),
});
}
}
}
(rows, stats)
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Clone)]
pub enum DiffMsg {
Open,
Close,
/// Scroll vertical en líneas (positivo = baja).
Scroll(i32),
/// Salta al próximo hunk (∆+/-) en dirección.
NextHunk,
PrevHunk,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffAction {
None,
/// El host debería remover el state del modelo.
Close,
}
pub fn apply(state: &mut DiffState, msg: DiffMsg, visible_rows: usize) -> DiffAction {
match msg {
DiffMsg::Open => DiffAction::None,
DiffMsg::Close => DiffAction::Close,
DiffMsg::Scroll(delta) => {
scroll_by(state, delta, visible_rows);
DiffAction::None
}
DiffMsg::NextHunk => {
jump_to_hunk(state, true, visible_rows);
DiffAction::None
}
DiffMsg::PrevHunk => {
jump_to_hunk(state, false, visible_rows);
DiffAction::None
}
}
}
fn scroll_by(state: &mut DiffState, delta: i32, visible_rows: usize) {
let max_scroll = state.rows.len().saturating_sub(visible_rows);
let new_scroll = (state.scroll as i64 + delta as i64).max(0) as usize;
state.scroll = new_scroll.min(max_scroll);
}
/// Busca la próxima fila con `kind != Equal` en la dirección dada,
/// empezando justo después/antes del scroll actual. Si no hay más,
/// no-op.
fn jump_to_hunk(state: &mut DiffState, forward: bool, visible_rows: usize) {
let start = state.scroll;
let n = state.rows.len();
let found = if forward {
(start + 1..n).find(|&i| !matches!(state.rows[i].kind, DiffKind::Equal))
} else {
(0..start.min(n)).rev().find(|&i| !matches!(state.rows[i].kind, DiffKind::Equal))
};
if let Some(i) = found {
let max_scroll = n.saturating_sub(visible_rows);
state.scroll = i.min(max_scroll);
}
}
/// Routing de teclas cuando el panel está abierto.
pub fn on_key(_state: &DiffState, event: &KeyEvent) -> Option<DiffMsg> {
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => DiffMsg::Close,
Key::Named(NamedKey::ArrowDown) => DiffMsg::Scroll(1),
Key::Named(NamedKey::ArrowUp) => DiffMsg::Scroll(-1),
Key::Named(NamedKey::PageDown) => DiffMsg::Scroll(20),
Key::Named(NamedKey::PageUp) => DiffMsg::Scroll(-20),
Key::Named(NamedKey::Home) => DiffMsg::Scroll(-(i32::MAX / 4)),
Key::Named(NamedKey::End) => DiffMsg::Scroll(i32::MAX / 4),
Key::Character(s) if s == "n" => DiffMsg::NextHunk,
Key::Character(s) if s == "N" => DiffMsg::PrevHunk,
_ => return None,
})
}
/// El atajo recomendado: **Ctrl+Shift+D**, similar al "Compare with
/// Saved" de VS Code (que usa Ctrl+Shift+P + comando).
pub fn open_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("d"))
}
/// Paleta visual con colores diff convencionales (verde para insert,
/// rojo apagado para delete).
#[derive(Debug, Clone)]
pub struct DiffPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_insert: Color,
pub bg_delete: Color,
pub bg_empty: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_insert: Color,
pub fg_delete: Color,
}
impl DiffPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
// Verde/rojo apagados — visibles sobre fondo oscuro pero sin
// saturar. Si el theme expone colores semánticos de diff en
// el futuro, los usamos; por ahora hardcoded.
Self {
bg_panel: t.bg_panel,
bg_header: t.bg_panel_alt,
bg_insert: Color::from_rgba8(40, 80, 50, 255),
bg_delete: Color::from_rgba8(90, 40, 45, 255),
bg_empty: t.bg_panel_alt,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_insert: Color::from_rgba8(170, 230, 180, 255),
fg_delete: Color::from_rgba8(240, 180, 185, 255),
}
}
}
/// Render del panel side-by-side. `height_px` es la altura total
/// disponible; el módulo divide entre el header de 18 px y la grid.
pub fn view<HostMsg, F>(
state: &DiffState,
palette: &DiffPalette,
height_px: f32,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(DiffMsg) -> HostMsg + Copy + 'static,
{
let _ = to_host; // v0 no monta eventos puntuales sobre filas
let header_text = format!(
"diff · {} ↔ {} · +{} -{} ={} · ↑↓ scroll · n/N hunk · Esc cierra",
state.before_label,
state.after_label,
state.stats.inserts,
state.stats.deletes,
state.stats.equals,
);
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let grid_h = (height_px - HEADER_H).max(0.0);
let max_rows = ((grid_h / ROW_H) as usize).max(1);
let end = (state.scroll + max_rows).min(state.rows.len());
let mut grid_rows: Vec<View<HostMsg>> = Vec::with_capacity(max_rows);
for row in &state.rows[state.scroll..end] {
grid_rows.push(render_row(row, palette));
}
while grid_rows.len() < max_rows {
// Padding visual para mantener altura constante.
grid_rows.push(empty_row(palette));
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + grid_rows.len());
children.push(header);
children.extend(grid_rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(height_px) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
fn render_row<HostMsg>(row: &DiffRow, palette: &DiffPalette) -> View<HostMsg>
where
HostMsg: Clone + 'static,
{
let (left_bg, left_fg, left_mark) = match row.kind {
DiffKind::Equal => (palette.bg_panel, palette.fg_text, " "),
DiffKind::Delete => (palette.bg_delete, palette.fg_delete, "-"),
DiffKind::Insert => (palette.bg_empty, palette.fg_muted, " "),
};
let (right_bg, right_fg, right_mark) = match row.kind {
DiffKind::Equal => (palette.bg_panel, palette.fg_text, " "),
DiffKind::Insert => (palette.bg_insert, palette.fg_insert, "+"),
DiffKind::Delete => (palette.bg_empty, palette.fg_muted, " "),
};
let left_text = match &row.left {
Some(c) => format!("{:>4} {}{}", c.line_no, left_mark, c.text),
None => String::new(),
};
let right_text = match &row.right {
Some(c) => format!("{:>4} {}{}", c.line_no, right_mark, c.text),
None => String::new(),
};
let cell = |bg: Color, fg: Color, text: String| {
View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.5_f32), height: length(ROW_H) },
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.text_aligned(text, 10.5, fg, Alignment::Start)
};
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![cell(left_bg, left_fg, left_text), cell(right_bg, right_fg, right_text)])
}
fn empty_row<HostMsg>(palette: &DiffPalette) -> View<HostMsg>
where
HostMsg: Clone + 'static,
{
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
}
+155
View File
@@ -0,0 +1,155 @@
//! Smoke tests del cómputo de filas y el routing de teclas. Sin
//! backend gráfico — pruebas puras sobre `compute_rows` y `apply`.
use llimphi_module_diff_viewer::{
self as diff, DiffAction, DiffKind, DiffMsg, DiffState,
};
#[test]
fn diff_basico_inserts_y_deletes() {
let before = "a\nb\nc\n";
let after = "a\nB\nc\nd\n";
let (rows, stats) = diff::compute_rows(before, after);
// El diff esperado:
// = a / a
// - b
// + B
// = c / c
// + d
assert_eq!(stats.equals, 2);
assert_eq!(stats.deletes, 1);
assert_eq!(stats.inserts, 2);
assert_eq!(rows[0].kind, DiffKind::Equal);
assert_eq!(rows[0].left.as_ref().unwrap().text, "a");
assert_eq!(rows[0].right.as_ref().unwrap().text, "a");
// El primer cambio debe ser un Delete o Insert (similar agrupa);
// verificamos que B aparezca y b no.
let texts_left: Vec<&str> = rows
.iter()
.filter_map(|r| r.left.as_ref().map(|c| c.text.as_str()))
.collect();
let texts_right: Vec<&str> = rows
.iter()
.filter_map(|r| r.right.as_ref().map(|c| c.text.as_str()))
.collect();
assert!(texts_left.contains(&"b"));
assert!(texts_right.contains(&"B"));
assert!(texts_right.contains(&"d"));
}
#[test]
fn numeros_de_linea_son_correctos() {
let before = "alpha\nbeta\ngamma\n";
let after = "alpha\nBETA\ngamma\ndelta\n";
let (rows, _) = diff::compute_rows(before, after);
// alpha en línea 1 de ambos.
let alpha_row = rows.iter().find(|r| {
r.left.as_ref().map(|c| c.text == "alpha").unwrap_or(false)
}).unwrap();
assert_eq!(alpha_row.left.as_ref().unwrap().line_no, 1);
assert_eq!(alpha_row.right.as_ref().unwrap().line_no, 1);
// beta (delete) en línea 2 izquierda.
let beta_row = rows.iter().find(|r| {
r.left.as_ref().map(|c| c.text == "beta").unwrap_or(false)
}).unwrap();
assert_eq!(beta_row.left.as_ref().unwrap().line_no, 2);
assert!(beta_row.right.is_none());
// delta (insert) en línea 4 derecha.
let delta_row = rows.iter().find(|r| {
r.right.as_ref().map(|c| c.text == "delta").unwrap_or(false)
}).unwrap();
assert_eq!(delta_row.right.as_ref().unwrap().line_no, 4);
assert!(delta_row.left.is_none());
}
#[test]
fn textos_identicos_solo_equal() {
let text = "uno\ndos\ntres\n";
let (rows, stats) = diff::compute_rows(text, text);
assert_eq!(rows.len(), 3);
assert!(rows.iter().all(|r| r.kind == DiffKind::Equal));
assert_eq!(stats.inserts, 0);
assert_eq!(stats.deletes, 0);
assert_eq!(stats.equals, 3);
}
#[test]
fn scroll_no_excede_los_limites() {
let before = (0..50).map(|i| i.to_string()).collect::<Vec<_>>().join("\n");
let after = before.clone(); // identical → 50 Equal rows
let mut state = DiffState::new("a", "b", &before, &after);
assert_eq!(state.scroll, 0);
// Scroll grande hacia abajo: tope = 50 - visible_rows.
diff::apply(&mut state, DiffMsg::Scroll(1000), 10);
assert_eq!(state.scroll, 40);
// Scroll arriba: tope mínimo 0.
diff::apply(&mut state, DiffMsg::Scroll(-1000), 10);
assert_eq!(state.scroll, 0);
}
#[test]
fn next_hunk_salta_a_la_proxima_diferencia() {
// 20 líneas iguales + 2 cambios + 20 más. visible_rows=5 deja
// espacio real para scrollear.
let mut before = String::new();
let mut after = String::new();
for i in 0..20 {
before.push_str(&format!("eq{i}\n"));
after.push_str(&format!("eq{i}\n"));
}
before.push_str("DEL\n");
after.push_str("INS\n");
for i in 20..40 {
before.push_str(&format!("eq{i}\n"));
after.push_str(&format!("eq{i}\n"));
}
let mut state = DiffState::new("a", "b", &before, &after);
assert_eq!(state.scroll, 0);
diff::apply(&mut state, DiffMsg::NextHunk, 5);
assert!(state.scroll > 0, "scroll quedó en 0 — no saltó al hunk");
let row = &state.rows[state.scroll];
assert!(
!matches!(row.kind, DiffKind::Equal),
"esperaba aterrizar en un hunk, vi {:?}",
row.kind
);
// PrevHunk: vuelve al inicio (no hay hunk antes del primer cambio).
diff::apply(&mut state, DiffMsg::PrevHunk, 5);
// Puede quedarse en el mismo hunk si era el único accesible hacia
// atrás, o saltar más arriba. Lo único que verificamos es que no
// hubo panic ni scroll fuera de rango.
assert!(state.scroll < state.rows.len());
}
#[test]
fn escape_cierra() {
let mut state = DiffState::new("a", "b", "x\n", "y\n");
let action = diff::apply(&mut state, DiffMsg::Close, 10);
assert_eq!(action, DiffAction::Close);
}
#[test]
fn open_shortcut_es_ctrl_shift_d() {
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
let mk = |ctrl: bool, shift: bool, c: &str| KeyEvent {
key: Key::Character(c.into()),
state: KeyState::Pressed,
text: Some(c.into()),
modifiers: Modifiers { ctrl, shift, ..Modifiers::default() },
repeat: false,
};
assert!(diff::open_shortcut(&mk(true, true, "d")));
assert!(diff::open_shortcut(&mk(true, true, "D")));
assert!(!diff::open_shortcut(&mk(true, false, "d")));
assert!(!diff::open_shortcut(&mk(false, true, "d")));
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-module-fif"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-fif — find-in-files reusable (estilo JetBrains). Módulo Llimphi: state + Msg + Action + apply/on_key/view. Cualquier app que mantenga una lista de paths puede enchufarlo."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-fif
> Find-in-files de [llimphi](../../README.md).
Buscar en todos los archivos del workspace con regex + glob de filenames. Streaming de resultados (no espera al fin del scan). Click en resultado abre el archivo en la línea.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-fif
> Find-in-files of [llimphi](../../README.md).
Search across all workspace files with regex + filename glob. Streamed results (doesn't wait for scan end). Click on result opens the file at the line.
+815
View File
@@ -0,0 +1,815 @@
//! `llimphi-module-fif` — find-in-files reutilizable (estilo JetBrains).
//!
//! Módulo Llimphi con dos vistas independientes:
//!
//! - [`view_dialog`] — popup compacto (header + input) que el host pinta
//! como overlay modal centrado. Sólo visible cuando
//! [`FifState::dialog_open`] es `true`.
//! - [`view_results_bar`] — barra inferior persistente con la lista de
//! matches. El host la pinta como tool window al pie (estilo JetBrains
//! "Find" tool window). Sobrevive al cierre del dialog: el user puede
//! Esc-cerrar el popup y seguir clickeando los resultados.
//!
//! El flujo típico es: `Ctrl+Shift+F` abre el dialog → tipear → Enter
//! ejecuta `search` → resultados aparecen en la barra inferior → Esc
//! cierra el popup pero la barra queda → click en una fila abre el
//! archivo. Re-disparar `Ctrl+Shift+F` reabre el popup conservando los
//! últimos resultados.
//!
//! ## Cómo lo enchufa una app
//!
//! ```ignore
//! struct AppModel {
//! all_files: Vec<PathBuf>,
//! fif: Option<FifState>,
//! // …
//! }
//!
//! enum AppMsg { Fif(llimphi_module_fif::FifMsg), … }
//!
//! // En update(model, msg):
//! AppMsg::Fif(fm) => {
//! // Lazy-init en Open:
//! if matches!(fm, FifMsg::Open) && model.fif.is_none() {
//! model.fif = Some(FifState::new());
//! } else if matches!(fm, FifMsg::Open) {
//! model.fif.as_mut().unwrap().dialog_open = true;
//! }
//! let action = match model.fif.as_mut() {
//! Some(s) => llimphi_module_fif::apply(s, fm, &model.all_files),
//! None => FifAction::None,
//! };
//! match action {
//! FifAction::None => {}
//! FifAction::CloseDialog => {
//! if let Some(s) = model.fif.as_mut() { s.dialog_open = false; }
//! }
//! FifAction::CloseAll => model.fif = None,
//! FifAction::Searched { .. } => { /* actualizar status bar */ }
//! FifAction::OpenAt { path, line, col } => {
//! if let Some(s) = model.fif.as_mut() { s.dialog_open = false; }
//! open_path_in_app(path, line, col);
//! }
//! }
//! }
//!
//! // En on_key(model, event): solo rutea cuando el dialog está visible.
//! if let Some(state) = model.fif.as_ref() {
//! if let Some(fm) = llimphi_module_fif::on_key(state, event) {
//! return Some(AppMsg::Fif(fm));
//! }
//! }
//! if llimphi_module_fif::open_shortcut(event) {
//! return Some(AppMsg::Fif(FifMsg::Open));
//! }
//!
//! // En view(model):
//! // - dialog como overlay arriba del editor:
//! if let Some(s) = model.fif.as_ref().filter(|s| s.dialog_open) {
//! overlay_children.push(view_dialog(s, &palette, AppMsg::Fif));
//! }
//! // - barra de resultados como panel inferior persistente:
//! if let Some(s) = model.fif.as_ref().filter(|s| !s.results.is_empty()) {
//! bottom_panels.push(view_results_bar(
//! s, &model.all_files, &model.root, &palette, AppMsg::Fif,
//! ));
//! }
//! ```
//!
//! ## Por qué Action en lugar de un trait `FifHost`
//!
//! El módulo no toma `&mut Host` porque acoplar el módulo a un trait
//! arrastra problemas de ownership/lifetimes en el loop tipo Elm que usa
//! Llimphi (Model se mueve por value en update). Devolver una [`FifAction`]
//! deja al host libre de aplicar el efecto donde y como quiera, y mantiene
//! al módulo libre de cualquier conocimiento sobre el host.
#![forbid(unsafe_code)]
use std::path::{Path, PathBuf};
use std::time::Duration;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
/// Capabilities que este módulo aporta al host. Convención del protocolo
/// Brahman Card aplicada a módulos compile-time: el host (cuando construye
/// su [`card_core::Card`]) puede agregar esto a `provides` para anunciar
/// — vía broker — que su instancia ofrece find-in-files al ecosistema.
pub const CAPABILITIES: &[&str] = &["editor.find-in-files"];
/// Caps razonables para que un workspace grande no funda el UI.
pub const MAX_RESULTS: usize = 1000;
pub const MAX_FILE_SIZE: u64 = 2_000_000;
pub const SNIPPET_MAX_CHARS: usize = 160;
pub const MIN_QUERY_LEN: usize = 2;
const DIALOG_W: f32 = 560.0;
const DIALOG_H: f32 = 116.0;
const BAR_H: f32 = 220.0;
const ROW_H: f32 = 20.0;
const MAX_VISIBLE: usize = 9;
/// Qué input tiene el foco dentro del dialog. `Tab` alterna.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FifFocus {
Search,
Replace,
}
/// Un match individual.
#[derive(Debug, Clone)]
pub struct FifMatch {
/// Índice dentro del slice de paths que el host pasa a [`apply`] y
/// las vistas. Convención: el host no debe reordenar/mutar el slice
/// entre frames mientras el módulo esté abierto.
pub file_idx: usize,
/// 0-based.
pub line: usize,
/// 0-based, en chars (no bytes).
pub col: usize,
/// Línea matcheada trimmed-left y truncada a [`SNIPPET_MAX_CHARS`].
pub snippet: String,
}
/// Estado interno del módulo.
pub struct FifState {
pub input: TextInputState,
/// Texto de reemplazo. Si vacío, `ReplaceAll` borra los matches.
pub replace: TextInputState,
pub focus: FifFocus,
pub results: Vec<FifMatch>,
pub selected: usize,
/// Última query realmente ejecutada (puede diferir del input si el
/// user siguió tipeando sin re-Enter).
pub last_query: String,
/// `true` cuando el popup modal está visible. La barra de resultados
/// se pinta independientemente de esto: sobrevive al cierre del popup.
pub dialog_open: bool,
}
impl Default for FifState {
fn default() -> Self {
Self::new()
}
}
impl FifState {
pub fn new() -> Self {
Self {
input: TextInputState::new(),
replace: TextInputState::new(),
focus: FifFocus::Search,
results: Vec::new(),
selected: 0,
last_query: String::new(),
dialog_open: true,
}
}
}
/// Vocabulario interno. El host lo wrapea en su propio Msg.
#[derive(Clone)]
pub enum FifMsg {
/// El host detectó el atajo de apertura (o un comando). Lazy-init del
/// state lo hace el host; `apply` sólo marca `dialog_open = true`.
Open,
/// El user pidió cerrar el popup (Esc). Los resultados quedan en la
/// barra inferior.
CloseDialog,
/// Cerrar todo: el host debería tirar el `FifState` completo.
CloseAll,
/// Tecla rumbo al input.
KeyInput(KeyEvent),
/// Navegación dentro de la lista de resultados.
Nav(i32),
/// Enter: la primera vez ejecuta search; subsiguientes abren el
/// match seleccionado.
Submit,
/// Click en una fila de la barra inferior: selecciona y abre.
ActivateAt(usize),
/// Alterna el foco entre los inputs search ↔ replace (Tab).
ToggleFocus,
/// Reemplaza el texto matcheado por `replace.text()` en todos los
/// matches actuales. Idempotente: re-leer el archivo, sustituir
/// case-insensitive por la query, escribir. Vacía `results` para
/// forzar nueva búsqueda si el user quiere ver el estado posterior.
ReplaceAll,
}
/// Efecto solicitado al host. El módulo nunca toca el FS ni el resto del
/// modelo de la app — devuelve el deseo, el host elige cómo lo aplica.
#[derive(Debug, Clone)]
pub enum FifAction {
None,
/// El host debería marcar `state.dialog_open = false` y dejar el
/// resto del state intacto (resultados visibles en la barra).
CloseDialog,
/// El host debería remover el state del modelo entero.
CloseAll,
/// Tras un Submit que ejecutó search.
Searched { matches: usize, elapsed: Duration, query: String },
/// El host debería abrir `path` y posicionar el caret en `(line, col)`.
/// El módulo NO se cierra automáticamente: el host decide si ocultar
/// el dialog tras abrir el match.
OpenAt { path: PathBuf, line: usize, col: usize },
/// Tras `ReplaceAll`: cuántos archivos tocados, cuántos matches
/// sustituidos, cuántos fallaron. El host debería refrescar buffers
/// abiertos (recargar de disco si no-dirty) y mostrar status.
Replaced {
files_changed: usize,
replacements: usize,
failures: usize,
query: String,
replacement: String,
},
}
/// Aplica un mensaje al estado y retorna el efecto que el host debe ejecutar.
///
/// `paths` es la lista canónica de archivos sobre la que buscar. El host
/// la pasa por referencia; cuando Submit dispara una búsqueda, este
/// vector se itera y se leen los archivos (skip binarios y >MAX_FILE_SIZE).
pub fn apply(state: &mut FifState, msg: FifMsg, paths: &[PathBuf]) -> FifAction {
match msg {
FifMsg::Open => {
state.dialog_open = true;
FifAction::None
}
FifMsg::CloseDialog => FifAction::CloseDialog,
FifMsg::CloseAll => FifAction::CloseAll,
FifMsg::KeyInput(ev) => {
let _ = match state.focus {
FifFocus::Search => state.input.apply_key(&ev),
FifFocus::Replace => state.replace.apply_key(&ev),
};
FifAction::None
}
FifMsg::ToggleFocus => {
state.focus = match state.focus {
FifFocus::Search => FifFocus::Replace,
FifFocus::Replace => FifFocus::Search,
};
FifAction::None
}
FifMsg::ReplaceAll => {
let query = state.last_query.clone();
if query.is_empty() || state.results.is_empty() {
return FifAction::None;
}
let replacement = state.replace.text();
let (files_changed, replacements, failures) =
replace_all(paths, &state.results, &query, &replacement);
// Invalidamos resultados: las posiciones (line, col) ya no
// necesariamente apuntan al mismo texto. El user puede re-Enter.
state.results.clear();
state.selected = 0;
FifAction::Replaced {
files_changed,
replacements,
failures,
query,
replacement,
}
}
FifMsg::Nav(d) => {
let n = state.results.len() as i32;
if n > 0 {
state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
}
FifAction::None
}
FifMsg::Submit => {
let query = state.input.text();
let needs_search = query != state.last_query || state.results.is_empty();
if needs_search {
if query.len() < MIN_QUERY_LEN {
return FifAction::None;
}
let started = std::time::Instant::now();
let results = search(paths, &query);
let elapsed = started.elapsed();
let n = results.len();
state.results = results;
state.selected = 0;
state.last_query = query.clone();
FifAction::Searched { matches: n, elapsed, query }
} else {
let Some(fm) = state.results.get(state.selected).cloned() else {
return FifAction::None;
};
let Some(path) = paths.get(fm.file_idx).cloned() else {
return FifAction::None;
};
FifAction::OpenAt { path, line: fm.line, col: fm.col }
}
}
FifMsg::ActivateAt(idx) => {
if idx >= state.results.len() {
return FifAction::None;
}
state.selected = idx;
let fm = state.results[idx].clone();
let Some(path) = paths.get(fm.file_idx).cloned() else {
return FifAction::None;
};
FifAction::OpenAt { path, line: fm.line, col: fm.col }
}
}
}
/// Routing de teclas cuando el dialog está abierto. Si el popup está
/// cerrado, devuelve `None` y el host puede seguir routeando al editor.
pub fn on_key(state: &FifState, event: &KeyEvent) -> Option<FifMsg> {
if !state.dialog_open {
return None;
}
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => FifMsg::CloseDialog,
Key::Named(NamedKey::Enter) => FifMsg::Submit,
Key::Named(NamedKey::Tab) => FifMsg::ToggleFocus,
Key::Named(NamedKey::ArrowDown) => FifMsg::Nav(1),
Key::Named(NamedKey::ArrowUp) => FifMsg::Nav(-1),
_ => FifMsg::KeyInput(event.clone()),
})
}
/// Chequea si el evento es el atajo recomendado: **Ctrl+Shift+F**. El
/// host puede ignorar esto y definir su propio binding.
pub fn open_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("f"))
}
/// Paleta visual. Construible desde un [`llimphi_theme::Theme`].
#[derive(Debug, Clone)]
pub struct FifPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub border: Color,
/// Theme cacheado para reusar en `TextInputPalette::from_theme`.
theme: llimphi_theme::Theme,
}
impl FifPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel,
bg_header: t.bg_panel_alt,
bg_selected: t.bg_selected,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
border: t.border,
theme: t.clone(),
}
}
}
/// Popup modal compacto: header + input. Sin lista de resultados — esa
/// vive en [`view_results_bar`]. El host lo pinta como overlay centrado.
///
/// El `View` devuelto tiene tamaño fijo ([`DIALOG_W`] × [`DIALOG_H`]). Si
/// el host quiere centrarlo, debe envolverlo en un container con
/// `JustifyContent::Center`/`AlignItems::Center` o usar el slot de overlay.
pub fn view_dialog<HostMsg, F>(
state: &FifState,
palette: &FifPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(FifMsg) -> HostMsg + Copy + 'static,
{
let dirty_query = state.input.text() != state.last_query;
let header = if state.last_query.is_empty() {
"find in files · Enter busca · Esc cierra".to_string()
} else if state.results.is_empty() {
format!("«{}» · sin matches · Esc cierra", state.last_query)
} else {
let staleness = if dirty_query { " · Enter re-busca" } else { "" };
format!(
"«{}» · {} matches · ↓↑ navega · Enter abre{staleness} · Esc cierra",
state.last_query,
state.results.len(),
)
};
let header_view = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
let tp = TextInputPalette::from_theme(&palette.theme);
let search_focus = state.focus == FifFocus::Search;
let search_view = labelled_input(
"buscar",
&state.input,
"buscar en archivos…",
search_focus,
palette,
&tp,
to_host(FifMsg::Open),
);
let replace_view = labelled_input(
"reemplazar",
&state.replace,
"(vacío para borrar)",
!search_focus,
palette,
&tp,
to_host(FifMsg::Open),
);
let replace_btn = View::new(Style {
size: Size { width: length(118.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.radius(3.0)
.text_aligned(
"reemplazar todo".to_string(),
10.0,
palette.fg_muted,
Alignment::Center,
)
.on_click(to_host(FifMsg::ReplaceAll));
let hint = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned("Tab alterna campos".to_string(), 9.0, palette.fg_muted, Alignment::Start);
let actions = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(vec![hint, replace_btn]);
// Wrapper exterior: tamaño fijo del dialog + borde sutil.
let dialog = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: length(DIALOG_W), height: length(DIALOG_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.radius(6.0)
.children(vec![header_view, search_view, replace_view, actions]);
// Container que centra el dialog horizontalmente — el host pone esto
// como overlay arriba del editor; un click en zona vacía no hace nada
// (no cerramos por click-outside, sería sorpresivo si el user está
// ojeando resultados en la barra).
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(DIALOG_H + 16.0) },
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(12.0_f32),
bottom: length(4.0_f32),
},
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::Start),
flex_shrink: 0.0,
..Default::default()
})
.children(vec![dialog])
}
/// Barra inferior persistente con los matches. Filas clickeables (click
/// → [`FifMsg::ActivateAt`]). El host la pinta como tool window al pie
/// del editor, hermana del terminal/output (estilo JetBrains).
///
/// Si no hay resultados, devuelve una barra mínima con un mensaje — el
/// host puede usar `state.results.is_empty()` para no renderizarla.
pub fn view_results_bar<HostMsg, F>(
state: &FifState,
paths: &[PathBuf],
root: &Path,
palette: &FifPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(FifMsg) -> HostMsg + Copy + 'static,
{
let header_text = if state.results.is_empty() {
format!("find · «{}» · sin matches", state.last_query)
} else {
format!(
"find · «{}» · {} / {} matches · click abre · Ctrl+Shift+F reabre",
state.last_query,
state.selected + 1,
state.results.len(),
)
};
let close_btn = View::new(Style {
size: Size { width: length(54.0_f32), height: length(18.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned("cerrar ✕".to_string(), 10.0, palette.fg_muted, Alignment::Center)
.on_click(to_host(FifMsg::CloseAll));
let header_label = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let header_bar = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.children(vec![header_label, close_btn]);
let visible_start = state
.selected
.saturating_sub(MAX_VISIBLE.saturating_sub(1));
let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
for i in visible_start..visible_end {
let Some(fm) = state.results.get(i) else { continue };
let Some(path) = paths.get(fm.file_idx) else { continue };
let rel = relative_to(root, path);
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
let dir = rel.strip_suffix(name).unwrap_or("").trim_end_matches('/');
let dir_label = if dir.is_empty() { String::new() } else { format!(" {dir}") };
let label = format!("{name}:{}{dir_label} {}", fm.line + 1, fm.snippet);
let selected = i == state.selected;
let bg = if selected { palette.bg_selected } else { palette.bg_panel };
let fg = if selected { palette.fg_text } else { palette.fg_muted };
rows.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
padding: Rect {
left: length(12.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.text_aligned(label, 11.0, fg, Alignment::Start)
.on_click(to_host(FifMsg::ActivateAt(i))),
);
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
children.push(header_bar);
children.extend(rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(BAR_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
/// Búsqueda substring case-insensitive. Pública para tests / hosts que
/// quieran disparar una búsqueda sin pasar por el state machine.
pub fn search(paths: &[PathBuf], query: &str) -> Vec<FifMatch> {
let mut out: Vec<FifMatch> = Vec::new();
let q_lc = query.to_lowercase();
for (file_idx, path) in paths.iter().enumerate() {
if out.len() >= MAX_RESULTS {
break;
}
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > MAX_FILE_SIZE {
continue;
}
}
let Ok(content) = std::fs::read_to_string(path) else { continue };
for (line_idx, line) in content.lines().enumerate() {
if out.len() >= MAX_RESULTS {
break;
}
let line_lc = line.to_ascii_lowercase();
let Some(byte_off) = line_lc.find(&q_lc) else { continue };
let col = line[..byte_off.min(line.len())].chars().count();
let trimmed = line.trim_start();
let snippet = if trimmed.chars().count() <= SNIPPET_MAX_CHARS {
trimmed.to_string()
} else {
let cut: String = trimmed.chars().take(SNIPPET_MAX_CHARS - 1).collect();
format!("{cut}")
};
out.push(FifMatch { file_idx, line: line_idx, col, snippet });
}
}
out
}
/// Reemplazo case-insensitive sobre los archivos involucrados en
/// `results`. Devuelve `(files_changed, replacements, failures)`.
/// Lee cada archivo una sola vez, sustituye todas las apariciones de
/// `query` por `replacement` (case-insensitive, preservando el resto), y
/// escribe sólo si hubo cambios. No toca buffers en memoria del host —
/// el host es responsable de recargar tabs si quiere ver los cambios.
pub fn replace_all(
paths: &[PathBuf],
results: &[FifMatch],
query: &str,
replacement: &str,
) -> (usize, usize, usize) {
if query.is_empty() {
return (0, 0, 0);
}
let mut touched: std::collections::BTreeSet<usize> =
std::collections::BTreeSet::new();
for fm in results {
touched.insert(fm.file_idx);
}
let mut files_changed = 0usize;
let mut total_replacements = 0usize;
let mut failures = 0usize;
let q_lc = query.to_lowercase();
for idx in touched {
let Some(path) = paths.get(idx) else { continue };
let Ok(content) = std::fs::read_to_string(path) else {
failures += 1;
continue;
};
let (new_content, n) = ci_replace_all(&content, query, &q_lc, replacement);
if n == 0 {
continue;
}
if std::fs::write(path, new_content).is_err() {
failures += 1;
continue;
}
files_changed += 1;
total_replacements += n;
}
(files_changed, total_replacements, failures)
}
/// Reemplazo case-insensitive preservando los bytes no-matchados.
fn ci_replace_all(haystack: &str, _needle: &str, needle_lc: &str, repl: &str) -> (String, usize) {
let hay_lc = haystack.to_lowercase();
let mut out = String::with_capacity(haystack.len());
let mut count = 0usize;
let mut i = 0usize;
while i <= hay_lc.len() {
if let Some(pos) = hay_lc[i..].find(needle_lc) {
let abs = i + pos;
out.push_str(&haystack[i..abs]);
out.push_str(repl);
i = abs + needle_lc.len();
count += 1;
} else {
out.push_str(&haystack[i..]);
break;
}
}
(out, count)
}
// ---------------------------------------------------------------------
// Helpers internos
// ---------------------------------------------------------------------
/// Pinta un input con etiqueta a la izquierda; cuando `focus` es true,
/// el fondo se realza para que el user vea dónde está tipeando.
fn labelled_input<HostMsg>(
label: &str,
state: &TextInputState,
placeholder: &str,
focus: bool,
palette: &FifPalette,
tp: &TextInputPalette,
fallback_msg: HostMsg,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
{
let bg = if focus { palette.bg_selected } else { palette.bg_panel };
let label_view = View::new(Style {
size: Size { width: length(82.0_f32), height: length(28.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(4.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(label.to_string(), 10.0, palette.fg_muted, Alignment::Start);
let input_view = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(28.0_f32) },
padding: Rect {
left: length(4.0_f32),
right: length(10.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.children(vec![text_input_view(
state,
placeholder,
focus,
tp,
fallback_msg,
)]);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.children(vec![label_view, input_view])
}
fn relative_to(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string())
}

Some files were not shown because too many files have changed in this diff Show More