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:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
@@ -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, 10–50× 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
File diff suppressed because it is too large
Load Diff
+441
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 1–10 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 200–300 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 (5–20×) confirma que el patrón correcto es
|
||||
"datos cambian → vello, datos estáticos → GPU directo persistente".
|
||||
|
||||
**Fase 1 — Hook en `llimphi-ui` (1–2 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` (3–5 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 (2–3 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, 2–3 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: 10–15 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.
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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");
|
||||
}
|
||||
Executable
+89
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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})"
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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`
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
"#;
|
||||
@@ -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;
|
||||
}
|
||||
"#;
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
"#;
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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.
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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 (0–255) 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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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, ¤t_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, ¤t_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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user