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