commit e65e9cc623a539e158050a10fdfafc4692efe928 Author: Sergio Date: Thu Jun 4 04:23:42 2026 +0000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7141ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +*.pdb diff --git a/COMPUTO-FUERA-DEL-HILO-UI.md b/COMPUTO-FUERA-DEL-HILO-UI.md new file mode 100644 index 0000000..6ad2f47 --- /dev/null +++ b/COMPUTO-FUERA-DEL-HILO-UI.md @@ -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 porque Msg: Clone. +enum Msg { /* … */ XComputed(u64, std::sync::Arc) } + +// 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, 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` 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.) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f58b6a5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3848 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.12.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.12.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "font-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-cache-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18" +dependencies = [ + "bytemuck", + "thiserror 1.0.69", +] + +[[package]] +name = "fontique" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7" +dependencies = [ + "bytemuck", + "fontconfig-cache-parser", + "hashbrown 0.15.5", + "icu_locid", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "peniko", + "read-fonts 0.29.3", + "roxmltree", + "smallvec", + "windows", + "windows-core", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.12.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.12.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.12.1", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "llimphi-clipboard" +version = "0.1.0" +dependencies = [ + "arboard", + "llimphi-widget-text-editor", +] + +[[package]] +name = "llimphi-compositor" +version = "0.1.0" +dependencies = [ + "llimphi-layout", + "llimphi-text", + "vello", + "wgpu", +] + +[[package]] +name = "llimphi-hal" +version = "0.1.0" +dependencies = [ + "pollster", + "raw-window-handle", + "wgpu", + "winit", +] + +[[package]] +name = "llimphi-icons" +version = "0.1.0" +dependencies = [ + "llimphi-ui", +] + +[[package]] +name = "llimphi-layout" +version = "0.1.0" +dependencies = [ + "llimphi-hal", + "llimphi-raster", + "pollster", + "taffy", +] + +[[package]] +name = "llimphi-module-bookmarks" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", + "nucleo-matcher", +] + +[[package]] +name = "llimphi-module-command-palette" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", + "nucleo-matcher", +] + +[[package]] +name = "llimphi-module-diff-viewer" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "similar", +] + +[[package]] +name = "llimphi-module-fif" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", +] + +[[package]] +name = "llimphi-module-file-picker" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", +] + +[[package]] +name = "llimphi-module-mini-map" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-module-selector" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-module-symbol-outline" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", + "nucleo-matcher", +] + +[[package]] +name = "llimphi-motion" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-raster" +version = "0.1.0" +dependencies = [ + "llimphi-hal", + "pollster", + "vello", +] + +[[package]] +name = "llimphi-surface" +version = "0.1.0" +dependencies = [ + "llimphi-hal", + "llimphi-ui", + "parking_lot", +] + +[[package]] +name = "llimphi-text" +version = "0.1.0" +dependencies = [ + "llimphi-hal", + "llimphi-raster", + "parley", + "pollster", + "vello", +] + +[[package]] +name = "llimphi-theme" +version = "0.1.0" +dependencies = [ + "llimphi-raster", +] + +[[package]] +name = "llimphi-ui" +version = "0.1.0" +dependencies = [ + "llimphi-compositor", + "llimphi-hal", + "llimphi-layout", + "llimphi-raster", + "llimphi-text", + "pollster", +] + +[[package]] +name = "llimphi-widget-app-header" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-avatar" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-badge" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-banner" +version = "0.1.0" +dependencies = [ + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-breadcrumb" +version = "0.1.0" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-button" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-card" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-context-menu" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-dock-rail" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-edit-menu" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-text-editor", +] + +[[package]] +name = "llimphi-widget-empty" +version = "0.1.0" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-field" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-grid" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-list" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-modal" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-navigator" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-nodegraph", + "llimphi-widget-segmented", + "llimphi-widget-tree", +] + +[[package]] +name = "llimphi-widget-nodegraph" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-panel" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-panes" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-progress" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-scroll" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-segmented" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-shortcuts-help" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-skeleton" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-slider" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-spinner" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-splash" +version = "0.1.0" +dependencies = [ + "llimphi-motion", + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-splitter" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-stat-card" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-card", +] + +[[package]] +name = "llimphi-widget-status-bar" +version = "0.1.0" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-switch" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-tabs" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-text-area" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-text-editor" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-editor-core", + "tree-sitter", +] + +[[package]] +name = "llimphi-widget-text-editor-core" +version = "0.1.0" +dependencies = [ + "peniko", + "ropey", + "tree-sitter", + "tree-sitter-python", + "tree-sitter-rust", +] + +[[package]] +name = "llimphi-widget-text-editor-lsp" +version = "0.1.0" +dependencies = [ + "llimphi-widget-text-editor", + "lsp-types", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "llimphi-widget-text-input" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-editor", +] + +[[package]] +name = "llimphi-widget-theme-switcher" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-tiled" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-timeline" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-toast" +version = "0.1.0" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-tooltip" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-tree" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-wawa-mark" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-workspace" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panes", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.12.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.12.1", + "cfg_aliases", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash", + "spirv", + "strum", + "termcolor", + "thiserror 2.0.18", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.12.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.12.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.12.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.12.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.12.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.12.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parley" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28dadbe655332fd7d996794ec8d0c376695f6ca47bc75aa01e0967c7f28e42a" +dependencies = [ + "fontique", + "hashbrown 0.15.5", + "peniko", + "skrifa 0.31.3", + "swash", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "peniko" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288" +dependencies = [ + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.12.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ea612a55c08586a1d15134be8a776186c440c312ebda3b9e8efbfe4255b7f4" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "skrifa" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607" +dependencies = [ + "bytemuck", + "read-fonts 0.29.3", +] + +[[package]] +name = "skrifa" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576e60c7de4bb6a803a0312f9bef17e78cf1e8d25a80e1ade76770d7a0237955" +dependencies = [ + "bytemuck", + "read-fonts 0.33.1", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.12.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +dependencies = [ + "skrifa 0.40.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "vello" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa3f8a53870a2ee699ce05b738a3f9974c92c35ed4874de86052ac68d214811c" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png 0.17.16", + "skrifa 0.35.0", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c69b0fe94b0ac7e47619c504ee2c377355174f5c46353c46d03fa5f7e435922b" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa 0.35.0", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ebea426bb2f95b7610bca09178b03d809ede1d3c500a9acf6eca43e8f200be" +dependencies = [ + "bytemuck", + "naga", + "thiserror 2.0.18", + "vello_encoding", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.12.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.12.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +dependencies = [ + "arrayvec", + "bitflags 2.12.1", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.12.1", + "cfg_aliases", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror 2.0.18", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "24.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.12.1", + "block", + "bytemuck", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags 2.12.1", + "js-sys", + "log", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.12.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.12.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a40cbb4 --- /dev/null +++ b/Cargo.toml @@ -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 "] +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" diff --git a/LEEME.md b/LEEME.md new file mode 100644 index 0000000..9949174 --- /dev/null +++ b/LEEME.md @@ -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`, 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` 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` 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ede9631 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/MANUAL.md b/MANUAL.md new file mode 100644 index 0000000..8f1e19a --- /dev/null +++ b/MANUAL.md @@ -0,0 +1,1041 @@ +# Manual de Llimphi + +> Motor gráfico soberano de gioser. `wgpu` + `vello` + `taffy` + `parley`, +> bucle Elm `input → update → view → layout → raster → present`. +> Reemplazo total de GPUI (extinto 2026-05-26): toda app gráfica de la suite +> corre sobre Llimphi. + +Este documento es la **referencia de uso** orientada a humanos y a IA. +Está organizado para salto directo: cada capa, widget y módulo trae su API +real (firmas copiadas del código). Para el **porqué** arquitectónico ver +[`SDD.md`](SDD.md); para la regla de concurrencia ver +[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md). + +--- + +## Índice + +1. [Modelo mental en 60 segundos](#1-modelo-mental-en-60-segundos) +2. [Arquitectura — las capas](#2-arquitectura--las-capas) +3. [Quickstart — la app mínima](#3-quickstart--la-app-mínima) +4. [El trait `App` (bucle Elm)](#4-el-trait-app-bucle-elm) +5. [`Handle` — efectos y concurrencia](#5-handle--efectos-y-concurrencia) +6. [`View` — el DSL declarativo](#6-viewmsg--el-dsl-declarativo) +7. [Layout (`taffy` / `Style`)](#7-layout-taffy--style) +8. [Eventos e interacción](#8-eventos-e-interacción) +9. [Texto](#9-texto) +10. [Canvas custom y GPU directo](#10-canvas-custom-y-gpu-directo) +11. [Theme y paletas](#11-theme-y-paletas) +12. [Capas base (hal · raster · text · motion · icons · surface)](#12-capas-base) +13. [Catálogo de widgets](#13-catálogo-de-widgets) +14. [Catálogo de módulos](#14-catálogo-de-módulos) +15. [`llimphi-workspace` — chasis tipo tmux](#15-llimphi-workspace--chasis-tipo-tmux) +16. [Reglas duras y gotchas](#16-reglas-duras-y-gotchas) +17. [Comandos y demos](#17-comandos-y-demos) +18. [Cheat-sheet](#18-cheat-sheet) +19. [Índice de crates](#19-índice-de-crates) + +--- + +## 1. Modelo mental en 60 segundos + +Llimphi es **Elm sobre la GPU**. Una app es un tipo que implementa el trait +`App` con cuatro piezas: + +- `Model` — estado **inmutable** de la app. +- `Msg` — todo lo que puede pasar (`Clone + Send`). +- `update(model, msg, handle) -> model` — transición **pura** que devuelve un + modelo nuevo. +- `view(&model) -> View` — función **pura** que describe la pantalla como + un árbol de `View`. + +El runtime hace el bucle: un evento (click/tecla/rueda) produce un `Msg`, +`update` deriva el nuevo `Model`, `view` reconstruye el árbol, `taffy` calcula +las cajas, `vello` rasteriza, y se hace swap del frame. **No hay mutabilidad +compartida, no hay vDOM ajeno, no hay callbacks imperativos**: declarás qué se +ve y qué `Msg` emite cada nodo. + +``` + evento ─▶ Msg ─▶ update(model,msg) ─▶ model' ─▶ view(model') ─▶ View + │ + present ◀─ raster(vello) ◀─ layout(taffy) ◀──────────────────────┘ +``` + +Tres reglas de oro: +1. **`view` es pura** — no muta nada, sólo lee el modelo y arma el árbol. +2. **Cómputo pesado va a un worker** vía `Handle::spawn`, nunca síncrono en + `update`/`init`/handlers (congela la ventana → "Not Responding"). +3. **Widgets son visuales y stateless**; el estado vive en tu `Model`. + **Módulos** sí encapsulan estado + comportamiento. + +--- + +## 2. Arquitectura — las capas + +``` +4. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent) + └ llimphi-compositor . árbol View, mount sobre taffy, paint, hit-test (winit-free) +3. llimphi-layout ....... motor de layout (taffy: flexbox + grid) +2. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo +1. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji +0. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer) +``` + +El **split compositor/runtime** (2026-05-31) es importante: `llimphi-compositor` +es *winit-free* (sólo `View`, `mount`, `paint`, hit-test). `llimphi-ui` lo corre +sobre winit y **re-exporta todo el compositor**, así escribís `llimphi_ui::View` +sin enterarte del split. Esto habilita un futuro runtime sobre el framebuffer +del kernel `wawa` reusando el mismo compositor. + +Auxiliares: `llimphi-theme` (paletas), `llimphi-motion` (tweens), +`llimphi-icons` (iconos vectoriales), `llimphi-surface` (texturas externas), +`llimphi-workspace` (chasis tmux), `llimphi-gallery` (showcase). + +Catálogo: **~45 widgets** (visuales) + **10 módulos** (features con estado). + +--- + +## 3. Quickstart — la app mínima + +```rust +use llimphi_ui::llimphi_layout::taffy::prelude::*; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{App, Handle, View}; + +#[derive(Clone)] +enum Msg { Increment, Reset } + +struct Counter; + +impl App for Counter { + type Model = u32; + type Msg = Msg; + + fn title() -> &'static str { "llimphi · counter" } + + fn init(_: &Handle) -> Self::Model { 0 } + + fn update(model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Increment => model.saturating_add(1), + Msg::Reset => 0, + } + } + + fn view(model: &Self::Model) -> View { + let boton = View::new(Style { + size: Size { width: length(160.0), height: length(56.0) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(Color::from_rgba8(60, 200, 130, 255)) + .radius(12.0) + .text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255)) + .on_click(Msg::Increment); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0), height: percent(1.0) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Size { width: length(0.0), height: length(24.0) }, + ..Default::default() + }) + .fill(Color::from_rgba8(20, 24, 32, 255)) + .children(vec![ + View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE), + boton, + ]) + } +} + +fn main() { llimphi_ui::run::(); } +``` + +`Cargo.toml`: +```toml +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +# + los widgets/modules que uses: +# llimphi-widget-button = { workspace = true } +``` + +Corre con `cargo run -p --release`. El ejemplo vivo está en +`llimphi-ui/examples/counter.rs`. + +--- + +## 4. El trait `App` (bucle Elm) + +Definido en `llimphi-ui/src/lib.rs`. El estado es inmutable; cada evento +produce un `Model` nuevo. + +```rust +pub trait App: 'static { + type Model: 'static; + type Msg: Clone + Send + 'static; + + fn init(handle: &Handle) -> Self::Model; + fn update(model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model; + fn view(model: &Self::Model) -> View; + + // --- Todo lo de abajo tiene default; sobreescribí lo que necesites --- + + fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option { None } + + fn on_wheel(_model: &Self::Model, _delta: WheelDelta, + _cursor: (f32, f32), _modifiers: Modifiers) -> Option { None } + + /// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta + /// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo + /// vidrio"). La transición la maneja tu Model. + fn view_overlay(_model: &Self::Model) -> Option> { None } + + /// Drag&drop de archivos desde el file manager. Un evento por archivo. + fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option { None } + + /// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El + /// runtime administra el foco; guardás `id` en tu Model para pintar el ring + /// y rutear el teclado. Ver §8 (Foco y teclado). + fn on_focus(_model: &Self::Model, _id: Option) -> Option { None } + + /// IME (composición de texto: CJK, acentos muertos, emoji). Opt-in vía + /// `ime_allowed()` para no robarle el texto a las apps que sólo leen + /// `on_key`. Flujo: Enabled → Preedit* → Commit/Disabled. Ver §8 (IME). + fn ime_allowed() -> bool { false } + fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option { None } + /// Área del caret en px físicos para ubicar la ventana de candidatos. + fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { None } + + fn title() -> &'static str { "llimphi" } + fn app_id() -> Option<&'static str> { None } // app_id del xdg-toplevel en Wayland + fn initial_size() -> (u32, u32) { (960, 540) } +} +``` + +Punto de entrada: `pub fn run()` — corre hasta que el usuario cierre la +ventana o la app llame `Handle::quit`. + +**Eventos de teclado** (`KeyEvent`): +```rust +pub struct KeyEvent { + pub key: Key, // re-export de winit; usar NamedKey para teclas especiales + pub state: KeyState, // Pressed | Released + pub text: Option, // texto resultante con IME/modifiers; None para flechas etc. + pub modifiers: Modifiers, // { shift, ctrl, alt, meta } + pub repeat: bool, +} +``` +`Key` y `NamedKey` se re-exportan desde `llimphi_ui`. + +**Rueda** (`WheelDelta { x, y }`): normalizado a "líneas". Convención CSS: +`y` positivo = scroll hacia abajo. + +--- + +## 5. `Handle` — efectos y concurrencia + +`Handle` es `Send + Clone`. Llega a `init` y `update`. Es el único modo +legítimo de producir efectos sin romper la pureza de la transición. + +```rust +impl Handle { + pub fn quit(&self); // cierra la ventana / termina el bucle + pub fn dispatch(&self, msg: Msg); // encola un Msg para el próximo turno + pub fn spawn Msg + Send + 'static>(&self, f: F); // worker; su Msg reentra al update + pub fn spawn_periodic Msg + Send + 'static>(&self, period: Duration, f: F); // tick periódico + pub fn for_test() -> Self; // handle "muerto" para tests sin event loop +} +``` + +- **`spawn`** — trabajo bloqueante (IO, PAM, parse, efemérides). El `Msg` que + devuelve la closure se entrega al `update` en el hilo de UI. **Este es el + patrón obligatorio para todo cómputo pesado** (§16). +- **`spawn_periodic`** — feeds a intervalos: ticks de simulación (~11 Hz en + dominium), polling, animaciones por reloj. El thread muere cuando se cierra + el event loop. + +--- + +## 6. `View` — el DSL declarativo + +Un `View` = `Style` de taffy + relleno + texto/imagen/painter + handlers + +hijos. Todo se arma con builders encadenables (`self -> Self`). Definido en +`llimphi-compositor/src/view.rs`. + +```rust +View::new(style: Style) -> View +``` + +### Apariencia +| Método | Efecto | +|---|---| +| `.fill(Color)` | color de fondo | +| `.hover_fill(Color)` | color al pasar el cursor (habilita hit-test de hover) | +| `.radius(f64)` | esquinas redondeadas | +| `.alpha(f32)` | opacidad de todo el subtree `[0,1]` (capa intermedia — no gratis) | +| `.transform(Affine)` | afín 2D alrededor del centro del rect (estilo CSS `transform-origin:50% 50%`) | +| `.clip(bool)` | recorta hijos al rect (paint + hit-test) | +| `.image(Image)` | pinta `peniko::Image` centrada, preservando aspect ratio | +| `.children(Vec>)` | hijos | + +### Texto (ver §9) +```rust +.text(content, size_px, color) // centrado +.text_aligned(content, size_px, color, Alignment) +.text_aligned_italic(content, size_px, color, Alignment, italic) +.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option) +.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada +.line_height(mult) // override interlínea (default 1.2) +``` + +### Interacción (ver §8) +```rust +.on_click(Msg) +.on_click_at(|lx, ly, w, h| -> Option) // posición local + tamaño del rect +.on_right_click(Msg) / .on_right_click_at(...) +.on_middle_click(Msg) +.on_pointer_enter(Msg) / .on_pointer_leave(Msg) +.draggable(|phase: DragPhase, dx, dy| -> Option) +.draggable_at(|phase, dx, dy, lx0, ly0| -> Option) // + posición inicial del press +.drag_payload(u64) // payload que viaja con el drag +.on_drop(|payload: u64| -> Option) // este nodo es drop target +.drop_hover_fill(Color) // resaltado mientras un drag lo sobrevuela +.on_scroll(|dx, dy| -> Option) // rueda local (antes del on_wheel global) +.focusable(u64) // nodo enfocable por Tab/click (id opaco) +``` + +### Pintura custom (ver §10) +```rust +.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... }) +.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... }) +``` + +Notas clave: +- **Un nodo es draggable *o* clickable**, no ambos: `draggable` sobreescribe + `on_click`. +- Las variantes `*_at` ganan sobre las simples si ambas están. +- `PaintRect { x, y, w, h }` es el rect **absoluto** del nodo en píxeles físicos. +- `DragPhase` = `Move` (un evento por `CursorMoved`, `dx/dy` = delta **desde el + evento anterior**, no acumulado) | `End` (al soltar). + +--- + +## 7. Layout (`taffy` / `Style`) + +`Style` es el `taffy::Style` directo, re-exportado vía +`llimphi_ui::llimphi_layout::taffy::prelude::*`. Es Flexbox + CSS Grid puro. + +Campos más usados: +```rust +Style { + flex_direction: FlexDirection::Row | Column, + size: Size { width, height }, // length(px) | percent(0..1) | Dimension::auto() + min_size, max_size, + flex_grow: f32, flex_shrink: f32, + align_items: Some(AlignItems::{Start,Center,End,Stretch}), + justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}), + gap: Size { width, height }, + padding: Rect { left, right, top, bottom }, // con length(px) + margin: Rect { ... }, + ..Default::default() +} +``` + +Helpers de `prelude`: `length(px)`, `percent(frac)`, `auto()`, `Dimension`, +`Size`, `Rect`, `FlexDirection`, `AlignItems`, `JustifyContent`. + +`llimphi-layout` además expone: +- `LayoutTree::new()` / `.clear()` (reuso entre frames), `.leaf(style)`, + `.node(style, &children)`, `.compute(...)`, `.compute_with_measure(F)`. +- `Rect { x, y, w, h }` y `ComputedLayout { rects: HashMap }`. + +En el 99% de los casos no tocás `LayoutTree` a mano: lo maneja el runtime al +montar tu `View`. Sólo armás `Style`s. + +--- + +## 8. Eventos e interacción + +| Quiero… | Cómo | +|---|---| +| Botón / fila clickable | `.on_click(Msg)` (+ `.hover_fill` para feedback) | +| Saber dónde se clickeó (canvas) | `.on_click_at(\|lx,ly,w,h\| ...)` → convertir a coords de mundo | +| Menú contextual | `.on_right_click(Msg::OpenMenu{..})`, guardar pos en Model, abrir en `view_overlay` | +| Abrir en pestaña nueva | `.on_middle_click(Msg)` | +| Preview al pasar el mouse | `.on_pointer_enter(Msg)` / `.on_pointer_leave(Msg)` | +| Resize de panel | `.draggable(\|phase,dx,dy\| ...)` acumulando delta en el Model | +| Arrastrar entidad de un canvas | `.draggable_at(\|phase,dx,dy,lx0,ly0\| ...)` | +| Drag&drop entre zonas | origen: `.drag_payload(id)`; destino: `.on_drop(\|id\| ...)` + `.drop_hover_fill` | +| Scroll global | `App::on_wheel(model, delta, cursor, mods)` | +| Área de scroll | widget `scroll_y(...)` (autocontenido) o `.on_scroll(\|dx,dy\| ...)` por nodo | +| Teclado | `App::on_key(model, &KeyEvent) -> Option` | +| Foco / Tab | `.focusable(id)` en los nodos + `App::on_focus(model, id)` (ver abajo) | +| IME (CJK, acentos) | `App::ime_allowed() -> true` + `App::on_ime(model, &ImeEvent)` (ver abajo) | +| Drop de archivos del SO | `App::on_file_drop(model, path)` | + +**Patrón overlay** (menús/modales): el modelo guarda "menú abierto sí/no". +Mientras esté abierto, `view_overlay` devuelve `Some(view)`; clicks fuera se +cierran envolviendo los items en un scrim a pantalla completa con +`on_click = DismissOverlay`. Cuando el modelo dice cerrado, `view_overlay` +devuelve `None`. + +**Scroll** (widget `llimphi-widget-scroll`). `scroll_y(offset, content_len, +viewport_len, content, on_scroll, &palette)` arma un viewport clipeado + +contenido desplazado `-offset` + barra arrastrable. Es **stateless**: el offset +vive en tu Model. `on_scroll(delta_px)` (rueda y arrastre) emite un delta a +sumar; clampealo con `scroll::clamp_offset` en tu `update`. Helpers: +`ensure_visible(offset, vp, item_top, item_h)` para llevar la selección a la +vista (teclado); `approach(cur, target, factor)` para scroll suave/inercia +(driveado por `Handle::spawn_periodic`). + +**Foco y teclado.** Marcá los nodos navegables con `.focusable(id)` (id `u64` +que vos elegís). El runtime es la **única fuente de verdad** del foco: lo mueve +con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo +enfocable, y te avisa con `App::on_focus(model, Option)`. Guardás el id en +tu Model para (a) pintar el ring (`if model.focus == Some(id) { .fill(accent) }` +en `view`) y (b) rutear el teclado al campo activo desde `on_key`. No setees el +foco por tu cuenta vía Msg: quedaría desincronizado del runtime. + +**IME** (composición de texto). Opt-in: `ime_allowed() -> true`. Con IME activo +el texto compuesto **no** llega por `KeyEvent.text` sino por `on_ime`: +`ImeEvent::Enabled` → uno o más `Preedit{text, cursor}` (texto en composición, a +pintar subrayado en el caret) → `Commit(text)` (insertá como tecleado) o +`Disabled`. Reportá el área del caret con `ime_cursor_area(model)` para ubicar +la ventana de candidatos (CJK) junto al cursor. + +--- + +## 9. Texto + +`TextSpec` (en compositor) describe el texto de un nodo: +```rust +pub struct TextSpec { + pub content: String, + pub size_px: f32, + pub color: Color, + pub alignment: Alignment, // Start | Center | End | Justify + pub italic: bool, + pub font_family: Option, // string CSS con fallbacks + pub line_height: f32, // múltiplo; default 1.2 + pub runs: Option>, // color por rango de BYTES +} +``` + +- `Center` es el default (apto para labels). Para editores/párrafos usar + `.text_aligned(..., Alignment::Start)`. +- **Multicolor en una sola pasada de shaping**: `.text_runs(...)` colorea + rangos de bytes — es la base del syntax highlighting (un nodo por línea, no + por token). Anclado arriba-izquierda; el caller dimensiona el rect. +- El runtime mide el texto con parley durante el layout (`compute_with_measure`) + para que taffy reserve el alto real del texto envuelto a varias líneas + (evita "textos aplastados"). +- Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique. + +--- + +## 10. Canvas custom y GPU directo + +Dos hooks para pintar primitivas no expresables como composición de `View`s. +Conviven en el mismo árbol; el runtime pinta **toda la pasada vello primero**, +luego los `gpu_painter` en orden DFS. + +### `paint_with` — vía vello (el default) +```rust +.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { + // dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`. + // NO dejar push_layer sin pop_layer; NO resetear la scene. +}) +``` +Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal. +Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada). + +### `gpu_paint_with` — sube vertex buffers directo a wgpu, salta vello +```rust +.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { + // abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello. + // (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC. +}) +``` +Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de +nakui, pineal denso. Rango 100 K – 10 M+ primitivos. **No** soporta texto ni AA +fino ni múltiples grosores de stroke por flush. Para texto encima de un render +GPU, usar `view_overlay` (segunda Scene vello). + +### ¿Cuándo cada uno? +| Pregunta | vello (`paint_with`) | GPU directo (`gpu_paint_with`) | +|---|---|---| +| Primitivos/frame | < ~500 K rebuild / < ~2 M Scene reusada | 100 K – 10 M+ | +| ¿Cambian cada frame? | sí, rebuild barato | mejor estático (buffer persistente) | +| Curvas Bezier | nativas | hay que teselar | +| Texto | sí | no | +| AA fino | sí (analítico) | no (sin MSAA) | + +**Default: `paint_with`** salvo que ya midas que el volumen lo justifica +(factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU +expone `GpuPipelines`/`GpuBatch` en `llimphi-raster` (§12). + +--- + +## 11. Theme y paletas + +`llimphi-theme::Theme` es un struct de slots semánticos de color. Cuatro presets +`const`: `Theme::dark()` (default), `light()`, `aurora()`, `sunset()`. + +```rust +pub struct Theme { + pub name: &'static str, + // fondos + pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus, + pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color, + // texto + pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color, + // bordes y acento + pub border, border_focus, accent: Color, +} + +Theme::all() -> Vec // orden de rotación canónico +Theme::by_name(name) -> Option +Theme::next_after(current_name) -> Theme // para el theme-switcher +``` + +Tokens auxiliares en el mismo crate: +- `motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms}` + `ease_out_cubic`, + `ease_in_out_cubic`, `linear`. +- `alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT}` (constantes `u8`). +- `radius::{XS=2, SM=4, MD=8, LG=12, XL=20}` (`f64`). + +**Patrón de widgets**: cada widget define su `XxxPalette` con +`Palette::from_theme(&theme)`. Tu app guarda un `Theme` en el Model, deriva las +paletas que necesita en `view`, y se las pasa a los widgets. Para cambiar de +tema, el `theme-switcher` emite `Msg(next_theme)` y reconstruís todo. + +--- + +## 12. Capas base + +### `llimphi-hal` — superficie +```rust +Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result // async +trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result; fn present(frame, hal); } +WinitSurface::new(hal, window: Arc) -> Result +Frame::view() -> &wgpu::TextureView; Frame::size() -> (u32,u32) +``` +`Hal::new` pide adapter `Backends::PRIMARY` (Vulkan) y cae a `all()` sólo si no +hay — **no volver a `InstanceDescriptor::default()`**: el backend GL de Mesa +sobre Wayland segfaultea en el teardown. El runtime de `llimphi-ui` ya maneja +todo esto; sólo tocás HAL si escribís un runtime nuevo. + +### `llimphi-raster` — rasterización +```rust +Renderer::new(hal) -> Result +Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color) +// GPU directo: +GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout +GpuBatch::new(&pipelines) + .line_width(w) .add_line(p0,p1,color) .add_polyline(&pts,color) + .add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color) + .primitive_count() -> u32 + .flush(device, queue, encoder, view, viewport, load_op) +``` +Re-exporta `vello` y `peniko` (`Color`, `Image`, `Fill`, etc.). + +### `llimphi-text` — shaping +```rust +Typesetter::new() // una por proceso (FontContext es caro) + .layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()> + .layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout +TextBlock::simple(text, size_px, color, origin) +layout_block(ts, &block) / measure(ts, &block) -> Measurement +draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin) +Alignment::{Start, Center, End, Justify} +``` + +### `llimphi-motion` — tweens +```rust +trait Lerp { fn lerp(self, other, t: f32) -> Self; } // impl para f32,f64,(f32,f32),(f64,f64),Color +Tween::new(from, to, duration, easing: fn(f32)->f32) // o Tween::idle(value) +tween.value() / .progress() / .done() +animate(handle, duration, make_msg) // arranca los ticks del tween +``` +Patrón: guardás `Tween` en el Model, `animate(...)` en el update, la `view` +lee `tween.value()` cada repaint. El tween se auto-termina. + +### `llimphi-icons` — iconos vectoriales (~23, grid 24×24) +```rust +Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right, + Home, Search, Info, Warning, Error, Bell, Settings, More, ...} +icon_view(Icon, color, stroke_width) -> View +paint_icon(scene, rect, icon, color, stroke_width) // dentro de un paint_with +``` +`stroke_width` en unidades del grid 24×24 (1.6 es armónico). + +### `llimphi-surface` — texturas externas +```rust +ExternalSurface::new(device, queue) // barato de clonar (Arc interno) + .upload(&rgba, w, h) // desde otro hilo/decoder/cámara + .view(style) -> View // blittea a su rect en el árbol Elm + .blit(queue, encoder, dst_view, rect, viewport) // o manual desde gpu_paint_with +``` + +--- + +## 13. Catálogo de widgets + +Los widgets son **funciones puras** que devuelven `View` (o specs que se +convierten a `View`). Son **stateless**: el estado vive en tu Model. Convención: +cada uno trae `XxxPalette::from_theme(&Theme)`. Crates en +`widgets//`, dep `llimphi-widget-`. + +### Controles + +**button** — `button_view(label, &ButtonPalette, on_click: Msg) -> View`; +`button_styled(label, style, alignment, &palette, on_click)`. + +**field** — wrapper de formulario (label + helper/error + requerido). +`field_view(FieldSpec { label, control: View, required, helper, error, palette })`. + +**text-input** — input single-line **con estado** `TextInputState` +(`new()`/`masked()`, `text()`, `set_text()`, `apply_key(&KeyEvent) -> bool`, +soporta undo/redo + selección con Shift). Render: +`text_input_view(&state, placeholder, focused, &palette, on_focus: Msg)`. + +**text-area** — multilínea con estado `TextAreaState` (Enter = newline, sin +auto-submit). `text_area_view(&state, placeholder, focused, body_height, &palette, on_focus)`. + +**slider** — sin estado. `slider_view(label, value, min, max, &palette, +on_change: Fn(DragPhase, delta_value) -> Option)`. El delta viene en +unidades, no píxeles. + +**switch** — `switch_view(progress: f32 [0..1], on_toggle: Msg, &palette)`. La +app guarda el `bool` y opcionalmente anima `progress` con un `Tween`. + +**segmented** — N opciones exclusivas. `segmented_view(&[&str], selected: usize, +make_msg: Fn(usize)->Msg, &palette)`. + +**progress** — `linear_progress_view(progress, track, fill, height)` y +`radial_progress_view(progress, track, fill, stroke_ratio)`. Sin eventos. + +**spinner** — `spinner_view(color, stroke_ratio, speed_rev_per_sec)`. Animado por +reloj absoluto; requiere redraws periódicos (`spawn_periodic`). + +**badge** — `count_badge_view(count, BadgeKind)` ("99+" si ≥100) y +`dot_badge_view(BadgeKind)`. `BadgeKind::{Info,Success,Warning,Error,Neutral}`. + +**avatar** — `avatar_view(name, size_px)`: círculo determinista (color por hash +del nombre + inicial). + +**tooltip** — render puro. `tooltip_view(TooltipSpec { anchor, viewport, side: +Side, text, palette })`. Se monta en `view_overlay`; la app controla visibilidad +con `on_pointer_enter/leave`. + +**empty** — empty-state. `empty_view(Icon, title, description: Option<&str>, &palette)`. + +**skeleton** — placeholder con shimmer. `skeleton_view`, `skeleton_box_view(w,h,..)`, +`skeleton_line_view(w,..)`. Requiere redraws periódicos. + +**banner** — tira de status. `banner_view(BannerKind::{Info,Success,Warning,Error}, message)`. + +### Contenedores y layout + +**panel** — chrome (gradiente + hairline accent). `panel_view(children, PanelStyle)`; +`PanelStyle::{from_theme, from_theme_large, neutral}`. `panel_signature_painter(style)` +para reusar el look en un `paint_with`. + +**card** — `card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette)`. + +**stat-card** — métrica de dashboard. `stat_card_view(label, value, description, +accent, &recent_items, &palette)`. + +**tabs** — `tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg, +content: View, tab_height, palette, tab_width })`. Selección la maneja la app. + +**splitter** — divisor draggable de 2 panes. `splitter_two(Direction::{Row,Column}, +a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option, &palette)`. +`PaneSize::{Fixed(px), Flex}`. La app acumula el delta en su Model. + +**scroll** — área de scroll vertical con barra arrastrable. `scroll_y(offset, +content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette)`. +Stateless (offset en el Model); rueda autocontenida. Helpers: `clamp_offset`, +`ensure_visible` (selección a la vista), `approach` (scroll suave). Ver §8. + +**tiled** — grilla auto cols×rows de tiles con title bar. `tiled_view(tiles, &palette)`, +`tiled_view_cols(tiles, cols, &palette)`, y variantes `*_reorderable*` con +`on_reorder: Fn(from, to)->Option` (drag-to-swap por la title bar). `TileSpec { label, content }`. + +**panes** — árbol binario BSP tipo tmux. La app guarda un `Layout`: +```rust +Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second } +layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves() +panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec,DragPhase,delta)->Option, + on_focus: Fn(PaneId)->Msg, &palette) +``` + +**grid** — grilla 2D virtualizada. `ventana_visible(total, vp_w, vp_h, scroll_fila, +&metrics) -> VisibleWindow` para virtualizar, luego `grid_view(GridSpec { cells: +Vec, cols, metrics, caption, ... })`. + +**list** — lista vertical virtualizada. `list_view(ListSpec { rows: Vec, total, caption, truncated_hint, row_height, palette })`. +La app prefiltra las filas visibles. + +**tree** — árbol expand/collapse. `tree_view(TreeSpec { rows: Vec, row_height, +indent_px, palette })`. La app aplana el árbol según nodos expandidos. + +**navigator** — navegador data-agnóstico de nodos en dos modos conmutables +(**árbol** ↔ **grafo**, reusa tree + nodegraph). Render-only: la app guarda +`expanded`/`selected`/`mode`. Pasa un bosque de `NavNode { id: u64, label, +kind: NavKind (Monad|Group|Dir|File|Other), children }` y callbacks por id. +```rust +navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides }, + is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg, + on_select: Fn(u64)->Msg, on_context: OptionMsg>) +// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de +// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para +// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser. +``` + +**app-header** — `app_header(label, actions: Vec>, &palette)`. + +**status-bar** — `status_bar_view(left, center, right, &palette)` con +`StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized()`. + +**breadcrumb** — `breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)` +(el último segmento no es clickable). + +**modal** — diálogo centrado con scrim. `modal_view(ModalSpec { title, body: +View, buttons: Vec, size, viewport, on_dismiss, palette })`. +`ModalButton::{primary, cancel, destructive}(label, msg)`. Se monta en `view_overlay`. + +**toast** — notificaciones efímeras bottom-right. La app guarda `Vec` +(`Toast::{info,success,warning,error}(id, text, duration)`), filtra +`is_alive(now)`, y `toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg)`. + +**splash** — splash de arranque (cuatro cuadrantes andinos). `splash_view(started_at: +Instant, bg, fg_text)`; basado en tiempo, requiere redraws. + +### Ricos / interactivos + +**nodegraph** — lienzo de nodos + cables Bezier. Sin estado (la app guarda +posiciones y `Wire`s). +```rust +NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec, outputs: Vec } +Wire { from_node, from_output: PinIdx(u16), to_node, to_input } +nodegraph_view(&nodes, &wires, &palette, &metrics, + on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option, + on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option) +// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable) +``` + +**timeline** — scrub clickeable. `timeline_view(progress: f32, &palette, +on_seek: Fn(f32 [0..1])->Option)`. + +**text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda +`EditorState`: +```rust +EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo(); +.add_cursor_at(line,col); .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult; +.ensure_caret_visible(visible_lines) +// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option +text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer) +text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer) +text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer) +syntax_palette_dark(&theme) -> SyntaxPalette // en lib.rs del widget +``` + +**text-editor-core** — núcleo **agnóstico** (sin GPU, sin Llimphi; sólo +`peniko::Color`). Reutilizable en TUI/web/headless. Tipos clave: +- `Buffer` (sobre `ropey`): `from_str`, `text`, `insert(offset,s)`, `delete(s,e)`, + `offset_to_pos`, `pos_to_offset`, `slice`, `line(n)`. +- `Pos { line, col }`, `Selection { anchor, caret }`, `Cursor { caret, anchor: + Option, desired_col }` con `move_left/right/up/down/word_left/...`, + `selection_range(&buf)`, `collapse`. +- Ops: `replace_selection`, `delete_backward/forward`, `indent_or_insert_tab`, + `insert_newline_auto_indent` → devuelven `EditDelta { start, removed, inserted, + cursor_before, cursor_after }` con `.apply()/.undo()`. +- `UndoStack`: `push(delta)`, `undo/redo(&mut buf, &mut cursor) -> bool`, `can_undo/redo`. +- `FindState { query, case_sensitive }`: `all_matches`, `find_next`, `find_prev`. +- Matching de brackets: `find_bracket_pair(&buf, &cursor) -> Option<(Pos, Pos)>`, `Direction`. +- `Clipboard` (trait `get/set`), `MemClipboard`, `NullClipboard`. +- `Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity, + message: String, source: Option }` (+ ctors `error(..)`, `warning(..)`); + `Severity::{Error, Warning, Information, Hint}`. +- Highlight tree-sitter: `Language::{Plain, Rust, Python, Wat}` + (+ `Language::from_cell_language(s)`); `Highlighter::new(lang)` con + `.highlight(&mut self, source: &str) -> Vec>` (un `Vec` **por + línea**), `.set_language(lang)`, `.language()`; helpers de módulo + `invalidate_tree_cache(lang)` y `apply_pending_edits(lang, &edits)` para el + caché incremental. `TokenKind`, `Span`, `SyntaxPalette::color(kind)`. + +**text-editor-lsp** — cliente LSP por stdin/stdout. `trait LspClient` (fire-and-forget +`request_*` + lecturas de caché `latest_*`/`clear_*`): completions, hover, +definition, references, rename, formatting, signature help, document symbols. +`RustAnalyzerClient::start(workspace_root)`; `NoopLspClient` para tests. + +**clipboard** — portapapeles del sistema vía `arboard`. `SystemClipboard::new()`, +`is_available()`, impl `Clipboard`. No-op silencioso si no hay display (CI headless). + +**menubar** — barra de menú mac-style. `menubar_view(&MenuBarSpec { menu: &AppMenu, +open: Option, theme, viewport, height, on_open: Fn(Option)->Msg, +on_command: Fn(&str)->Msg })`; dropdown en `view_overlay` con `menubar_overlay(spec)` +o `menubar_overlay_animated(spec, active, appear)`. Navegación por teclado: +`menubar_nav`, `menubar_command_at`. + +**edit-menu** — menú estándar de edición sobre un editor. +`EditFlags::from_editor(&state, masked)`, `edit_context_menu(anchor, viewport, +&theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss)` → +`ContextMenuSpec`. `apply(&mut state, EditAction, &mut clipboard) -> ApplyResult`. +`EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}`. + +**context-menu** — menú contextual genérico (look "webpage"). `ContextMenuItem:: +action(label).with_shortcut(..).icon(..).disabled().destructive().submenu(children)` +o `::separator()`. `context_menu_view(ContextMenuSpec { anchor, viewport, header, +items, active, on_pick: Fn(usize)->Msg, on_dismiss, palette })`; `context_menu_view_ex` +con submenús/animación. Se monta en `view_overlay` con scrim. + +**theme-switcher** — `theme_switcher_view(¤t: &Theme, on_change: Fn(Theme)->Msg)` +(+ `_styled`/`_flex`). Cicla `Theme::next_after`. + +**shortcuts-help** — overlay "?" con atajos agrupados. `shortcuts_help_view( +ShortcutsHelpSpec { title, groups: Vec }>, viewport, on_dismiss, palette })`. + +**wawa-mark** — sello vectorial del SO wawa. `wawa_mark_view(&WawaMarkPalette)`; +`paint_mark(scene, rect, &palette)` para canvas custom. Usar en contenedor cuadrado. + +--- + +## 14. Catálogo de módulos + +Los módulos encapsulan **estado + comportamiento** (a diferencia de los widgets). +Todos siguen el mismo contrato: + +``` +State + Msg + Action + apply(state, msg, ...) -> Action + + on_key(state, &KeyEvent) -> Option + + open_shortcut(&KeyEvent) -> bool + + view(state, ..., to_host: F) -> View + + Palette +``` + +La app guarda `Option` (o el state directo, p. ej. bookmarks), +rutea el atajo de apertura con `open_shortcut`, rutea teclas con `on_key`, aplica +`Msg`s con `apply`, y monta el `view` pasando un mapeo `to_host: Fn(ModuleMsg) -> +HostMsg`. Cuando `apply` devuelve una `Action` (p. ej. `Invoke(id)`, `OpenAt{..}`, +`GoTo{..}`), la app ejecuta el efecto. Crates en `modules//`. + +| Módulo | Atajo | Acción que devuelve | Propósito | +|---|---|---|---| +| **command-palette** | `Ctrl+Shift+P` | `Invoke(String)` | paleta de comandos fuzzy. El host declara `&[Command]` | +| **file-picker** | `Ctrl+P` | `Open(PathBuf)` | fuzzy file picker; host pasa `&[PathBuf]` + `root` | +| **fif** (find-in-files) | `Ctrl+Shift+F` | `OpenAt{path,line,col}`, `Searched{..}`, `Replaced{..}` | buscar/reemplazar; dual-view (dialog + barra). `search()` / `replace_all()` hacen el I/O | +| **diff-viewer** | `Ctrl+Shift+D` | — | diff side-by-side. `DiffState::new(before_label, after_label, before, after)` computa con `similar` | +| **mini-map** | `Ctrl+Shift+M` | `JumpTo(line)` | minimapa del buffer; agnóstico del editor (recibe `Snapshot`) | +| **bookmarks** | `Ctrl+Alt+B` toggle, `Ctrl+Shift+B` lista, `Ctrl+Alt+N/P` nav | `JumpTo{path,line}` | marcadores per-file persistentes (state directo, no Option) | +| **symbol-outline** | `Ctrl+Shift+O` | `GoTo{line,col}` | outline de símbolos; host arma `Vec` (LSP/tree-sitter/custom) | +| **selector** | — | — | abstracción portátil abrir/guardar: `trait Selector` (`HostSelector` con PathBuf, `WawaSelector` placeholder content-addressed) | +| **plugin-host** | — | `OpenAt{..}`, `SetStatus(..)` | runtime WASM (wasmi) con permisos por bitfield; `PluginHost::load_from_dir`/`invoke(id, cap, args)` | +| **shuma-term** | `` Ctrl+` `` | `SetStatus(..)` | terminal integrada. `spawn(cwd)` lanza PTY (`shuma_exec`), `vt100::Parser` renderiza; `Tick` drena el PTY | + +Patrón típico de integración (command-palette): +```rust +struct Model { palette: Option, commands: Vec, /* … */ } +enum Msg { Palette(PaletteMsg), /* … */ } + +// on_key: +if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); } +if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); } + +// update: +Msg::Palette(m) => { + if let Some(state) = model.palette.as_mut() { + match command_palette::apply(state, m, &model.commands) { + PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; } + PaletteAction::Close => model.palette = None, + PaletteAction::None => {} + } + } +} + +// view_overlay: +model.palette.as_ref().map(|s| + command_palette::view(s, &model.commands, &palette, Msg::Palette)) +``` + +--- + +## 15. `llimphi-workspace` — chasis tipo tmux + +Monta cualquier componente en un layout intercambiable con splits resizables +(máquina de estados de foco/split/cierre + chrome estándar). Construido sobre +`llimphi-widget-panes`. + +```rust +Workspace::new() + .focused() -> PaneId .count() .leaves() -> Vec .layout() -> &Layout + .focus(id) .split(Axis) -> PaneId .close() -> Option .resize(&path, delta) + .apply(WsMsg) -> WsEffect + +enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec, f32) } +enum WsEffect { None, Created(PaneId), Closed(PaneId) } + +workspace_view(&ws, &WorkspacePalette, + leaf: FnMut(PaneId)->View, // materializa el contenido de cada panel + lift: Fn(WsMsg)->Host) // sube los Msg del chasis a tu Msg +``` + +Patrón: `enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }`. En `update`, +`ws.apply(msg)` te avisa con `WsEffect::{Created,Closed}(id)` para que crees o +destruyas el estado del panel correspondiente. + +--- + +## 16. Reglas duras y gotchas + +### 🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE) +Ningún `update`/`init`/handler puede ejecutar trabajo **síncrono** pesado +(efemérides, simulación, IO, parse, embeddings, layout de árboles grandes). +Bloquea el hilo → "Not Responding". **`init` corre dentro de `resumed`, después +de crear la ventana**, así que un cómputo pesado ahí ya congela una ventana +visible. + +Patrón (referencia: `cosmos-app-llimphi`): +```rust +// Model: Option (None = "calculando…") + flag dirty + contador de generación. +struct Model { x: Option, x_dirty: bool, x_gen: u64 } +enum Msg { XComputed(u64, Arc) } + +// al FINAL de update() (que tiene el Handle): +if m.x_dirty { + m.x_dirty = false; + m.x_gen = m.x_gen.wrapping_add(1); + let (gen, input) = (m.x_gen, m.input.clone()); // sólo lo que el worker necesita (Send) + handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input)))); +} +// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un +// resultado tardío pise a uno más nuevo en drags/toggles rápidos). +Msg::XComputed(gen, x) => if gen == m.x_gen { + m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone())); +} +``` +La **generación** es imprescindible si el recálculo se dispara seguido. Ver +[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) y su checklist por app. + +### Otras +- **Solvers iterativos** (Newton/bisección): cota dura `for _ in 0..N`, nunca + `loop {}` con corte pegado al epsilon de f64 — en debug no converge → loop + infinito. +- **Backend GPU**: preferir Vulkan (`Backends::PRIMARY`); el GL de Mesa sobre + Wayland segfaultea en el teardown. Ya está hecho en `Hal::new`, no revertir. +- **Un nodo es draggable o clickable**, no ambos. +- **`alpha` y `clip`** crean capas intermedias: tienen costo, usar sólo cuando + hace falta. +- **`paint_with`** no debe dejar `push_layer` sin `pop_layer` ni resetear la + Scene. +- **Hit-test respeta `.transform()`**: un nodo rotado/escalado/trasladado recibe + los clicks donde se ve pintado (el runtime invierte el afín acumulado). Lo que + **no** se ajusta todavía: la posición local que reciben los handlers `*_at` se + reporta en coords de pantalla, no en el espacio local del nodo transformado. +- **GPUI está extinto**: no agregar dependencias ni código GPUI (regla §3 de + `CLAUDE.md`). +- **Texto en regla pesada**: crear un `Typesetter` por frame es caro + (`FontContext::new` enumera fuentes del sistema). El runtime ya cachea uno y lo + pasa a `paint_with`. + +--- + +## 17. Comandos y demos + +```bash +cargo check --workspace # smoke test mínimo (debe pasar siempre) +cargo run -p --release # correr una app +cargo run -p --example --release # correr un demo + +# demos del propio framework: +cargo run -p llimphi-ui --example counter --release # bucle Elm completo +cargo run -p llimphi-ui --example editor --release # text field + teclado +cargo run -p llimphi-ui --example gpu_paint_demo --release +cargo run -p llimphi-gallery --release # showcase de TODO el kit +cargo run -p nada --release # editor real para ejercitar widgets + +# benchmark GPU directo vs vello: +cargo run -p llimphi-gpu-bench --release +``` + +`llimphi-gallery` (`src/main.rs`, ~967 líneas) es la **referencia viva** del +patrón completo: `Model`/`Msg`/`init`/`update`/`view`/`view_overlay` con overlays +mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown). +Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; `?` abre +atajos; `Esc` cierra el overlay activo. + +--- + +## 18. Cheat-sheet + +```rust +// ── App mínima ────────────────────────────────────────────── +impl App for X { type Model; type Msg; init; update; view; } +llimphi_ui::run::(); + +// ── Nodo ──────────────────────────────────────────────────── +View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() }) + .fill(c).hover_fill(c).radius(r).clip(b).alpha(a).transform(xf) + .text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al) + .image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{}) + .on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m) + .on_pointer_enter(m) | .on_pointer_leave(m) + .draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|) + .drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c) + .children(vec![..]) + +// ── Efectos ───────────────────────────────────────────────── +handle.spawn(|| Msg::Done(compute())); // worker → reentra al update +handle.spawn_periodic(dur, || Msg::Tick); // feed periódico +handle.dispatch(Msg::X); handle.quit(); + +// ── Estilo de layout (taffy prelude) ──────────────────────── +length(px) percent(0..1) Dimension::auto() +FlexDirection::{Row,Column} AlignItems::{Start,Center,End,Stretch} +JustifyContent::{Start,Center,End,SpaceBetween} + +// ── Theme ─────────────────────────────────────────────────── +Theme::dark()/light()/aurora()/sunset(); Theme::next_after(name); XxxPalette::from_theme(&t) + +// ── Overlay (menús/modales) ───────────────────────────────── +fn view_overlay(m) -> Option> { if m.open { Some(menu) } else { None } } +``` + +--- + +## 19. Índice de crates + +**Framework** (`02_ruway/llimphi/`): +`llimphi-hal` · `llimphi-raster` · `llimphi-text` · `llimphi-layout` · +`llimphi-compositor` · `llimphi-ui` · `llimphi-theme` · `llimphi-motion` · +`llimphi-icons` · `llimphi-surface` · `llimphi-workspace` · `llimphi-gallery` · +`llimphi-gpu-bench`. + +**Widgets** (`widgets/`, dep `llimphi-widget-`): app-header · avatar · badge · +banner · breadcrumb · button · card · clipboard · context-menu · edit-menu · +empty · field · gallery · grid · list · menubar · modal · navigator · nodegraph · +panel · panes · progress · segmented · shortcuts-help · skeleton · slider · splash · +splitter · stat-card · status-bar · switch · tabs · text-area · text-editor · +text-editor-core · text-editor-lsp · text-input · theme-switcher · tiled · +timeline · toast · tooltip · tree · wawa-mark. + +**Módulos** (`modules/`): bookmarks · command-palette · diff-viewer · fif · +file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline. + +**Android** (`android/`): clear-screen-android · vello-hello-android · +vello-text-android. + +--- + +> Documentos hermanos: [`SDD.md`](SDD.md) (diseño y roadmap), +> [`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) (regla de +> concurrencia), [`README.md`](README.md) / [`LEEME.md`](LEEME.md) (overview). +> Las firmas de este manual reflejan el código al 2026-06-01; ante divergencia, +> la fuente autoritativa es el `lib.rs` de cada crate. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b038ede --- /dev/null +++ b/README.md @@ -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` 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`.** No imperative, no foreign vDOM. +- **Same scene tree on Wayland and Wawa**: HAL abstracts the surface. +- Widgets are **purely visual**; modules encapsulate state + behavior. diff --git a/README.qu.md b/README.qu.md new file mode 100644 index 0000000..5470a43 --- /dev/null +++ b/README.qu.md @@ -0,0 +1,35 @@ + + +# 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` 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`.** Mana imperativo, mana hawanka vDOM. +- **Kikin escena Wayland Wawapipas**: HAL superficie huñun. +- Widgets **ch'uya rikuq**; módulos estado + ruway huñun. diff --git a/SDD.md b/SDD.md new file mode 100644 index 0000000..2f7a55d --- /dev/null +++ b/SDD.md @@ -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`, 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>` 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` 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`, `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` 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. diff --git a/android/LEEME.md b/android/LEEME.md new file mode 100644 index 0000000..0b1c254 --- /dev/null +++ b/android/LEEME.md @@ -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` + 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 `. + +## 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). diff --git a/android/clear-screen-android/Cargo.toml b/android/clear-screen-android/Cargo.toml new file mode 100644 index 0000000..ee6b91f --- /dev/null +++ b/android/clear-screen-android/Cargo.toml @@ -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" diff --git a/android/clear-screen-android/LEEME.md b/android/clear-screen-android/LEEME.md new file mode 100644 index 0000000..2b7e4c5 --- /dev/null +++ b/android/clear-screen-android/LEEME.md @@ -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 +``` diff --git a/android/clear-screen-android/README.md b/android/clear-screen-android/README.md new file mode 100644 index 0000000..0dbaf6f --- /dev/null +++ b/android/clear-screen-android/README.md @@ -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 +``` diff --git a/android/clear-screen-android/src/lib.rs b/android/clear-screen-android/src/lib.rs new file mode 100644 index 0000000..4632869 --- /dev/null +++ b/android/clear-screen-android/src/lib.rs @@ -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, + hal: Hal, + surface: WinitSurface, +} + +struct App { + state: Option, + 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 { + 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::().map(|s| s.as_str())) + .unwrap_or(""); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_else(|| "".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"); +} diff --git a/android/scripts/build-android.sh b/android/scripts/build-android.sh new file mode 100755 index 0000000..e99519a --- /dev/null +++ b/android/scripts/build-android.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# ============================================================================ +# build-android.sh — empaca un crate Llimphi-Android como APK firmado. +# +# Uso: +# ./build-android.sh [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//android/.apk — APK firmado v2, instalable con +# `adb install -r `. +# ============================================================================ +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" diff --git a/android/vello-hello-android/Cargo.toml b/android/vello-hello-android/Cargo.toml new file mode 100644 index 0000000..187fe94 --- /dev/null +++ b/android/vello-hello-android/Cargo.toml @@ -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" diff --git a/android/vello-hello-android/LEEME.md b/android/vello-hello-android/LEEME.md new file mode 100644 index 0000000..8b8be30 --- /dev/null +++ b/android/vello-hello-android/LEEME.md @@ -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 +``` diff --git a/android/vello-hello-android/README.md b/android/vello-hello-android/README.md new file mode 100644 index 0000000..d3e79a6 --- /dev/null +++ b/android/vello-hello-android/README.md @@ -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 +``` diff --git a/android/vello-hello-android/src/lib.rs b/android/vello-hello-android/src/lib.rs new file mode 100644 index 0000000..e8fc845 --- /dev/null +++ b/android/vello-hello-android/src/lib.rs @@ -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, + hal: Hal, + surface: WinitSurface, + renderer: Renderer, + scene: vello::Scene, +} + +struct App { + state: Option, + 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 { + 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::().map(|s| s.as_str())) + .unwrap_or(""); + 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}"); + } +} diff --git a/android/vello-text-android/Cargo.toml b/android/vello-text-android/Cargo.toml new file mode 100644 index 0000000..dd8cdf5 --- /dev/null +++ b/android/vello-text-android/Cargo.toml @@ -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" diff --git a/android/vello-text-android/LEEME.md b/android/vello-text-android/LEEME.md new file mode 100644 index 0000000..6a0c760 --- /dev/null +++ b/android/vello-text-android/LEEME.md @@ -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 +``` diff --git a/android/vello-text-android/README.md b/android/vello-text-android/README.md new file mode 100644 index 0000000..7d1f846 --- /dev/null +++ b/android/vello-text-android/README.md @@ -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 +``` diff --git a/android/vello-text-android/src/lib.rs b/android/vello-text-android/src/lib.rs new file mode 100644 index 0000000..234f7ad --- /dev/null +++ b/android/vello-text-android/src/lib.rs @@ -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, + hal: Hal, + surface: WinitSurface, + renderer: llimphi_raster::Renderer, + scene: vello::Scene, + typesetter: Typesetter, +} + +struct App { + state: Option, + 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, + 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 { + // 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::().map(|s| s.as_str())) + .unwrap_or(""); + 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}"); + } +} diff --git a/llimphi-compositor/Cargo.toml b/llimphi-compositor/Cargo.toml new file mode 100644 index 0000000..49bcc92 --- /dev/null +++ b/llimphi-compositor/Cargo.toml @@ -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`, 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 } diff --git a/llimphi-compositor/src/lib.rs b/llimphi-compositor/src/lib.rs new file mode 100644 index 0000000..7172d2c --- /dev/null +++ b/llimphi-compositor/src/lib.rs @@ -0,0 +1,348 @@ +//! llimphi-compositor — el núcleo declarativo de Llimphi, sin winit. +//! +//! Aquí vive el árbol de vista `View` (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 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, + /// 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>, +} + +/// 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` 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 = Arc Option + 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 = Arc Option + 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 = Arc Option + 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 = Arc Option + 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 = Arc Option + 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 { + pub style: Style, + pub fill: Option, + /// Relleno cuando el cursor está sobre este nodo. Sin valor (`None`) + /// = no se reacciona al hover. + pub hover_fill: Option, + pub radius: f64, + pub text: Option, + /// 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, + /// 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, + /// 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, + pub on_click: Option, + /// 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>, + /// 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, + /// 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>, + /// 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, + /// 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>, + /// 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>, + /// 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, + /// Handler invocado al soltar un drag sobre este nodo (drop target). + pub on_drop: Option>, + /// Color a pintar mientras un drag activo está hovereando este drop + /// target. Sobrepone a `fill`/`hover_fill` cuando aplica. + pub drop_hover_fill: Option, + /// 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 a emitir cuando el cursor sale del rect del nodo. + pub on_pointer_leave: Option, + /// 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>, + /// 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, + /// 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, + /// 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, + /// 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, + pub children: Vec>, +} + +/// 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 { + pub root: NodeId, + pub nodes: Vec>, + /// 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, +} + +/// 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, + pub line_height: f32, +} + +pub struct MountedNode { + pub id: NodeId, + pub fill: Option, + pub hover_fill: Option, + pub radius: f64, + pub text: Option, + pub image: Option, + pub painter: Option, + pub gpu_painter: Option, + pub on_click: Option, + pub on_click_at: Option>, + pub on_right_click: Option, + pub on_right_click_at: Option>, + pub on_middle_click: Option, + pub drag: Option>, + pub drag_at: Option>, + pub drag_payload: Option, + pub on_drop: Option>, + pub drop_hover_fill: Option, + pub clip: bool, + pub on_pointer_enter: Option, + pub on_pointer_leave: Option, + pub on_scroll: Option>, + pub focusable: Option, + pub alpha: Option, + /// 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, + /// 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, + /// Í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, +} diff --git a/llimphi-compositor/src/render.rs b/llimphi-compositor/src/render.rs new file mode 100644 index 0000000..074e815 --- /dev/null +++ b/llimphi-compositor/src/render.rs @@ -0,0 +1,705 @@ +use super::*; + +pub fn mount(layout: &mut LayoutTree, v: View) -> Mounted { + 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( + layout: &mut LayoutTree, + v: View, + out: &mut Vec>, + text_measures: &mut std::collections::HashMap, +) -> 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>, + available: llimphi_layout::taffy::Size, +) -> llimphi_layout::taffy::Size { + use llimphi_layout::taffy::AvailableSpace; + let max_width: Option = 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( + scene: &mut vello::Scene, + mounted: &Mounted, + computed: &ComputedLayout, + typesetter: &mut llimphi_text::Typesetter, + hover_idx: Option, + drop_hover_idx: Option, +) { + // 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 = 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(mounted: &Mounted) -> bool { + mounted.nodes.iter().any(|n| n.gpu_painter.is_some()) +} + +pub fn paint_gpu( + mounted: &Mounted, + 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( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, + pred: F, +) -> Option +where + F: Fn(&MountedNode) -> bool, +{ + let mut hit: Option = None; + let mut clip_stack: Vec = 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( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + 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( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + 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( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + 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( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + 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( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + 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( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + 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( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + 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(mounted: &Mounted, computed: &ComputedLayout) -> Vec { + 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, reverse: bool) -> Option { + 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, + ) -> (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); + } +} diff --git a/llimphi-compositor/src/view.rs b/llimphi-compositor/src/view.rs new file mode 100644 index 0000000..32bd1e4 --- /dev/null +++ b/llimphi-compositor/src/view.rs @@ -0,0 +1,408 @@ +use super::*; + +impl View { + 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) -> 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(mut self, handler: F) -> Self + where + F: Fn(f32, f32) -> Option + 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(mut self, handler: F) -> Self + where + F: Fn(DragPhase, f32, f32) -> Option + 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(mut self, handler: F) -> Self + where + F: Fn(DragPhase, f32, f32, f32, f32) -> Option + 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(mut self, handler: F) -> Self + where + F: Fn(u64) -> Option + 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, 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, + 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, + 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, + size_px: f32, + color: Color, + alignment: llimphi_text::Alignment, + italic: bool, + font_family: Option, + ) -> 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, + 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(mut self, handler: F) -> Self + where + F: Fn(f32, f32, f32, f32) -> Option + 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(mut self, handler: F) -> Self + where + F: Fn(f32, f32, f32, f32) -> Option + 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` + `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(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(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>) -> Self { + self.children = children; + self + } +} diff --git a/llimphi-compositor/tests/text_measure.rs b/llimphi-compositor/tests/text_measure.rs new file mode 100644 index 0000000..32b9b82 --- /dev/null +++ b/llimphi-compositor/tests/text_measure.rs @@ -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 = 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})" + ); +} diff --git a/llimphi-gallery/Cargo.toml b/llimphi-gallery/Cargo.toml new file mode 100644 index 0000000..bd0388a --- /dev/null +++ b/llimphi-gallery/Cargo.toml @@ -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 } diff --git a/llimphi-gallery/src/main.rs b/llimphi-gallery/src/main.rs new file mode 100644 index 0000000..807f45e --- /dev/null +++ b/llimphi-gallery/src/main.rs @@ -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), + /// Comando elegido en la barra principal (id `menu.`). + MenuCommand(String), +} + +struct Model { + started_at: Instant, + switch_a: bool, + switch_b: bool, + seg: usize, + toasts: Vec, + 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, + /// Índice del menú raíz de la barra principal abierto. `None` = ninguno. + menubar_open: Option, +} + +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::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::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 { + 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 { + 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> { + 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 { + let mut children: Vec> = 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 { + let mut children: Vec> = 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 { + let mut children: Vec> = 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::(200.0, &palette)); + children.push(spacer_v(6.0)); + children.push(skeleton_line_view::(280.0, &palette)); + children.push(spacer_v(6.0)); + children.push(skeleton_line_view::(160.0, &palette)); + children.push(spacer_v(10.0)); + children.push(skeleton_box_view::(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 { + 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 { + 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>, theme: &Theme) -> View { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> = 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 { + 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 { + 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 { + 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 { + tooltip_view::(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::(); +} diff --git a/llimphi-gpu-bench/Cargo.toml b/llimphi-gpu-bench/Cargo.toml new file mode 100644 index 0000000..e189878 --- /dev/null +++ b/llimphi-gpu-bench/Cargo.toml @@ -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 } diff --git a/llimphi-gpu-bench/src/main.rs b/llimphi-gpu-bench/src/main.rs new file mode 100644 index 0000000..d00247e --- /dev/null +++ b/llimphi-gpu-bench/src/main.rs @@ -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 { + let max = std::env::var("LLIMPHI_BENCH_SPIKE_MAX") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(u32::MAX); + SPIKE_SIZES.iter().copied().filter(|&n| n <= max).collect() +} + +fn scale_sizes() -> Vec { + let max = std::env::var("LLIMPHI_BENCH_SCALE_MAX") + .ok() + .and_then(|v| v.parse::().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 = 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 = 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 = 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, + vello_crashed: bool, + directo_ms: f64, + factor: Option, +} + +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 = 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 = 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 = 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::()); + hal.device.poll(wgpu::Maintain::Wait); + + let mut samples: Vec = 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(()) +} diff --git a/llimphi-hal/Cargo.toml b/llimphi-hal/Cargo.toml new file mode 100644 index 0000000..f9288fd --- /dev/null +++ b/llimphi-hal/Cargo.toml @@ -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" diff --git a/llimphi-hal/LEEME.md b/llimphi-hal/LEEME.md new file mode 100644 index 0000000..6f0bb0a --- /dev/null +++ b/llimphi-hal/LEEME.md @@ -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) diff --git a/llimphi-hal/README.md b/llimphi-hal/README.md new file mode 100644 index 0000000..85b907b --- /dev/null +++ b/llimphi-hal/README.md @@ -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) diff --git a/llimphi-hal/examples/clear_screen.rs b/llimphi-hal/examples/clear_screen.rs new file mode 100644 index 0000000..0b1233c --- /dev/null +++ b/llimphi-hal/examples/clear_screen.rs @@ -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, + hal: Hal, + surface: WinitSurface, +} + +struct App { + state: Option, + 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"); +} diff --git a/llimphi-hal/src/lib.rs b/llimphi-hal/src/lib.rs new file mode 100644 index 0000000..36822db --- /dev/null +++ b/llimphi-hal/src/lib.rs @@ -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; + /// 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 { + 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, + 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) -> Result { + 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, + surface: wgpu::Surface<'static>, + ) -> Result { + 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 { + 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 { + 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, + @location(0) uv: vec2, +}; + +@vertex +fn vs(@builtin(vertex_index) vi: u32) -> VsOut { + var corners = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0), + ); + let xy = corners[vi]; + var out: VsOut; + out.pos = vec4(xy, 0.0, 1.0); + out.uv = vec2((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5); + return out; +} + +@group(0) @binding(0) var src_tex: texture_2d; +@group(0) @binding(1) var src_samp: sampler; + +@fragment +fn fs(in: VsOut) -> @location(0) vec4 { + 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 { + 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(); + } +} diff --git a/llimphi-icons/Cargo.toml b/llimphi-icons/Cargo.toml new file mode 100644 index 0000000..e731f53 --- /dev/null +++ b/llimphi-icons/Cargo.toml @@ -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 } diff --git a/llimphi-icons/examples/app_icons_gallery.rs b/llimphi-icons/examples/app_icons_gallery.rs new file mode 100644 index 0000000..97652ca --- /dev/null +++ b/llimphi-icons/examples/app_icons_gallery.rs @@ -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 { + // 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 { + 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) -> Model { + Model + } + + fn update(_model: Model, msg: Msg, _: &Handle) -> Model { + match msg {} + } + + fn view(_: &Model) -> View { + let rows: Vec> = 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::(); +} diff --git a/llimphi-icons/src/app_icons.rs b/llimphi-icons/src/app_icons.rs new file mode 100644 index 0000000..cd97aa5 --- /dev/null +++ b/llimphi-icons/src/app_icons.rs @@ -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 { + 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(icon: AppIcon, stroke_width: f32) -> View { + 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( + icon: AppIcon, + color: Color, + stroke_width: f32, +) -> View { + 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); + } +} diff --git a/llimphi-icons/src/lib.rs b/llimphi-icons/src/lib.rs new file mode 100644 index 0000000..e237da9 --- /dev/null +++ b/llimphi-icons/src/lib.rs @@ -0,0 +1,1005 @@ +//! `llimphi-icons` — set canónico de iconos vectoriales para apps gioser. +//! +//! Cada icono es una función pura que devuelve un `BezPath` definido en +//! un grid lógico de **24×24 unidades**. El renderer escala al rect que +//! reciba, así un mismo icono sirve para 12px (en una fila de lista) y +//! para 64px (en una hero card) sin pérdida de nitidez — es vector, +//! no bitmap. +//! +//! ## Diseño +//! +//! - **Stroke-based, no fill**: los iconos son trazos de ancho 2 (en +//! unidades del grid) con joins/caps suaves. El stroke se renderiza +//! con el color que la app elija (típicamente `theme.fg_text` o +//! `theme.accent`). +//! - **Geometría minimal, no marca**: glifos genéricos universales, +//! no "marca registrada". Cada uno debe ser reconocible al primer +//! vistazo aún en 12×12. +//! - **Set acotado**: suficientes para cubrir el grueso de acciones y +//! tipos que aparecen en cualquier UI gioser. Si una app necesita uno +//! más, lo agrega aquí (no en su propio crate) — la consistencia +//! visual importa más que el aislamiento. +//! +//! ## Catálogo +//! +//! | Categoría | Iconos | +//! |--------------|-----------------------------------------------------| +//! | Documento | `file`, `folder`, `folder_open`, `save`, `open` | +//! | Edición | `plus`, `minus`, `x`, `check`, `edit`, `trash` | +//! | Navegación | `chevron_up`, `chevron_down`, `chevron_left`, `chevron_right`, `home`, `search` | +//! | Estado | `info`, `warning`, `error`, `bell` | +//! | Sistema | `settings`, `more` | +//! | Multimedia | `play`, `pause`, `stop`, `skip_*`, `volume*`, `repeat`, `shuffle`, `record`, `equalizer`, `camera`, `gauge` | +//! | Archivos | `image`, `music`, `film`, `archive`, `code`, `file_text`, `link`, `font` | +//! +//! ## Uso +//! +//! ```ignore +//! use llimphi_icons::{Icon, icon_view}; +//! +//! // Botón con icono "save": +//! let btn = View::new(style) +//! .fill(palette.bg_button) +//! .children(vec![icon_view(Icon::Save, palette.fg_text, 1.6)]); +//! ``` +//! +//! El parámetro `stroke_width` (3er arg de `icon_view`) está en unidades +//! del grid (24×24). `1.6` es el default armonioso; `2.0` para énfasis; +//! `1.2` para iconos en tipografías pequeñas. + +#![forbid(unsafe_code)] + +pub mod app_icons; + +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; + +/// Catálogo de iconos del set canónico. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Icon { + // --- Documento --- + File, + Folder, + FolderOpen, + Save, + Open, + // --- Edición --- + Plus, + Minus, + X, + Check, + Edit, + Trash, + // --- Navegación --- + ChevronUp, + ChevronDown, + ChevronLeft, + ChevronRight, + Home, + Search, + // --- Estado --- + Info, + Warning, + Error, + Bell, + // --- Sistema --- + Settings, + More, + // --- Multimedia --- + Play, + Pause, + Stop, + SkipBack, + SkipForward, + Rewind, + FastForward, + Volume, + VolumeMute, + Repeat, + Shuffle, + Record, + Equalizer, + Camera, + Gauge, + // --- Archivos (tipos por extensión, para listados de file manager) --- + Image, + Music, + Film, + Archive, + Code, + FileText, + Link, + Font, +} + +impl Icon { + /// Identificador estable en lowercase con underscores. Útil para + /// debugging, persistir choices del usuario, o mapear desde strings + /// en config. + pub const fn name(self) -> &'static str { + match self { + Icon::File => "file", + Icon::Folder => "folder", + Icon::FolderOpen => "folder_open", + Icon::Save => "save", + Icon::Open => "open", + Icon::Plus => "plus", + Icon::Minus => "minus", + Icon::X => "x", + Icon::Check => "check", + Icon::Edit => "edit", + Icon::Trash => "trash", + Icon::ChevronUp => "chevron_up", + Icon::ChevronDown => "chevron_down", + Icon::ChevronLeft => "chevron_left", + Icon::ChevronRight => "chevron_right", + Icon::Home => "home", + Icon::Search => "search", + Icon::Info => "info", + Icon::Warning => "warning", + Icon::Error => "error", + Icon::Bell => "bell", + Icon::Settings => "settings", + Icon::More => "more", + Icon::Play => "play", + Icon::Pause => "pause", + Icon::Stop => "stop", + Icon::SkipBack => "skip_back", + Icon::SkipForward => "skip_forward", + Icon::Rewind => "rewind", + Icon::FastForward => "fast_forward", + Icon::Volume => "volume", + Icon::VolumeMute => "volume_mute", + Icon::Repeat => "repeat", + Icon::Shuffle => "shuffle", + Icon::Record => "record", + Icon::Equalizer => "equalizer", + Icon::Camera => "camera", + Icon::Gauge => "gauge", + Icon::Image => "image", + Icon::Music => "music", + Icon::Film => "film", + Icon::Archive => "archive", + Icon::Code => "code", + Icon::FileText => "file_text", + Icon::Link => "link", + Icon::Font => "font", + } + } + + /// Devuelve el `BezPath` del icono en coords del grid 24×24. La + /// app raramente lo necesita directamente — es lo que consume + /// internamente [`icon_view`] / [`paint_icon`]. + pub fn path(self) -> BezPath { + match self { + Icon::File => path_file(), + Icon::Folder => path_folder(), + Icon::FolderOpen => path_folder_open(), + Icon::Save => path_save(), + Icon::Open => path_open(), + Icon::Plus => path_plus(), + Icon::Minus => path_minus(), + Icon::X => path_x(), + Icon::Check => path_check(), + Icon::Edit => path_edit(), + Icon::Trash => path_trash(), + Icon::ChevronUp => path_chevron(0.0), + Icon::ChevronDown => path_chevron(180.0), + Icon::ChevronLeft => path_chevron(270.0), + Icon::ChevronRight => path_chevron(90.0), + Icon::Home => path_home(), + Icon::Search => path_search(), + Icon::Info => path_info(), + Icon::Warning => path_warning(), + Icon::Error => path_error(), + Icon::Bell => path_bell(), + Icon::Settings => path_settings(), + Icon::More => path_more(), + Icon::Play => path_play(), + Icon::Pause => path_pause(), + Icon::Stop => path_stop(), + Icon::SkipBack => path_skip(true), + Icon::SkipForward => path_skip(false), + Icon::Rewind => path_seek(true), + Icon::FastForward => path_seek(false), + Icon::Volume => path_volume(false), + Icon::VolumeMute => path_volume(true), + Icon::Repeat => path_repeat(), + Icon::Shuffle => path_shuffle(), + Icon::Record => path_record(), + Icon::Equalizer => path_equalizer(), + Icon::Camera => path_camera(), + Icon::Gauge => path_gauge(), + Icon::Image => path_image(), + Icon::Music => path_music(), + Icon::Film => path_film(), + Icon::Archive => path_archive(), + Icon::Code => path_code(), + Icon::FileText => path_file_text(), + Icon::Link => path_link(), + Icon::Font => path_font(), + } + } +} + +/// Construye un `View` que pinta el icono ocupando todo el rect del +/// padre. El icono se escala uniformemente al mínimo lado y se centra. +/// +/// - `stroke_width` en unidades del grid 24×24 (típico: `1.6`). +pub fn icon_view( + icon: Icon, + color: Color, + stroke_width: f32, +) -> View { + 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_icon(scene, rect, icon, color, stroke_width); + }) +} + +/// Pintor crudo — útil cuando una app quiere stampear varios iconos +/// dentro del mismo `paint_with` (paneles compuestos, toolbars +/// generadas dinámicamente). +pub fn paint_icon( + scene: &mut llimphi_ui::llimphi_raster::vello::Scene, + rect: llimphi_ui::PaintRect, + icon: Icon, + 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); +} + +// ===================================================================== +// Paths — todos en grid 24×24, origen top-left, eje Y hacia abajo +// ===================================================================== +// +// Cada path es geometría minimalista. Los joins y caps son Round (los +// fija el renderer), así que los corners salen suaves sin tener que +// definir curvas extra. + +fn path_file() -> BezPath { + // Documento: rectángulo con esquina superior-derecha plegada. + let mut p = BezPath::new(); + p.move_to((6.0, 3.0)); + p.line_to((14.0, 3.0)); + p.line_to((19.0, 8.0)); + p.line_to((19.0, 21.0)); + p.line_to((6.0, 21.0)); + p.close_path(); + // Pliegue: línea desde la esquina superior-derecha del file hasta + // donde "se dobla", luego al borde. + p.move_to((14.0, 3.0)); + p.line_to((14.0, 8.0)); + p.line_to((19.0, 8.0)); + p +} + +fn path_folder() -> BezPath { + // Folder cerrado: cuerpo + lengüeta arriba-izquierda. + let mut p = BezPath::new(); + p.move_to((3.0, 8.0)); + p.line_to((3.0, 19.0)); + p.line_to((21.0, 19.0)); + p.line_to((21.0, 8.0)); + p.line_to((11.0, 8.0)); + p.line_to((9.0, 5.0)); + p.line_to((3.0, 5.0)); + p.close_path(); + p +} + +fn path_folder_open() -> BezPath { + // Folder con tapa levantada: el "techo" se inclina hacia la derecha. + let mut p = BezPath::new(); + // Caja inferior. + p.move_to((3.0, 8.0)); + p.line_to((3.0, 19.0)); + p.line_to((21.0, 19.0)); + p.line_to((23.0, 11.0)); + p.line_to((7.0, 11.0)); + p.line_to((5.0, 19.0)); + // Lengüeta de la izquierda (sigue ahí). + p.move_to((3.0, 8.0)); + p.line_to((9.0, 8.0)); + p.line_to((11.0, 11.0)); + p.line_to((21.0, 11.0)); + p +} + +fn path_save() -> BezPath { + // Floppy: cuadrado con muesca top-right y rectángulo de label abajo. + let mut p = BezPath::new(); + p.move_to((4.0, 4.0)); + p.line_to((17.0, 4.0)); + p.line_to((20.0, 7.0)); + p.line_to((20.0, 20.0)); + p.line_to((4.0, 20.0)); + p.close_path(); + // Slot del shutter arriba. + p.move_to((8.0, 4.0)); + p.line_to((8.0, 9.0)); + p.line_to((15.0, 9.0)); + p.line_to((15.0, 4.0)); + // Rectángulo de label abajo. + p.move_to((7.0, 13.0)); + p.line_to((17.0, 13.0)); + p.line_to((17.0, 20.0)); + p.line_to((7.0, 20.0)); + p.close_path(); + p +} + +fn path_open() -> BezPath { + // Carpeta abriéndose hacia arriba con una flecha que entra. + let mut p = BezPath::new(); + // Folder base. + p.move_to((3.0, 19.0)); + p.line_to((3.0, 8.0)); + p.line_to((9.0, 8.0)); + p.line_to((11.0, 10.0)); + p.line_to((21.0, 10.0)); + p.line_to((21.0, 19.0)); + p.close_path(); + // Flecha entrando desde arriba al centro. + p.move_to((12.0, 2.0)); + p.line_to((12.0, 14.0)); + p.move_to((9.0, 11.0)); + p.line_to((12.0, 14.0)); + p.line_to((15.0, 11.0)); + p +} + +fn path_plus() -> BezPath { + let mut p = BezPath::new(); + p.move_to((12.0, 5.0)); + p.line_to((12.0, 19.0)); + p.move_to((5.0, 12.0)); + p.line_to((19.0, 12.0)); + p +} + +fn path_minus() -> BezPath { + let mut p = BezPath::new(); + p.move_to((5.0, 12.0)); + p.line_to((19.0, 12.0)); + p +} + +fn path_x() -> BezPath { + let mut p = BezPath::new(); + p.move_to((6.0, 6.0)); + p.line_to((18.0, 18.0)); + p.move_to((18.0, 6.0)); + p.line_to((6.0, 18.0)); + p +} + +fn path_check() -> BezPath { + let mut p = BezPath::new(); + p.move_to((5.0, 13.0)); + p.line_to((10.0, 18.0)); + p.line_to((20.0, 6.0)); + p +} + +fn path_edit() -> BezPath { + // Lápiz: cuerpo diagonal + punta + tag de borrador. + let mut p = BezPath::new(); + p.move_to((4.0, 20.0)); + p.line_to((8.0, 19.0)); + p.line_to((20.0, 7.0)); + p.line_to((17.0, 4.0)); + p.line_to((5.0, 16.0)); + p.close_path(); + p.move_to((14.0, 7.0)); + p.line_to((17.0, 10.0)); + p +} + +fn path_trash() -> BezPath { + // Tacho: tapa con manijita + cuerpo con tres barras verticales. + let mut p = BezPath::new(); + // Tapa. + p.move_to((4.0, 6.0)); + p.line_to((20.0, 6.0)); + // Manijita. + p.move_to((9.0, 6.0)); + p.line_to((9.0, 4.0)); + p.line_to((15.0, 4.0)); + p.line_to((15.0, 6.0)); + // Cuerpo. + p.move_to((6.0, 6.0)); + p.line_to((7.0, 21.0)); + p.line_to((17.0, 21.0)); + p.line_to((18.0, 6.0)); + // Barras internas. + p.move_to((10.0, 10.0)); + p.line_to((10.0, 17.0)); + p.move_to((14.0, 10.0)); + p.line_to((14.0, 17.0)); + p +} + +/// Chevron apuntando hacia arriba (default) o rotado por `angle_deg` +/// alrededor del centro del grid (12, 12). 90° = derecha, 180° = abajo, +/// 270° = izquierda. +fn path_chevron(angle_deg: f64) -> BezPath { + let mut p = BezPath::new(); + // Chevron base: forma de ^ apuntando arriba. + p.move_to((6.0, 14.0)); + p.line_to((12.0, 8.0)); + p.line_to((18.0, 14.0)); + let theta = angle_deg.to_radians(); + let center = (12.0, 12.0); + Affine::translate(center) + * Affine::rotate(theta) + * Affine::translate((-center.0, -center.1)) + * p +} + +fn path_home() -> BezPath { + // Casa: triángulo de techo + caja rectangular. + let mut p = BezPath::new(); + p.move_to((3.0, 12.0)); + p.line_to((12.0, 4.0)); + p.line_to((21.0, 12.0)); + // Cuerpo. + p.move_to((5.0, 11.0)); + p.line_to((5.0, 20.0)); + p.line_to((19.0, 20.0)); + p.line_to((19.0, 11.0)); + // Puerta. + p.move_to((10.0, 20.0)); + p.line_to((10.0, 14.0)); + p.line_to((14.0, 14.0)); + p.line_to((14.0, 20.0)); + p +} + +fn path_search() -> BezPath { + // Lupa: círculo (poligonal 16 segmentos) + mango diagonal. + let mut p = BezPath::new(); + let cx = 10.5; + let cy = 10.5; + let r = 5.5; + let segments = 24; + 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)); + } + } + // Mango. + p.move_to((14.5, 14.5)); + p.line_to((20.0, 20.0)); + p +} + +fn path_info() -> BezPath { + // i: círculo + punto arriba + barra abajo. + let mut p = path_circle(12.0, 12.0, 9.0, 32); + // Punto. + p.move_to((12.0, 7.0)); + p.line_to((12.0, 8.5)); + // Barra. + p.move_to((12.0, 11.0)); + p.line_to((12.0, 17.0)); + p +} + +fn path_warning() -> BezPath { + // Triángulo con ! adentro. + let mut p = BezPath::new(); + p.move_to((12.0, 3.0)); + p.line_to((22.0, 21.0)); + p.line_to((2.0, 21.0)); + p.close_path(); + p.move_to((12.0, 10.0)); + p.line_to((12.0, 15.0)); + p.move_to((12.0, 17.5)); + p.line_to((12.0, 18.5)); + p +} + +fn path_error() -> BezPath { + // Círculo con X adentro. + let mut p = path_circle(12.0, 12.0, 9.0, 32); + p.move_to((8.5, 8.5)); + p.line_to((15.5, 15.5)); + p.move_to((15.5, 8.5)); + p.line_to((8.5, 15.5)); + p +} + +fn path_bell() -> BezPath { + // Campana: domo + base + badajo. + let mut p = BezPath::new(); + // Cuerpo con curva suave. + p.move_to((5.0, 17.0)); + p.curve_to((5.0, 8.0), (8.0, 5.0), (12.0, 5.0)); + p.curve_to((16.0, 5.0), (19.0, 8.0), (19.0, 17.0)); + p.close_path(); + // Base. + p.move_to((3.5, 17.0)); + p.line_to((20.5, 17.0)); + // Badajo. + p.move_to((10.5, 20.0)); + p.line_to((13.5, 20.0)); + p +} + +fn path_settings() -> BezPath { + // Engranaje: 8 dientes radiales + agujero central. + let mut p = BezPath::new(); + let cx = 12.0; + let cy = 12.0; + let inner_r = 6.5; + let outer_r = 9.5; + let teeth = 8; + for i in 0..teeth * 2 { + let theta = std::f64::consts::TAU * (i as f64) / (teeth as f64 * 2.0); + // Cada paso alterna entre inner y outer para formar los dientes. + let r = if i % 2 == 0 { outer_r } else { inner_r }; + 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.close_path(); + // Agujero central. + let inner = path_circle(cx, cy, 3.0, 16); + for el in inner.elements() { + p.push(*el); + } + p +} + +fn path_more() -> BezPath { + // Tres puntos horizontales (cada "punto" es un círculo pequeño). + let mut p = BezPath::new(); + for (cx, cy) in &[(6.0, 12.0), (12.0, 12.0), (18.0, 12.0)] { + let dot = path_circle(*cx, *cy, 1.5, 12); + for el in dot.elements() { + p.push(*el); + } + } + p +} + +/// Helper: aproxima un círculo con `segments` lados rectos. Para iconos +/// stroke esto se ve liso a partir de ~16 segmentos por la suavidad del +/// Cap::Round. Más barato y más predecible que cubic Beziers para los +/// glifos chiquitos donde vivimos. +fn path_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 +} + +// --------------------------------------------------------------------- +// Multimedia — transporte de reproductor (media-app y demás) +// --------------------------------------------------------------------- + +fn append(dst: &mut BezPath, src: &BezPath) { + for el in src.elements() { + dst.push(*el); + } +} + +fn path_play() -> BezPath { + // Triángulo apuntando a la derecha. + let mut p = BezPath::new(); + p.move_to((8.0, 5.0)); + p.line_to((8.0, 19.0)); + p.line_to((18.0, 12.0)); + p.close_path(); + p +} + +fn path_pause() -> BezPath { + // Dos barras verticales. + let mut p = BezPath::new(); + p.move_to((9.0, 6.0)); + p.line_to((9.0, 18.0)); + p.move_to((15.0, 6.0)); + p.line_to((15.0, 18.0)); + p +} + +fn path_stop() -> BezPath { + let mut p = BezPath::new(); + p.move_to((7.0, 7.0)); + p.line_to((17.0, 7.0)); + p.line_to((17.0, 17.0)); + p.line_to((7.0, 17.0)); + p.close_path(); + p +} + +/// Saltar pista: barra + triángulo (a la izquierda si `back`). +fn path_skip(back: bool) -> BezPath { + let mut p = BezPath::new(); + if back { + p.move_to((7.0, 6.0)); + p.line_to((7.0, 18.0)); + p.move_to((17.0, 6.0)); + p.line_to((17.0, 18.0)); + p.line_to((8.0, 12.0)); + p.close_path(); + } else { + p.move_to((7.0, 6.0)); + p.line_to((7.0, 18.0)); + p.line_to((16.0, 12.0)); + p.close_path(); + p.move_to((17.0, 6.0)); + p.line_to((17.0, 18.0)); + } + p +} + +/// Avance rápido: dos triángulos (a la izquierda si `rewind`). +fn path_seek(rewind: bool) -> BezPath { + let mut p = BezPath::new(); + if rewind { + p.move_to((11.0, 6.0)); + p.line_to((11.0, 18.0)); + p.line_to((4.0, 12.0)); + p.close_path(); + p.move_to((20.0, 6.0)); + p.line_to((20.0, 18.0)); + p.line_to((13.0, 12.0)); + p.close_path(); + } else { + p.move_to((4.0, 6.0)); + p.line_to((4.0, 18.0)); + p.line_to((11.0, 12.0)); + p.close_path(); + p.move_to((13.0, 6.0)); + p.line_to((13.0, 18.0)); + p.line_to((20.0, 12.0)); + p.close_path(); + } + p +} + +/// Altavoz; con ondas (normal) o con una X (mute). +fn path_volume(mute: bool) -> BezPath { + let mut p = BezPath::new(); + p.move_to((3.0, 9.0)); + p.line_to((8.0, 9.0)); + p.line_to((12.0, 5.0)); + p.line_to((12.0, 19.0)); + p.line_to((8.0, 15.0)); + p.line_to((3.0, 15.0)); + p.close_path(); + if mute { + p.move_to((15.0, 9.0)); + p.line_to((21.0, 15.0)); + p.move_to((21.0, 9.0)); + p.line_to((15.0, 15.0)); + } else { + p.move_to((15.0, 9.0)); + p.quad_to((17.5, 12.0), (15.0, 15.0)); + p.move_to((17.5, 7.0)); + p.quad_to((21.5, 12.0), (17.5, 17.0)); + } + p +} + +fn path_repeat() -> BezPath { + // Dos flechas horizontales opuestas (loop compacto). + let mut p = BezPath::new(); + p.move_to((6.0, 9.0)); + p.line_to((16.0, 9.0)); + p.move_to((14.0, 7.0)); + p.line_to((17.0, 9.0)); + p.line_to((14.0, 11.0)); + p.move_to((18.0, 15.0)); + p.line_to((8.0, 15.0)); + p.move_to((10.0, 13.0)); + p.line_to((7.0, 15.0)); + p.line_to((10.0, 17.0)); + p +} + +fn path_shuffle() -> BezPath { + // Dos flechas que se cruzan. + let mut p = BezPath::new(); + p.move_to((5.0, 8.0)); + p.line_to((19.0, 16.0)); + p.move_to((16.0, 15.5)); + p.line_to((20.0, 16.5)); + p.line_to((17.5, 13.0)); + p.move_to((5.0, 16.0)); + p.line_to((19.0, 8.0)); + p.move_to((17.5, 11.0)); + p.line_to((20.0, 7.5)); + p.line_to((16.0, 8.5)); + p +} + +fn path_record() -> BezPath { + path_circle(12.0, 12.0, 5.0, 20) +} + +fn path_equalizer() -> BezPath { + let mut p = BezPath::new(); + // Tres deslizadores verticales. + p.move_to((7.0, 5.0)); + p.line_to((7.0, 19.0)); + p.move_to((12.0, 5.0)); + p.line_to((12.0, 19.0)); + p.move_to((17.0, 5.0)); + p.line_to((17.0, 19.0)); + // Perillas a distinta altura. + p.move_to((5.0, 9.0)); + p.line_to((9.0, 9.0)); + p.move_to((10.0, 14.0)); + p.line_to((14.0, 14.0)); + p.move_to((15.0, 8.0)); + p.line_to((19.0, 8.0)); + p +} + +fn path_camera() -> BezPath { + let mut p = BezPath::new(); + p.move_to((4.0, 8.0)); + p.line_to((7.0, 8.0)); + p.line_to((9.0, 6.0)); + p.line_to((15.0, 6.0)); + p.line_to((17.0, 8.0)); + p.line_to((20.0, 8.0)); + p.line_to((20.0, 18.0)); + p.line_to((4.0, 18.0)); + p.close_path(); + append(&mut p, &path_circle(12.0, 13.0, 3.5, 16)); + p +} + +fn path_gauge() -> BezPath { + // Esfera + aguja (velocidad). + let mut p = path_circle(12.0, 13.0, 6.0, 20); + p.move_to((12.0, 13.0)); + p.line_to((16.0, 9.0)); + p +} + +// --------------------------------------------------------------------- +// Archivos — tipos por extensión (listados de file manager / shell) +// --------------------------------------------------------------------- + +fn path_image() -> BezPath { + // Marco con una montaña y un sol (el clásico "imagen"). + let mut p = BezPath::new(); + p.move_to((4.0, 5.0)); + p.line_to((20.0, 5.0)); + p.line_to((20.0, 19.0)); + p.line_to((4.0, 19.0)); + p.close_path(); + // Sol. + append(&mut p, &path_circle(8.5, 9.5, 1.6, 12)); + // Montaña (línea quebrada hasta el borde derecho). + p.move_to((4.0, 17.0)); + p.line_to((10.0, 12.0)); + p.line_to((14.0, 15.0)); + p.line_to((17.0, 12.0)); + p.line_to((20.0, 15.0)); + p +} + +fn path_music() -> BezPath { + // Nota musical: dos cabezas redondas unidas por una plica con bandera. + let mut p = BezPath::new(); + // Plicas. + p.move_to((9.0, 18.0)); + p.line_to((9.0, 6.0)); + p.line_to((19.0, 4.0)); + p.line_to((19.0, 16.0)); + // Cabeza izquierda. + append(&mut p, &path_circle(7.0, 18.0, 2.0, 14)); + // Cabeza derecha. + append(&mut p, &path_circle(17.0, 16.0, 2.0, 14)); + p +} + +fn path_film() -> BezPath { + // Tira de película: rectángulo con perforaciones a los lados. + let mut p = BezPath::new(); + p.move_to((4.0, 5.0)); + p.line_to((20.0, 5.0)); + p.line_to((20.0, 19.0)); + p.line_to((4.0, 19.0)); + p.close_path(); + // Rieles internos (separan perforaciones del cuadro central). + p.move_to((8.0, 5.0)); + p.line_to((8.0, 19.0)); + p.move_to((16.0, 5.0)); + p.line_to((16.0, 19.0)); + // Perforaciones (cuatro tics por lado). + for y in [7.5, 11.0, 14.5] { + p.move_to((4.0, y)); + p.line_to((8.0, y)); + p.move_to((16.0, y)); + p.line_to((20.0, y)); + } + p +} + +fn path_archive() -> BezPath { + // Caja/paquete: tapa arriba + cuerpo + tirador del cierre. + let mut p = BezPath::new(); + // Tapa. + p.move_to((3.0, 5.0)); + p.line_to((21.0, 5.0)); + p.line_to((21.0, 9.0)); + p.line_to((3.0, 9.0)); + p.close_path(); + // Cuerpo. + p.move_to((4.5, 9.0)); + p.line_to((4.5, 20.0)); + p.line_to((19.5, 20.0)); + p.line_to((19.5, 9.0)); + // Pestaña del cierre. + p.move_to((10.0, 12.0)); + p.line_to((14.0, 12.0)); + p +} + +fn path_code() -> BezPath { + // Corchetes angulares — universal para "código". + let mut p = BezPath::new(); + // Chevron izquierdo. + p.move_to((9.0, 7.0)); + p.line_to((4.0, 12.0)); + p.line_to((9.0, 17.0)); + // Chevron derecho. + p.move_to((15.0, 7.0)); + p.line_to((20.0, 12.0)); + p.line_to((15.0, 17.0)); + // Barra diagonal central. + p.move_to((13.0, 6.0)); + p.line_to((11.0, 18.0)); + p +} + +fn path_file_text() -> BezPath { + // Documento (como `file`) con líneas de texto adentro. + let mut p = path_file(); + p.move_to((8.5, 12.0)); + p.line_to((16.5, 12.0)); + p.move_to((8.5, 15.0)); + p.line_to((16.5, 15.0)); + p.move_to((8.5, 18.0)); + p.line_to((13.5, 18.0)); + p +} + +fn path_link() -> BezPath { + // Symlink: dos eslabones de cadena en diagonal. + let mut p = BezPath::new(); + // Eslabón superior-izquierdo (cápsula inclinada). + p.move_to((10.0, 14.0)); + p.line_to((7.0, 11.0)); + p.curve_to((5.0, 9.0), (5.0, 7.0), (7.0, 5.0)); + p.curve_to((9.0, 3.0), (11.0, 3.0), (13.0, 5.0)); + p.line_to((15.0, 7.0)); + // Eslabón inferior-derecho. + p.move_to((14.0, 10.0)); + p.line_to((17.0, 13.0)); + p.curve_to((19.0, 15.0), (19.0, 17.0), (17.0, 19.0)); + p.curve_to((15.0, 21.0), (13.0, 21.0), (11.0, 19.0)); + p.line_to((9.0, 17.0)); + p +} + +fn path_font() -> BezPath { + // Letra "A" serif — glifo de fuente tipográfica. + let mut p = BezPath::new(); + // Astas de la A. + p.move_to((6.0, 20.0)); + p.line_to((12.0, 4.0)); + p.line_to((18.0, 20.0)); + // Travesaño. + p.move_to((8.5, 14.0)); + p.line_to((15.5, 14.0)); + // Serifas inferiores. + p.move_to((4.5, 20.0)); + p.line_to((7.5, 20.0)); + p.move_to((16.5, 20.0)); + p.line_to((19.5, 20.0)); + p +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_icons_have_nonempty_path() { + let all = [ + Icon::File, Icon::Folder, Icon::FolderOpen, Icon::Save, Icon::Open, + Icon::Plus, Icon::Minus, Icon::X, Icon::Check, Icon::Edit, Icon::Trash, + Icon::ChevronUp, Icon::ChevronDown, Icon::ChevronLeft, Icon::ChevronRight, + Icon::Home, Icon::Search, Icon::Info, Icon::Warning, Icon::Error, + Icon::Bell, Icon::Settings, Icon::More, + Icon::Play, Icon::Pause, Icon::Stop, Icon::SkipBack, Icon::SkipForward, + Icon::Rewind, Icon::FastForward, Icon::Volume, Icon::VolumeMute, + Icon::Repeat, Icon::Shuffle, Icon::Record, Icon::Equalizer, + Icon::Camera, Icon::Gauge, + Icon::Image, Icon::Music, Icon::Film, Icon::Archive, + Icon::Code, Icon::FileText, Icon::Link, Icon::Font, + ]; + for icon in all { + let p = icon.path(); + assert!( + p.elements().len() > 0, + "icono {} produjo path vacío", + icon.name() + ); + } + } + + #[test] + fn icon_names_are_unique() { + let all = [ + Icon::File, Icon::Folder, Icon::FolderOpen, Icon::Save, Icon::Open, + Icon::Plus, Icon::Minus, Icon::X, Icon::Check, Icon::Edit, Icon::Trash, + Icon::ChevronUp, Icon::ChevronDown, Icon::ChevronLeft, Icon::ChevronRight, + Icon::Home, Icon::Search, Icon::Info, Icon::Warning, Icon::Error, + Icon::Bell, Icon::Settings, Icon::More, + Icon::Play, Icon::Pause, Icon::Stop, Icon::SkipBack, Icon::SkipForward, + Icon::Rewind, Icon::FastForward, Icon::Volume, Icon::VolumeMute, + Icon::Repeat, Icon::Shuffle, Icon::Record, Icon::Equalizer, + Icon::Camera, Icon::Gauge, + Icon::Image, Icon::Music, Icon::Film, Icon::Archive, + Icon::Code, Icon::FileText, Icon::Link, Icon::Font, + ]; + 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 Icon::name()"); + } +} diff --git a/llimphi-layout/Cargo.toml b/llimphi-layout/Cargo.toml new file mode 100644 index 0000000..bbcb88b --- /dev/null +++ b/llimphi-layout/Cargo.toml @@ -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" diff --git a/llimphi-layout/LEEME.md b/llimphi-layout/LEEME.md new file mode 100644 index 0000000..35f1fa1 --- /dev/null +++ b/llimphi-layout/LEEME.md @@ -0,0 +1,10 @@ +# llimphi-layout + +> Layout taffy + extensiones de [llimphi](../README.md). + +Wrapper sobre `taffy` (Flexbox + Grid) con tipos ergonómicos para `View`. Cache del layout calculado entre frames; invalidación dirigida cuando el árbol cambia. + +## Deps + +- `taffy`, `glam` +- `serde` diff --git a/llimphi-layout/README.md b/llimphi-layout/README.md new file mode 100644 index 0000000..01e1677 --- /dev/null +++ b/llimphi-layout/README.md @@ -0,0 +1,10 @@ +# llimphi-layout + +> Taffy layout + extensions of [llimphi](../README.md). + +Wrapper over `taffy` (Flexbox + Grid) with ergonomic types for `View`. Cache of computed layout between frames; directed invalidation when the tree changes. + +## Deps + +- `taffy`, `glam` +- `serde` diff --git a/llimphi-layout/examples/layout_panels.rs b/llimphi-layout/examples/layout_panels.rs new file mode 100644 index 0000000..f3ecdb2 --- /dev/null +++ b/llimphi-layout/examples/layout_panels.rs @@ -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, + hal: Hal, + surface: WinitSurface, + renderer: Renderer, + scene: vello::Scene, + layout: LayoutTree, + panels: Panels, +} + +struct App { + state: Option, +} + +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 { + 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"); +} diff --git a/llimphi-layout/src/lib.rs b/llimphi-layout/src/lib.rs new file mode 100644 index 0000000..b6932e5 --- /dev/null +++ b/llimphi-layout/src/lib.rs @@ -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, +} + +impl ComputedLayout { + pub fn get(&self, node: NodeId) -> Option { + 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 { + 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 { + 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 { + 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( + &mut self, + root: NodeId, + viewport: (f32, f32), + mut measure: F, + ) -> Result + where + F: FnMut(NodeId, taffy::Size>, taffy::Size) -> taffy::Size, + { + 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, +) -> 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(()) +} diff --git a/llimphi-motion/Cargo.toml b/llimphi-motion/Cargo.toml new file mode 100644 index 0000000..cb28654 --- /dev/null +++ b/llimphi-motion/Cargo.toml @@ -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 + 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 } diff --git a/llimphi-motion/src/lib.rs b/llimphi-motion/src/lib.rs new file mode 100644 index 0000000..2b636d8 --- /dev/null +++ b/llimphi-motion/src/lib.rs @@ -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 } +//! +//! // 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 { + 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 Tween { + /// 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(handle: &Handle, 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); + } +} diff --git a/llimphi-raster/Cargo.toml b/llimphi-raster/Cargo.toml new file mode 100644 index 0000000..37ce9d1 --- /dev/null +++ b/llimphi-raster/Cargo.toml @@ -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" diff --git a/llimphi-raster/LEEME.md b/llimphi-raster/LEEME.md new file mode 100644 index 0000000..7e3e456 --- /dev/null +++ b/llimphi-raster/LEEME.md @@ -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` diff --git a/llimphi-raster/README.md b/llimphi-raster/README.md new file mode 100644 index 0000000..c929b08 --- /dev/null +++ b/llimphi-raster/README.md @@ -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` diff --git a/llimphi-raster/examples/gpu_million_points.rs b/llimphi-raster/examples/gpu_million_points.rs new file mode 100644 index 0000000..1449bcb --- /dev/null +++ b/llimphi-raster/examples/gpu_million_points.rs @@ -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 = 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] +} diff --git a/llimphi-raster/examples/render_node.rs b/llimphi-raster/examples/render_node.rs new file mode 100644 index 0000000..dcfa31a --- /dev/null +++ b/llimphi-raster/examples/render_node.rs @@ -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, + hal: Hal, + surface: WinitSurface, + renderer: Renderer, + scene: vello::Scene, +} + +struct App { + state: Option, + 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"); +} diff --git a/llimphi-raster/examples/spike_gpu_directo.rs b/llimphi-raster/examples/spike_gpu_directo.rs new file mode 100644 index 0000000..5d82cbd --- /dev/null +++ b/llimphi-raster/examples/spike_gpu_directo.rs @@ -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 = 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 = 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 = 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 = 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 { + 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, + @location(1) rgba: u32, +}; + +struct V2F { + @builtin(position) pos: vec4, + @location(0) color: vec4, +}; + +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, 6>( + vec2(-0.75, -0.75), + vec2( 0.75, -0.75), + vec2( 0.75, 0.75), + vec2(-0.75, -0.75), + vec2( 0.75, 0.75), + vec2(-0.75, 0.75), + ); + let off = corners[vid]; + let px = inst.xy + vec2(0.5, 0.5) + off; + // pixel → NDC, Y invertido (vello / textura framebuffer). + let ndc = vec2(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(ndc, 0.0, 1.0); + out.color = vec4(r, g, b, a); + return out; +} + +@fragment +fn fs(in: V2F) -> @location(0) vec4 { + return in.color; +} +"#; diff --git a/llimphi-raster/src/gpu.rs b/llimphi-raster/src/gpu.rs new file mode 100644 index 0000000..d25885b --- /dev/null +++ b/llimphi-raster/src/gpu.rs @@ -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, 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, + tri_verts: Vec, + rect_insts: Vec, + 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, + ) { + 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, + line_width: f32, + _pad: f32, +}; + +@group(0) @binding(0) var u: Uniforms; + +struct V2F { + @builtin(position) pos: vec4, + @location(0) color: vec4, +}; + +fn unpack_rgba(c: u32) -> vec4 { + 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(r, g, b, a); +} + +fn px_to_ndc(p: vec2) -> vec2 { + return vec2(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, @location(1) rgba: u32) -> V2F { + var out: V2F; + out.pos = vec4(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, + @location(1) inst_wh: vec2, + @location(2) inst_rgba: u32, +) -> V2F { + var corners = array, 6>( + vec2(0.0, 0.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0), + vec2(0.0, 0.0), + vec2(1.0, 1.0), + vec2(0.0, 1.0), + ); + let local = corners[vid]; + let px = inst_xy + local * inst_wh; + var out: V2F; + out.pos = vec4(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, + @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(-dir.y, dir.x); + let half_w = u.line_width * 0.5; + let offsets = array, 6>( + vec2(0.0, -half_w), // p0 -n + vec2(0.0, half_w), // p0 +n + vec2(1.0, half_w), // p1 +n + vec2(0.0, -half_w), // p0 -n + vec2(1.0, half_w), // p1 +n + vec2(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(px_to_ndc(px), 0.0, 1.0); + out.color = unpack_rgba(rgba); + return out; +} + +@fragment +fn fs(in: V2F) -> @location(0) vec4 { + return in.color; +} +"#; diff --git a/llimphi-raster/src/lib.rs b/llimphi-raster/src/lib.rs new file mode 100644 index 0000000..cf63a08 --- /dev/null +++ b/llimphi-raster/src/lib.rs @@ -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 { + 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())) + } +} diff --git a/llimphi-raster/tests/gpu_batch_smoke.rs b/llimphi-raster/tests/gpu_batch_smoke.rs new file mode 100644 index 0000000..0241dc0 --- /dev/null +++ b/llimphi-raster/tests/gpu_batch_smoke.rs @@ -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); +} diff --git a/llimphi-surface/Cargo.toml b/llimphi-surface/Cargo.toml new file mode 100644 index 0000000..af35105 --- /dev/null +++ b/llimphi-surface/Cargo.toml @@ -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 } diff --git a/llimphi-surface/src/lib.rs b/llimphi-surface/src/lib.rs new file mode 100644 index 0000000..06279c8 --- /dev/null +++ b/llimphi-surface/src/lib.rs @@ -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>, +} + +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(&self, style: llimphi_ui::llimphi_layout::taffy::Style) -> View + 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, // x, y, w, h en pixels del frame + viewport: vec4, // vw, vh, _, _ +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var tex: texture_2d; +@group(0) @binding(2) var samp: sampler; + +struct V2F { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs(@builtin(vertex_index) vid: u32) -> V2F { + // Dos triángulos en UV-space, recorridos CCW. + var uvs = array, 6>( + vec2(0.0, 0.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0), + vec2(0.0, 0.0), + vec2(1.0, 1.0), + vec2(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( + px / u.viewport.x * 2.0 - 1.0, + 1.0 - py / u.viewport.y * 2.0, + ); + + var out: V2F; + out.pos = vec4(ndc, 0.0, 1.0); + out.uv = uv; + return out; +} + +@fragment +fn fs(in: V2F) -> @location(0) vec4 { + return textureSample(tex, samp, in.uv); +} +"#; diff --git a/llimphi-text/Cargo.toml b/llimphi-text/Cargo.toml new file mode 100644 index 0000000..7fbb288 --- /dev/null +++ b/llimphi-text/Cargo.toml @@ -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" diff --git a/llimphi-text/LEEME.md b/llimphi-text/LEEME.md new file mode 100644 index 0000000..82ff585 --- /dev/null +++ b/llimphi-text/LEEME.md @@ -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) diff --git a/llimphi-text/README.md b/llimphi-text/README.md new file mode 100644 index 0000000..19f9c2d --- /dev/null +++ b/llimphi-text/README.md @@ -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) diff --git a/llimphi-text/assets/DejaVuSans.ttf b/llimphi-text/assets/DejaVuSans.ttf new file mode 100644 index 0000000..5267218 Binary files /dev/null and b/llimphi-text/assets/DejaVuSans.ttf differ diff --git a/llimphi-text/examples/hello_text.rs b/llimphi-text/examples/hello_text.rs new file mode 100644 index 0000000..7cc8614 --- /dev/null +++ b/llimphi-text/examples/hello_text.rs @@ -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, + hal: Hal, + surface: WinitSurface, + renderer: llimphi_raster::Renderer, + scene: llimphi_raster::vello::Scene, + typesetter: Typesetter, +} + +struct App { + state: Option, +} + +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"); +} diff --git a/llimphi-text/src/lib.rs b/llimphi-text/src/lib.rs new file mode 100644 index 0000000..730cf96 --- /dev/null +++ b/llimphi-text/src/lib.rs @@ -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, +} + +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, + 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 { + 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 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, + 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, +} + +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, + 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); +} diff --git a/llimphi-theme/Cargo.toml b/llimphi-theme/Cargo.toml new file mode 100644 index 0000000..03cc663 --- /dev/null +++ b/llimphi-theme/Cargo.toml @@ -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" } diff --git a/llimphi-theme/LEEME.md b/llimphi-theme/LEEME.md new file mode 100644 index 0000000..c08bb09 --- /dev/null +++ b/llimphi-theme/LEEME.md @@ -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` diff --git a/llimphi-theme/README.md b/llimphi-theme/README.md new file mode 100644 index 0000000..cd06e10 --- /dev/null +++ b/llimphi-theme/README.md @@ -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` diff --git a/llimphi-theme/src/lib.rs b/llimphi-theme/src/lib.rs new file mode 100644 index 0000000..3ff7990 --- /dev/null +++ b/llimphi-theme/src/lib.rs @@ -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 { + vec![Self::dark(), Self::light(), Self::aurora(), Self::sunset()] + } + + /// Busca un preset por nombre exacto. + pub fn by_name(name: &str) -> Option { + 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"); + } +} diff --git a/llimphi-ui/Cargo.toml b/llimphi-ui/Cargo.toml new file mode 100644 index 0000000..303b2ab --- /dev/null +++ b/llimphi-ui/Cargo.toml @@ -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" diff --git a/llimphi-ui/LEEME.md b/llimphi-ui/LEEME.md new file mode 100644 index 0000000..851c27c --- /dev/null +++ b/llimphi-ui/LEEME.md @@ -0,0 +1,9 @@ +# llimphi-ui + +> `View` 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) diff --git a/llimphi-ui/README.md b/llimphi-ui/README.md new file mode 100644 index 0000000..6db31b4 --- /dev/null +++ b/llimphi-ui/README.md @@ -0,0 +1,9 @@ +# llimphi-ui + +> Retained-mode `View` + 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) diff --git a/llimphi-ui/examples/counter.rs b/llimphi-ui/examples/counter.rs new file mode 100644 index 0000000..8146812 --- /dev/null +++ b/llimphi-ui/examples/counter.rs @@ -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::Model { + 0 + } + + fn update(model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Increment => model.saturating_add(1), + Msg::Reset => 0, + } + } + + fn view(model: &Self::Model) -> View { + 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::(); +} diff --git a/llimphi-ui/examples/editor.rs b/llimphi-ui/examples/editor.rs new file mode 100644 index 0000000..5dd8a46 --- /dev/null +++ b/llimphi-ui/examples/editor.rs @@ -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::Model { + String::new() + } + + fn update(model: Self::Model, msg: Self::Msg, _: &Handle) -> 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 { + 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 { + 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::(); +} diff --git a/llimphi-ui/examples/gpu_paint_demo.rs b/llimphi-ui/examples/gpu_paint_demo.rs new file mode 100644 index 0000000..eaba54c --- /dev/null +++ b/llimphi-ui/examples/gpu_paint_demo.rs @@ -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::Model { + 0 + } + + fn update(model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Bump => model.wrapping_add(1), + } + } + + fn view(model: &Self::Model) -> View { + 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::(); +} + +// ============================================================ +// 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> { + static SLOT: OnceLock> = 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, // x, y, w, h en pixels del frame + seed: u32, + _pad0: u32, + _pad1: u32, + _pad2: u32, +}; + +@group(0) @binding(0) var u: Uniforms; + +struct V2F { + @builtin(position) pos: vec4, + @location(0) color: vec4, +}; + +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, 6>( + vec2(-1.0, -1.0), + vec2( 1.0, -1.0), + vec2( 1.0, 1.0), + vec2(-1.0, -1.0), + vec2( 1.0, 1.0), + vec2(-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( + 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(ndc, 0.0, 1.0); + out.color = vec4(r, g, b, 0.85); + return out; +} + +@fragment +fn fs(in: V2F) -> @location(0) vec4 { + return in.color; +} +"#; diff --git a/llimphi-ui/src/eventloop.rs b/llimphi-ui/src/eventloop.rs new file mode 100644 index 0000000..a20abe2 --- /dev/null +++ b/llimphi-ui/src/eventloop.rs @@ -0,0 +1,1301 @@ +use super::*; + +pub(crate) fn build_window_attributes() -> WindowAttributes { + let (w, h) = A::initial_size(); + let attrs = WindowAttributes::default() + .with_title(A::title()) + .with_inner_size(LogicalSize::new(w, h)); + // En Linux, `with_name` del trait de Wayland mapea al `app_id` del + // xdg-toplevel — lo que el compositor (`mirada-compositor`) usa para + // reconocer ventanas especiales (greeter, launcher…). + #[cfg(all(target_os = "linux", not(target_os = "android")))] + { + if let Some(id) = A::app_id() { + use llimphi_hal::winit::platform::wayland::WindowAttributesExtWayland; + return attrs.with_name(id, ""); + } + } + attrs +} + +impl ApplicationHandler> for Runtime { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.state.is_some() { + return; + } + let window = event_loop + .create_window(build_window_attributes::()) + .expect("create window"); + let window = Arc::new(window); + // IME opt-in: sólo se habilita si la app lo pide (ver `App::ime_allowed`). + // Con IME activo el texto compuesto llega por `WindowEvent::Ime`. + if A::ime_allowed() { + window.set_ime_allowed(true); + } + 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 overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device); + let typesetter = llimphi_text::Typesetter::new(); + window.request_redraw(); + self.state = Some(RuntimeState { + window, + hal, + surface, + renderer, + scene: vello::Scene::new(), + overlay_compositor, + model: Some(A::init(&self.handle)), + cursor: PhysicalPosition::new(0.0, 0.0), + modifiers: Modifiers::default(), + typesetter, + layout: LayoutTree::new(), + overlay_layout: LayoutTree::new(), + last_render: None, + hovered: None, + drag: None, + focused: None, + last_title: None, + }); + // Sincroniza el factor de escala inicial (el de la ventana recién + // creada) ANTES del primer render: así una app que dependa del DPI + // (p. ej. `devicePixelRatio` en puriy) ya lo tiene correcto en su + // primera pasada, sin esperar a un ScaleFactorChanged. + if let Some(state) = self.state.as_mut() { + let scale = state.window.scale_factor(); + if let Some(msg) = A::on_scale_factor(state.model.as_ref().expect("model"), scale) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + } + } + } + + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { + match event { + UserEvent::Quit => event_loop.exit(), + UserEvent::Msg(msg) => { + // Un Msg del canal (Handle::dispatch, ticks periódicos, trabajo + // de fondo) muta el modelo compartido y repinta TODAS las + // ventanas — así un cambio se refleja tanto en la primaria como + // en las secundarias (config) sin importar de dónde vino. + self.dispatch_model(msg); + } + UserEvent::OpenWindow { key, title, width, height } => { + self.open_secondary(event_loop, key, title, width, height); + } + UserEvent::CloseWindow { key } => { + if let Some(pos) = self.secondaries.iter().position(|s| s.key == key) { + // Drop de la SecondaryState → se destruye la ventana/surface. + self.secondaries.remove(pos); + } + } + } + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + // ¿El evento es de una ventana secundaria? Lo atiende su handler + // dedicado (path aparte: la primaria queda 100% intacta). + if let Some(idx) = self.secondaries.iter().position(|s| s.window.id() == _id) { + self.handle_secondary_event(idx, event); + return; + } + 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); + // La app puede reaccionar al nuevo viewport (emitir un + // evento `resize`, recalcular layout, etc.). El update se + // corre tras reconfigurar la surface; el cache se invalida + // para repintar con el tamaño nuevo. + if let Some(msg) = + A::on_resize(state.model.as_ref().expect("model"), size.width, size.height) + { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + } + state.window.request_redraw(); + } + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + // El DPI de la ventana cambió (movida a otro monitor, escalado + // del sistema). winit envía un Resized aparte para el nuevo + // tamaño físico; aquí sólo propagamos el factor. + if let Some(msg) = + A::on_scale_factor(state.model.as_ref().expect("model"), scale_factor) + { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + } + state.window.request_redraw(); + } + WindowEvent::CursorMoved { position, .. } => { + let prev_cursor = state.cursor; + state.cursor = position; + // Drag activo: dispatchear delta al handler + actualizar + // tracking del drop target hovereado (solo si hay payload). + if let Some(drag) = state.drag.as_mut() { + let dx = (position.x - drag.last_cursor.x) as f32; + let dy = (position.y - drag.last_cursor.y) as f32; + drag.last_cursor = position; + let payload_active = drag.payload.is_some(); + let mut need_redraw = false; + if dx != 0.0 || dy != 0.0 { + let msg_opt = match &drag.handler { + DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy), + DragHandlerKind::DeltaAt(h, lx0, ly0) => { + h(DragPhase::Move, dx, dy, *lx0, *ly0) + } + }; + if let Some(msg) = msg_opt { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + // Durante drag NO invalidamos el cache — + // queda válido para el próximo Move. + need_redraw = true; + } + } + if payload_active { + if let Some(cache) = state.last_render.as_mut() { + let new_drop = hit_test_drop( + &cache.mounted, + &cache.computed, + position.x as f32, + position.y as f32, + ); + if new_drop != cache.drop_hover_idx { + cache.drop_hover_idx = new_drop; + need_redraw = true; + } + } + } + if need_redraw { + state.window.request_redraw(); + } + } else { + // Sin drag: chequear hover. Si hay overlay, el + // hover-test va contra él; el árbol principal queda + // congelado mientras el overlay esté arriba. + // + // Además del repintado (para el `hover_fill`), si el + // nodo recién hovereado declara un `on_pointer_enter`, + // lo dispatcheamos: es lo que permite, p.ej., cambiar + // de menú con el mouse o abrir un submenú al pasar por + // encima. Extraemos el Msg en un scope para soltar el + // borrow del cache antes de mutar el modelo. + let mut enter_msg: Option = None; + let mut hovered_changed = false; + let mut new_hovered: Option = state.hovered; + if let Some(cache) = state.last_render.as_ref() { + let (mounted, computed) = match cache.overlay.as_ref() { + Some(ov) => (&ov.mounted, &ov.computed), + None => (&cache.mounted, &cache.computed), + }; + let new_hover = hit_test_hover( + mounted, + computed, + position.x as f32, + position.y as f32, + ); + // Comparamos contra el hover PERSISTENTE (state.hovered), + // no contra el del cache: el render recomputa el del cache + // al cursor actual cada cuadro, así que en una app que + // re-renderiza sin parar la transición de hover se perdería + // (y el hover-switch de menús no andaría). Ver `hovered`. + if new_hover != state.hovered { + hovered_changed = true; + enter_msg = new_hover + .and_then(|i| mounted.nodes.get(i)) + .and_then(|n| n.on_pointer_enter.clone()); + } + new_hovered = new_hover; + } + state.hovered = new_hovered; + if hovered_changed { + state.window.request_redraw(); + } + if let Some(msg) = enter_msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + // El estado cambió → invalidamos el cache para + // re-render (p.ej. el submenú que se abre). + state.last_render = None; + } + let _ = prev_cursor; + } + } + WindowEvent::ModifiersChanged(mods) => { + state.modifiers = mods.state().into(); + } + WindowEvent::Ime(ime) if A::ime_allowed() => { + use llimphi_hal::winit::event::Ime; + let ev = match ime { + Ime::Enabled => ImeEvent::Enabled, + Ime::Preedit(text, cursor) => ImeEvent::Preedit { text, cursor }, + Ime::Commit(text) => ImeEvent::Commit(text), + Ime::Disabled => ImeEvent::Disabled, + }; + if let Some(msg) = A::on_ime(state.model.as_ref().expect("model"), &ev) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::KeyboardInput { event, .. } => { + // Tab / Shift+Tab mueven el foco entre nodos `focusable`, + // que administra el runtime. Sólo intercepta si hay + // enfocables y en Pressed; si no, cae al `on_key` normal + // (apps que usan Tab para otra cosa lo siguen recibiendo). + let is_tab = event.state == ElementState::Pressed + && matches!(event.logical_key, Key::Named(NamedKey::Tab)); + if is_tab { + let order = state + .last_render + .as_ref() + .map(|c| focus_order(&c.mounted, &c.computed)) + .unwrap_or_default(); + if !order.is_empty() { + let next = next_focus(&order, state.focused, state.modifiers.shift); + state.focused = next; + if let Some(msg) = + A::on_focus(state.model.as_ref().expect("model"), next) + { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + } + state.last_render = None; + state.window.request_redraw(); + return; + } + } + let ev = KeyEvent { + key: event.logical_key.clone(), + state: match event.state { + ElementState::Pressed => KeyState::Pressed, + ElementState::Released => KeyState::Released, + }, + text: event.text.as_ref().map(|t| t.to_string()), + modifiers: state.modifiers, + repeat: event.repeat, + }; + if let Some(msg) = A::on_key(state.model.as_ref().expect("model"), &ev) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::DroppedFile(path) => { + // Un evento por archivo (winit los entrega serializados); si + // el usuario suelta varios, el bucle re-entra y aplicamos + // updates en orden. + if let Some(msg) = A::on_file_drop(state.model.as_ref().expect("model"), path) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::MouseWheel { delta, .. } => { + // Convención winit: LineDelta es líneas; PixelDelta es + // píxeles físicos (touchpads). En CSS y aquí, positivo + // (rueda hacia adelante / dos dedos arriba) = scroll + // hacia arriba, así que invertimos `y` para que el + // contenido "siga al dedo" en y positivo. `x` queda + // como llega. + let wd = match delta { + MouseScrollDelta::LineDelta(x, y) => WheelDelta { x, y: -y }, + MouseScrollDelta::PixelDelta(p) => WheelDelta { + x: (p.x as f32) / 20.0, + y: -(p.y as f32) / 20.0, + }, + }; + let cursor = (state.cursor.x as f32, state.cursor.y as f32); + // Primero: ¿hay un nodo con `on_scroll` bajo el cursor? Si + // consume el evento (`Some`), no cae al `on_wheel` global. + // El overlay tiene prioridad, igual que con clicks. Se + // extrae el handler en un scope para soltar el borrow del + // cache antes de mutar el modelo. + let scroll_handler: Option> = + if let Some(cache) = state.last_render.as_ref() { + let (m, c) = match cache.overlay.as_ref() { + Some(ov) => (&ov.mounted, &ov.computed), + None => (&cache.mounted, &cache.computed), + }; + hit_test_scroll(m, c, cursor.0, cursor.1) + .and_then(|i| m.nodes[i].on_scroll.clone()) + } else { + None + }; + let msg = match scroll_handler { + Some(h) => h(wd.x, wd.y), + None => A::on_wheel( + state.model.as_ref().expect("model"), + wd, + cursor, + state.modifiers, + ), + }; + if let Some(msg) = msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Left, + .. + } => { + // Hit-test contra el cache del último redraw (siempre + // representa lo visible). Fallback raro: cache vacío. + let cursor = state.cursor; + // Click-to-focus: si el click cae sobre un nodo enfocable, + // el runtime le da el foco ANTES de procesar la acción de + // click. Extraemos el id en un scope (suelta el borrow del + // cache) y recién después mutamos el foco/modelo. + let focus_hit = state + .last_render + .as_ref() + .and_then(|cache| { + let (m, c) = match cache.overlay.as_ref() { + Some(ov) => (&ov.mounted, &ov.computed), + None => (&cache.mounted, &cache.computed), + }; + hit_test_focusable(m, c, cursor.x as f32, cursor.y as f32) + }); + if focus_hit.is_some() && focus_hit != state.focused { + state.focused = focus_hit; + if let Some(msg) = + A::on_focus(state.model.as_ref().expect("model"), focus_hit) + { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + } + state.last_render = None; + } + // Tupla: (drag_fn, drag_at_fn, payload, on_click_msg, + // on_click_at_handler, rect: (x, y, w, h)) + type HitInfo = ( + Option>, + Option>, + Option, + Option, + Option>, + Option<(f32, f32, f32, f32)>, + ); + let lookup_hit = |m: &Mounted, c: &ComputedLayout| -> Option> { + hit_test_click(m, c, cursor.x as f32, cursor.y as f32).map(|i| { + let node = &m.nodes[i]; + let rect = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h)); + ( + node.drag.clone(), + node.drag_at.clone(), + node.drag_payload, + node.on_click.clone(), + node.on_click_at.clone(), + rect, + ) + }) + }; + // Con overlay activo, los clicks van EXCLUSIVAMENTE a él. + // Si el cursor cae sobre un nodo del overlay sin handler, + // el click se descarta — la convención de "scrim que + // dismissa" pide que la app meta su propio fondo + // clicable con `on_click = DismissOverlay`. + let idx_and_action: Option> = if let Some(cache) = + state.last_render.as_ref() + { + if let Some(ov) = cache.overlay.as_ref() { + lookup_hit(&ov.mounted, &ov.computed) + } else { + lookup_hit(&cache.mounted, &cache.computed) + } + } else { + let model_ref = state.model.as_ref().expect("model"); + let view = A::view(model_ref); + let overlay_view = A::view_overlay(model_ref); + let mut layout = LayoutTree::new(); + let mounted: Mounted = mount(&mut layout, view); + let (w, h) = state.surface.size(); + let ts = &mut state.typesetter; + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout") + }; + if let Some(ov) = overlay_view { + let mut olay = LayoutTree::new(); + let omounted: Mounted = mount(&mut olay, ov); + let ocomp = { + let tmap = &omounted.text_measures; + olay + .compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout overlay") + }; + lookup_hit(&omounted, &ocomp) + } else { + lookup_hit(&mounted, &computed) + } + }; + // drag_at + on_click_at COEXISTEN: el press dispara + // on_click_at (si está) y arranca un drag rastreado con la + // posición inicial. Diseño pensado para canvas elements + // que necesitan select-on-press + move-on-drag. + // + // En cambio, `drag` simple (sin _at) mantiene la semántica + // antigua: gana exclusivo sobre on_click. + if let Some((_, Some(handler_at), payload, _, click_at, Some((ox, oy, rw, rh)))) = + &idx_and_action + { + let lx0 = cursor.x as f32 - ox; + let ly0 = cursor.y as f32 - oy; + // Disparar on_click_at en el press (si también está). + if let Some(click_at_h) = click_at { + if let Some(msg) = click_at_h(lx0, ly0, *rw, *rh) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + } + } + state.drag = Some(DragState { + handler: DragHandlerKind::DeltaAt(handler_at.clone(), lx0, ly0), + last_cursor: cursor, + payload: *payload, + }); + state.window.request_redraw(); + } else if let Some((Some(handler), _, payload, _, _, _)) = &idx_and_action { + state.drag = Some(DragState { + handler: DragHandlerKind::Delta(handler.clone()), + last_cursor: cursor, + payload: *payload, + }); + // Si hay payload, repintar para que el drop target + // bajo cursor (si lo hay) se ilumine de entrada. + if payload.is_some() { + if let Some(cache) = state.last_render.as_mut() { + let new_drop = hit_test_drop( + &cache.mounted, + &cache.computed, + cursor.x as f32, + cursor.y as f32, + ); + if new_drop != cache.drop_hover_idx { + cache.drop_hover_idx = new_drop; + state.window.request_redraw(); + } + } + } + } else if let Some((_, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) = + &idx_and_action + { + // on_click_at gana sobre on_click si ambos existen. + let lx = cursor.x as f32 - ox; + let ly = cursor.y as f32 - oy; + if let Some(msg) = handler(lx, ly, *rw, *rh) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } else if let Some((_, _, _, Some(msg), _, _)) = idx_and_action { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Middle, + .. + } => { + // Middle-click: dispatcha `on_middle_click` del nodo + // bajo cursor si lo declaró. La capa overlay tiene + // prioridad (mismo razonamiento que el left/right click). + let cursor = state.cursor; + let lookup = + |m: &Mounted, c: &ComputedLayout| -> Option { + hit_test_middle_click(m, c, cursor.x as f32, cursor.y as f32) + .and_then(|i| m.nodes[i].on_middle_click.clone()) + }; + let msg = if let Some(cache) = state.last_render.as_ref() { + if let Some(ov) = cache.overlay.as_ref() { + lookup(&ov.mounted, &ov.computed) + } else { + lookup(&cache.mounted, &cache.computed) + } + } else { + None + }; + if let Some(msg) = msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Right, + .. + } => { + // Right-click: dispatcheamos `on_right_click` o + // `on_right_click_at` del nodo bajo cursor. La capa + // overlay tiene prioridad (mismo razonamiento que el + // left-click). Nodos sin handler de right-click no + // reaccionan — no "filtramos" al left. + let cursor = state.cursor; + let lookup = + |m: &Mounted, c: &ComputedLayout| -> Option<(Option, Option>, (f32, f32, f32, f32))> { + hit_test_right_click(m, c, cursor.x as f32, cursor.y as f32).map(|i| { + let node = &m.nodes[i]; + let rect = c + .get(node.id) + .map(|r| (r.x, r.y, r.w, r.h)) + .unwrap_or((0.0, 0.0, 0.0, 0.0)); + ( + node.on_right_click.clone(), + node.on_right_click_at.clone(), + rect, + ) + }) + }; + let hit = if let Some(cache) = state.last_render.as_ref() { + if let Some(ov) = cache.overlay.as_ref() { + lookup(&ov.mounted, &ov.computed) + } else { + lookup(&cache.mounted, &cache.computed) + } + } else { + None + }; + if let Some((msg_opt, at_opt, (ox, oy, rw, rh))) = hit { + let msg = if let Some(handler) = at_opt { + handler( + cursor.x as f32 - ox, + cursor.y as f32 - oy, + rw, + rh, + ) + } else { + msg_opt + }; + if let Some(msg) = msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + } + WindowEvent::MouseInput { + state: ElementState::Released, + button: MouseButton::Left, + .. + } => { + if let Some(drag) = state.drag.take() { + let cursor = state.cursor; + // 1. Drop: si hay payload + drop target bajo cursor, + // invocamos su handler. El Msg resultante se aplica + // ANTES del End del drag — la convención es "drop + // primero, cleanup del drag después". + if let Some(payload) = drag.payload { + if let Some(cache) = state.last_render.as_ref() { + if let Some(idx) = hit_test_drop( + &cache.mounted, + &cache.computed, + cursor.x as f32, + cursor.y as f32, + ) { + if let Some(drop_h) = + cache.mounted.nodes[idx].on_drop.clone() + { + if let Some(msg) = (drop_h)(payload) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + } + } + } + } + } + // 2. Cierre del drag. + let end_msg = match &drag.handler { + DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0), + DragHandlerKind::DeltaAt(h, lx0, ly0) => { + h(DragPhase::End, 0.0, 0.0, *lx0, *ly0) + } + }; + if let Some(msg) = end_msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + } + // Cache invalidado siempre — hover/drop pueden cambiar + // y el modelo posiblemente mutó. + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::RedrawRequested => { + // Título dinámico (App::window_title): si cambió respecto del + // último aplicado, se lo pasamos a winit. Barato: una + // comparación de String por frame, set_title sólo en el cambio. + if let Some(t) = A::window_title(state.model.as_ref().expect("model")) { + if state.last_title.as_deref() != Some(t.as_str()) { + state.window.set_title(&t); + state.last_title = Some(t); + } + } + // Posicioná la ventana de candidatos del IME junto al caret + // (sólo con IME activo y si la app reporta el área). + if A::ime_allowed() { + if let Some((x, y, w, h)) = + A::ime_cursor_area(state.model.as_ref().expect("model")) + { + state.window.set_ime_cursor_area( + PhysicalPosition::new(x as f64, y as f64), + llimphi_hal::winit::dpi::PhysicalSize::new( + w.max(1.0) as u32, + h.max(1.0) as u32, + ), + ); + } + } + 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 model_ref = state.model.as_ref().expect("model"); + let view = A::view(model_ref); + let overlay_view = A::view_overlay(model_ref); + // Reusamos los árboles de layout del runtime: `clear()` + + // `mount` evita re-allocar el slotmap de taffy por frame. + state.layout.clear(); + let mounted: Mounted = mount(&mut state.layout, view); + let computed = { + let ts = &mut state.typesetter; + let tmap = &mounted.text_measures; + state + .layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout") + }; + // Mount + layout del overlay en un árbol aparte. Lo + // computamos con el mismo tamaño de viewport para que + // un scrim a percent(1.0) cubra toda la pantalla. + let overlay_built = if let Some(v) = overlay_view { + state.overlay_layout.clear(); + let omounted: Mounted = mount(&mut state.overlay_layout, v); + let ocomputed = { + let ts = &mut state.typesetter; + let tmap = &omounted.text_measures; + state + .overlay_layout + .compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout overlay") + }; + let ohover = hit_test_hover( + &omounted, + &ocomputed, + state.cursor.x as f32, + state.cursor.y as f32, + ); + Some(OverlayCache { + mounted: omounted, + computed: ocomputed, + hover_idx: ohover, + }) + } else { + None + }; + // Hover en el main solo si NO hay overlay — durante un + // menú abierto, el fondo no debe reaccionar al ratón. + let hover_idx = if overlay_built.is_some() { + None + } else { + hit_test_hover( + &mounted, + &computed, + state.cursor.x as f32, + state.cursor.y as f32, + ) + }; + // Drop hover sólo si hay drag activo con payload (un + // drag bloquea el overlay; rara combinación pero la + // resolvemos a favor del drag). + let drop_hover_idx = state + .drag + .as_ref() + .and_then(|d| d.payload.map(|_| ())) + .and_then(|_| { + hit_test_drop( + &mounted, + &computed, + state.cursor.x as f32, + state.cursor.y as f32, + ) + }); + // Z-order del overlay sobre contenido `gpu_paint`: si el + // árbol principal tiene painters gpu (p. ej. el video de + // media) Y hay un overlay activo, el overlay NO va en la + // escena principal (quedaría debajo del blit gpu). Se + // rasteriza aparte sobre fondo transparente y se compone con + // alpha DESPUÉS del pase gpu. Sin gpu o sin overlay, el camino + // de siempre (overlay en la escena principal) — coste cero. + let composite_overlay = + overlay_built.is_some() && has_gpu_painter(&mounted); + + state.scene.reset(); + paint( + &mut state.scene, + &mounted, + &computed, + &mut state.typesetter, + hover_idx, + drop_hover_idx, + ); + if !composite_overlay { + if let Some(ov) = overlay_built.as_ref() { + paint( + &mut state.scene, + &ov.mounted, + &ov.computed, + &mut state.typesetter, + ov.hover_idx, + None, + ); + } + } + if let Err(e) = state.renderer.render( + &state.hal, + &state.scene, + &frame, + palette::css::BLACK, + ) { + eprintln!("render error: {e}"); + } + let (vw, vh) = frame.size(); + // Capa de overlay aparte (camino composite): vello la + // rasteriza con fondo transparente en `frame.overlay_view()`. + // Se renderiza ANTES del pase gpu para que el blit del + // compositor (en `gpu_encoder`) la lea ya escrita. + if composite_overlay { + if let Some(ov) = overlay_built.as_ref() { + state.scene.reset(); + paint( + &mut state.scene, + &ov.mounted, + &ov.computed, + &mut state.typesetter, + ov.hover_idx, + None, + ); + if let Err(e) = state.renderer.render_to_view( + &state.hal, + &state.scene, + frame.overlay_view(), + vw, + vh, + palette::css::TRANSPARENT, + ) { + eprintln!("render overlay error: {e}"); + } + } + } + // Pasada GPU directo (Fase 1 del SDD §"GPU directo wgpu"): + // si algún View del main o del overlay registró un + // `gpu_painter`, ejecutamos todos sus callbacks contra un + // único `CommandEncoder`, encima de lo que vello acaba de + // pintar sobre la intermediate. Submitimos antes del + // present para que el blit al swapchain incluya las + // primitivas GPU. Si nadie usó el hook, no se crea ni + // submitea nada — coste cero. + let mut gpu_encoder = state.hal.device.create_command_encoder( + &llimphi_hal::wgpu::CommandEncoderDescriptor { + label: Some("llimphi-ui-gpu-paint"), + }, + ); + let viewport = frame.size(); + let mut any_gpu = paint_gpu( + &mounted, + &computed, + &state.hal.device, + &state.hal.queue, + &mut gpu_encoder, + frame.view(), + viewport, + ); + if let Some(ov) = overlay_built.as_ref() { + // En el camino composite, los painters gpu del overlay van + // sobre SU textura; si no, sobre la intermedia. + let target = if composite_overlay { + frame.overlay_view() + } else { + frame.view() + }; + any_gpu |= paint_gpu( + &ov.mounted, + &ov.computed, + &state.hal.device, + &state.hal.queue, + &mut gpu_encoder, + target, + viewport, + ); + } + // Composición alpha del overlay SOBRE la intermedia (que ya + // tiene UI + video). Último pase del encoder → corre después + // del blit del video. Garantiza menús por encima del video. + if composite_overlay { + state.overlay_compositor.composite( + &state.hal.device, + &mut gpu_encoder, + frame.view(), + frame.overlay_view(), + ); + any_gpu = true; + } + if any_gpu { + state + .hal + .queue + .submit(std::iter::once(gpu_encoder.finish())); + } + state.surface.present(frame, &state.hal); + state.last_render = Some(RenderCache { + mounted, + computed, + hover_idx, + drop_hover_idx, + overlay: overlay_built, + }); + } + _ => {} + } + } +} + +// ── Ventanas secundarias (multiventana, opt-in) ────────────────────────────── +// Path APARTE del de la primaria: comparten modelo (vive en `self.state`) y +// `Hal`/`Renderer`, pero cada secundaria lleva su surface + caches. Sin +// overlay ni foco (la config no los necesita); se puede ampliar luego. +impl Runtime { + /// Aplica un Msg al modelo (que vive en la primaria) e invalida + repinta + /// TODAS las ventanas. Es el camino de cualquier evento de una secundaria, + /// así un cambio hecho en la config se refleja al toque en el reproductor + /// (y viceversa, vía los ticks que pasan por `user_event`). + fn dispatch_model(&mut self, msg: A::Msg) { + if let Some(prim) = self.state.as_mut() { + let model = prim.model.take().expect("model"); + prim.model = Some(A::update(model, msg, &self.handle)); + prim.last_render = None; + prim.window.request_redraw(); + } + // OJO: NO repintamos las secundarias acá. `dispatch_model` corre en + // cada Msg (incluido el tick ~33 fps), y repintar una secundaria por + // tick serializaba dos `acquire()` de swapchain en Wayland FIFO → + // ralentización y cuelgue. Cada secundaria se repinta sola al + // interactuar con ella (`handle_secondary_event` llama + // `render_secondary` tras un cambio) y en su `RedrawRequested` del + // compositor (expose/resize). El modelo igual quedó actualizado, así + // que el próximo repintado de la secundaria refleja el cambio. + } + + /// Despacha un Msg y repinta la secundaria `idx` en el acto (si sigue + /// viva). El camino de los eventos de una secundaria: como su + /// `request_redraw` no dispara `RedrawRequested` en algunos compositores, + /// la pintamos directo tras el cambio. + fn dispatch_and_render_secondary(&mut self, idx: usize, msg: A::Msg) { + self.dispatch_model(msg); + if idx < self.secondaries.len() { + self.render_secondary(idx); + } + } + + /// Crea una ventana OS secundaria (o enfoca la existente con esa key). Toma + /// el `Hal` de la primaria — no levanta un segundo device GPU. + fn open_secondary( + &mut self, + event_loop: &ActiveEventLoop, + key: u64, + title: String, + width: u32, + height: u32, + ) { + if let Some(sec) = self.secondaries.iter().find(|s| s.key == key) { + sec.window.focus_window(); + return; + } + let Some(prim) = self.state.as_ref() else { + return; // no hay primaria todavía (no debería pasar) + }; + let attrs = WindowAttributes::default() + .with_title(title) + .with_inner_size(LogicalSize::new(width, height)); + let window = match event_loop.create_window(attrs) { + Ok(w) => Arc::new(w), + Err(e) => { + eprintln!("open_window: no pude crear la ventana: {e}"); + return; + } + }; + let surface = match WinitSurface::new(&prim.hal, window.clone()) { + Ok(s) => s, + Err(e) => { + eprintln!("open_window: no pude crear la surface: {e}"); + return; + } + }; + window.request_redraw(); + self.secondaries.push(SecondaryState { + key, + window, + surface, + scene: vello::Scene::new(), + typesetter: llimphi_text::Typesetter::new(), + layout: LayoutTree::new(), + cursor: PhysicalPosition::new(0.0, 0.0), + modifiers: Modifiers::default(), + last_render: None, + hovered: None, + drag: None, + last_title: None, + }); + } + + /// Pinta la ventana secundaria `idx` con `A::secondary_view`. Reusa el + /// `Hal`/`Renderer` de la primaria; camino simple (sin overlay ni + /// composite gpu de menús), pero soporta `gpu_paint` por si el contenido + /// lo usa. + fn render_secondary(&mut self, idx: usize) { + let key = self.secondaries[idx].key; + let Some(prim) = self.state.as_mut() else { + return; + }; + // Título dinámico de la secundaria. + if let Some(t) = A::secondary_title(prim.model.as_ref().expect("model"), key) { + let sec = &mut self.secondaries[idx]; + if sec.last_title.as_deref() != Some(t.as_str()) { + sec.window.set_title(&t); + sec.last_title = Some(t); + } + } + let view = A::secondary_view(prim.model.as_ref().expect("model"), key) + .unwrap_or_else(|| View::new(Default::default())); + let hal = &prim.hal; + let renderer = &mut prim.renderer; + let sec = &mut self.secondaries[idx]; + + let frame = match sec.surface.acquire() { + Ok(f) => f, + Err(_) => { + let (w, h) = sec.surface.size(); + sec.surface.resize(w, h); + sec.window.request_redraw(); + return; + } + }; + let (w, h) = frame.size(); + sec.layout.clear(); + let mounted: Mounted = mount(&mut sec.layout, view); + let computed = { + let ts = &mut sec.typesetter; + let tmap = &mounted.text_measures; + sec.layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout secundario") + }; + let hover_idx = hit_test_hover(&mounted, &computed, sec.cursor.x as f32, sec.cursor.y as f32); + let drop_hover_idx = sec + .drag + .as_ref() + .and_then(|d| d.payload) + .and_then(|_| hit_test_drop(&mounted, &computed, sec.cursor.x as f32, sec.cursor.y as f32)); + sec.scene.reset(); + paint( + &mut sec.scene, + &mounted, + &computed, + &mut sec.typesetter, + hover_idx, + drop_hover_idx, + ); + if let Err(e) = renderer.render(hal, &sec.scene, &frame, palette::css::BLACK) { + eprintln!("render secundario error: {e}"); + } + // gpu_paint del contenido de la secundaria (si lo hubiera). + let mut enc = hal + .device + .create_command_encoder(&llimphi_hal::wgpu::CommandEncoderDescriptor { + label: Some("llimphi-ui-sec-gpu"), + }); + let viewport = frame.size(); + let any = paint_gpu( + &mounted, + &computed, + &hal.device, + &hal.queue, + &mut enc, + frame.view(), + viewport, + ); + if any { + hal.queue.submit(std::iter::once(enc.finish())); + } + sec.surface.present(frame, hal); + let _ = (hover_idx, drop_hover_idx); // se usaron al pintar; no se cachean + sec.last_render = Some(SecRenderCache { mounted, computed }); + } + + /// Atiende un evento de la ventana secundaria `idx`. Subconjunto de lo que + /// hace la primaria (sin overlay/foco/IME): render, resize, cierre, hover, + /// click, drag, teclado y rueda — suficiente para un panel de config. + fn handle_secondary_event(&mut self, idx: usize, event: WindowEvent) { + match event { + WindowEvent::CloseRequested => { + let key = self.secondaries[idx].key; + let msg = self + .state + .as_ref() + .and_then(|p| A::on_secondary_close(p.model.as_ref().expect("model"), key)); + self.secondaries.remove(idx); + if let Some(msg) = msg { + self.dispatch_model(msg); + } + } + WindowEvent::Resized(size) => { + self.secondaries[idx].surface.resize(size.width, size.height); + self.render_secondary(idx); + } + WindowEvent::ScaleFactorChanged { .. } => { + self.render_secondary(idx); + } + WindowEvent::RedrawRequested => { + self.render_secondary(idx); + } + WindowEvent::ModifiersChanged(mods) => { + self.secondaries[idx].modifiers = mods.state().into(); + } + WindowEvent::CursorMoved { position, .. } => { + let mut drag_msg: Option = None; + let mut redraw = false; + { + let sec = &mut self.secondaries[idx]; + sec.cursor = position; + if let Some(drag) = sec.drag.as_mut() { + let dx = (position.x - drag.last_cursor.x) as f32; + let dy = (position.y - drag.last_cursor.y) as f32; + drag.last_cursor = position; + if dx != 0.0 || dy != 0.0 { + drag_msg = match &drag.handler { + DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy), + DragHandlerKind::DeltaAt(h, lx0, ly0) => { + h(DragPhase::Move, dx, dy, *lx0, *ly0) + } + }; + } + redraw = true; + } else { + let new_hover = sec.last_render.as_ref().and_then(|c| { + hit_test_hover(&c.mounted, &c.computed, position.x as f32, position.y as f32) + }); + if new_hover != sec.hovered { + sec.hovered = new_hover; + redraw = true; + } + } + } + if let Some(msg) = drag_msg { + self.dispatch_and_render_secondary(idx, msg); + } else if redraw { + self.render_secondary(idx); + } + } + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Left, + .. + } => { + type SecHit = ( + Option>, + Option>, + Option, + Option, + Option>, + Option<(f32, f32, f32, f32)>, + ); + let cursor = self.secondaries[idx].cursor; + let hit: Option> = { + let sec = &self.secondaries[idx]; + sec.last_render.as_ref().and_then(|c| { + hit_test_click(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32).map( + |i| { + let node = &c.mounted.nodes[i]; + let rect = c.computed.get(node.id).map(|r| (r.x, r.y, r.w, r.h)); + ( + node.drag.clone(), + node.drag_at.clone(), + node.drag_payload, + node.on_click.clone(), + node.on_click_at.clone(), + rect, + ) + }, + ) + }) + }; + // Misma prioridad que la primaria: drag_at + on_click_at, luego + // drag simple, luego on_click_at, luego on_click. + match hit { + Some((_, Some(handler_at), payload, _, click_at, Some((ox, oy, rw, rh)))) => { + let lx0 = cursor.x as f32 - ox; + let ly0 = cursor.y as f32 - oy; + if let Some(h) = click_at { + if let Some(msg) = h(lx0, ly0, rw, rh) { + self.dispatch_model(msg); + } + } + self.secondaries[idx].drag = Some(DragState { + handler: DragHandlerKind::DeltaAt(handler_at, lx0, ly0), + last_cursor: cursor, + payload, + }); + self.render_secondary(idx); + } + Some((Some(handler), _, payload, _, _, _)) => { + self.secondaries[idx].drag = Some(DragState { + handler: DragHandlerKind::Delta(handler), + last_cursor: cursor, + payload, + }); + self.render_secondary(idx); + } + Some((_, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) => { + let lx = cursor.x as f32 - ox; + let ly = cursor.y as f32 - oy; + if let Some(msg) = handler(lx, ly, rw, rh) { + self.dispatch_and_render_secondary(idx, msg); + } + } + Some((_, _, _, Some(msg), _, _)) => { + self.dispatch_and_render_secondary(idx, msg); + } + _ => {} + } + } + WindowEvent::MouseInput { + state: ElementState::Released, + button: MouseButton::Left, + .. + } => { + let cursor = self.secondaries[idx].cursor; + let drag = self.secondaries[idx].drag.take(); + if let Some(drag) = drag { + // Drop primero (si hay payload + target), luego End. + if let Some(payload) = drag.payload { + let drop_h = self.secondaries[idx].last_render.as_ref().and_then(|c| { + hit_test_drop(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32) + .and_then(|i| c.mounted.nodes[i].on_drop.clone()) + }); + if let Some(h) = drop_h { + if let Some(msg) = h(payload) { + self.dispatch_model(msg); + } + } + } + let end_msg = match &drag.handler { + DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0), + DragHandlerKind::DeltaAt(h, lx0, ly0) => h(DragPhase::End, 0.0, 0.0, *lx0, *ly0), + }; + if let Some(msg) = end_msg { + self.dispatch_model(msg); + } + self.render_secondary(idx); + } + } + WindowEvent::MouseWheel { delta, .. } => { + let wd = match delta { + MouseScrollDelta::LineDelta(x, y) => WheelDelta { x, y: -y }, + MouseScrollDelta::PixelDelta(p) => WheelDelta { + x: (p.x as f32) / 20.0, + y: -(p.y as f32) / 20.0, + }, + }; + let cursor = self.secondaries[idx].cursor; + let handler = { + let sec = &self.secondaries[idx]; + sec.last_render.as_ref().and_then(|c| { + hit_test_scroll(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32) + .and_then(|i| c.mounted.nodes[i].on_scroll.clone()) + }) + }; + if let Some(msg) = handler.and_then(|h| h(wd.x, wd.y)) { + self.dispatch_and_render_secondary(idx, msg); + } + } + WindowEvent::KeyboardInput { event, .. } => { + let ev = KeyEvent { + key: event.logical_key.clone(), + state: match event.state { + ElementState::Pressed => KeyState::Pressed, + ElementState::Released => KeyState::Released, + }, + text: event.text.as_ref().map(|t| t.to_string()), + modifiers: self.secondaries[idx].modifiers, + repeat: event.repeat, + }; + let msg = self + .state + .as_ref() + .and_then(|p| A::on_key(p.model.as_ref().expect("model"), &ev)); + if let Some(msg) = msg { + self.dispatch_and_render_secondary(idx, msg); + } + } + _ => {} + } + } +} diff --git a/llimphi-ui/src/lib.rs b/llimphi-ui/src/lib.rs new file mode 100644 index 0000000..304dbf7 --- /dev/null +++ b/llimphi-ui/src/lib.rs @@ -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`, `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`. + +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::Model; + fn update(model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model; + fn view(model: &Self::Model) -> View; + + /// 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 { + 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) -> Option { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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), + 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 { + inner: HandleInner, +} + +enum HandleInner { + Real(EventLoopProxy>), + /// Handle de tests: drop silencioso de todos los dispatches. Permite + /// llamar funciones que toman `&Handle` sin levantar un event + /// loop real (que en CI sin display tiraría). + Test, +} + +impl Clone for Handle { + fn clone(&self) -> Self { + Self { + inner: match &self.inner { + HandleInner::Real(p) => HandleInner::Real(p.clone()), + HandleInner::Test => HandleInner::Test, + }, + } + } +} + +impl Handle { + /// Construye un handle desactivado para tests — todos los dispatch + /// se descartan silenciosamente. Útil para probar funciones que toman + /// `&Handle` 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, 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(&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(&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, + 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 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 { + handle: Handle, + state: Option>, + /// 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>, +} + +/// 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 { + /// La key con la que la app la abrió (la pasa a `secondary_view`). + key: u64, + window: Arc, + surface: WinitSurface, + scene: vello::Scene, + typesetter: llimphi_text::Typesetter, + layout: LayoutTree, + cursor: PhysicalPosition, + modifiers: Modifiers, + last_render: Option>, + hovered: Option, + drag: Option>, + last_title: Option, +} + +/// 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 { + mounted: Mounted, + computed: ComputedLayout, +} + +struct RuntimeState { + window: Arc, + 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, + cursor: PhysicalPosition, + 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>, + /// 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, + /// Drag activo. Mantiene su propio handler clonado del MountedNode + /// — así el drag sobrevive aunque el cache se invalide entre + /// eventos. + drag: Option>, + /// 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, + /// Ú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, +} + +struct RenderCache { + mounted: Mounted, + 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, + /// Índice del drop target hovereado en el frame ya pintado. Solo + /// se setea durante un drag activo con `payload` declarado. + drop_hover_idx: Option, + /// 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>, +} + +struct OverlayCache { + mounted: Mounted, + computed: ComputedLayout, + hover_idx: Option, +} + +/// 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 { + Delta(DragFn), + DeltaAt(DragAtFn, f32, f32), +} + +struct DragState { + handler: DragHandlerKind, + /// 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, + /// 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, +} + +/// Punto de entrada: corre el bucle Elm hasta que el usuario cierre la +/// ventana (o la app llame [`Handle::quit`]). +pub fn run() { + let event_loop = EventLoop::>::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 = Runtime { + handle, + state: None, + secondaries: Vec::new(), + }; + event_loop.run_app(&mut runtime).expect("run app"); +} diff --git a/llimphi-workspace/Cargo.toml b/llimphi-workspace/Cargo.toml new file mode 100644 index 0000000..4477080 --- /dev/null +++ b/llimphi-workspace/Cargo.toml @@ -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" } diff --git a/llimphi-workspace/examples/workspace_demo.rs b/llimphi-workspace/examples/workspace_demo.rs new file mode 100644 index 0000000..54ebcc4 --- /dev/null +++ b/llimphi-workspace/examples/workspace_demo.rs @@ -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), +} + +struct Model { + ws: Workspace, + panes: HashMap, + theme: Theme, +} + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "workspace — chasis tmux de gioser" + } + + fn init(_: &Handle) -> 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) -> 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 { + 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, t: &Theme, id: PaneId) -> View { + 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> = 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 { + 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 { + 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 { + 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 { + View::new(Style::default()).text(text, size, color) +} + +fn uniform(px: f32) -> Rect { + Rect { + left: length(px), + right: length(px), + top: length(px), + bottom: length(px), + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-workspace/src/lib.rs b/llimphi-workspace/src/lib.rs new file mode 100644 index 0000000..11e73f7 --- /dev/null +++ b/llimphi-workspace/src/lib.rs @@ -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` +//! 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`: 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 { + 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 { + 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, 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( + ws: &Workspace, + palette: &WorkspacePalette, + mut leaf: impl FnMut(PaneId) -> View, + lift: impl Fn(WsMsg) -> Host + Clone + Send + Sync + 'static, +) -> View +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(label: &str, msg: Host, palette: &WorkspacePalette) -> View +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(content: String, size: f32, color: Color) -> View +where + Host: Clone + Send + Sync + 'static, +{ + View::new(Style::default()).text(content, size, color) +} + +fn full() -> Size { + Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + } +} + +fn zero() -> Size { + Size { + width: length(0.0_f32), + height: length(0.0_f32), + } +} + +fn uniform(px: f32) -> Rect { + 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); + } +} diff --git a/modules/bookmarks/Cargo.toml b/modules/bookmarks/Cargo.toml new file mode 100644 index 0000000..9a94244 --- /dev/null +++ b/modules/bookmarks/Cargo.toml @@ -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] diff --git a/modules/bookmarks/LEEME.md b/modules/bookmarks/LEEME.md new file mode 100644 index 0000000..bc86f96 --- /dev/null +++ b/modules/bookmarks/LEEME.md @@ -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. diff --git a/modules/bookmarks/README.md b/modules/bookmarks/README.md new file mode 100644 index 0000000..011fd2e --- /dev/null +++ b/modules/bookmarks/README.md @@ -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. diff --git a/modules/bookmarks/src/lib.rs b/modules/bookmarks/src/lib.rs new file mode 100644 index 0000000..d974177 --- /dev/null +++ b/modules/bookmarks/src/lib.rs @@ -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, + 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, +} + +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 { + 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( + state: &BookmarksState, + root: &Path, + palette: &BookmarksPalette, + to_host: F, +) -> View +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> = 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> = 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) +} diff --git a/modules/bookmarks/tests/smoke.rs b/modules/bookmarks/tests/smoke.rs new file mode 100644 index 0000000..65d6f68 --- /dev/null +++ b/modules/bookmarks/tests/smoke.rs @@ -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()); +} diff --git a/modules/command-palette/Cargo.toml b/modules/command-palette/Cargo.toml new file mode 100644 index 0000000..0934b8f --- /dev/null +++ b/modules/command-palette/Cargo.toml @@ -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 } diff --git a/modules/command-palette/LEEME.md b/modules/command-palette/LEEME.md new file mode 100644 index 0000000..dbce288 --- /dev/null +++ b/modules/command-palette/LEEME.md @@ -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. diff --git a/modules/command-palette/README.md b/modules/command-palette/README.md new file mode 100644 index 0000000..1f8fc3e --- /dev/null +++ b/modules/command-palette/README.md @@ -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. diff --git a/modules/command-palette/src/lib.rs b/modules/command-palette/src/lib.rs new file mode 100644 index 0000000..7259684 --- /dev/null +++ b/modules/command-palette/src/lib.rs @@ -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, +} + +impl Command { + pub fn new( + id: impl Into, + title: impl Into, + group: impl Into, + ) -> Self { + Self { id: id.into(), title: title.into(), group: group.into(), shortcut: None } + } + + pub fn with_shortcut(mut self, s: impl Into) -> 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, + 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 { + 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( + state: &PaletteState, + commands: &[Command], + palette: &PalettePalette, + to_host: F, +) -> View +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> = 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> = 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) +} diff --git a/modules/command-palette/tests/smoke.rs b/modules/command-palette/tests/smoke.rs new file mode 100644 index 0000000..5a6a6cd --- /dev/null +++ b/modules/command-palette/tests/smoke.rs @@ -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 { + 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:?}"); +} diff --git a/modules/diff-viewer/Cargo.toml b/modules/diff-viewer/Cargo.toml new file mode 100644 index 0000000..4fba92d --- /dev/null +++ b/modules/diff-viewer/Cargo.toml @@ -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 } diff --git a/modules/diff-viewer/LEEME.md b/modules/diff-viewer/LEEME.md new file mode 100644 index 0000000..b295b14 --- /dev/null +++ b/modules/diff-viewer/LEEME.md @@ -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. diff --git a/modules/diff-viewer/README.md b/modules/diff-viewer/README.md new file mode 100644 index 0000000..a082f50 --- /dev/null +++ b/modules/diff-viewer/README.md @@ -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. diff --git a/modules/diff-viewer/src/lib.rs b/modules/diff-viewer/src/lib.rs new file mode 100644 index 0000000..3d96f03 --- /dev/null +++ b/modules/diff-viewer/src/lib.rs @@ -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, + /// Contenido de la celda derecha (Equal o Insert) o vacío. + pub right: Option, +} + +#[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, + 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, + after_label: impl Into, + 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, DiffStats) { + let diff = TextDiff::from_lines(before, after); + let mut rows: Vec = 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 { + 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( + state: &DiffState, + palette: &DiffPalette, + height_px: f32, + to_host: F, +) -> View +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> = 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> = 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(row: &DiffRow, palette: &DiffPalette) -> View +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(palette: &DiffPalette) -> View +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) +} diff --git a/modules/diff-viewer/tests/smoke.rs b/modules/diff-viewer/tests/smoke.rs new file mode 100644 index 0000000..bd0dbc8 --- /dev/null +++ b/modules/diff-viewer/tests/smoke.rs @@ -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::>().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"))); +} diff --git a/modules/fif/Cargo.toml b/modules/fif/Cargo.toml new file mode 100644 index 0000000..c8de60f --- /dev/null +++ b/modules/fif/Cargo.toml @@ -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 } diff --git a/modules/fif/LEEME.md b/modules/fif/LEEME.md new file mode 100644 index 0000000..4c6fe9a --- /dev/null +++ b/modules/fif/LEEME.md @@ -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. diff --git a/modules/fif/README.md b/modules/fif/README.md new file mode 100644 index 0000000..fa072a4 --- /dev/null +++ b/modules/fif/README.md @@ -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. diff --git a/modules/fif/src/lib.rs b/modules/fif/src/lib.rs new file mode 100644 index 0000000..35165aa --- /dev/null +++ b/modules/fif/src/lib.rs @@ -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, +//! fif: Option, +//! // … +//! } +//! +//! 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, + 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 { + 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( + state: &FifState, + palette: &FifPalette, + to_host: F, +) -> View +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( + state: &FifState, + paths: &[PathBuf], + root: &Path, + palette: &FifPalette, + to_host: F, +) -> View +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> = 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> = 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 { + let mut out: Vec = 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 = + 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( + label: &str, + state: &TextInputState, + placeholder: &str, + focus: bool, + palette: &FifPalette, + tp: &TextInputPalette, + fallback_msg: HostMsg, +) -> View +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()) +} diff --git a/modules/file-picker/Cargo.toml b/modules/file-picker/Cargo.toml new file mode 100644 index 0000000..88307c1 --- /dev/null +++ b/modules/file-picker/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-module-file-picker" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-file-picker — fuzzy file picker (estilo Ctrl+P de VS Code). Módulo Llimphi reutilizable: state + Msg + Action + apply/on_key/view sobre un slice de paths que provee el host." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-text-input = { workspace = true } diff --git a/modules/file-picker/LEEME.md b/modules/file-picker/LEEME.md new file mode 100644 index 0000000..3480bee --- /dev/null +++ b/modules/file-picker/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-file-picker + +> Picker de archivos de [llimphi](../../README.md). + +Fuzzy-finder de paths. Modal sobre la app. Devuelve `PathBuf` por `Msg::FilePicked`. Recientes priorizados. diff --git a/modules/file-picker/README.md b/modules/file-picker/README.md new file mode 100644 index 0000000..e6a192f --- /dev/null +++ b/modules/file-picker/README.md @@ -0,0 +1,5 @@ +# llimphi-module-file-picker + +> File picker of [llimphi](../../README.md). + +Path fuzzy-finder. Modal over the app. Returns `PathBuf` via `Msg::FilePicked`. Recents prioritized. diff --git a/modules/file-picker/src/lib.rs b/modules/file-picker/src/lib.rs new file mode 100644 index 0000000..2109da4 --- /dev/null +++ b/modules/file-picker/src/lib.rs @@ -0,0 +1,382 @@ +//! `llimphi-module-file-picker` — fuzzy file picker reutilizable. +//! +//! Equivalente a Ctrl+P de VS Code / "Go to file" de JetBrains: el host +//! mantiene una lista de paths candidatos (típicamente walk del workspace +//! cacheado al arrancar) y el módulo presenta un overlay con input + +//! resultados rankeados. Cuando el user pica uno, el módulo emite +//! [`PickerAction::Open`] y el host decide cómo abrir (tab nuevo, split, +//! etc.). +//! +//! Sigue el contrato Llimphi de [`docs/MODULES.md`]: +//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`. +//! +//! ## Cómo lo enchufa una app +//! +//! ```ignore +//! use llimphi_module_file_picker::{self as picker, PickerAction, PickerMsg, +//! PickerPalette, PickerState}; +//! +//! struct Model { all_files: Vec, picker: Option, … } +//! enum Msg { Picker(PickerMsg), … } +//! +//! // update: +//! Msg::Picker(pm) => { +//! let mut m = model; +//! if matches!(pm, PickerMsg::Open) && m.picker.is_none() { +//! m.picker = Some(PickerState::new(&m.all_files, &m.root)); +//! return m; +//! } +//! let action = match m.picker.as_mut() { +//! Some(s) => picker::apply(s, pm, &m.all_files, &m.root), +//! None => return m, +//! }; +//! match action { +//! PickerAction::Close => m.picker = None, +//! PickerAction::Open(path) => { +//! m.picker = None; +//! m = open_path_in_app(m, path); +//! } +//! PickerAction::None => {} +//! } +//! m +//! } +//! +//! // on_key: +//! if let Some(state) = model.picker.as_ref() { +//! if let Some(pm) = picker::on_key(state, event) { +//! return Some(Msg::Picker(pm)); +//! } +//! } +//! if picker::open_shortcut(event) { +//! return Some(Msg::Picker(PickerMsg::Open)); +//! } +//! +//! // view: +//! if let Some(state) = model.picker.as_ref() { +//! let panel = picker::view( +//! state, &model.all_files, &model.root, +//! &PickerPalette::from_theme(&theme), +//! Msg::Picker, +//! ); +//! children.push(panel); +//! } +//! ``` + +#![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 este módulo aporta al host. El host (cuando construye +/// su `card_core::Card`) puede agregar esto a `provides` para anunciar +/// vía broker que ofrece file-picker al ecosistema. +pub const CAPABILITIES: &[&str] = &["editor.file-picker"]; + +/// Máximo de resultados rankeados que entran al popup. +pub const MAX_RESULTS: usize = 200; + +const BAR_H: f32 = 220.0; +const ROW_H: f32 = 20.0; +const MAX_VISIBLE: usize = 9; + +/// Estado interno. Los `results` son índices al slice de paths que pasa +/// el host: el módulo no copia paths, sólo guarda índices. +pub struct PickerState { + pub input: TextInputState, + pub results: Vec, + pub selected: usize, +} + +impl Default for PickerState { + fn default() -> Self { + Self::new_empty() + } +} + +impl PickerState { + /// Crea un picker vacío. Si querés pre-filtrar con los paths que ya + /// tenés, llamá [`PickerState::new`] en su lugar. + pub fn new_empty() -> Self { + Self { + input: TextInputState::new(), + results: Vec::new(), + selected: 0, + } + } + + /// Crea un picker con todos los `paths` como resultados iniciales + /// (sin filtrar). Conveniente para el ack visual del Ctrl+P recién + /// disparado. + pub fn new(paths: &[PathBuf], root: &Path) -> Self { + let mut s = Self::new_empty(); + refilter(&mut s, paths, root); + s + } +} + +/// Vocabulario interno. El host lo wrapea en su Msg. +#[derive(Clone)] +pub enum PickerMsg { + /// Símbolo conveniente para que el host dispatche al detectar el + /// shortcut. El módulo no maneja Open él mismo — la creación del + /// state corre por cuenta del host (porque típicamente quiere pasar + /// la lista canónica de paths). + Open, + Close, + KeyInput(KeyEvent), + Nav(i32), + /// Enter: abre el match seleccionado. + Apply, +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone)] +pub enum PickerAction { + None, + /// El host debería remover el state del modelo. + Close, + /// El host debería abrir este `path`. El módulo NO se cierra + /// automáticamente — el host decide si ocultar el picker tras abrir. + Open(PathBuf), +} + +/// Aplica un mensaje al estado. +pub fn apply( + state: &mut PickerState, + msg: PickerMsg, + paths: &[PathBuf], + root: &Path, +) -> PickerAction { + match msg { + PickerMsg::Open => PickerAction::None, + PickerMsg::Close => PickerAction::Close, + PickerMsg::KeyInput(ev) => { + state.input.apply_key(&ev); + refilter(state, paths, root); + PickerAction::None + } + PickerMsg::Nav(d) => { + let n = state.results.len() as i32; + if n > 0 { + state.selected = (state.selected as i32 + d).rem_euclid(n) as usize; + } + PickerAction::None + } + PickerMsg::Apply => { + let Some(&file_idx) = state.results.get(state.selected) else { + return PickerAction::None; + }; + let Some(path) = paths.get(file_idx).cloned() else { + return PickerAction::None; + }; + PickerAction::Open(path) + } + } +} + +/// Routing de teclas cuando el panel está abierto. +pub fn on_key(_state: &PickerState, event: &KeyEvent) -> Option { + if event.state != KeyState::Pressed { + return None; + } + Some(match &event.key { + Key::Named(NamedKey::Escape) => PickerMsg::Close, + Key::Named(NamedKey::Enter) => PickerMsg::Apply, + Key::Named(NamedKey::ArrowDown) => PickerMsg::Nav(1), + Key::Named(NamedKey::ArrowUp) => PickerMsg::Nav(-1), + _ => PickerMsg::KeyInput(event.clone()), + }) +} + +/// Chequea si el evento es el atajo recomendado: **Ctrl+P**. +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. Match case-insensitive +/// sobre el path relativo. Score penaliza paths largos y premia hits en el +/// basename. Query vacío = todos los paths ordenados por longitud asc. +/// Cap: [`MAX_RESULTS`]. +pub fn refilter(state: &mut PickerState, paths: &[PathBuf], root: &Path) { + let q = state.input.text(); + let q_lc = q.to_lowercase(); + let mut scored: Vec<(i64, usize)> = Vec::new(); + for (i, path) in paths.iter().enumerate() { + let rel = relative_to(root, path); + if q_lc.is_empty() { + scored.push((rel.len() as i64, i)); + continue; + } + let rel_lc = rel.to_lowercase(); + let Some(rel_hit) = rel_lc.find(&q_lc) else { continue }; + let name = path + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_default(); + let name_hit = name.find(&q_lc); + let score = match name_hit { + Some(pos) => pos as i64 * 4 + rel.len() as i64, + None => 10_000 + rel_hit as i64 + rel.len() as i64, + }; + scored.push((score, i)); + } + scored.sort_by_key(|(s, _)| *s); + scored.truncate(MAX_RESULTS); + state.results = scored.into_iter().map(|(_, i)| i).collect(); + state.selected = 0; +} + +/// Paleta visual. +#[derive(Debug, Clone)] +pub struct PickerPalette { + 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 PickerPalette { + 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 panel. `to_host` mapea cada `PickerMsg` interno al `Msg` +/// de la app. +pub fn view( + state: &PickerState, + paths: &[PathBuf], + root: &Path, + palette: &PickerPalette, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(PickerMsg) -> HostMsg + Copy + 'static, +{ + let header = if state.results.is_empty() { + format!("file picker · sin matches · {} archivos · Esc cierra", paths.len()) + } else { + format!( + "file picker · {} / {} · ↓↑ navega · Enter abre · 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 o ruta…", + true, + &tp, + to_host(PickerMsg::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> = Vec::with_capacity(MAX_VISIBLE); + for i in visible_start..visible_end { + let Some(&file_idx) = state.results.get(i) else { continue }; + let Some(path) = paths.get(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(""); + let label = if dir.is_empty() { + name.to_string() + } else { + format!("{name} {}", dir.trim_end_matches('/')) + }; + 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, 11.0, fg, Alignment::Start), + ); + } + + let mut children: Vec> = 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) +} + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + +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()) +} diff --git a/modules/mini-map/Cargo.toml b/modules/mini-map/Cargo.toml new file mode 100644 index 0000000..1ce231f --- /dev/null +++ b/modules/mini-map/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-module-mini-map" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-mini-map — overlay minimap del buffer activo. Modulo Llimphi: el host le pasa un snapshot del buffer + viewport + caret, el modulo pinta un panel vertical con un slab por linea (ancho aprox chars), resalta el viewport visible y emite Jump(line) al click. Estilo VS Code/Sublime." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[dev-dependencies] diff --git a/modules/mini-map/LEEME.md b/modules/mini-map/LEEME.md new file mode 100644 index 0000000..5324a2f --- /dev/null +++ b/modules/mini-map/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-mini-map + +> Mini-mapa del editor de [llimphi](../../README.md). + +Overview a la derecha del [`text-editor`](../../widgets/text-editor/README.md): renderizado escalado del archivo con highlight de la posición actual. Click salta a esa porción. diff --git a/modules/mini-map/README.md b/modules/mini-map/README.md new file mode 100644 index 0000000..1643a3e --- /dev/null +++ b/modules/mini-map/README.md @@ -0,0 +1,5 @@ +# llimphi-module-mini-map + +> Editor mini-map of [llimphi](../../README.md). + +Right-side overview of [`text-editor`](../../widgets/text-editor/README.md): scaled rendering of the file with highlight of current position. Click jumps to that section. diff --git a/modules/mini-map/src/lib.rs b/modules/mini-map/src/lib.rs new file mode 100644 index 0000000..3f8c1c9 --- /dev/null +++ b/modules/mini-map/src/lib.rs @@ -0,0 +1,274 @@ +//! `llimphi-module-mini-map` — minimap del buffer activo. +//! +//! Equivalente al "Minimap" de VS Code / "thumbnail" de Sublime: un +//! panel angosto pegado al editor que pinta una linea horizontal por +//! cada linea del buffer (ancho ~= len_chars, cap a `usable_w`), +//! resalta el viewport visible como rect translucido y marca el caret. +//! Click sobre el minimap salta esa linea al editor. +//! +//! El modulo es agnostico del editor: el host pasa un slice con la +//! cantidad de chars por linea, el rango visible y la linea del +//! caret. No depende de `llimphi-widget-text-editor` — cualquier +//! buffer (rope, vec, archivo memmaped) sirve. +//! +//! Sigue el contrato Llimphi de `docs/MODULES.md`: +//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KRect}; +use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; +use llimphi_ui::{Key, KeyEvent, KeyState, View}; + +/// Capabilities que aporta este modulo al host. +pub const CAPABILITIES: &[&str] = &["editor.mini-map"]; + +/// Ancho del panel en pixeles (estilo VS Code). +pub const PANEL_W: f32 = 120.0; +/// Altura maxima por linea del buffer dentro del minimap (cap). +pub const LINE_PX: f32 = 2.0; +/// Escala chars->pixels para el ancho de cada slab. ~75 chars caben +/// completos en `PANEL_W - PAD * 2`; lo demas se trunca. +pub const CHAR_PX: f32 = 1.4; +/// Padding lateral del panel (los slabs no tocan los bordes). +pub const PAD: f32 = 6.0; + +/// Estado interno. Hoy efectivamente vacio — la informacion del buffer +/// la pasa el host en cada frame via [`view`] — pero existe como +/// `Option` en el host para representar abierto/cerrado +/// y para futuras extensiones (scrubbing, fold-aware, syntax per slab). +#[derive(Debug, Default, Clone)] +pub struct MiniMapState { + /// Reservado para drag-scrub: la y inicial en pixeles dentro del + /// panel cuando el usuario empieza a arrastrar. `None` = sin drag + /// activo. Hoy no se consume (click es suficiente); declarado para + /// que el contrato del state no cambie cuando se agregue. + pub drag_anchor_y: Option, +} + +impl MiniMapState { + pub fn new() -> Self { + Self::default() + } +} + +/// Vocabulario interno. El host lo wrapea en su Msg. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MiniMapMsg { + /// Convencional: el host abre el panel guardando un `MiniMapState` + /// en el modelo. El modulo no construye state global. + Open, + Close, + /// El usuario clickeo o arrastro: salta a la linea indicada. + Jump(usize), +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MiniMapAction { + None, + /// El host deberia remover el state del modelo. + Close, + /// El host deberia centrar el viewport en esta linea del buffer + /// activo. El modulo NO se cierra — el minimap es persistente. + JumpTo(usize), +} + +/// Snapshot del buffer que el host pasa en cada frame. El modulo no +/// copia, solo lee. La cantidad de chars por linea es lo unico que +/// necesita para dibujar; viewport + caret se overlayean encima. +pub struct Snapshot<'a> { + /// `lines[i]` = numero de chars (no bytes) en la linea `i`. + pub lines: &'a [usize], + /// Rango visible en el editor: `[start, end)`. + pub viewport_start: usize, + pub viewport_end: usize, + /// Linea del caret (0-based). Se pinta como marker accent. + pub caret_line: usize, +} + +/// Aplica un mensaje al estado. +pub fn apply(state: &mut MiniMapState, msg: MiniMapMsg) -> MiniMapAction { + match msg { + MiniMapMsg::Open => MiniMapAction::None, + MiniMapMsg::Close => MiniMapAction::Close, + MiniMapMsg::Jump(line) => { + state.drag_anchor_y = None; + MiniMapAction::JumpTo(line) + } + } +} + +/// Routing de teclas. El minimap NO captura teclas (es un viewer +/// pasivo). Devolvemos `None`; el host sigue su routing normal. +pub fn on_key(_state: &MiniMapState, _event: &KeyEvent) -> Option { + None +} + +/// Atajo recomendado: **Ctrl+Shift+M** (mnemonic M = Minimap). +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("m")) +} + +/// Convierte una posicion-y dentro del panel a indice de linea. La +/// conversion es proporcional al total de lineas; clamping en ambos +/// extremos. +pub fn y_to_line(y: f32, panel_h: f32, total_lines: usize) -> usize { + if total_lines == 0 || panel_h <= 0.0 { + return 0; + } + let t = (y / panel_h).clamp(0.0, 1.0); + let line = (t * total_lines as f32) as usize; + line.min(total_lines.saturating_sub(1)) +} + +/// Paleta visual derivable del theme. +#[derive(Debug, Clone)] +pub struct MiniMapPalette { + /// Fondo del panel del minimap. + pub bg_panel: Color, + /// Color de los slabs (uno por linea de buffer). + pub fg_slab: Color, + /// Color del rect translucido que marca el viewport visible. + pub bg_viewport: Color, + /// Borde del rect del viewport. + pub border_viewport: Color, + /// Color del marker del caret. + pub fg_caret: Color, +} + +impl MiniMapPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel_alt, + fg_slab: t.fg_muted, + bg_viewport: with_alpha(t.bg_selected, 0.35), + border_viewport: t.border_focus, + fg_caret: t.accent, + } + } +} + +fn with_alpha(c: Color, alpha: f32) -> Color { + let rgba = c.to_rgba8(); + let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8; + Color::from_rgba8(rgba.r, rgba.g, rgba.b, a) +} + +/// Render del panel. `to_host` mapea cada `MiniMapMsg` al `Msg` de la app. +/// `snapshot` es la vista del buffer en este frame (sin copia). +/// +/// Layout: columna fija de `PANEL_W` px que ocupa todo el alto del +/// contenedor padre. El host la mete en el `Row` del editor +/// (tipicamente al final, al estilo VS Code). +pub fn view( + _state: &MiniMapState, + snapshot: &Snapshot, + palette: &MiniMapPalette, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(MiniMapMsg) -> HostMsg + Copy + Send + Sync + 'static, +{ + // Capturamos por valor porque el painter es Arc: 'static + Send + Sync. + let lines: Vec = snapshot.lines.to_vec(); + let viewport_start = snapshot.viewport_start; + let viewport_end = snapshot.viewport_end; + let caret_line = snapshot.caret_line; + let pal = palette.clone(); + + let total_lines = lines.len(); + let click_host = to_host; + let on_click: Arc Option + Send + Sync> = Arc::new(move |_x: f32, y: f32, _w: f32, h: f32| { + let line = y_to_line(y, h, total_lines); + Some(click_host(MiniMapMsg::Jump(line))) + }); + + let mut view = View::new(Style { + size: Size { width: length(PANEL_W), height: percent(1.0_f32) }, + flex_shrink: 0.0, + flex_direction: FlexDirection::Column, + ..Default::default() + }) + .fill(pal.bg_panel) + .clip(true) + .paint_with(move |scene, _ts, rect| { + if rect.w <= 0.0 || rect.h <= 0.0 || lines.is_empty() { + return; + } + let n = lines.len() as f32; + let line_h = (rect.h / n).min(LINE_PX); + let usable_w = (rect.w - PAD * 2.0).max(1.0); + + // 1) Viewport overlay debajo de los slabs. + if viewport_end > viewport_start { + let y0 = rect.y + (viewport_start as f32 / n) * rect.h; + let y1 = rect.y + (viewport_end as f32 / n) * rect.h; + let vp = KRect::new( + rect.x as f64, + y0 as f64, + (rect.x + rect.w) as f64, + y1.max(y0 + 2.0) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, pal.bg_viewport, None, &vp); + } + + // 2) Slabs: uno por linea de buffer. + for (i, &chars) in lines.iter().enumerate() { + if chars == 0 { + continue; + } + let w = (chars as f32 * CHAR_PX).min(usable_w); + let y = rect.y + (i as f32 / n) * rect.h; + let slab_h = line_h.max(1.0); + let r = KRect::new( + (rect.x + PAD) as f64, + y as f64, + (rect.x + PAD + w) as f64, + (y + slab_h) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fg_slab, None, &r); + } + + // 3) Borde del viewport encima de los slabs. + if viewport_end > viewport_start { + let y0 = rect.y + (viewport_start as f32 / n) * rect.h; + let y1 = (rect.y + (viewport_end as f32 / n) * rect.h).max(y0 + 2.0); + let top = KRect::new( + rect.x as f64, + y0 as f64, + (rect.x + rect.w) as f64, + (y0 + 1.0) as f64, + ); + let bot = KRect::new( + rect.x as f64, + (y1 - 1.0) as f64, + (rect.x + rect.w) as f64, + y1 as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, pal.border_viewport, None, &top); + scene.fill(Fill::NonZero, Affine::IDENTITY, pal.border_viewport, None, &bot); + } + + // 4) Marker del caret: barra horizontal accent. + if caret_line < lines.len() { + let y = rect.y + (caret_line as f32 / n) * rect.h; + let r = KRect::new( + rect.x as f64, + y as f64, + (rect.x + rect.w) as f64, + (y + 2.0) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fg_caret, None, &r); + } + }); + view.on_click_at = Some(on_click); + view +} diff --git a/modules/mini-map/tests/smoke.rs b/modules/mini-map/tests/smoke.rs new file mode 100644 index 0000000..bb9259c --- /dev/null +++ b/modules/mini-map/tests/smoke.rs @@ -0,0 +1,63 @@ +//! Smoke tests del minimap. Sin backend grafico — solo `apply`, +//! `on_key`, `open_shortcut` y la conversion y->line. + +use llimphi_module_mini_map::{ + self as minimap, MiniMapAction, MiniMapMsg, MiniMapState, +}; +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + +fn key_with(ctrl: bool, shift: bool, ch: &str) -> KeyEvent { + KeyEvent { + key: Key::Character(ch.into()), + state: KeyState::Pressed, + text: Some(ch.into()), + modifiers: Modifiers { ctrl, shift, ..Modifiers::default() }, + repeat: false, + } +} + +#[test] +fn open_shortcut_es_ctrl_shift_m() { + assert!(minimap::open_shortcut(&key_with(true, true, "m"))); + assert!(minimap::open_shortcut(&key_with(true, true, "M"))); + assert!(!minimap::open_shortcut(&key_with(true, false, "m"))); + assert!(!minimap::open_shortcut(&key_with(false, true, "m"))); +} + +#[test] +fn jump_emite_jumpto() { + let mut s = MiniMapState::new(); + let action = minimap::apply(&mut s, MiniMapMsg::Jump(42)); + assert_eq!(action, MiniMapAction::JumpTo(42)); +} + +#[test] +fn close_emite_close() { + let mut s = MiniMapState::new(); + let action = minimap::apply(&mut s, MiniMapMsg::Close); + assert_eq!(action, MiniMapAction::Close); +} + +#[test] +fn y_to_line_proporcional() { + // 100 lineas, panel de 200 px → cada linea ocupa 2 px. + assert_eq!(minimap::y_to_line(0.0, 200.0, 100), 0); + assert_eq!(minimap::y_to_line(100.0, 200.0, 100), 50); + assert_eq!(minimap::y_to_line(200.0, 200.0, 100), 99); + // Clamping fuera de rango. + assert_eq!(minimap::y_to_line(-50.0, 200.0, 100), 0); + assert_eq!(minimap::y_to_line(500.0, 200.0, 100), 99); +} + +#[test] +fn y_to_line_buffer_vacio_no_paniquea() { + assert_eq!(minimap::y_to_line(0.0, 100.0, 0), 0); + assert_eq!(minimap::y_to_line(50.0, 100.0, 0), 0); +} + +#[test] +fn on_key_es_pasivo() { + let s = MiniMapState::new(); + let ev = key_with(false, false, "a"); + assert!(minimap::on_key(&s, &ev).is_none()); +} diff --git a/modules/plugin-host/Cargo.toml b/modules/plugin-host/Cargo.toml new file mode 100644 index 0000000..45ba6f8 --- /dev/null +++ b/modules/plugin-host/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "llimphi-plugin-host" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-plugin-host — runtime de plugins WASM (Tier 2) para apps Llimphi. Carga .wasm + manifest.toml, aplica sandbox por card_core::Permissions, e invoca capabilities devolviendo PluginAction." + +[dependencies] +card-core = { path = "../../../../shared/card/card-core" } +wasmi = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +# wat sólo aparece en tests: los fixtures se escriben en WAT y se +# compilan en runtime, evitando una dependencia de toolchain wasm32. +wat = { workspace = true } diff --git a/modules/plugin-host/LEEME.md b/modules/plugin-host/LEEME.md new file mode 100644 index 0000000..ee25861 --- /dev/null +++ b/modules/plugin-host/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-plugin-host + +> Host para plugins WASM de [llimphi](../../README.md). + +Carga módulos WASM con la capability API del notebook (sandbox), los enchufa a la app como handlers de `Msg` extra. Permite extender la app sin recompilar. diff --git a/modules/plugin-host/README.md b/modules/plugin-host/README.md new file mode 100644 index 0000000..5adc29f --- /dev/null +++ b/modules/plugin-host/README.md @@ -0,0 +1,5 @@ +# llimphi-module-plugin-host + +> WASM plugin host for [llimphi](../../README.md). + +Loads WASM modules with the notebook's capability API (sandbox), plugs them into the app as extra `Msg` handlers. Lets you extend the app without recompiling. diff --git a/modules/plugin-host/src/lib.rs b/modules/plugin-host/src/lib.rs new file mode 100644 index 0000000..12caafe --- /dev/null +++ b/modules/plugin-host/src/lib.rs @@ -0,0 +1,334 @@ +//! llimphi-plugin-host — runtime de plugins WASM Tier 2 para apps Llimphi. +//! +//! Vea `docs/MODULES.md` (§Tier 2 — Plugins WASM) para el contrato +//! completo. En síntesis: +//! +//! - Un plugin es un `.wasm` + un `manifest.toml` hermano que declara +//! `name`, `version`, `capabilities`, y los `Permissions` que pide. +//! - El host expone imports bajo el namespace `"plugin"`. Cada uno se +//! gatea por un campo de `card_core::Permissions`: si el permiso falta, +//! el import **no se enlaza** y el plugin trap-ea al intentar usarlo. +//! - El `.wasm` exporta `_invoke(cap_ptr, cap_len, arg_ptr, arg_len) -> i32` +//! y una `memory` lineal. +//! - Invocar un plugin devuelve `PluginAction` — intención, no ejecución. +//! El host decide cómo materializar `OpenAt`/`SetStatus` en su contexto. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +use card_core::{FsPolicy, Permissions}; +use serde::Deserialize; +use thiserror::Error; +use tracing::{info, warn}; +use wasmi::{Caller, CompilationMode, Config, Engine, Linker, Memory, Module, Store}; + +// ===================================================================== +// Manifest +// ===================================================================== + +/// Manifest sidecar (`manifest.toml`) que acompaña a cada `.wasm`. +/// +/// El formato es estable: campos extra se ignoran con `#[serde(default)]` +/// donde aplica, para que plugins viejos sigan cargando si el host suma +/// metadatos opcionales. +#[derive(Debug, Clone, Deserialize)] +pub struct PluginManifest { + pub name: String, + pub version: String, + /// Capabilities que el plugin atiende. El host enruta invocaciones + /// por el nombre exacto pasado a `PluginHost::invoke(_, cap, _)`. + #[serde(default)] + pub capabilities: Vec, + /// Permisos que el plugin necesita para no trap-ear. Si el manifest + /// pide más de lo que el host está dispuesto a conceder, la carga + /// puede aceptarse "downgraded" — pero el plugin entonces trap-eará + /// al intentar los imports que no se enlazaron. La política la fija + /// quien llama a `PluginHost::load_*`. + #[serde(default)] + pub permissions: Permissions, +} + +impl PluginManifest { + pub fn from_toml(s: &str) -> Result { + toml::from_str(s).map_err(|e| PluginError::Manifest(e.to_string())) + } +} + +// ===================================================================== +// Acciones y errores +// ===================================================================== + +/// Intención que el plugin emite. Igual que en los módulos Tier 1, el +/// plugin no sabe cómo el host materializa cada variante — sólo declara +/// qué quiere que pase. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginAction { + None, + SetStatus(String), + OpenAt { path: PathBuf, line: u32, col: u32 }, +} + +#[derive(Debug, Error)] +pub enum PluginError { + #[error("manifest inválido: {0}")] + Manifest(String), + #[error("no se pudo leer {0}: {1}")] + Io(PathBuf, String), + #[error("compilando wasm: {0}")] + Compile(String), + #[error("instanciando wasm: {0}")] + Instantiate(String), + #[error("plugin no exporta `_invoke` con la signatura esperada: {0}")] + MissingEntry(String), + #[error("trap durante la ejecución del plugin: {0}")] + Trap(String), + #[error("no existe plugin con id {0:?}")] + UnknownPlugin(PluginId), +} + +// ===================================================================== +// Host +// ===================================================================== + +/// Identificador opaco de un plugin cargado. Sólo se construye desde +/// `PluginHost::load_*`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PluginId(u32); + +struct LoadedPlugin { + manifest: PluginManifest, + module: Module, +} + +/// Estado por invocación. Vive sólo durante un `invoke` — se descarta al +/// volver. Lo usamos como `Store::data()` para que los host imports +/// puedan emitir su `PluginAction` sin globals. Los permisos no viajan +/// aquí porque su efecto es link-time: los imports prohibidos +/// simplemente no se enlazan. +struct InvokeCtx { + /// Acción a devolver al host. `RefCell` porque los closures de + /// `func_wrap` toman `Caller` por referencia compartida. + pending: RefCell, +} + +pub struct PluginHost { + engine: Engine, + plugins: Vec, +} + +impl Default for PluginHost { + fn default() -> Self { + Self::new() + } +} + +impl PluginHost { + pub fn new() -> Self { + // Eager: mismo modo que arje-wasm, comportamiento predecible y + // los traps de compilación salen en `load_*`, no en `invoke`. + let mut config = Config::default(); + config.compilation_mode(CompilationMode::Eager); + Self { engine: Engine::new(&config), plugins: Vec::new() } + } + + /// Carga `dir/plugin.wasm` + `dir/manifest.toml`. Por convención el + /// `.wasm` se llama igual que el directorio o `plugin.wasm`. Probamos + /// ambos para ser indulgentes con el packaging. + pub fn load_from_dir(&mut self, dir: impl AsRef) -> Result { + let dir = dir.as_ref(); + let manifest_path = dir.join("manifest.toml"); + let manifest_str = std::fs::read_to_string(&manifest_path) + .map_err(|e| PluginError::Io(manifest_path.clone(), e.to_string()))?; + let manifest = PluginManifest::from_toml(&manifest_str)?; + + let candidates = [dir.join("plugin.wasm"), dir.join(format!("{}.wasm", manifest.name))]; + let (wasm_path, wasm_bytes) = candidates + .iter() + .find_map(|p| std::fs::read(p).ok().map(|b| (p.clone(), b))) + .ok_or_else(|| { + PluginError::Io(dir.join("plugin.wasm"), "no encontré ningún .wasm".into()) + })?; + + let _ = wasm_path; + self.load_bytes(manifest, &wasm_bytes) + } + + /// Carga un plugin desde bytes ya en memoria (útil en tests y para + /// plugins embebidos en el binario del host). + pub fn load_bytes( + &mut self, + manifest: PluginManifest, + wasm_bytes: &[u8], + ) -> Result { + let module = Module::new(&self.engine, wasm_bytes) + .map_err(|e| PluginError::Compile(e.to_string()))?; + let id = PluginId(self.plugins.len() as u32); + info!( + plugin = %manifest.name, + version = %manifest.version, + capabilities = ?manifest.capabilities, + "plugin Tier 2 cargado" + ); + self.plugins.push(LoadedPlugin { manifest, module }); + Ok(id) + } + + pub fn manifest(&self, id: PluginId) -> Result<&PluginManifest, PluginError> { + self.plugins + .get(id.0 as usize) + .map(|p| &p.manifest) + .ok_or(PluginError::UnknownPlugin(id)) + } + + /// Devuelve la unión de capabilities de todos los plugins cargados — + /// la lista que el host enrola en su Card antes de `spawn_sidecar()`. + pub fn all_capabilities(&self) -> Vec { + let mut caps: Vec = + self.plugins.iter().flat_map(|p| p.manifest.capabilities.iter().cloned()).collect(); + caps.sort(); + caps.dedup(); + caps + } + + /// Invoca una capability sobre el plugin indicado. `args` se entrega + /// tal cual al plugin (bytes opacos — la app y el plugin acuerdan el + /// schema). El retorno colapsa el `_invoke` exit code y la + /// `PluginAction` que el plugin haya emitido. + pub fn invoke( + &self, + id: PluginId, + capability: &str, + args: &[u8], + ) -> Result { + let plugin = self.plugins.get(id.0 as usize).ok_or(PluginError::UnknownPlugin(id))?; + let ctx = InvokeCtx { pending: RefCell::new(PluginAction::None) }; + let mut store = Store::new(&self.engine, ctx); + let linker = build_linker(&self.engine, &plugin.manifest.permissions)?; + + // wasmi 1.0: `instantiate_and_start` corre la `(start)` section + // si la hay; nuestros plugins no la usan — su entrada es + // `_invoke`, llamada explícitamente más abajo. + let instance = linker + .instantiate_and_start(&mut store, &plugin.module) + .map_err(|e| PluginError::Instantiate(e.to_string()))?; + + let memory = instance + .get_memory(&store, "memory") + .ok_or_else(|| PluginError::MissingEntry("plugin sin export `memory`".into()))?; + + // Escribimos cap + args al inicio de la memoria del plugin. v0 + // del ABI: layout fijo, no negociado. Si el plugin necesita más + // espacio se va a cualquier offset por encima — su asunto. + let cap_bytes = capability.as_bytes(); + write_memory(&mut store, memory, 0, cap_bytes)?; + let args_off = cap_bytes.len(); + write_memory(&mut store, memory, args_off, args)?; + + let func = instance + .get_typed_func::<(i32, i32, i32, i32), i32>(&store, "_invoke") + .map_err(|e| PluginError::MissingEntry(e.to_string()))?; + + let _exit = func + .call( + &mut store, + (0, cap_bytes.len() as i32, args_off as i32, args.len() as i32), + ) + .map_err(|e| PluginError::Trap(e.to_string()))?; + + let action = store.data().pending.borrow().clone(); + Ok(action) + } +} + +// ===================================================================== +// Host imports — gateados por Permissions +// ===================================================================== + +fn build_linker( + engine: &Engine, + perms: &Permissions, +) -> Result, PluginError> { + let mut linker = Linker::::new(engine); + + // log — siempre disponible. Aún plugins sin permisos pueden trazar. + linker + .func_wrap("plugin", "log", |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32| { + if let Some(s) = read_utf8(&caller, ptr, len) { + info!("[plugin] {s}"); + } + }) + .map_err(|e| PluginError::Instantiate(e.to_string()))?; + + // set_status — siempre disponible. No toca recursos del sistema. + linker + .func_wrap("plugin", "set_status", |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32| { + if let Some(s) = read_utf8(&caller, ptr, len) { + *caller.data().pending.borrow_mut() = PluginAction::SetStatus(s); + } + }) + .map_err(|e| PluginError::Instantiate(e.to_string()))?; + + // open_at — requiere filesystem >= read-only. Si el permiso falta NO + // enlazamos el import: el plugin trap-eará al invocarlo, que es la + // semántica correcta para un sandbox. + if matches!(perms.filesystem, FsPolicy::ReadOnly | FsPolicy::ReadWrite) { + linker + .func_wrap( + "plugin", + "open_at", + |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32, line: i32, col: i32| { + if let Some(s) = read_utf8(&caller, ptr, len) { + *caller.data().pending.borrow_mut() = PluginAction::OpenAt { + path: PathBuf::from(s), + line: line.max(0) as u32, + col: col.max(0) as u32, + }; + } + }, + ) + .map_err(|e| PluginError::Instantiate(e.to_string()))?; + } else { + warn!( + "plugin sin permiso filesystem — `plugin.open_at` no enlazado; \ + llamarlo trap-eará" + ); + } + + Ok(linker) +} + +// ===================================================================== +// Helpers de memoria +// ===================================================================== + +fn read_utf8(caller: &Caller<'_, InvokeCtx>, ptr: i32, len: i32) -> Option { + let memory = caller.get_export("memory")?.into_memory()?; + let bytes = read_memory(caller, memory, ptr, len)?; + String::from_utf8(bytes).ok() +} + +fn read_memory( + caller: &Caller<'_, InvokeCtx>, + memory: Memory, + ptr: i32, + len: i32, +) -> Option> { + let ptr = ptr.max(0) as usize; + let len = len.max(0) as usize; + let data = memory.data(caller); + if ptr.saturating_add(len) > data.len() { + return None; + } + Some(data[ptr..ptr + len].to_vec()) +} + +fn write_memory( + store: &mut Store, + memory: Memory, + off: usize, + bytes: &[u8], +) -> Result<(), PluginError> { + memory + .write(store, off, bytes) + .map_err(|e| PluginError::Trap(format!("write_memory off={off} len={}: {e}", bytes.len()))) +} diff --git a/modules/plugin-host/tests/fixtures/hello-status/.gitignore b/modules/plugin-host/tests/fixtures/hello-status/.gitignore new file mode 100644 index 0000000..ef7d91f --- /dev/null +++ b/modules/plugin-host/tests/fixtures/hello-status/.gitignore @@ -0,0 +1 @@ +plugin.wasm diff --git a/modules/plugin-host/tests/fixtures/hello-status/manifest.toml b/modules/plugin-host/tests/fixtures/hello-status/manifest.toml new file mode 100644 index 0000000..d2ab1af --- /dev/null +++ b/modules/plugin-host/tests/fixtures/hello-status/manifest.toml @@ -0,0 +1,11 @@ +name = "hello-status" +version = "0.1.0" +capabilities = ["status.greet"] + +[permissions] +networking = "none" +filesystem = "none" +processes = false + +[permissions.ipc] +allow = [] diff --git a/modules/plugin-host/tests/fixtures/hello-status/plugin.wat b/modules/plugin-host/tests/fixtures/hello-status/plugin.wat new file mode 100644 index 0000000..b890bc7 --- /dev/null +++ b/modules/plugin-host/tests/fixtures/hello-status/plugin.wat @@ -0,0 +1,44 @@ +;; Plugin fixture: "hello-status". +;; +;; Lee el payload de args que el host escribió en memoria justo +;; después del nombre de la capability, y lo concatena con un saludo +;; fijo "hola, " en otro offset. Después emite el resultado via +;; `plugin.set_status`. +;; +;; Layout de memoria al entrar `_invoke`: +;; [0 .. cap_len) nombre de capability (UTF-8) +;; [cap_len .. cap_len+arg_len) args del host (UTF-8) +;; +;; El plugin coloca su buffer de salida en el offset 256 para no +;; pisar lo anterior. v0 del ABI no negocia layouts — la convención +;; es que el plugin elige offsets altos. +(module + (import "plugin" "log" (func $log (param i32 i32))) + (import "plugin" "set_status" (func $set_status (param i32 i32))) + + (memory (export "memory") 1) + + ;; "hola, " en offset 256 (6 bytes) + (data (i32.const 256) "hola, ") + + (func (export "_invoke") + (param $cap_ptr i32) (param $cap_len i32) + (param $arg_ptr i32) (param $arg_len i32) + (result i32) + ;; Traza para debug: el host capturará "[plugin] greet" + (call $log (i32.const 256) (i32.const 5)) + + ;; Copia los args al final del prefijo "hola, " en 256+6=262 + (memory.copy + (i32.const 262) ;; dst = 256 + len("hola, ") + (local.get $arg_ptr) ;; src = donde el host puso args + (local.get $arg_len)) + + ;; Total len = 6 ("hola, ") + arg_len + (call $set_status + (i32.const 256) + (i32.add (i32.const 6) (local.get $arg_len))) + + (i32.const 0) + ) +) diff --git a/modules/plugin-host/tests/smoke.rs b/modules/plugin-host/tests/smoke.rs new file mode 100644 index 0000000..e6ec36d --- /dev/null +++ b/modules/plugin-host/tests/smoke.rs @@ -0,0 +1,109 @@ +//! Smoke tests del runtime Tier 2 — verifican: +//! +//! 1. Carga desde disco (`manifest.toml` + `.wasm`) e invocación que +//! devuelve `PluginAction::SetStatus` con el saludo concatenado. +//! 2. Sandbox por permisos: un plugin con `filesystem = "none"` que +//! intenta llamar `plugin.open_at` trap-ea — el import no se +//! enlazó, así que el módulo importa una función inexistente. +//! 3. Permiso concedido: el mismo plugin con `filesystem = "read-only"` +//! sí enlaza, ejecuta, y emite `PluginAction::OpenAt`. + +use std::path::PathBuf; + +use card_core::{FsPolicy, Permissions}; +use llimphi_plugin_host::{PluginAction, PluginError, PluginHost, PluginManifest}; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hello-status") +} + +/// Compila el .wat del fixture a .wasm en el OUT_DIR efímero del test. +/// Lo hacemos por test (no en build.rs) para mantener el crate sin +/// build script — el costo es trivial y la lógica vive con el test. +fn compile_fixture_to(dir: &std::path::Path) { + let wat = std::fs::read_to_string(dir.join("plugin.wat")).expect("leo plugin.wat"); + let wasm = wat::parse_str(&wat).expect("WAT del fixture compila a wasm"); + std::fs::write(dir.join("plugin.wasm"), wasm).expect("escribo plugin.wasm"); +} + +#[test] +fn carga_desde_directorio_y_devuelve_set_status() { + let dir = fixture_dir(); + compile_fixture_to(&dir); + + let mut host = PluginHost::new(); + let id = host.load_from_dir(&dir).expect("plugin carga desde dir"); + + let manifest = host.manifest(id).expect("manifest accesible"); + assert_eq!(manifest.name, "hello-status"); + assert_eq!(manifest.capabilities, vec!["status.greet".to_string()]); + + let action = host.invoke(id, "status.greet", b"mundo").expect("invoke ok"); + assert_eq!(action, PluginAction::SetStatus("hola, mundo".into())); + + // El host puede enumerar capabilities agregadas para construir su Card. + assert_eq!(host.all_capabilities(), vec!["status.greet".to_string()]); +} + +/// WAT que intenta importar `plugin.open_at`. Sirve como "plugin +/// malicioso" para verificar el sandbox: si el host no concede +/// `filesystem`, el linker no enlaza el import → wasmi rechaza la +/// instanciación con un error de import faltante. +fn wants_open_at_wat() -> &'static str { + // El path va en offset 256 para no colisionar con el buffer + // [cap | args] que el host escribe a partir del offset 0. + r#" +(module + (import "plugin" "open_at" (func $open_at (param i32 i32 i32 i32))) + (memory (export "memory") 1) + (data (i32.const 256) "/etc/passwd") + (func (export "_invoke") + (param i32) (param i32) (param i32) (param i32) + (result i32) + (call $open_at (i32.const 256) (i32.const 11) (i32.const 10) (i32.const 5)) + (i32.const 0) + ) +) +"# +} + +#[test] +fn sin_permiso_filesystem_el_plugin_no_instancia() { + let bytes = wat::parse_str(wants_open_at_wat()).unwrap(); + let manifest = PluginManifest { + name: "wants-fs".into(), + version: "0.1.0".into(), + capabilities: vec!["fs.open".into()], + permissions: Permissions::default(), // filesystem = none + }; + + let mut host = PluginHost::new(); + let id = host.load_bytes(manifest, &bytes).expect("carga ok — el sandbox actúa al invocar"); + + let err = host.invoke(id, "fs.open", b"").expect_err("debe fallar sin permiso fs"); + // wasmi reporta el import faltante en la instanciación. + assert!( + matches!(err, PluginError::Instantiate(_)), + "esperaba Instantiate, vi {err:?}" + ); +} + +#[test] +fn con_permiso_filesystem_el_plugin_emite_open_at() { + let bytes = wat::parse_str(wants_open_at_wat()).unwrap(); + let manifest = PluginManifest { + name: "wants-fs".into(), + version: "0.1.0".into(), + capabilities: vec!["fs.open".into()], + permissions: Permissions { filesystem: FsPolicy::ReadOnly, ..Permissions::default() }, + }; + + let mut host = PluginHost::new(); + let id = host.load_bytes(manifest, &bytes).unwrap(); + let action = host.invoke(id, "fs.open", b"").expect("con permiso, debe correr"); + + assert_eq!( + action, + PluginAction::OpenAt { path: PathBuf::from("/etc/passwd"), line: 10, col: 5 } + ); +} diff --git a/modules/selector/Cargo.toml b/modules/selector/Cargo.toml new file mode 100644 index 0000000..ace3212 --- /dev/null +++ b/modules/selector/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-module-selector" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-selector — trait Selector con dos backends: HostSelector (paths del FS via std::fs) y WawaSelector (khipus por hash, sello digital). Una sola API 'abrir/guardar' que funciona en cualquier entorno gioser." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/modules/selector/src/lib.rs b/modules/selector/src/lib.rs new file mode 100644 index 0000000..5910a3c --- /dev/null +++ b/modules/selector/src/lib.rs @@ -0,0 +1,245 @@ +//! `llimphi-module-selector` — abstracción de "abrir/guardar" portable +//! entre host (paths del FS) y wawa (khipus por hash). +//! +//! ## Por qué +//! +//! Una app gioser que sólo conoce paths (`PathBuf`) se rompe en wawa, +//! donde el almacenamiento es direccionado por contenido (BLAKE3 + DAG) +//! y no existe el concepto de "carpeta /home/usuario". Pero la mayoría +//! de las apps no necesitan saber la diferencia: sólo quieren preguntar +//! "qué item quiere abrir el usuario" o "dónde guardo este blob". +//! +//! Este crate expone: +//! - El trait [`Selector`] con dos métodos: `list_candidates()` (para +//! armar la UI del picker) y `realize(handle)` (para resolver el +//! item elegido a bytes). +//! - Un `ItemHandle` opaco — la app no debe inspeccionarlo, sólo +//! pasarlo de vuelta al selector. +//! - [`HostSelector`] con root path + extension filter (impl real). +//! - [`WawaSelector`] como **placeholder** con la API definida — la +//! integración real con `akasha` / `wawa-kernel` ocurre cuando la +//! suite empiece a correr in-cage. Por ahora exporta tipos y panica +//! si se invoca, lo cual está bien: el código que lo construye +//! queda compilable y las apps pueden tipar contra el trait. +//! +//! ## API mínima +//! +//! ```ignore +//! use llimphi_module_selector::{HostSelector, Selector}; +//! +//! let sel = HostSelector::new("/home/usr/docs", &[".pluma", ".khipu"]); +//! let items = sel.list_candidates()?; +//! // (la app muestra `items.iter().map(|i| &i.display_name)` en su picker) +//! // user elige el index N: +//! let bytes = sel.realize(&items[N].handle)?; +//! ``` + +#![forbid(unsafe_code)] + +use std::path::{Path, PathBuf}; + +/// Resultado de la operación — `String` como error para que no le +/// importe a la app si el backend es FS o wawa. +pub type SelectorResult = Result; + +/// Item visible en el picker. `handle` es opaco — sólo el `Selector` +/// que lo emitió sabe interpretarlo. +#[derive(Debug, Clone)] +pub struct Item { + /// Nombre legible para mostrar en el picker. Para `HostSelector` + /// es el path relativo al root; para `WawaSelector` será el alias + /// del khipu o un hash truncado si no tiene alias. + pub display_name: String, + /// Tamaño en bytes si se conoce — para mostrar al lado del nombre. + /// `None` cuando es caro de calcular (e.g. khipu blob remoto). + pub size_bytes: Option, + pub handle: ItemHandle, +} + +/// Handle opaco. Internamente puede ser un path (host) o un hash +/// (wawa). La app no debe construir uno a mano — lo recibe del +/// `Selector` y se lo devuelve al `realize()`. +#[derive(Debug, Clone)] +pub enum ItemHandle { + /// Path absoluto en el FS del host. + HostPath(PathBuf), + /// Hash de contenido BLAKE3 (32 bytes hex) en el almacén wawa. + /// La integración real lo resuelve via `almacen::cargar(hash)`. + WawaHash([u8; 32]), +} + +/// Trait que abstrae el medio de almacenamiento. Una app gioser que +/// quiera funcionar tanto en host como en wawa toma un `&dyn Selector` +/// en su modelo en lugar de un `PathBuf` concreto. +pub trait Selector { + /// Lista los items "abribles" según los criterios del selector + /// (extensión, glob, scope). Para host suele ser un walk del root; + /// para wawa, los khipus marcados con cierto namespace. + fn list_candidates(&self) -> SelectorResult>; + + /// Resuelve un `ItemHandle` a los bytes del item. + fn realize(&self, handle: &ItemHandle) -> SelectorResult>; + + /// Guarda `bytes` bajo el nombre lógico `name`. Devuelve el + /// `ItemHandle` del item recién creado. Para host esto es + /// `root.join(name) + write`; para wawa, ingerir en el almacén. + fn save(&self, name: &str, bytes: &[u8]) -> SelectorResult; +} + +// ===================================================================== +// HostSelector — backend de FS clásico +// ===================================================================== + +/// Selector que walkea un root del filesystem y filtra por extensión. +/// Implementación lineal — para roots gigantes la app debería cachear +/// los candidates al arrancar (igual que hace el `file-picker` actual). +pub struct HostSelector { + root: PathBuf, + /// Lista de extensiones aceptadas (con el punto, ej. `".pluma"`). + /// Vacío = todas. + extensions: Vec, +} + +impl HostSelector { + pub fn new(root: impl Into, extensions: &[&str]) -> Self { + Self { + root: root.into(), + extensions: extensions.iter().map(|s| (*s).to_string()).collect(), + } + } + + fn accept(&self, path: &Path) -> bool { + if self.extensions.is_empty() { + return true; + } + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + return false; + }; + self.extensions.iter().any(|ext| name.ends_with(ext)) + } + + fn walk(&self, dir: &Path, out: &mut Vec) -> SelectorResult<()> { + let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + // Saltamos directorios "ruidosos" (target, .git, node_modules). + if let Some(name) = path.file_name().and_then(|s| s.to_str()) { + if matches!(name, "target" | ".git" | "node_modules" | ".idea") { + continue; + } + } + self.walk(&path, out)?; + } else if self.accept(&path) { + let display_name = path + .strip_prefix(&self.root) + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| path.display().to_string()); + let size_bytes = entry.metadata().ok().map(|m| m.len()); + out.push(Item { + display_name, + size_bytes, + handle: ItemHandle::HostPath(path), + }); + } + } + Ok(()) + } +} + +impl Selector for HostSelector { + fn list_candidates(&self) -> SelectorResult> { + let mut out = Vec::new(); + self.walk(&self.root, &mut out)?; + Ok(out) + } + + fn realize(&self, handle: &ItemHandle) -> SelectorResult> { + match handle { + ItemHandle::HostPath(p) => std::fs::read(p).map_err(|e| e.to_string()), + ItemHandle::WawaHash(_) => Err("HostSelector no resuelve hashes wawa".into()), + } + } + + fn save(&self, name: &str, bytes: &[u8]) -> SelectorResult { + let path = self.root.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&path, bytes).map_err(|e| e.to_string())?; + Ok(ItemHandle::HostPath(path)) + } +} + +// ===================================================================== +// WawaSelector — placeholder para integración con akasha/almacen +// ===================================================================== + +/// Selector para entorno wawa. **No implementado** — la integración real +/// requiere bindings al `wawa-kernel::almacen` (BLAKE3 + log + GC), que +/// vive fuera del workspace global. Por ahora expone la API para que el +/// código que la usa compile, y panica en runtime para flaggear que +/// alguien intentó usarla antes de tiempo. +/// +/// Cuando llegue la integración real: +/// 1. `wawa-kernel` exporta una crate `wawa-almacen-client` cross-bound +/// accesible desde apps WASM. +/// 2. `WawaSelector::new(namespace)` se conecta a ese cliente. +/// 3. `list_candidates()` consulta `almacen::listar(namespace)`. +/// 4. `realize(WawaHash(h))` invoca `almacen::cargar(h)`. +/// 5. `save(name, bytes)` invoca `almacen::ingerir(bytes)` y registra +/// el alias `name → hash`. +pub struct WawaSelector { + /// Namespace lógico (ej. `"pluma.documentos"`) — el almacén filtra + /// los khipus marcados con este tag. + pub namespace: String, +} + +impl WawaSelector { + pub fn new(namespace: impl Into) -> Self { + Self { namespace: namespace.into() } + } +} + +impl Selector for WawaSelector { + fn list_candidates(&self) -> SelectorResult> { + Err(format!( + "WawaSelector('{}') sin backend wawa registrado — pendiente de integración con wawa-almacen-client", + self.namespace + )) + } + + fn realize(&self, _handle: &ItemHandle) -> SelectorResult> { + Err("WawaSelector::realize sin backend wawa registrado".into()) + } + + fn save(&self, _name: &str, _bytes: &[u8]) -> SelectorResult { + Err("WawaSelector::save sin backend wawa registrado".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_selector_accept_with_extensions() { + let s = HostSelector::new("/tmp", &[".pluma", ".khipu"]); + assert!(s.accept(Path::new("/tmp/foo.pluma"))); + assert!(s.accept(Path::new("/tmp/bar.khipu"))); + assert!(!s.accept(Path::new("/tmp/baz.txt"))); + } + + #[test] + fn host_selector_empty_extensions_accepts_all() { + let s = HostSelector::new("/tmp", &[]); + assert!(s.accept(Path::new("/tmp/anything.rs"))); + assert!(s.accept(Path::new("/tmp/anything.unknown"))); + } + + #[test] + fn wawa_selector_returns_err_until_backend_lands() { + let s = WawaSelector::new("pluma.documentos"); + assert!(s.list_candidates().is_err()); + } +} diff --git a/modules/shuma-term/Cargo.toml b/modules/shuma-term/Cargo.toml new file mode 100644 index 0000000..49beab4 --- /dev/null +++ b/modules/shuma-term/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-module-shuma-term" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-shuma-term — terminal integrado tipo Ctrl+\\` de VS Code. Módulo Llimphi sobre shuma-exec (PTY real) + vt100 (emulación). Cualquier app Llimphi puede enchufar un terminal sandboxeado por el shell del usuario." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +shuma-exec = { path = "../../../shuma/sandbox/shuma-exec" } +vt100 = { workspace = true } diff --git a/modules/shuma-term/LEEME.md b/modules/shuma-term/LEEME.md new file mode 100644 index 0000000..b1d6806 --- /dev/null +++ b/modules/shuma-term/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-shuma-term + +> Terminal embebida (shell shuma) de [llimphi](../../README.md). + +Wrapper de [`shuma-shell-llimphi`](../../../shuma/shuma-shell-llimphi/README.md) montable adentro de cualquier app. Útil para `nada`, IDE-like setups. diff --git a/modules/shuma-term/README.md b/modules/shuma-term/README.md new file mode 100644 index 0000000..182a103 --- /dev/null +++ b/modules/shuma-term/README.md @@ -0,0 +1,5 @@ +# llimphi-module-shuma-term + +> Embedded terminal (shuma shell) of [llimphi](../../README.md). + +Wrapper of [`shuma-shell-llimphi`](../../../shuma/shuma-shell-llimphi/README.md) mountable inside any app. Useful for `nada`, IDE-like setups. diff --git a/modules/shuma-term/src/lib.rs b/modules/shuma-term/src/lib.rs new file mode 100644 index 0000000..5d14cc6 --- /dev/null +++ b/modules/shuma-term/src/lib.rs @@ -0,0 +1,511 @@ +//! `llimphi-module-shuma-term` — terminal integrado al estilo Ctrl+` de +//! VS Code o "Terminal" de JetBrains, pero enchufable en cualquier app +//! Llimphi. +//! +//! Lo monta sobre dos piezas que ya existen: +//! +//! - [`shuma_exec::Exec::Pty`] aloja un pseudo-terminal cross-platform +//! (`portable-pty`), lanza el shell con `TERM=xterm-256color`, y +//! entrega los bytes crudos por un canal MPSC. El módulo no toca +//! syscalls — sólo consume eventos. +//! - [`vt100::Parser`] convierte esos bytes en un buffer de pantalla +//! ANSI: cursor, erase, OSC, scrollback. El módulo le pasa los bytes +//! y al renderizar pide `screen().contents()`. +//! +//! Sigue el contrato Llimphi de `docs/MODULES.md`: `State + Msg + +//! Action + apply/on_key/open_shortcut/view + Palette`. +//! +//! ## Cómo lo enchufa una app (resumen) +//! +//! ```ignore +//! struct Model { term: Option, … } +//! enum Msg { Term(ShumaTermMsg), Tick, … } +//! +//! // open: shuma_term::spawn("/home/user", 100, 30)? +//! // on_key: si term.is_some() y on_key devuelve Some(msg) → Msg::Term(msg) +//! // si term.is_none() y open_shortcut(ev) → Msg::Term(Open) +//! // tick periódico: dispatch Msg::Term(Tick) para drenar PTY +//! // apply: match action { Close → model.term = None, SetStatus(s) → … } +//! // view: si term.is_some() → push view(...) +//! ``` + +#![forbid(unsafe_code)] + +use std::time::Instant; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use shuma_exec::{CommandSpec, Exec, Killer, RunEvent, RunHandle}; + +/// Capabilities que aporta este módulo al host. El host las puede +/// agregar a `provides` en su `card_core::Card` para que el broker +/// chasqui descubra que la instancia ofrece terminal integrado. +pub const CAPABILITIES: &[&str] = &["editor.terminal"]; + +/// Dimensiones por defecto del PTY. Cubren un panel inferior tipo +/// VS Code en una pantalla 1080p. Las apps pueden pasar otras a +/// [`spawn_with`]. +pub const DEFAULT_COLS: u16 = 100; +pub const DEFAULT_ROWS: u16 = 24; + +const SCROLLBACK: usize = 2000; + +// ===================================================================== +// State +// ===================================================================== + +/// Estado del panel terminal. Encapsula el `RunHandle` del shell y un +/// `vt100::Parser` que mantiene el buffer de pantalla. No es `Clone` +/// (los handles son únicos), y el host lo embebe como +/// `Option`. +pub struct ShumaTermState { + handle: RunHandle, + killer: Killer, + parser: vt100::Parser, + cols: u16, + rows: u16, + /// Si el shell ya emitió `Exited(code)`. El panel se queda visible + /// para que el usuario pueda leer la última salida antes de cerrar. + exit_code: Option, + /// CWD inicial — útil para el header sin tener que tocar /proc. + cwd: String, + started_at: Instant, +} + +impl ShumaTermState { + /// Bytes que el módulo ya consumió desde el PTY. Útil para tests y + /// debug — no es parte del contrato Tier 1. + pub fn screen_contents(&self) -> String { + self.parser.screen().contents() + } + + pub fn cols(&self) -> u16 { + self.cols + } + pub fn rows(&self) -> u16 { + self.rows + } + pub fn exit_code(&self) -> Option { + self.exit_code + } + pub fn cwd(&self) -> &str { + &self.cwd + } +} + +impl Drop for ShumaTermState { + fn drop(&mut self) { + // Si el host descarta el state (panel cerrado), no dejamos al + // shell huérfano consumiendo CPU. SIGTERM educado primero; + // shuma-exec se encarga del SIGKILL si hace falta. + self.killer.term(); + } +} + +/// Lanza el shell por defecto (`$SHELL`, fallback `/bin/sh`) en `cwd` +/// con tamaño de PTY por defecto. +pub fn spawn(cwd: impl Into) -> ShumaTermState { + spawn_with(cwd, default_shell(), Vec::new(), DEFAULT_COLS, DEFAULT_ROWS) +} + +/// Variante con control fino de programa, args y tamaño. +pub fn spawn_with( + cwd: impl Into, + program: String, + args: Vec, + cols: u16, + rows: u16, +) -> ShumaTermState { + let cwd = cwd.into(); + let spec = CommandSpec { + exec: Exec::Pty { program, args, cols, rows }, + cwd: cwd.clone(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + }; + let handle = shuma_exec::run(&spec); + let killer = handle.killer(); + ShumaTermState { + handle, + killer, + parser: vt100::Parser::new(rows, cols, SCROLLBACK), + cols, + rows, + exit_code: None, + cwd, + started_at: Instant::now(), + } +} + +fn default_shell() -> String { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) +} + +// ===================================================================== +// Msg / Action +// ===================================================================== + +/// Vocabulario interno. El host lo wrapea en su `Msg`. +#[derive(Debug, Clone)] +pub enum ShumaTermMsg { + /// Símbolo conveniente para que el host dispatche al detectar el + /// shortcut. El módulo no crea el state él mismo — el host lo crea + /// con [`spawn`] porque conoce el cwd canónico de la app. + Open, + /// El usuario pidió cerrar el panel. + Close, + /// Tecla mientras el panel está enfocado. Se traduce a bytes y se + /// reenvía al PTY. + KeyInput(KeyEvent), + /// Tick del host: drena eventos pendientes del PTY (bytes y exit). + /// El host debe enviar este Msg de forma periódica (en cada frame, + /// o cuando hay actividad). Sin Tick el terminal no avanza. + Tick, + /// Mata el shell (SIGTERM); el panel queda visible mostrando el + /// estado final hasta que el host reciba `Close`. + Terminate, +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShumaTermAction { + None, + /// El host debería remover el state del modelo. + Close, + /// El host debería actualizar su barra de estado. + SetStatus(String), +} + +// ===================================================================== +// apply / on_key / open_shortcut +// ===================================================================== + +/// Aplica un mensaje al estado. +pub fn apply(state: &mut ShumaTermState, msg: ShumaTermMsg) -> ShumaTermAction { + match msg { + ShumaTermMsg::Open => ShumaTermAction::None, + ShumaTermMsg::Close => ShumaTermAction::Close, + ShumaTermMsg::Terminate => { + state.killer.term(); + ShumaTermAction::SetStatus("shuma · SIGTERM".into()) + } + ShumaTermMsg::Tick => drain(state), + ShumaTermMsg::KeyInput(ev) => { + // Interceptaciones del módulo (no llegan al PTY): + // Ctrl+Shift+W → cierra el panel. + // Cualquier otra combinación se traduce a bytes y se envía. + if ev.state == KeyState::Pressed + && ev.modifiers.ctrl + && ev.modifiers.shift + && matches!(&ev.key, Key::Character(s) if s.eq_ignore_ascii_case("w")) + { + return ShumaTermAction::Close; + } + let bytes = key_to_bytes(&ev); + if !bytes.is_empty() { + state.handle.write_input(bytes); + } + ShumaTermAction::None + } + } +} + +/// Routing de teclas cuando el panel está enfocado. Devuelve `Some` para +/// todo evento `Pressed` — el terminal **traga** las teclas; el host no +/// debe reusarlas para sus propios atajos mientras este panel esté +/// activo (la excepción es el atajo de apertura, que el host filtra +/// antes de delegar). +pub fn on_key(_state: &ShumaTermState, event: &KeyEvent) -> Option { + if event.state != KeyState::Pressed { + return None; + } + Some(ShumaTermMsg::KeyInput(event.clone())) +} + +/// El atajo recomendado para abrir: **Ctrl+`** (backtick), igual que +/// VS Code. Los hosts pueden ignorarlo y usar otro. +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 == "`") +} + +// ===================================================================== +// Drenado del PTY +// ===================================================================== + +fn drain(state: &mut ShumaTermState) -> ShumaTermAction { + let mut bytes_in = 0usize; + let mut final_action = ShumaTermAction::None; + for ev in state.handle.try_events() { + match ev { + RunEvent::Bytes(b) => { + bytes_in += b.len(); + state.parser.process(&b); + } + RunEvent::Exited(code) => { + state.exit_code = Some(code); + let elapsed = state.started_at.elapsed().as_secs_f64(); + final_action = ShumaTermAction::SetStatus(format!( + "shuma · exit {code} · {elapsed:.1}s" + )); + } + RunEvent::Failed(err) => { + state.exit_code = Some(-1); + final_action = + ShumaTermAction::SetStatus(format!("shuma · falló: {err}")); + } + // Stdout/Stderr/Truncated/Spilled no aplican al modo Pty. + _ => {} + } + } + if matches!(final_action, ShumaTermAction::None) && bytes_in > 0 { + // Nada que reportar — el repaint que el host hará por el frame + // basta para mostrar lo nuevo. + ShumaTermAction::None + } else { + final_action + } +} + +// ===================================================================== +// Mapeo KeyEvent → bytes +// ===================================================================== + +/// Convierte un `KeyEvent` ya recibido en los bytes que un terminal +/// xterm espera. Cubre el subset usable (chars + control + flechas + +/// home/end/page + fn keys), suficiente para shells modernos, TUIs +/// (vim, htop, less) y CLIs interactivas (claude code, fzf). +pub fn key_to_bytes(ev: &KeyEvent) -> Vec { + if ev.state != KeyState::Pressed { + return Vec::new(); + } + + // Teclas con nombre primero: flechas, etc. Se mapean a CSI/SS3 + // estándar (xterm-256color). + if let Key::Named(named) = &ev.key { + return named_to_bytes(*named); + } + + // Caracter: si hay Ctrl+letra → control byte (Ctrl+C = 0x03). + if let Key::Character(s) = &ev.key { + if ev.modifiers.ctrl && !ev.modifiers.alt { + if let Some(b) = ctrl_byte(s) { + return vec![b]; + } + } + // Alt+x → ESC + x (convención xterm meta-sends-escape). + if ev.modifiers.alt { + let mut out = vec![0x1b]; + out.extend_from_slice(s.as_bytes()); + return out; + } + } + + // Caso general: si el backend ya nos dio el texto resultante + // (con shift/IME aplicados), eso es lo correcto para mandar. + if let Some(text) = &ev.text { + return text.as_bytes().to_vec(); + } + Vec::new() +} + +fn named_to_bytes(k: NamedKey) -> Vec { + match k { + // PTYs en modo raw esperan CR para Enter; el driver convierte a LF. + NamedKey::Enter => b"\r".to_vec(), + // Backspace moderno = DEL (0x7f). Los shells lo entienden mejor + // que 0x08, que se reserva para ^H en TUIs viejos. + NamedKey::Backspace => vec![0x7f], + NamedKey::Tab => b"\t".to_vec(), + NamedKey::Escape => vec![0x1b], + NamedKey::ArrowUp => b"\x1b[A".to_vec(), + NamedKey::ArrowDown => b"\x1b[B".to_vec(), + NamedKey::ArrowRight => b"\x1b[C".to_vec(), + NamedKey::ArrowLeft => b"\x1b[D".to_vec(), + NamedKey::Home => b"\x1b[H".to_vec(), + NamedKey::End => b"\x1b[F".to_vec(), + NamedKey::PageUp => b"\x1b[5~".to_vec(), + NamedKey::PageDown => b"\x1b[6~".to_vec(), + NamedKey::Delete => b"\x1b[3~".to_vec(), + NamedKey::Insert => b"\x1b[2~".to_vec(), + NamedKey::F1 => b"\x1bOP".to_vec(), + NamedKey::F2 => b"\x1bOQ".to_vec(), + NamedKey::F3 => b"\x1bOR".to_vec(), + NamedKey::F4 => b"\x1bOS".to_vec(), + NamedKey::F5 => b"\x1b[15~".to_vec(), + NamedKey::F6 => b"\x1b[17~".to_vec(), + NamedKey::F7 => b"\x1b[18~".to_vec(), + NamedKey::F8 => b"\x1b[19~".to_vec(), + NamedKey::F9 => b"\x1b[20~".to_vec(), + NamedKey::F10 => b"\x1b[21~".to_vec(), + NamedKey::F11 => b"\x1b[23~".to_vec(), + NamedKey::F12 => b"\x1b[24~".to_vec(), + _ => Vec::new(), + } +} + +/// Ctrl+letter → byte de control ASCII (Ctrl+A=1, Ctrl+B=2, ..., Ctrl+Z=26). +/// Maneja también Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+\\ (FS), Ctrl+] (GS), +/// Ctrl+^ (RS), Ctrl+_ (US), Ctrl+? (DEL). +fn ctrl_byte(s: &str) -> Option { + let c = s.chars().next()?; + match c { + 'a'..='z' => Some((c as u8) - b'a' + 1), + 'A'..='Z' => Some((c as u8) - b'A' + 1), + '@' => Some(0), + '[' => Some(0x1b), + '\\' => Some(0x1c), + ']' => Some(0x1d), + '^' => Some(0x1e), + '_' => Some(0x1f), + '?' => Some(0x7f), + ' ' => Some(0), // Ctrl+Space = NUL, convención xterm + _ => None, + } +} + +// ===================================================================== +// View +// ===================================================================== + +/// Paleta visual del terminal. Monospace; fondo más oscuro que el +/// panel general para que el terminal "viva" visualmente. +#[derive(Debug, Clone)] +pub struct ShumaTermPalette { + pub bg_panel: llimphi_ui::llimphi_raster::peniko::Color, + pub bg_header: llimphi_ui::llimphi_raster::peniko::Color, + pub fg_text: llimphi_ui::llimphi_raster::peniko::Color, + pub fg_muted: llimphi_ui::llimphi_raster::peniko::Color, +} + +impl ShumaTermPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel_alt, + bg_header: t.bg_panel, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + } + } +} + +const HEADER_H: f32 = 18.0; +const ROW_H: f32 = 14.0; +const CHAR_W: f32 = 7.5; + +/// Render del panel. `to_host` mapea cada `ShumaTermMsg` al `Msg` del +/// host. `height_px` es la altura total del panel — el módulo divide +/// entre header + grid. +pub fn view( + state: &ShumaTermState, + palette: &ShumaTermPalette, + height_px: f32, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(ShumaTermMsg) -> HostMsg + Copy + 'static, +{ + let _ = to_host; // v0 no monta eventos puntuales sobre el grid + + let header_text = match state.exit_code { + Some(code) => format!( + "shuma · {} · exit {code} · Ctrl+Shift+W cierra", + state.cwd + ), + None => format!( + "shuma · {} · {}×{} · Ctrl+Shift+W cierra · Esc envía al shell", + state.cwd, state.cols, state.rows + ), + }; + + 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 contents = state.parser.screen().contents(); + let grid_h = (height_px - HEADER_H).max(0.0); + let max_rows = ((grid_h / ROW_H) as usize).max(1); + + // Tomamos las últimas `max_rows` líneas — preferimos mostrar el + // tail (donde está el cursor / prompt) si el render no alcanza + // para toda la pantalla. + let all_lines: Vec<&str> = contents.split('\n').collect(); + let start = all_lines.len().saturating_sub(max_rows); + let mut rows: Vec> = Vec::with_capacity(max_rows); + for line in &all_lines[start..] { + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_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_panel) + .text_aligned((*line).to_string(), 11.0, palette.fg_text, Alignment::Start), + ); + } + // Si el render quedó corto, rellenamos con líneas vacías para que el + // panel mantenga su altura visual. + while rows.len() < max_rows { + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel), + ); + } + + let mut children: Vec> = Vec::with_capacity(1 + rows.len()); + children.push(header); + children.extend(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) +} + +/// Estimación heurística de cuántas columnas caben en `width_px` con la +/// fuente actual. Útil para que el host calcule el tamaño antes de +/// llamar a [`spawn_with`]. +pub fn cols_for_width(width_px: f32) -> u16 { + ((width_px / CHAR_W).floor() as u16).max(20) +} + +/// Idem para filas a partir de la altura disponible del panel +/// (descontando el header). +pub fn rows_for_height(height_px: f32) -> u16 { + (((height_px - HEADER_H) / ROW_H).floor() as u16).max(5) +} diff --git a/modules/shuma-term/tests/smoke.rs b/modules/shuma-term/tests/smoke.rs new file mode 100644 index 0000000..1200a6c --- /dev/null +++ b/modules/shuma-term/tests/smoke.rs @@ -0,0 +1,157 @@ +//! Smoke test del terminal: spawnea un shell, le tipea `echo hola`, +//! drena hasta ver el output, y verifica que el contenido del screen +//! contenga "hola". Cierre con SIGTERM se valida por el Drop. +//! +//! Requiere `/bin/sh` y un sistema Linux real (no corre en sandbox +//! puro). Es razonable porque shuma-exec ya lo asume. + +use std::time::{Duration, Instant}; + +use llimphi_module_shuma_term::{self as term, ShumaTermAction, ShumaTermMsg}; + +#[test] +fn echo_a_traves_del_pty_aparece_en_el_screen() { + let mut state = term::spawn_with( + "/tmp".to_string(), + "/bin/sh".to_string(), + Vec::new(), + 80, + 24, + ); + + // El shell escribe su prompt al arrancar; lo drenamos sin asumir + // su contenido (cambia por distro). + spin_drain(&mut state, Duration::from_millis(200)); + + // Tipeamos el comando. Sin Llimphi alrededor llamamos a write_input + // directamente — el módulo permite hacerlo via KeyInput, pero + // construir KeyEvents acá es ruido para este test. + write_raw(&mut state, b"echo hola_del_test\n"); + + // Esperamos hasta 2s a que el output llegue al screen. + let deadline = Instant::now() + Duration::from_secs(2); + let mut visto = false; + while Instant::now() < deadline { + spin_drain(&mut state, Duration::from_millis(50)); + if state.screen_contents().contains("hola_del_test") { + visto = true; + break; + } + } + assert!( + visto, + "esperaba ver 'hola_del_test' en el screen, contenido actual:\n{}", + state.screen_contents() + ); +} + +#[test] +fn ctrl_shift_w_emite_action_close_sin_pasar_al_pty() { + use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + + let mut state = term::spawn_with( + "/tmp".to_string(), + "/bin/sh".to_string(), + Vec::new(), + 80, + 24, + ); + spin_drain(&mut state, Duration::from_millis(100)); + + let ev = KeyEvent { + key: Key::Character("w".into()), + state: KeyState::Pressed, + text: Some("w".into()), + modifiers: Modifiers { ctrl: true, shift: true, ..Modifiers::default() }, + repeat: false, + }; + let action = term::apply(&mut state, ShumaTermMsg::KeyInput(ev)); + assert_eq!(action, ShumaTermAction::Close); +} + +#[test] +fn key_to_bytes_mapea_los_casos_canonicos() { + use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey}; + + let mk = |key: Key, mods: Modifiers, text: Option<&str>| KeyEvent { + key, + state: KeyState::Pressed, + text: text.map(|s| s.to_string()), + modifiers: mods, + repeat: false, + }; + + // Enter → CR (no LF — el driver del PTY lo expande). + assert_eq!( + term::key_to_bytes(&mk(Key::Named(NamedKey::Enter), Modifiers::default(), None)), + b"\r" + ); + // Backspace → DEL. + assert_eq!( + term::key_to_bytes(&mk( + Key::Named(NamedKey::Backspace), + Modifiers::default(), + None + )), + vec![0x7f] + ); + // ArrowUp → CSI A. + assert_eq!( + term::key_to_bytes(&mk( + Key::Named(NamedKey::ArrowUp), + Modifiers::default(), + None + )), + b"\x1b[A" + ); + // Ctrl+C → 0x03. + let ctrl = Modifiers { ctrl: true, ..Modifiers::default() }; + assert_eq!( + term::key_to_bytes(&mk(Key::Character("c".into()), ctrl, Some("c"))), + vec![0x03] + ); + // Texto plano (con shift aplicado por el backend) → ese mismo texto. + assert_eq!( + term::key_to_bytes(&mk(Key::Character("A".into()), Modifiers::default(), Some("A"))), + b"A" + ); + // Alt+x → ESC + x. + let alt = Modifiers { alt: true, ..Modifiers::default() }; + assert_eq!( + term::key_to_bytes(&mk(Key::Character("x".into()), alt, Some("x"))), + vec![0x1b, b'x'] + ); +} + +// --------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------- + +/// Pequeño polling: dispara Tick varias veces durante `total` para que +/// el módulo drene los bytes que el reader thread haya emitido. +fn spin_drain(state: &mut llimphi_module_shuma_term::ShumaTermState, total: Duration) { + let deadline = Instant::now() + total; + while Instant::now() < deadline { + term::apply(state, ShumaTermMsg::Tick); + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// Atajo: enviar bytes crudos al PTY sin construir un KeyEvent. Usa la +/// API pública via un truco — convertimos a un KeyEvent "texto" para +/// evitar exponer write_input crudo en el contrato. +fn write_raw(state: &mut llimphi_module_shuma_term::ShumaTermState, bytes: &[u8]) { + use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + // Texto entero (incluyendo el LF) en un solo KeyInput. apply() lo + // copia tal cual al PTY via la rama `text`. + let s = std::str::from_utf8(bytes).expect("test usa ascii"); + let ev = KeyEvent { + // Key::Character vacío para que no entremos por la rama ctrl/alt. + key: Key::Character("".into()), + state: KeyState::Pressed, + text: Some(s.to_string()), + modifiers: Modifiers::default(), + repeat: false, + }; + term::apply(state, ShumaTermMsg::KeyInput(ev)); +} diff --git a/modules/symbol-outline/Cargo.toml b/modules/symbol-outline/Cargo.toml new file mode 100644 index 0000000..88d6866 --- /dev/null +++ b/modules/symbol-outline/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-module-symbol-outline" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-symbol-outline — outline del documento (funciones, structs, métodos) navegable con fuzzy filter. Módulo Llimphi reutilizable: el host le pasa un Vec y el módulo emite GoTo(line, col). No depende de LSP — el host puede poblarlo desde cualquier fuente (tree-sitter, parser propio, LSP)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-text-input = { workspace = true } +nucleo-matcher = { workspace = true } diff --git a/modules/symbol-outline/LEEME.md b/modules/symbol-outline/LEEME.md new file mode 100644 index 0000000..9b7d8e2 --- /dev/null +++ b/modules/symbol-outline/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-symbol-outline + +> Outline de símbolos LSP de [llimphi](../../README.md). + +Vista jerárquica de los símbolos del archivo activo (funciones, structs, módulos, ...) — alimentada por el LSP servidor del lenguaje. Click salta al símbolo. diff --git a/modules/symbol-outline/README.md b/modules/symbol-outline/README.md new file mode 100644 index 0000000..9b71192 --- /dev/null +++ b/modules/symbol-outline/README.md @@ -0,0 +1,5 @@ +# llimphi-module-symbol-outline + +> LSP symbol outline of [llimphi](../../README.md). + +Hierarchical view of the active file's symbols (functions, structs, modules, ...) — fed by the language's LSP server. Click jumps to the symbol. diff --git a/modules/symbol-outline/src/lib.rs b/modules/symbol-outline/src/lib.rs new file mode 100644 index 0000000..5e5b02d --- /dev/null +++ b/modules/symbol-outline/src/lib.rs @@ -0,0 +1,352 @@ +//! `llimphi-module-symbol-outline` — outline navegable de símbolos. +//! +//! Equivalente al "Outline" panel de VS Code o "Structure" de JetBrains. +//! El host arma una lista plana de [`SymbolItem`] (funciones, structs, +//! métodos, con su posición en el buffer) y el módulo presenta un +//! overlay con input + lista rankeada por fuzzy. Cuando el user pica +//! uno, el módulo emite [`OutlineAction::GoTo`] y el host mueve el caret. +//! +//! El módulo es **agnóstico de la fuente de símbolos**. El host puede +//! poblarlo desde: +//! +//! - LSP (`textDocument/documentSymbol`) — fuente canónica. +//! - tree-sitter — sirve para archivos sin LSP. +//! - parser propio del lenguaje del host. +//! - una lista hardcodeada (en una app no-código que tenga "secciones"). +//! +//! 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.symbol-outline"]; + +pub const MAX_RESULTS: usize = 500; + +const BAR_H: f32 = 320.0; +const ROW_H: f32 = 20.0; +const MAX_VISIBLE: usize = 12; + +/// Un símbolo del documento. Los campos son convencionales: +/// +/// - `name`: nombre visible (`foo`, `MyStruct`, `parse_line`). +/// - `kind`: etiqueta corta del tipo de símbolo (`fn`, `struct`, `method`, +/// `mod`, `const`, …). El módulo la pinta sin interpretar — el host +/// elige el vocabulario (LSP usa `SymbolKind` numérico; el host +/// convierte a string). +/// - `line`, `col`: posición 0-based en el buffer. El módulo no toca +/// coordenadas — sólo las devuelve en `GoTo`. +/// - `container`: nombre del símbolo padre (`Some("MyStruct")` para +/// un método). Visible en el render como anotación a la derecha; +/// también participa del fuzzy match para que tipear el nombre de +/// la clase filtre sus métodos. +/// - `depth`: profundidad jerárquica para indentación visual. El +/// módulo asume que la lista ya viene ordenada (parent antes que +/// children, en orden de aparición). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SymbolItem { + pub name: String, + pub kind: String, + pub line: usize, + pub col: usize, + pub container: Option, + pub depth: u32, +} + +/// Estado interno. `results` son índices al slice de symbols que pasa +/// el host: el módulo no copia, sólo guarda índices. +pub struct OutlineState { + pub input: TextInputState, + pub results: Vec, + pub selected: usize, +} + +impl Default for OutlineState { + fn default() -> Self { + Self::new_empty() + } +} + +impl OutlineState { + pub fn new_empty() -> Self { + Self { + input: TextInputState::new(), + results: Vec::new(), + selected: 0, + } + } + + /// Crea un outline poblado con todos los símbolos sin filtro. + pub fn new(items: &[SymbolItem]) -> Self { + let mut s = Self::new_empty(); + refilter(&mut s, items); + s + } +} + +/// Vocabulario interno. El host lo wrapea en su Msg. +#[derive(Clone)] +pub enum OutlineMsg { + /// Símbolo conveniente que el host emite al detectar el shortcut. + /// El módulo no construye el state ni la lista él mismo. + Open, + Close, + KeyInput(KeyEvent), + Nav(i32), + /// Enter: salta al símbolo seleccionado. + Apply, +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutlineAction { + None, + /// El host debería remover el state del modelo. + Close, + /// El host debería mover el caret a esta posición del buffer activo. + /// El módulo NO se cierra automáticamente — el host decide + /// (típicamente sí, para que la navegación sea "salta y mira"). + GoTo { line: usize, col: usize }, +} + +/// Aplica un mensaje al estado. +pub fn apply( + state: &mut OutlineState, + msg: OutlineMsg, + items: &[SymbolItem], +) -> OutlineAction { + match msg { + OutlineMsg::Open => OutlineAction::None, + OutlineMsg::Close => OutlineAction::Close, + OutlineMsg::KeyInput(ev) => { + state.input.apply_key(&ev); + refilter(state, items); + OutlineAction::None + } + OutlineMsg::Nav(d) => { + let n = state.results.len() as i32; + if n > 0 { + state.selected = (state.selected as i32 + d).rem_euclid(n) as usize; + } + OutlineAction::None + } + OutlineMsg::Apply => { + let Some(&idx) = state.results.get(state.selected) else { + return OutlineAction::None; + }; + let Some(it) = items.get(idx) else { + return OutlineAction::None; + }; + OutlineAction::GoTo { line: it.line, col: it.col } + } + } +} + +/// Routing de teclas cuando el outline está abierto. +pub fn on_key(_state: &OutlineState, event: &KeyEvent) -> Option { + if event.state != KeyState::Pressed { + return None; + } + Some(match &event.key { + Key::Named(NamedKey::Escape) => OutlineMsg::Close, + Key::Named(NamedKey::Enter) => OutlineMsg::Apply, + Key::Named(NamedKey::ArrowDown) => OutlineMsg::Nav(1), + Key::Named(NamedKey::ArrowUp) => OutlineMsg::Nav(-1), + _ => OutlineMsg::KeyInput(event.clone()), + }) +} + +/// El atajo recomendado: **Ctrl+Shift+O**, 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("o")) +} + +/// Recalcula `state.results` con fuzzy match sobre `"name kind container"`. +/// Query vacío = lista completa. Cap: [`MAX_RESULTS`]. +pub fn refilter(state: &mut OutlineState, items: &[SymbolItem]) { + let q = state.input.text(); + if q.trim().is_empty() { + state.results = (0..items.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, it) in items.iter().enumerate() { + let hay_str = match &it.container { + Some(c) => format!("{} {} {c}", it.name, it.kind), + None => format!("{} {}", it.name, it.kind), + }; + 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 OutlinePalette { + 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 OutlinePalette { + 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 `OutlineMsg` al `Msg` de la +/// app. +pub fn view( + state: &OutlineState, + items: &[SymbolItem], + palette: &OutlinePalette, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(OutlineMsg) -> HostMsg + Copy + 'static, +{ + let header = if items.is_empty() { + "outline · sin símbolos · Esc cierra".to_string() + } else if state.results.is_empty() { + format!("outline · sin matches · {} símbolos · Esc cierra", items.len()) + } else { + format!( + "outline · {} / {} · ↓↑ navega · Enter salta · 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 símbolo o clase…", + true, + &tp, + to_host(OutlineMsg::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> = Vec::with_capacity(MAX_VISIBLE); + for i in visible_start..visible_end { + let Some(&idx) = state.results.get(i) else { continue }; + let Some(it) = items.get(idx) else { continue }; + // Indentación visual por depth (sólo cuando no hay query — con + // query el orden ya vino del ranking y la jerarquía se pierde). + let indent = if state.input.text().trim().is_empty() { + " ".repeat(it.depth as usize) + } else { + String::new() + }; + let container_tag = match &it.container { + Some(c) if !c.is_empty() => format!(" in {c}"), + _ => String::new(), + }; + let label = format!( + "{indent}{} {} line {}{container_tag}", + it.kind, + it.name, + it.line + 1, + ); + 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, 11.0, fg, Alignment::Start), + ); + } + + let mut children: Vec> = 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) +} diff --git a/modules/symbol-outline/tests/smoke.rs b/modules/symbol-outline/tests/smoke.rs new file mode 100644 index 0000000..c72686a --- /dev/null +++ b/modules/symbol-outline/tests/smoke.rs @@ -0,0 +1,130 @@ +//! Smoke tests del fuzzy match y el routing de teclas. Sin backend +//! gráfico — sólo `apply` + `refilter`. + +use llimphi_module_symbol_outline::{ + self as outline, OutlineAction, OutlineMsg, OutlineState, SymbolItem, +}; +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + +fn seed() -> Vec { + vec![ + SymbolItem { + name: "Model".into(), + kind: "struct".into(), + line: 100, + col: 0, + container: None, + depth: 0, + }, + SymbolItem { + name: "init".into(), + kind: "fn".into(), + line: 110, + col: 4, + container: Some("Model".into()), + depth: 1, + }, + SymbolItem { + name: "update".into(), + kind: "fn".into(), + line: 200, + col: 4, + container: Some("Model".into()), + depth: 1, + }, + SymbolItem { + name: "Renderer".into(), + kind: "struct".into(), + line: 300, + col: 0, + container: None, + depth: 0, + }, + SymbolItem { + name: "draw".into(), + kind: "fn".into(), + line: 310, + col: 4, + container: Some("Renderer".into()), + depth: 1, + }, + ] +} + +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_simbolos() { + let items = seed(); + let s = OutlineState::new(&items); + assert_eq!(s.results.len(), items.len()); +} + +#[test] +fn fuzzy_match_filtra_por_nombre_de_clase_contenedora() { + // Tipear "render" debería traer `draw` (su container es "Renderer") + // gracias a que refilter incluye container en la haystack. + let items = seed(); + let mut s = OutlineState::new(&items); + for ch in ["r", "e", "n", "d", "e", "r"] { + outline::apply(&mut s, OutlineMsg::KeyInput(key_char(ch)), &items); + } + let names: Vec<&str> = s.results.iter().map(|&i| items[i].name.as_str()).collect(); + assert!( + names.contains(&"draw") || names.contains(&"Renderer"), + "esperaba draw o Renderer en {names:?}" + ); +} + +#[test] +fn apply_emite_goto_con_line_col_del_item_seleccionado() { + let items = seed(); + let mut s = OutlineState::new(&items); + // Filtrar "update". + for ch in ["u", "p", "d", "a", "t", "e"] { + outline::apply(&mut s, OutlineMsg::KeyInput(key_char(ch)), &items); + } + let action = outline::apply(&mut s, OutlineMsg::Apply, &items); + assert_eq!(action, OutlineAction::GoTo { line: 200, col: 4 }); +} + +#[test] +fn nav_wrap_around() { + let items = seed(); + let mut s = OutlineState::new(&items); + assert_eq!(s.selected, 0); + outline::apply(&mut s, OutlineMsg::Nav(-1), &items); + assert_eq!(s.selected, items.len() - 1); +} + +#[test] +fn open_shortcut_es_ctrl_shift_o() { + 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!(outline::open_shortcut(&mk(true, true, "o"))); + assert!(outline::open_shortcut(&mk(true, true, "O"))); + assert!(!outline::open_shortcut(&mk(true, false, "o"))); + assert!(!outline::open_shortcut(&mk(false, true, "o"))); +} + +#[test] +fn items_vacios_no_paniquean() { + let items: Vec = Vec::new(); + let mut s = OutlineState::new(&items); + assert!(s.results.is_empty()); + let action = outline::apply(&mut s, OutlineMsg::Apply, &items); + assert_eq!(action, OutlineAction::None); +} diff --git a/widgets/app-header/Cargo.toml b/widgets/app-header/Cargo.toml new file mode 100644 index 0000000..ac9d061 --- /dev/null +++ b/widgets/app-header/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-app-header" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-app-header — tira superior estándar para apps Llimphi: label dinámico a la izquierda + slot de acciones opcional a la derecha. Análogo Llimphi al `nahual-widget-app-header` GPUI." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/app-header/LEEME.md b/widgets/app-header/LEEME.md new file mode 100644 index 0000000..98d8427 --- /dev/null +++ b/widgets/app-header/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-app-header + +> Header común de app para [llimphi](../../README.md). + +Barra superior estándar: logo/icono · título · acciones a la derecha · breadcrumb opcional. Cualquier app del monorepo lo usa para coherencia visual. diff --git a/widgets/app-header/README.md b/widgets/app-header/README.md new file mode 100644 index 0000000..5786f78 --- /dev/null +++ b/widgets/app-header/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-app-header + +> Common app header for [llimphi](../../README.md). + +Standard top bar: logo/icon · title · right-side actions · optional breadcrumb. Any monorepo app uses it for visual coherence. diff --git a/widgets/app-header/src/lib.rs b/widgets/app-header/src/lib.rs new file mode 100644 index 0000000..b8fd724 --- /dev/null +++ b/widgets/app-header/src/lib.rs @@ -0,0 +1,145 @@ +//! `llimphi-widget-app-header` — tira superior estándar de las apps. +//! +//! Reproduce el contrato del `nahual-widget-app-header` GPUI: label +//! dinámico a la izquierda con `flex_grow`, slot a la derecha para +//! acciones (theme switcher, botones de toolbar, etc.). bg = `bg_panel`, +//! line-bottom como `border` del theme. +//! +//! Uso típico: +//! +//! ```ignore +//! app_header(format!("Log: {} · {} entries", path, n), vec![], &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::View; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta del header. Defaults desde el theme global. +#[derive(Debug, Clone, Copy)] +pub struct AppHeaderPalette { + pub bg: Color, + pub border_bottom: Color, + pub fg_text: Color, + pub height: f32, + /// Firma visual: gradient sutil + hairline accent en el top edge. Se + /// activa por defecto al construir desde theme. `None` cae al fill + /// plano de `bg` (modo back-compat para sitios que arman la palette + /// a mano sin theme). + pub signature: Option, +} + +impl Default for AppHeaderPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl AppHeaderPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_panel, + border_bottom: t.border, + fg_text: t.fg_text, + height: 40.0, + signature: Some(PanelStyle { + radius: 0.0, + ..PanelStyle::from_theme(t) + }), + } + } +} + +/// Header con `label` a la izquierda y `actions` a la derecha. `actions` +/// es vacío para apps sin toolbar; viene como Vec para que la app meta +/// botones / switcher / status pill / lo que necesite. +pub fn app_header( + label: impl Into, + actions: Vec>, + palette: &AppHeaderPalette, +) -> View { + let label_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(palette.height), + }, + flex_grow: 1.0, + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(label.into(), 14.0, palette.fg_text, Alignment::Start); + + let actions_view = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(), + height: length(palette.height), + }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(6.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(actions); + + // Bottom border: el header rellena `bg` (o aplica la firma si está + // habilitada), y debajo va una línea 1px de `border_bottom`. Lo + // metemos como un wrapper column. + let bar_style = Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(palette.height), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }; + let bar = match palette.signature { + Some(style) => View::new(bar_style) + .paint_with(panel_signature_painter(style)) + .children(vec![label_view, actions_view]), + None => View::new(bar_style) + .fill(palette.bg) + .children(vec![label_view, actions_view]), + }; + + let underline = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.border_bottom); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: length(palette.height + 1.0), + }, + ..Default::default() + }) + .children(vec![bar, underline]) +} diff --git a/widgets/avatar/Cargo.toml b/widgets/avatar/Cargo.toml new file mode 100644 index 0000000..73fe6f7 --- /dev/null +++ b/widgets/avatar/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-avatar" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-avatar — círculo de identidad con inicial sobre color generado del hash del nombre. Determinista (mismo nombre → mismo color) y tonal (paleta limitada para que no choque)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/avatar/src/lib.rs b/widgets/avatar/src/lib.rs new file mode 100644 index 0000000..b6a1622 --- /dev/null +++ b/widgets/avatar/src/lib.rs @@ -0,0 +1,116 @@ +//! `llimphi-widget-avatar` — círculo de identidad con inicial. +//! +//! Genera un avatar **determinista** de un nombre: el color de fondo +//! viene de un hash del nombre, mapeado a una paleta limitada de 8 +//! tonos (para que dos usuarios distintos no acaben con colores que +//! se confundan). La inicial es la primera letra del nombre (uppercase), +//! pintada centrada en blanco-cálido. +//! +//! Útil para chats (ayni), authorship en pluma, presencia en +//! herramientas colaborativas. Una sola función — sin state, sin +//! animación, sin paleta configurable (la consistencia importa más +//! que la personalización). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +/// Construye el avatar de `name` con diámetro `size_px`. +pub fn avatar_view(name: &str, size_px: f32) -> View { + let bg = color_for(name); + let initial = name + .chars() + .next() + .map(|c| c.to_uppercase().next().unwrap_or(c)) + .unwrap_or('·'); + let fg = Color::from_rgba8(248, 248, 250, 255); + let font = (size_px * 0.42).max(8.0); + + View::new(Style { + size: Size { + width: length(size_px), + height: length(size_px), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .radius((size_px * 0.5) as f64) + .paint_with(move |scene, _ts, rect| { + // Highlight radial en el cuadrante superior — el avatar se lee + // como esfera. paint_with corre entre el fill y la inicial, así + // que la luz se suma al color del nombre sin tapar el texto. + // Mismo patrón dot-badge / switch-thumb (P6/P7). + use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.30) as f64; + let r = (rect.w as f64 * 0.18).max(1.0); + let highlight = Color::from_rgba8(255, 255, 255, 60); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + highlight, + None, + &Circle::new((cx, cy), r), + ); + }) + .text_aligned(initial.to_string(), font, fg, Alignment::Center) +} + +/// Paleta tonal limitada — 8 colores HSL-ish elegidos para destacar +/// sobre fondos oscuros sin ser estridentes. +const PALETTE: &[Color] = &[ + Color::from_rgba8(96, 130, 220, 255), // azul + Color::from_rgba8(110, 180, 130, 255), // verde aurora + Color::from_rgba8(220, 140, 80, 255), // naranja sunset + Color::from_rgba8(160, 110, 220, 255), // púrpura + Color::from_rgba8(80, 180, 180, 255), // aqua + Color::from_rgba8(220, 120, 160, 255), // rosa + Color::from_rgba8(180, 170, 90, 255), // mostaza + Color::from_rgba8(130, 150, 175, 255), // gris-azul +]; + +/// Hash FNV-1a simple sobre los bytes del nombre, mod paleta. No +/// requiere crypto — sólo necesitamos que mismo input dé mismo color. +fn color_for(name: &str) -> Color { + let mut h: u32 = 0x811c9dc5; + for b in name.bytes() { + h ^= b as u32; + h = h.wrapping_mul(0x01000193); + } + PALETTE[(h as usize) % PALETTE.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_for_is_deterministic() { + assert_eq!(color_for("sergio").components, color_for("sergio").components); + assert_eq!(color_for("calcetin").components, color_for("calcetin").components); + } + + #[test] + fn different_names_can_have_different_colors() { + let names = ["a", "b", "c", "d", "e", "f", "g", "h"]; + let colors: Vec<_> = names.iter().map(|n| color_for(n)).collect(); + // Al menos 2 colores distintos en 8 nombres — el hash es trivial, + // colisiones esperadas, no garantizamos 8 distintos. + let unique: std::collections::HashSet<_> = + colors.iter().map(|c| c.components.map(|x| (x * 255.0) as u8)).collect(); + assert!(unique.len() >= 2); + } +} diff --git a/widgets/badge/Cargo.toml b/widgets/badge/Cargo.toml new file mode 100644 index 0000000..f1eefb5 --- /dev/null +++ b/widgets/badge/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-badge" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-badge — chip pequeño (count o dot) para notificaciones, contadores, estado de conexión. Cuatro variants semánticas." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/badge/src/lib.rs b/widgets/badge/src/lib.rs new file mode 100644 index 0000000..7586687 --- /dev/null +++ b/widgets/badge/src/lib.rs @@ -0,0 +1,136 @@ +//! `llimphi-widget-badge` — chip pequeño para conteo o estado. +//! +//! Dos formas: +//! - `count_badge_view(n, kind)` — chip ovalado con número adentro +//! ("3", "12", "99+"). Para notificaciones, items sin leer, etc. +//! - `dot_badge_view(kind)` — círculo de 8px sin contenido. Para +//! estado de conexión (online/offline/idle) o "hay algo nuevo". +//! +//! Cuatro `BadgeKind` con paleta semántica (Info / Success / Warning +//! / Error / Neutral) — los colores no cambian con el theme para +//! mantener la consistencia semántica. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BadgeKind { + Info, + Success, + Warning, + Error, + Neutral, +} + +impl BadgeKind { + pub fn bg(self) -> Color { + match self { + BadgeKind::Info => Color::from_rgba8(60, 130, 220, 255), + BadgeKind::Success => Color::from_rgba8(70, 180, 110, 255), + BadgeKind::Warning => Color::from_rgba8(220, 160, 40, 255), + BadgeKind::Error => Color::from_rgba8(220, 80, 80, 255), + BadgeKind::Neutral => Color::from_rgba8(120, 130, 150, 255), + } + } + pub fn fg(self) -> Color { + // Texto siempre blanco-cálido sobre los colores sólidos del bg. + Color::from_rgba8(248, 248, 250, 255) + } +} + +const BADGE_H: f32 = 16.0; +const FONT: f32 = 10.0; +const DOT_R: f32 = 4.0; // dot diameter = 8 + +/// Chip con número. Si `count >= 100`, muestra "99+". +pub fn count_badge_view(count: u32, kind: BadgeKind) -> View { + let text = if count >= 100 { "99+".to_string() } else { count.to_string() }; + // Ancho proporcional al texto, con padding generoso. + let w = (text.chars().count() as f32 * 6.5 + 10.0).max(BADGE_H); + let badge_radius = (BADGE_H * 0.5) as f64; + + View::new(Style { + size: Size { + width: length(w), + height: length(BADGE_H), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(kind.bg()) + .radius(badge_radius) + .paint_with(move |scene, _ts, rect| { + // Gloss superior: blanco alpha 35 → 0 sobre la mitad de arriba. + // Da volumen de pill — el chip se lee como una superficie con + // luz cayendo, no como un rect plano. Match: button/splash — + // misma firma vertical descendente. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, badge_radius); + let top = Color::from_rgba8(255, 255, 255, 35); + let bot = Color::from_rgba8(255, 255, 255, 0); + let gradient = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr); + }) + .text_aligned(text, FONT, kind.fg(), Alignment::Center) +} + +/// Dot sin contenido — sólo color. +pub fn dot_badge_view(kind: BadgeKind) -> View { + let dot_radius = DOT_R as f64; + View::new(Style { + size: Size { + width: length(DOT_R * 2.0), + height: length(DOT_R * 2.0), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(kind.bg()) + .radius(dot_radius) + .paint_with(move |scene, _ts, rect| { + // Highlight radial chiquito en el cuadrante superior — lectura + // de esfera, no de círculo plano. El dot es 8px; el highlight + // ocupa ~3px centrado a 1/3 del top. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.33) as f64; + let r = (rect.w as f64 * 0.18).max(1.0); + let highlight = Color::from_rgba8(255, 255, 255, 90); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + highlight, + None, + &Circle::new((cx, cy), r), + ); + }) +} diff --git a/widgets/banner/Cargo.toml b/widgets/banner/Cargo.toml new file mode 100644 index 0000000..4ac2561 --- /dev/null +++ b/widgets/banner/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "llimphi-widget-banner" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-banner — tiras horizontales de status (Info/Success/Warning/Error). Colores semánticos hardcoded por severidad — no dependen del theme. Análogo Llimphi al `nahual-widget-banner` GPUI." + +[dependencies] +llimphi-ui = { workspace = true } diff --git a/widgets/banner/LEEME.md b/widgets/banner/LEEME.md new file mode 100644 index 0000000..14734a8 --- /dev/null +++ b/widgets/banner/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-banner + +> Banner / alerts para [llimphi](../../README.md). + +Mensaje destacado al tope de la vista: info / warning / error / success. Auto-dismiss configurable. diff --git a/widgets/banner/README.md b/widgets/banner/README.md new file mode 100644 index 0000000..942f338 --- /dev/null +++ b/widgets/banner/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-banner + +> Banner / alerts for [llimphi](../../README.md). + +Prominent message at the top of the view: info / warning / error / success. Configurable auto-dismiss. diff --git a/widgets/banner/src/lib.rs b/widgets/banner/src/lib.rs new file mode 100644 index 0000000..406eb8b --- /dev/null +++ b/widgets/banner/src/lib.rs @@ -0,0 +1,109 @@ +//! `llimphi-widget-banner` — tiras horizontales de status. +//! +//! Cuatro variants con paleta consistente entre apps: +//! +//! - [`BannerKind::Info`] — azul tenue, mensajes neutros. +//! - [`BannerKind::Success`] — verde, confirmaciones de op exitosa. +//! - [`BannerKind::Warning`] — amber, llamadas de atención. +//! - [`BannerKind::Error`] — rojo, errores fatales o de carga. +//! +//! Análogo Llimphi al `nahual-widget-banner` GPUI. Los colores son +//! **semánticos** y no cambian con el theme (un Error en dark y en +//! light tiene que seguir leyéndose como rojo). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +/// Severidad / tono del banner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BannerKind { + Info, + Success, + Warning, + Error, +} + +impl BannerKind { + pub fn bg(self) -> Color { + match self { + BannerKind::Info => Color::from_rgba8(0x1d, 0x2a, 0x3a, 0xff), + BannerKind::Success => Color::from_rgba8(0x2d, 0x3a, 0x2a, 0xff), + BannerKind::Warning => Color::from_rgba8(0x4a, 0x3a, 0x1a, 0xff), + BannerKind::Error => Color::from_rgba8(0x4a, 0x20, 0x20, 0xff), + } + } + + pub fn fg(self) -> Color { + match self { + BannerKind::Info => Color::from_rgba8(0xc0, 0xd0, 0xe0, 0xff), + BannerKind::Success => Color::from_rgba8(0xc0, 0xe0, 0xa0, 0xff), + BannerKind::Warning => Color::from_rgba8(0xf0, 0xe0, 0xa0, 0xff), + BannerKind::Error => Color::from_rgba8(0xff, 0xd0, 0xd0, 0xff), + } + } +} + +/// Ancho del rail de severidad en el edge izquierdo. Mismo valor que +/// `llimphi-widget-toast` — banner y toast son las versiones persistente +/// y efímera del mismo lenguaje (P5 → P8). +const RAIL_W: f32 = 3.0; + +/// Banner simple: una fila con `message` centrado verticalmente y +/// alineado a la izquierda. bg/fg vienen del `kind`. +pub fn banner_view( + kind: BannerKind, + message: impl Into, +) -> View { + use llimphi_ui::llimphi_layout::taffy::prelude::FlexDirection; + + // Rail de severidad en el edge izquierdo — stripe del color fg + // semántico, visible al pasar el ojo. Mismo patrón que toast P5. + let rail = View::new(Style { + size: Size { + width: length(RAIL_W), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(kind.fg()); + + // Contenedor del mensaje: padding original ahora vive acá para que + // el rail pegue al borde sin offset y el texto arranque después. + let body = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(message.into(), 11.0, kind.fg(), Alignment::Start); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(kind.bg()) + .radius(3.0) + .clip(true) + .children(vec![rail, body]) +} diff --git a/widgets/breadcrumb/Cargo.toml b/widgets/breadcrumb/Cargo.toml new file mode 100644 index 0000000..d741fe3 --- /dev/null +++ b/widgets/breadcrumb/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-breadcrumb" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-breadcrumb — ruta navegable con separadores chevron. Cada segmento clicable salta a su nivel." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-icons = { workspace = true } diff --git a/widgets/breadcrumb/src/lib.rs b/widgets/breadcrumb/src/lib.rs new file mode 100644 index 0000000..f5d1c6a --- /dev/null +++ b/widgets/breadcrumb/src/lib.rs @@ -0,0 +1,128 @@ +//! `llimphi-widget-breadcrumb` — ruta navegable con separadores chevron. +//! +//! Patrón clásico: `home › docs › 2026 › nota.md`. Cada segmento es +//! clicable y emite un Msg con su índice. El último segmento (la +//! "página actual") se renderiza con énfasis y sin click handler. + +#![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::View; +use llimphi_icons::{icon_view, Icon}; +use llimphi_theme::Theme; + +/// Paleta del breadcrumb. +#[derive(Debug, Clone, Copy)] +pub struct BreadcrumbPalette { + pub fg_link: Color, + pub fg_current: Color, + pub fg_separator: Color, + pub bg_hover: Color, +} + +impl BreadcrumbPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + fg_link: t.fg_muted, + fg_current: t.fg_text, + fg_separator: t.fg_placeholder, + bg_hover: t.bg_row_hover, + } + } +} + +const SEG_H: f32 = 22.0; +const SEG_PAD: f32 = 6.0; +const FONT: f32 = 11.5; +const SEP_BOX: f32 = 12.0; + +/// Construye el breadcrumb. `segments` son los labels visibles, en +/// orden de raíz a hoja. `make_msg(i)` se llama al click en el +/// segmento `i` (no se llama para el último — la "página actual"). +pub fn breadcrumb_view( + segments: &[&str], + make_msg: F, + palette: &BreadcrumbPalette, +) -> View +where + Msg: Clone + 'static, + F: Fn(usize) -> Msg, +{ + let last = segments.len().saturating_sub(1); + let mut children: Vec> = Vec::with_capacity(segments.len() * 2); + for (i, &label) in segments.iter().enumerate() { + let is_current = i == last; + children.push(segment_view( + label, + is_current, + if is_current { None } else { Some(make_msg(i)) }, + palette, + )); + if !is_current { + children.push(separator_view(palette)); + } + } + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(SEG_H), + }, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(2.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(children) +} + +fn segment_view( + label: &str, + is_current: bool, + msg: Option, + palette: &BreadcrumbPalette, +) -> View { + let fg = if is_current { palette.fg_current } else { palette.fg_link }; + let approx_w = label.chars().count() as f32 * 6.5 + SEG_PAD * 2.0; + let mut node = View::new(Style { + size: Size { + width: length(approx_w), + height: length(SEG_H), + }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(SEG_PAD), + right: length(SEG_PAD), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(label.to_string(), FONT, fg, Alignment::Center) + .radius(llimphi_theme::radius::XS); + if let Some(m) = msg { + node = node.hover_fill(palette.bg_hover).on_click(m); + } + node +} + +fn separator_view(palette: &BreadcrumbPalette) -> View { + View::new(Style { + size: Size { + width: length(SEP_BOX), + height: length(SEP_BOX), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![icon_view(Icon::ChevronRight, palette.fg_separator, 1.6)]) +} diff --git a/widgets/button/Cargo.toml b/widgets/button/Cargo.toml new file mode 100644 index 0000000..d0c41b3 --- /dev/null +++ b/widgets/button/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-button" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-button — botón clicable con estado hover. Reusable entre apps Llimphi; cambia el bg cuando el cursor pasa por encima. Compuesto de `View::fill().hover_fill().on_click()` con una paleta tematizable." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/button/LEEME.md b/widgets/button/LEEME.md new file mode 100644 index 0000000..c64508a --- /dev/null +++ b/widgets/button/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-button + +> Botón con variantes para [llimphi](../../README.md). + +Variantes: `primary`, `secondary`, `ghost`, `danger`. Estado hover/active/disabled. Soporta icono + label, o sólo icono. diff --git a/widgets/button/README.md b/widgets/button/README.md new file mode 100644 index 0000000..eb4b252 --- /dev/null +++ b/widgets/button/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-button + +> Button with variants for [llimphi](../../README.md). + +Variants: `primary`, `secondary`, `ghost`, `danger`. Hover/active/disabled states. Supports icon + label, or icon-only. diff --git a/widgets/button/examples/button_demo.rs b/widgets/button/examples/button_demo.rs new file mode 100644 index 0000000..2e0381f --- /dev/null +++ b/widgets/button/examples/button_demo.rs @@ -0,0 +1,136 @@ +//! Showcase de `llimphi-widget-button`: tres botones con hover. +//! +//! Corré con: `cargo run -p llimphi-widget-button --example showcase --release`. + +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::{App, Handle, View}; +use llimphi_widget_button::{button_styled, button_view, ButtonPalette}; + +#[derive(Clone, Debug)] +enum Msg { + A, + B, + C, +} + +struct Model { + last: Option, + counter: u32, +} + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · button showcase" + } + + fn init(_: &Handle) -> Model { + Model { + last: None, + counter: 0, + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + m.counter += 1; + m.last = Some(msg); + m + } + + fn view(model: &Model) -> View { + let palette = ButtonPalette::default(); + let warning = ButtonPalette { + bg: Color::from_rgba8(140, 70, 30, 255), + bg_hover: Color::from_rgba8(200, 100, 40, 255), + ..palette + }; + let danger = ButtonPalette { + bg: Color::from_rgba8(150, 40, 40, 255), + bg_hover: Color::from_rgba8(220, 70, 70, 255), + ..palette + }; + + let a = button_view("acción A", &palette, Msg::A); + let b = button_view("acción B (warning)", &warning, Msg::B); + let c = button_styled( + "borrar (left-aligned, fixed width)", + Style { + size: Size { + width: length(320.0_f32), + height: length(34.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }, + Alignment::Start, + &danger, + Msg::C, + ); + + let status = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(40.0_f32), + }, + ..Default::default() + }) + .text_aligned( + format!( + "clicks: {} · último: {}", + model.counter, + match model.last { + Some(Msg::A) => "A", + Some(Msg::B) => "B", + Some(Msg::C) => "C", + None => "—", + } + ), + 14.0, + Color::from_rgba8(180, 190, 205, 255), + Alignment::Start, + ); + + 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(14.0_f32), + }, + padding: Rect { + left: length(32.0_f32), + right: length(32.0_f32), + top: length(32.0_f32), + bottom: length(32.0_f32), + }, + align_items: Some(AlignItems::Start), + justify_content: Some(JustifyContent::Start), + ..Default::default() + }) + .fill(Color::from_rgba8(20, 24, 32, 255)) + .children(vec![a, b, c, status]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/button/src/lib.rs b/widgets/button/src/lib.rs new file mode 100644 index 0000000..638b7ec --- /dev/null +++ b/widgets/button/src/lib.rs @@ -0,0 +1,124 @@ +//! `llimphi-widget-button` — botón clicable con estado hover. +//! +//! Reusable entre apps Llimphi: `button_view(label, palette, on_click)` +//! devuelve una vista que cambia de color cuando el cursor pasa por +//! encima y emite `on_click` al ser apretada. El caller controla las +//! dimensiones envolviendo el `View` retornado en un contenedor flex +//! con el tamaño que necesite (botón ancho completo, chip 80×30, etc). +//! +//! No expone estado interno — todo el estado vive en el `Model` del App +//! (el hover lo trackea llimphi-ui automáticamente vía `hover_fill`). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +/// Paleta del botón. Por default un chip dark con highlight tenue al +/// hover — similar al patrón `bg_panel_alt` + `bg_row_hover` de +/// `nahual-theme`. +#[derive(Debug, Clone, Copy)] +pub struct ButtonPalette { + pub bg: Color, + pub bg_hover: Color, + pub fg: Color, + pub radius: f64, +} + +impl Default for ButtonPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl ButtonPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_button, + bg_hover: t.bg_button_hover, + fg: t.fg_text, + radius: 5.0, + } + } +} + +/// Compone un botón rectangular: bg + texto + on_click + hover. Por +/// default ocupa ancho 100% del padre y alto 30 px; sobre-escribir +/// pasando un `Style` propio vía [`button_styled`]. +pub fn button_view( + label: impl Into, + palette: &ButtonPalette, + on_click: Msg, +) -> View { + button_styled( + label, + Style { + size: Size { + width: percent(1.0_f32), + height: length(30.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), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }, + Alignment::Center, + palette, + on_click, + ) +} + +/// Variante con `Style` y alineación de texto explícitos — útil cuando +/// la app necesita un botón con dimensiones particulares o el texto a +/// la izquierda. +pub fn button_styled( + label: impl Into, + style: Style, + text_alignment: Alignment, + palette: &ButtonPalette, + on_click: Msg, +) -> View { + // Gloss superior: gradient blanco alpha 28 → 0 sobre la mitad de + // arriba. `paint_with` corre entre el fill (que respeta hover_fill) + // y el texto, así que la luz se suma al color de base sin sustituirlo + // — el hover sigue funcionando idéntico. El RoundedRect cubre el + // botón completo y `Extend::Pad` (default de peniko) deja la mitad + // inferior en alpha 0. Match: chrome/splash — superficie con luz + // descendente desde el edge superior. + let radius = palette.radius; + View::new(style) + .fill(palette.bg) + .hover_fill(palette.bg_hover) + .radius(radius) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, radius); + let top = Color::from_rgba8(255, 255, 255, 28); + let bot = Color::from_rgba8(255, 255, 255, 0); + let gradient = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr); + }) + .text_aligned(label.into(), 13.0, palette.fg, text_alignment) + .on_click(on_click) +} diff --git a/widgets/card/Cargo.toml b/widgets/card/Cargo.toml new file mode 100644 index 0000000..985db9d --- /dev/null +++ b/widgets/card/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-card" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-card — container card-shape con padding consistente, esquinas redondeadas y opcional accent border a la izquierda. Análogo Llimphi al `nahual-widget-card` GPUI." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/card/LEEME.md b/widgets/card/LEEME.md new file mode 100644 index 0000000..aee2f80 --- /dev/null +++ b/widgets/card/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-card + +> Card base para [llimphi](../../README.md). + +Contenedor con borde + radius + padding consistente con el theme. Slot de contenido libre. Base de `stat-card` y otras especializadas. diff --git a/widgets/card/README.md b/widgets/card/README.md new file mode 100644 index 0000000..97b5524 --- /dev/null +++ b/widgets/card/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-card + +> Base card for [llimphi](../../README.md). + +Container with border + radius + theme-consistent padding. Free content slot. Base for `stat-card` and other specialized variants. diff --git a/widgets/card/src/lib.rs b/widgets/card/src/lib.rs new file mode 100644 index 0000000..550ea0e --- /dev/null +++ b/widgets/card/src/lib.rs @@ -0,0 +1,146 @@ +//! `llimphi-widget-card` — container card-shape para entries de +//! timeline, info cards, dashboards, etc. +//! +//! Aporta la **forma**: padding consistente (12/8), `radius` 4, gap +//! pequeño entre children, y opcionalmente un accent vertical +//! (4 px) pegado a la izquierda para entries semánticas (verde = +//! OK, rojo = error, ámbar = warning, etc). +//! +//! Análogo Llimphi al `nahual-widget-card` GPUI. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +#[derive(Debug, Clone, Copy)] +pub struct CardPalette { + pub bg: Color, +} + +impl Default for CardPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl CardPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { bg: t.bg_panel } + } +} + +/// Opciones del card. +#[derive(Debug, Clone, Copy)] +pub struct CardOptions { + /// Accent vertical a la izquierda (4 px). `None` = sin accent. + pub accent: Option, + pub padding: f32, + pub gap: f32, + pub radius: f64, + /// Firma visual del panel (gradient sutil + hairline accent en el + /// top). `Some(style)` reemplaza el fill plano del body por el + /// painter de la firma — usar para cards prominentes (dashboards, + /// timeline entries grandes) donde se nota el "tallado". `None` + /// mantiene el fill sólido del `CardPalette` (default). + pub signature: Option, +} + +impl Default for CardOptions { + fn default() -> Self { + Self { + accent: None, + padding: 12.0, + gap: 4.0, + radius: 4.0, + signature: None, + } + } +} + +impl CardOptions { + /// Variante con firma visual derivada del theme. El `radius` del + /// card se alinea al del `PanelStyle` para que la silueta del + /// gradiente coincida con las esquinas del nodo. + pub fn with_signature(t: &llimphi_theme::Theme) -> Self { + let style = PanelStyle::from_theme(t); + Self { + accent: None, + padding: 12.0, + gap: 4.0, + radius: style.radius, + signature: Some(style), + } + } +} + +/// Compone un card: bg + radius + padding + flex-column con gap entre +/// children. Si `opts.accent` está presente, hay una franja vertical +/// de 4 px del color del accent pegada al borde izquierdo. +pub fn card_view( + children: Vec>, + opts: CardOptions, + palette: &CardPalette, +) -> View { + let pad = opts.padding; + let body_style = Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(), + }, + flex_grow: 1.0, + padding: Rect { + left: length(pad), + right: length(pad), + top: length(pad * 0.66), + bottom: length(pad * 0.66), + }, + gap: Size { + width: length(0.0_f32), + height: length(opts.gap), + }, + ..Default::default() + }; + let body = if let Some(style) = opts.signature { + View::new(body_style) + .paint_with(panel_signature_painter(style)) + .radius(opts.radius) + .clip(true) + .children(children) + } else { + View::new(body_style) + .fill(palette.bg) + .radius(opts.radius) + .children(children) + }; + + let Some(accent) = opts.accent else { + return body; + }; + + let accent_strip = View::new(Style { + size: Size { + width: length(4.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(accent) + .radius(opts.radius); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(), + }, + ..Default::default() + }) + .children(vec![accent_strip, body]) +} diff --git a/widgets/clipboard/Cargo.toml b/widgets/clipboard/Cargo.toml new file mode 100644 index 0000000..8e0add2 --- /dev/null +++ b/widgets/clipboard/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-clipboard" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-clipboard — backend de portapapeles del sistema (vía arboard) que implementa el trait Clipboard del text-editor. Una línea para que el menú de edición y los atajos Ctrl+C/X/V de cualquier app Llimphi usen el clipboard real del SO, con degradación silenciosa a no-op si no hay display." + +[dependencies] +llimphi-widget-text-editor = { workspace = true } +arboard = { workspace = true } diff --git a/widgets/clipboard/src/lib.rs b/widgets/clipboard/src/lib.rs new file mode 100644 index 0000000..1080ffc --- /dev/null +++ b/widgets/clipboard/src/lib.rs @@ -0,0 +1,55 @@ +//! `llimphi-clipboard` — el portapapeles del sistema para apps Llimphi. +//! +//! El `text-editor` define el trait [`Clipboard`] pero deja el backend al +//! caller (no quiere acoplarse a X11/Wayland/macOS/Windows). Este crate +//! aporta el backend obvio — [`arboard`] — para que cualquier app lo +//! enchufe en una línea: +//! +//! ```ignore +//! let mut clip = llimphi_clipboard::SystemClipboard::new(); +//! editor.apply_key_with_clipboard(&ev, &mut clip); +//! ``` +//! +//! Si no hay display (CI headless, sesión sin servidor gráfico) degrada +//! a no-op silencioso: `get` devuelve `None`, `set` descarta. Nunca +//! panica. + +#![forbid(unsafe_code)] + +use llimphi_widget_text_editor::Clipboard; + +/// Portapapeles del sistema vía `arboard`. `None` interno = no se pudo +/// abrir (sin display); en ese caso opera como [`llimphi_widget_text_editor::NullClipboard`]. +pub struct SystemClipboard { + inner: Option, +} + +impl SystemClipboard { + pub fn new() -> Self { + Self { + inner: arboard::Clipboard::new().ok(), + } + } + + /// `true` si el backend del SO está disponible. + pub fn is_available(&self) -> bool { + self.inner.is_some() + } +} + +impl Default for SystemClipboard { + fn default() -> Self { + Self::new() + } +} + +impl Clipboard for SystemClipboard { + fn get(&mut self) -> Option { + self.inner.as_mut()?.get_text().ok() + } + fn set(&mut self, s: &str) { + if let Some(c) = self.inner.as_mut() { + let _ = c.set_text(s.to_owned()); + } + } +} diff --git a/widgets/context-menu/Cargo.toml b/widgets/context-menu/Cargo.toml new file mode 100644 index 0000000..2b993e7 --- /dev/null +++ b/widgets/context-menu/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-context-menu" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-context-menu — menú contextual gioser: panel negro, barra accent vertical de 3px a la izquierda, sin esquinas redondeadas ni sombras, header en uppercase tiny. Se monta sobre App::view_overlay con un scrim full-screen que dismissa al click-fuera." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/context-menu/LEEME.md b/widgets/context-menu/LEEME.md new file mode 100644 index 0000000..59dd7f2 --- /dev/null +++ b/widgets/context-menu/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-context-menu + +> Menú contextual para [llimphi](../../README.md). + +Look distintivo: barra accent vertical 3px, hard edges, header tiny. API: `View::on_right_click[_at]` + `App::view_overlay`. diff --git a/widgets/context-menu/README.md b/widgets/context-menu/README.md new file mode 100644 index 0000000..4438a4a --- /dev/null +++ b/widgets/context-menu/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-context-menu + +> Context menu for [llimphi](../../README.md). + +Distinctive look: 3px vertical accent bar, hard edges, tiny header. API: `View::on_right_click[_at]` + `App::view_overlay`. diff --git a/widgets/context-menu/src/lib.rs b/widgets/context-menu/src/lib.rs new file mode 100644 index 0000000..5d1e11d --- /dev/null +++ b/widgets/context-menu/src/lib.rs @@ -0,0 +1,761 @@ +//! `llimphi-widget-context-menu` — menú contextual con look gioser. +//! +//! Distintivo y minimalista: +//! +//! ```text +//! ┃ B5 ← header (uppercase tiny) +//! ┃ ✂ Cortar Ctrl+X +//! ┃ ⧉ Copiar Ctrl+C ← gutter de íconos + barra accent (3px) +//! ┃ ⎘ Pegar Ctrl+V +//! ┃ ───────────────────── +//! ┃ ◐ Tema ▸ ← submenú (flyout a la derecha) +//! ``` +//! +//! Cada fila: barra accent vertical (firma) · gutter de ícono · label +//! (centrado vertical) · atajo o chevron de submenú. Sin radios, sin +//! sombras: color sólido + tipografía + la barra accent. +//! +//! Se monta como `View` que se devuelve desde +//! [`llimphi_ui::App::view_overlay`]. Internamente arma: +//! 1. Un **scrim** full-screen con `on_click = on_dismiss` que cierra +//! el menú al click-fuera. +//! 2. Un **panel** absoluto (clampeado al viewport). +//! 3. Si hay un submenú abierto ([`ContextMenuSpec::open_sub`]), un +//! segundo panel-flyout a la derecha del item padre. +//! +//! Animación: [`ContextMenuSpec::appear`] (0..1) controla un fade + un +//! leve desplazamiento vertical de entrada. La app que quiera animarlo +//! guarda un `Tween` y lo va subiendo; pasar `1.0` lo muestra fijo. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta del menú — estilo "webpage" elegante derivado del theme: +/// panel redondeado con borde hairline, filas como píldoras con hover +/// suave (`bg_hover`) y resaltado de teclado (`bg_active`, más un +/// indicador accent a la izquierda). Defaults dark; override por la app. +#[derive(Debug, Clone, Copy)] +pub struct ContextMenuPalette { + pub bg_panel: Color, + /// Fila bajo el cursor (hover) — tinte suave. + pub bg_hover: Color, + /// Fila activa por teclado (flechas) — algo más marcado que el hover. + pub bg_active: Color, + pub fg_text: Color, + /// Texto de la fila activa/hover (legible sobre el tinte suave). + pub fg_active: Color, + pub fg_shortcut: Color, + pub fg_disabled: Color, + pub fg_destructive: Color, + pub fg_header: Color, + /// Ícono en gutter (estado normal) — algo más apagado que el texto. + pub fg_icon: Color, + pub accent: Color, + pub border: Color, + pub separator: Color, + pub scrim: Color, + /// Radio de las esquinas del panel. + pub radius: f64, + pub panel: PanelStyle, +} + +impl ContextMenuPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + // El panel se eleva sobre el fondo: usa `bg_panel` (no `bg_app`) + // con su gradiente sutil + esquinas redondeadas. + let mut panel = PanelStyle::neutral(t); + panel.bg_base = t.bg_panel; + panel.radius = PANEL_RADIUS as f64; + Self { + bg_panel: t.bg_panel, + bg_hover: t.bg_row_hover, + bg_active: t.bg_selected, + fg_text: t.fg_text, + fg_active: t.fg_text, + fg_shortcut: t.fg_muted, + fg_disabled: t.fg_muted, + fg_destructive: t.fg_destructive, + fg_header: t.fg_muted, + fg_icon: t.fg_muted, + accent: t.accent, + border: t.border, + separator: t.border, + scrim: Color::from_rgba8(0, 0, 0, 64), + radius: PANEL_RADIUS as f64, + panel, + } + } +} + +/// Un item del menú. `separator = true` ignora el resto y pinta una +/// línea. `children` no vacío → es un submenú (muestra chevron ▸ y, si +/// está abierto, despliega un flyout). `icon` es un glifo opcional que +/// se pinta en el gutter izquierdo. +#[derive(Debug, Clone)] +pub struct ContextMenuItem { + pub label: String, + pub shortcut: Option, + pub icon: Option, + pub enabled: bool, + pub separator: bool, + pub destructive: bool, + /// Items del submenú. Vacío = acción simple. + pub children: Vec, +} + +impl ContextMenuItem { + pub fn action(label: impl Into) -> Self { + Self { + label: label.into(), + shortcut: None, + icon: None, + enabled: true, + separator: false, + destructive: false, + children: Vec::new(), + } + } + + pub fn with_shortcut(mut self, shortcut: impl Into) -> Self { + self.shortcut = Some(shortcut.into()); + self + } + + /// Glifo del gutter izquierdo (unicode; no acopla a `llimphi-icons`). + pub fn icon(mut self, glyph: impl Into) -> Self { + self.icon = Some(glyph.into()); + self + } + + pub fn disabled(mut self) -> Self { + self.enabled = false; + self + } + + pub fn destructive(mut self) -> Self { + self.destructive = true; + self + } + + /// Convierte el item en submenú con estos hijos. + pub fn submenu(mut self, children: Vec) -> Self { + self.children = children; + self + } + + pub fn has_submenu(&self) -> bool { + !self.children.is_empty() + } + + pub fn separator() -> Self { + Self { + label: String::new(), + shortcut: None, + icon: None, + enabled: false, + separator: true, + destructive: false, + children: Vec::new(), + } + } +} + +/// Especificación del menú. Mantiene los 8 campos clásicos para no +/// romper los call-sites por literal; las capacidades nuevas (submenús, +/// animación, hover) viajan aparte en [`ContextMenuExtras`] vía +/// [`context_menu_view_ex`]. +pub struct ContextMenuSpec { + pub anchor: (f32, f32), + pub viewport: (f32, f32), + pub header: Option, + pub items: Vec, + /// Índice resaltado por keyboard. `usize::MAX` = ninguno. + pub active: usize, + /// Click en un item de nivel raíz (índice). + pub on_pick: Arc Msg + Send + Sync>, + /// Msg al click-fuera (scrim) o Esc. + pub on_dismiss: Msg, + pub palette: ContextMenuPalette, +} + +/// Capacidades extra opcionales para [`context_menu_view_ex`]: submenús +/// (flyout), animación de aparición y hover. Su `Default` reproduce el +/// menú clásico (sin animación ni submenús). +pub struct ContextMenuExtras { + /// Índice del item-submenú desplegado (flyout). La app lo guarda y lo + /// actualiza vía `on_hover`. + pub open_sub: Option, + /// Progreso de aparición 0..1 (fade + leve slide). `1.0` = fijo. + pub appear: f32, + /// Click en un item de submenú: `(parent_idx, child_idx)`. + pub on_pick_sub: Option Msg + Send + Sync>>, + /// Hover sobre un item raíz: `Some(idx)` si es submenú (abrir flyout), + /// `None` si es item normal (cerrar). La app guarda el resultado en + /// `open_sub`. + pub on_hover: Option) -> Msg + Send + Sync>>, +} + +impl Default for ContextMenuExtras { + fn default() -> Self { + Self { + open_sub: None, + appear: 1.0, + on_pick_sub: None, + on_hover: None, + } + } +} + +const PANEL_W: f32 = 252.0; +/// Altura de cada item (no-separator). +const ITEM_H: f32 = 32.0; +const SEP_H: f32 = 11.0; +const HEADER_H: f32 = 26.0; +/// Gutter del ícono a la izquierda del label. +const ICON_W: f32 = 24.0; +const ITEM_PAD_LEFT: f32 = 10.0; +const ITEM_PAD_RIGHT: f32 = 12.0; +/// Radio de las esquinas del panel (estilo webpage). +const PANEL_RADIUS: f32 = 10.0; +/// Radio de la píldora de hover/activo de cada fila. +const ITEM_RADIUS: f32 = 6.0; +/// Padding interno del panel (entre el borde y la columna de píldoras). +const PANEL_PAD: f32 = 6.0; +/// Ancho del indicador accent vertical de la fila activa. +const INDICATOR_W: f32 = 3.0; +/// Desplazamiento vertical de entrada (px) cuando `appear` = 0. +const APPEAR_SLIDE: f32 = 8.0; + +/// Compone el menú clásico (sin submenús ni animación) como `View` +/// para `App::view_overlay`. Íconos, centrado vertical y separadores ya +/// vienen incluidos. +pub fn context_menu_view(spec: ContextMenuSpec) -> View { + context_menu_view_ex(spec, ContextMenuExtras::default()) +} + +/// Como [`context_menu_view`] pero con [`ContextMenuExtras`]: submenús +/// (flyout en hover), animación de aparición y hover. +pub fn context_menu_view_ex( + spec: ContextMenuSpec, + extras: ContextMenuExtras, +) -> View { + let ContextMenuSpec { + anchor, + viewport, + header, + items, + active, + on_pick, + on_dismiss, + palette, + } = spec; + let ContextMenuExtras { + open_sub, + appear, + on_pick_sub, + on_hover, + } = extras; + + let appear = appear.clamp(0.0, 1.0); + let slide = (1.0 - appear) * APPEAR_SLIDE; + + let (panel, panel_x, panel_y) = panel_view( + anchor, + viewport, + &header, + &items, + active, + slide, + &on_pick, + on_hover.as_ref(), + &palette, + ); + + let mut layers: Vec> = vec![panel]; + + // Flyout del submenú abierto (sólo si la app provee `on_pick_sub`). + if let (Some(pidx), Some(on_pick_sub)) = (open_sub, on_pick_sub.as_ref()) { + if let Some(parent) = items.get(pidx).filter(|it| it.has_submenu()) { + let sub_anchor = submenu_anchor(panel_x, panel_y, &header, &items, pidx); + let flyout = submenu_view( + sub_anchor, + viewport, + pidx, + &parent.children, + slide, + on_pick_sub, + &palette, + ); + layers.push(flyout); + } + } + + // Scrim full-screen: cualquier click "fuera" dismissa. + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.scrim) + .alpha(appear) + .on_click(on_dismiss) + .children(layers) +} + +/// Arma el panel raíz y devuelve `(view, x, y)` ya clampeados. +#[allow(clippy::too_many_arguments)] +fn panel_view( + anchor: (f32, f32), + viewport: (f32, f32), + header: &Option, + items: &[ContextMenuItem], + active: usize, + slide: f32, + on_pick: &Arc Msg + Send + Sync>, + on_hover: Option<&Arc) -> Msg + Send + Sync>>, + palette: &ContextMenuPalette, +) -> (View, f32, f32) { + let header_h = if header.is_some() { HEADER_H } else { 0.0 }; + let items_h: f32 = items + .iter() + .map(|it| if it.separator { SEP_H } else { ITEM_H }) + .sum(); + // borde (1+1) + padding interno (PANEL_PAD ×2) + header + items. + let panel_h = 2.0 + 2.0 * PANEL_PAD + header_h + items_h; + + let margin = 4.0; + let x = anchor + .0 + .min((viewport.0 - PANEL_W - margin).max(margin)) + .max(margin); + let y = anchor + .1 + .min((viewport.1 - panel_h - margin).max(margin)) + .max(margin); + + let mut children: Vec> = Vec::with_capacity(items.len() + 1); + if let Some(text) = header { + children.push(header_view(text.clone(), palette)); + } + for (i, item) in items.iter().enumerate() { + children.push(item_view( + i, + None, + item, + i == active, + on_pick, + on_hover, + palette, + )); + } + + let panel = panel_container(x, y + slide, panel_h, children, palette); + (panel, x, y) +} + +/// Flyout del submenú: mismo look, posicionado a la derecha del padre. +#[allow(clippy::too_many_arguments)] +fn submenu_view( + anchor: (f32, f32), + viewport: (f32, f32), + parent_idx: usize, + children_items: &[ContextMenuItem], + slide: f32, + on_pick_sub: &Arc Msg + Send + Sync>, + palette: &ContextMenuPalette, +) -> View { + let panel_h: f32 = children_items + .iter() + .map(|it| if it.separator { SEP_H } else { ITEM_H }) + .sum::() + + 2.0 + + 2.0 * PANEL_PAD; + let margin = 4.0; + let x = anchor + .0 + .min((viewport.0 - PANEL_W - margin).max(margin)) + .max(margin); + let y = anchor + .1 + .min((viewport.1 - panel_h - margin).max(margin)) + .max(margin); + + let mut children: Vec> = Vec::with_capacity(children_items.len()); + for (j, item) in children_items.iter().enumerate() { + children.push(item_view( + j, + Some((parent_idx, on_pick_sub.clone())), + item, + false, + // on_pick raíz no se usa cuando hay parent; pasamos un dummy. + &dummy_pick(), + None, + palette, + )); + } + panel_container(x, y + slide, panel_h, children, palette) +} + +/// El contenedor visual: panel redondeado con borde hairline (un nodo +/// exterior del color del borde + uno interior con el gradiente del +/// PanelStyle) y padding interno para que las píldoras de cada fila +/// queden inset — el look de menú de webpage. +fn panel_container( + x: f32, + y: f32, + panel_h: f32, + children: Vec>, + palette: &ContextMenuPalette, +) -> View { + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(PANEL_W), + height: length(panel_h), + }, + padding: Rect { + left: length(1.0_f32), + right: length(1.0_f32), + top: length(1.0_f32), + bottom: length(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.border) + .radius(palette.radius as f64) + .children(vec![View::new(Style { + flex_direction: FlexDirection::Column, + flex_grow: 1.0, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(PANEL_PAD), + right: length(PANEL_PAD), + top: length(PANEL_PAD), + bottom: length(PANEL_PAD), + }, + ..Default::default() + }) + .radius((palette.radius - 1.0) as f64) + .paint_with(panel_signature_painter(palette.panel)) + .children(children)]) +} + +/// Ancla del flyout: a la derecha del panel padre, alineado al item. +fn submenu_anchor( + panel_x: f32, + panel_y: f32, + header: &Option, + items: &[ContextMenuItem], + parent_idx: usize, +) -> (f32, f32) { + let mut off = if header.is_some() { HEADER_H } else { 0.0 }; + off += 1.0 + PANEL_PAD; // borde + padding interno del contenedor + for it in items.iter().take(parent_idx) { + off += if it.separator { SEP_H } else { ITEM_H }; + } + // pequeño solape para que el flyout se lea continuo con el padre. + (panel_x + PANEL_W - PANEL_PAD, panel_y + off) +} + +fn header_view(text: String, palette: &ContextMenuPalette) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(HEADER_H), + }, + padding: Rect { + left: length(ITEM_PAD_LEFT + INDICATOR_W + ICON_W + 4.0), + right: length(ITEM_PAD_RIGHT), + top: length(2.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(text.to_uppercase(), 9.5, palette.fg_header, Alignment::Start) +} + +/// Pinta una fila. Si `parent` es `Some((pidx, cb))`, es un item de +/// submenú y clickea vía `cb(pidx, idx)`; si es `None`, es raíz y usa +/// `on_pick(idx)` + (si corresponde) `on_hover` para abrir su flyout. +#[allow(clippy::too_many_arguments)] +fn item_view( + idx: usize, + parent: Option<(usize, Arc Msg + Send + Sync>)>, + item: &ContextMenuItem, + is_active: bool, + on_pick: &Arc Msg + Send + Sync>, + on_hover: Option<&Arc) -> Msg + Send + Sync>>, + palette: &ContextMenuPalette, +) -> View { + if item.separator { + return separator_view(palette); + } + + // Color del texto y del atajo según estado. + let (fg, fg_dim): (Color, Color) = if !item.enabled { + (palette.fg_disabled, palette.fg_disabled) + } else if item.destructive { + (palette.fg_destructive, palette.fg_shortcut) + } else if is_active { + (palette.fg_active, palette.fg_active) + } else { + (palette.fg_text, palette.fg_shortcut) + }; + // Ícono: accent cuando la fila está activa (cue del menú), si no + // apagado. + let icon_fg = if !item.enabled { + palette.fg_disabled + } else if is_active { + palette.accent + } else { + palette.fg_icon + }; + + // Indicador accent vertical a la izquierda — visible sólo en la fila + // activa; reserva su ancho siempre para que el texto no salte. + let indicator = View::new(Style { + size: Size { + width: length(INDICATOR_W), + height: percent(0.55_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }); + let indicator = if is_active && item.enabled { + indicator.fill(palette.accent).radius(2.0) + } else { + indicator + }; + + // Gutter de ícono — auto height para que el row lo centre vertical. + let icon_cell = View::new(Style { + size: Size { + width: length(ICON_W), + height: auto(), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text_aligned(item.icon.clone().unwrap_or_default(), 13.0, icon_fg, Alignment::Center); + + // Label — auto height (lo centra el align_items Center del row). + let label = View::new(Style { + size: Size { + width: auto(), + height: auto(), + }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(item.label.clone(), 12.5, fg, Alignment::Start); + + // Cola: chevron de submenú o atajo de teclado. + let trailing_text = if item.has_submenu() { + Some(("\u{203A}".to_string(), fg)) // › + } else { + item.shortcut.clone().map(|s| (s, fg_dim)) + }; + let mut row_children: Vec> = vec![indicator, icon_cell, label]; + if let Some((txt, color)) = trailing_text { + row_children.push( + View::new(Style { + size: Size { + width: length(64.0_f32), + height: auto(), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(txt, 11.0, color, Alignment::End), + ); + } + + let mut row = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(ITEM_H), + }, + flex_direction: FlexDirection::Row, + padding: Rect { + left: length(ITEM_PAD_LEFT), + right: length(ITEM_PAD_RIGHT), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(2.0_f32), + height: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .radius(ITEM_RADIUS as f64) + .children(row_children); + + // Fondo: píldora suave en activo (teclado). El hover lo aporta + // `hover_fill` (tinte aún más suave) para no competir con el activo. + if is_active && item.enabled { + row = row.fill(palette.bg_active); + } + + if item.enabled { + row = row.hover_fill(palette.bg_hover); + match &parent { + Some((pidx, cb)) => { + let cb = cb.clone(); + let pidx = *pidx; + row = row.on_click_at(move |_, _, _, _| Some(cb(pidx, idx))); + } + None => { + let on_pick = on_pick.clone(); + row = row.on_click_at(move |_, _, _, _| Some(on_pick(idx))); + // Hover abre/cierra el flyout según sea submenú o no. + if let Some(on_hover) = on_hover { + let on_hover = on_hover.clone(); + let target = if item.has_submenu() { Some(idx) } else { None }; + row = row.on_pointer_enter(on_hover(target)); + } + } + } + } + row +} + +fn separator_view(palette: &ContextMenuPalette) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(SEP_H), + }, + flex_direction: FlexDirection::Column, + justify_content: Some(JustifyContent::Center), + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(ITEM_PAD_LEFT), + right: length(ITEM_PAD_RIGHT), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.separator)]) +} + +/// `on_pick` dummy para los items de submenú (que usan `on_pick_sub`). +/// Nunca se invoca: `item_view` con `parent=Some` ignora `on_pick`. +fn dummy_pick() -> Arc Msg + Send + Sync> { + Arc::new(|_| unreachable!("submenu item usa on_pick_sub, no on_pick")) +} + +/// Navegación por teclado: dado el activo + dirección (`+1`/`-1`), el +/// siguiente índice válido (saltea separators y disabled). `usize::MAX` +/// si no hay elegibles. +pub fn step_active(items: &[ContextMenuItem], current: usize, direction: i32) -> usize { + if items.is_empty() { + return usize::MAX; + } + let n = items.len() as i32; + let start = if current == usize::MAX { + if direction >= 0 { + -1 + } else { + n + } + } else { + current as i32 + }; + let mut i = start; + for _ in 0..n { + i += direction; + if i < 0 { + i = n - 1; + } else if i >= n { + i = 0; + } + let item = &items[i as usize]; + if !item.separator && item.enabled { + return i as usize; + } + } + usize::MAX +} + +#[cfg(test)] +mod tests { + use super::*; + + fn it(label: &str) -> ContextMenuItem { + ContextMenuItem::action(label) + } + + #[test] + fn step_active_skips_separators() { + let items = vec![it("A"), ContextMenuItem::separator(), it("B"), it("C")]; + assert_eq!(step_active(&items, 0, 1), 2); + assert_eq!(step_active(&items, 2, -1), 0); + } + + #[test] + fn step_active_skips_disabled() { + let items = vec![it("A"), it("B").disabled(), it("C")]; + assert_eq!(step_active(&items, 0, 1), 2); + assert_eq!(step_active(&items, 2, -1), 0); + } + + #[test] + fn step_active_wraps_around() { + let items = vec![it("A"), it("B"), it("C")]; + assert_eq!(step_active(&items, 2, 1), 0); + assert_eq!(step_active(&items, 0, -1), 2); + } + + #[test] + fn submenu_y_icono_se_setean() { + let item = it("Tema") + .icon("◐") + .submenu(vec![it("Oscuro"), it("Claro")]); + assert!(item.has_submenu()); + assert_eq!(item.children.len(), 2); + assert_eq!(item.icon.as_deref(), Some("◐")); + } + + #[test] + fn extras_default_es_menu_clasico() { + let extras: ContextMenuExtras = ContextMenuExtras::default(); + assert_eq!(extras.appear, 1.0); + assert!(extras.open_sub.is_none()); + assert!(extras.on_hover.is_none()); + assert!(extras.on_pick_sub.is_none()); + } +} diff --git a/widgets/dock-rail/Cargo.toml b/widgets/dock-rail/Cargo.toml new file mode 100644 index 0000000..ca9cf7e --- /dev/null +++ b/widgets/dock-rail/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-dock-rail" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-dock-rail — rail vertical de dientes (pestañas con barra de acento + icono) para sidebars acoplables; clic activa, arrastre mueve entre rails." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/dock-rail/src/lib.rs b/widgets/dock-rail/src/lib.rs new file mode 100644 index 0000000..c69e7a6 --- /dev/null +++ b/widgets/dock-rail/src/lib.rs @@ -0,0 +1,184 @@ +//! `llimphi-widget-dock-rail` — rail vertical de **dientes** para +//! sidebars acoplables. +//! +//! Cada diente es una pestaña vertical: una **barra de acento** de 3px +//! pegada al borde interno (encendida cuando el item está activo) + un +//! **icono** centrado. Los dientes apilan en una columna redondeada que +//! se pinta como una franja flotante al borde del centro — el patrón del +//! dock de cosmos, ahora reutilizable. +//! +//! Render-only y agnóstico del `Msg`: el item se identifica por un `u64` +//! opaco. El clic (en el *press*, para no pelear con el arrastre) activa +//! vía `on_activate(id)`; el diente es **arrastrable** con su `id` como +//! payload, y el rail entero es **drop target** (`on_drop(payload)`) — +//! así una app puede mover un diente de un sidebar a otro soltándolo +//! sobre el rail opuesto. El icono lo dibuja el caller (`make_icon`), que +//! recibe el color ya resuelto según el estado activo/inactivo. +//! +//! ```ignore +//! let items = [DockRailItem { id: 0, active: true }, DockRailItem { id: 1, active: false }]; +//! dock_rail_view( +//! &items, +//! 44.0, +//! &DockRailPalette::from_theme(&theme), +//! |id, size, color| my_icon_view(id, size, color), +//! |id| Msg::DockActivate(side, id), +//! move |payload| Some(Msg::DockDrop(side, payload)), +//! ) +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; +use llimphi_theme::Theme; + +/// Alto de cada diente (px). +const TOOTH_H: f32 = 42.0; +/// Alto de la barra de acento (px) — un poco menor que el diente para +/// dejar aire arriba y abajo. +const BAR_H: f32 = 40.0; +/// Tamaño del icono que se le pide al caller (px). +const ICON_PX: f32 = 20.0; + +/// Paleta del rail. +#[derive(Debug, Clone, Copy)] +pub struct DockRailPalette { + /// Fondo de la franja del rail. + pub bg_rail: Color, + /// Fondo del diente activo. + pub bg_active: Color, + /// Fondo al hover (diente) y al sobrevolar un drop válido (rail). + pub bg_hover: Color, + /// Color del acento: barra del diente activo + su icono. + pub accent: Color, + /// Color del icono de un diente inactivo. + pub icon_inactive: Color, +} + +impl DockRailPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + bg_rail: t.bg_panel_alt, + bg_active: t.bg_selected, + bg_hover: t.bg_row_hover, + accent: t.accent, + icon_inactive: t.fg_muted, + } + } +} + +/// Un diente del rail: su id opaco y si está activo. +#[derive(Debug, Clone, Copy)] +pub struct DockRailItem { + pub id: u64, + pub active: bool, +} + +/// Construye el rail de dientes. +/// +/// - `items`: en el orden en que se quieren mostrar (el widget no +/// reordena). +/// - `width`: ancho de la franja del rail (px). +/// - `make_icon(id, size, color)`: dibuja el icono del item con el color +/// ya resuelto (acento si activo, atenuado si no). +/// - `on_activate(id)`: `Msg` al clickear (en el press). +/// - `on_drop(payload)`: `Msg` opcional cuando se suelta un diente +/// (cualquier `id`) sobre este rail. +pub fn dock_rail_view( + items: &[DockRailItem], + width: f32, + palette: &DockRailPalette, + make_icon: FIcon, + on_activate: FAct, + on_drop: FDrop, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FIcon: Fn(u64, f32, Color) -> View, + FAct: Fn(u64) -> Msg, + FDrop: Fn(u64) -> Option + Send + Sync + 'static, +{ + let mut teeth: Vec> = Vec::with_capacity(items.len()); + for item in items { + let fg = if item.active { + palette.accent + } else { + palette.icon_inactive + }; + // Barra de acento, pegada al borde interno. + let accent_bar = { + let b = View::new(Style { + size: Size { + width: length(3.0_f32), + height: length(BAR_H), + }, + flex_shrink: 0.0, + ..Default::default() + }); + if item.active { + b.fill(palette.accent).radius(2.0) + } else { + b + } + }; + let icon_box = View::new(Style { + flex_grow: 1.0, + size: Size { + width: percent(0.0_f32), + height: length(TOOTH_H), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .children(vec![make_icon(item.id, ICON_PX, fg)]); + + let id = item.id; + let msg = on_activate(id); + let mut tooth = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(TOOTH_H), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .hover_fill(palette.bg_hover) + // Click en el press activa; arrastrar mueve de rail (payload=id). + .on_click_at(move |_, _, _, _| Some(msg.clone())) + .draggable_at(|phase, _, _, _, _| match phase { + DragPhase::Move | DragPhase::End => None, + }) + .drag_payload(id) + .children(vec![accent_bar, icon_box]); + if item.active { + tooth = tooth.fill(palette.bg_active); + } + teeth.push(tooth); + } + + // La franja: sólo del alto de los dientes (el hueco de abajo lo deja + // libre para que el área central lo aproveche si el rail flota como + // overlay). Es además el drop target del lado. + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: length(width), + height: auto(), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_rail) + .radius(5.0) + .on_drop(on_drop) + .drop_hover_fill(palette.bg_hover) + .children(teeth) +} diff --git a/widgets/edit-menu/Cargo.toml b/widgets/edit-menu/Cargo.toml new file mode 100644 index 0000000..1223669 --- /dev/null +++ b/widgets/edit-menu/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-widget-edit-menu" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-edit-menu — el menú de edición estándar (Deshacer/Rehacer/Cortar/Copiar/Pegar/Eliminar/Seleccionar todo) para cualquier campo que use EditorState (input single-line e IDE enriquecido). Arma el ContextMenuSpec desde flags derivados del estado y aplica las acciones reutilizando apply_key_with_clipboard." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-context-menu = { workspace = true } +llimphi-widget-text-editor = { workspace = true } diff --git a/widgets/edit-menu/src/lib.rs b/widgets/edit-menu/src/lib.rs new file mode 100644 index 0000000..777900e --- /dev/null +++ b/widgets/edit-menu/src/lib.rs @@ -0,0 +1,361 @@ +//! `llimphi-widget-edit-menu` — el menú de edición estándar para +//! cualquier campo de texto Llimphi. +//! +//! Tanto el input single-line ([`llimphi_widget_text_input`]) como el +//! editor IDE enriquecido ([`llimphi_widget_text_editor`]) se apoyan en +//! el mismo [`EditorState`]. Este widget arma, a partir de ese estado, +//! el menú contextual canónico: +//! +//! ```text +//! ┃ EDICIÓN +//! ┃ Deshacer Ctrl+Z +//! ┃ Rehacer Ctrl+Y +//! ┃ ───────────────────── +//! ┃ Cortar Ctrl+X +//! ┃ Copiar Ctrl+C +//! ┃ Pegar Ctrl+V +//! ┃ Eliminar Supr +//! ┃ ───────────────────── +//! ┃ Seleccionar todo Ctrl+A +//! ``` +//! +//! Cada ítem se habilita o no según el estado real (sin selección → +//! Cortar/Copiar/Eliminar grises; sin historial → Deshacer gris; etc). +//! +//! Uso típico, en tres pasos por app: +//! 1. El campo emite la posición del click derecho — `View::on_right_click_at` +//! → `Msg::AbrirMenuEdicion(x, y)`. El `update` guarda el ancla. +//! 2. `App::view_overlay` devuelve +//! `Some(context_menu_view(edit_menu::edit_context_menu(...)))` cuando el +//! ancla está presente. +//! 3. El pick produce `Msg::Edicion(EditAction)`; el `update` llama a +//! [`apply`] con el `EditorState` del campo focuseado y el clipboard. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_theme::Theme; +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey}; +use llimphi_widget_context_menu::{step_active, ContextMenuItem, ContextMenuPalette, ContextMenuSpec}; +use llimphi_widget_text_editor::{ApplyResult, Clipboard, EditorState}; + +/// Una acción de edición del menú estándar. Es `Copy` para que el +/// `on_pick` la capture sin clonar y la app la rebote en un `Msg`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditAction { + Undo, + Redo, + Cut, + Copy, + Paste, + /// Borra la selección (Supr/Delete sin mover el resto). + Delete, + SelectAll, +} + +/// Banderas que deciden qué ítems van habilitados. Derivalas del estado +/// del campo focuseado con [`EditFlags::from_editor`]. +#[derive(Debug, Clone, Copy)] +pub struct EditFlags { + /// Hay selección no-vacía → Cortar/Copiar/Eliminar habilitados. + pub has_selection: bool, + /// Hay algo que deshacer. + pub can_undo: bool, + /// Hay algo que rehacer. + pub can_redo: bool, + /// El clipboard tiene contenido pegable → Pegar habilitado. Si no se + /// puede saber barato, pasá `true` (Pegar no-opea si está vacío). + pub can_paste: bool, + /// El buffer no está vacío → Seleccionar todo habilitado. + pub has_text: bool, + /// Campo enmascarado (password): Cortar/Copiar se deshabilitan para + /// no filtrar el secreto al clipboard. + pub masked: bool, +} + +impl Default for EditFlags { + fn default() -> Self { + Self { + has_selection: false, + can_undo: false, + can_redo: false, + can_paste: true, + has_text: false, + masked: false, + } + } +} + +impl EditFlags { + /// Deriva las banderas del estado del editor. `can_paste` se deja en + /// `true` (consultar el clipboard real requiere `&mut`; pegar vacío + /// es no-op). `masked` lo decide el caller (el input lo sabe vía + /// `TextInputState::is_masked`). + pub fn from_editor(state: &EditorState, masked: bool) -> Self { + Self { + has_selection: state.has_selection(), + can_undo: state.can_undo(), + can_redo: state.can_redo(), + can_paste: true, + has_text: !state.is_empty(), + masked, + } + } + + /// Igual que [`Self::from_editor`] pero fijando `can_paste` + /// explícitamente (cuando el caller ya sabe si el clipboard tiene + /// algo, p.ej. consultándolo una vez por frame). + pub fn from_editor_with_paste(state: &EditorState, masked: bool, can_paste: bool) -> Self { + Self { + can_paste, + ..Self::from_editor(state, masked) + } + } +} + +/// Los ítems del menú + la acción de cada uno alineadas por índice. Las +/// filas separador llevan una acción de relleno (`SelectAll`) que **nunca +/// se dispara**: el `context-menu` no engancha `on_click` en separadores +/// ni en ítems deshabilitados, así que `on_pick(i)` sólo recibe índices +/// de ítems-acción habilitados. Mantener un `EditAction` por fila (en vez +/// de `Option`) permite que el closure de `on_pick` capture sólo `Arc`s +/// y no un `Msg` crudo — clave para satisfacer `Send + Sync` sin exigirle +/// esos bounds al `Msg` de la app. +fn entries(flags: EditFlags) -> (Vec, Vec) { + let mut items: Vec = Vec::with_capacity(9); + let mut actions: Vec = Vec::with_capacity(9); + const FILL: EditAction = EditAction::SelectAll; + + let mut push = |item: ContextMenuItem, action: EditAction| { + items.push(item); + actions.push(action); + }; + + let undo = ContextMenuItem::action("Deshacer").icon("\u{21A9}").with_shortcut("Ctrl+Z"); + push( + if flags.can_undo { undo } else { undo.disabled() }, + EditAction::Undo, + ); + let redo = ContextMenuItem::action("Rehacer").icon("\u{21AA}").with_shortcut("Ctrl+Y"); + push( + if flags.can_redo { redo } else { redo.disabled() }, + EditAction::Redo, + ); + + push(ContextMenuItem::separator(), FILL); + + let can_copy = flags.has_selection && !flags.masked; + let cut = ContextMenuItem::action("Cortar").icon("\u{2702}").with_shortcut("Ctrl+X"); + push(if can_copy { cut } else { cut.disabled() }, EditAction::Cut); + let copy = ContextMenuItem::action("Copiar").icon("\u{29C9}").with_shortcut("Ctrl+C"); + push(if can_copy { copy } else { copy.disabled() }, EditAction::Copy); + let paste = ContextMenuItem::action("Pegar").icon("\u{2398}").with_shortcut("Ctrl+V"); + push( + if flags.can_paste { paste } else { paste.disabled() }, + EditAction::Paste, + ); + let del = ContextMenuItem::action("Eliminar") + .icon("\u{2717}") + .with_shortcut("Supr") + .destructive(); + push( + if flags.has_selection { del } else { del.disabled() }, + EditAction::Delete, + ); + + push(ContextMenuItem::separator(), FILL); + + let sel = ContextMenuItem::action("Seleccionar todo").icon("\u{2750}").with_shortcut("Ctrl+A"); + push( + if flags.has_text { sel } else { sel.disabled() }, + EditAction::SelectAll, + ); + + (items, actions) +} + +/// Sólo los ítems (para componer un menú custom que incluya el bloque de +/// edición seguido de acciones propias de la app). +pub fn edit_menu_items(flags: EditFlags) -> Vec { + entries(flags).0 +} + +/// Mueve el resaltado de teclado por las filas del menú de edición, saltando +/// separadores y filas deshabilitadas. `active == usize::MAX` significa "ninguna +/// fila"; `direction` +1 baja, −1 sube. Pensado para enganchar flechas +/// arriba/abajo sobre el menú de edición abierto (paralelo a [`step_active`]). +pub fn edit_menu_step(flags: EditFlags, active: usize, direction: i32) -> usize { + let items = entries(flags).0; + step_active(&items, active, direction) +} + +/// La [`EditAction`] de la fila `active`, o `None` si esa fila es un separador, +/// está deshabilitada o fuera de rango. Pensado para resolver la tecla Enter +/// sobre la fila resaltada por [`edit_menu_step`]. +pub fn edit_menu_action_at(flags: EditFlags, active: usize) -> Option { + let (items, actions) = entries(flags); + let item = items.get(active)?; + if item.separator || !item.enabled { + return None; + } + actions.get(active).copied() +} + +/// Arma el [`ContextMenuSpec`] del menú de edición listo para +/// `context_menu_view`. `on_action` rebota cada pick en un `Msg` de la +/// app; `on_dismiss` cierra al click-fuera o Esc. +pub fn edit_context_menu( + anchor: (f32, f32), + viewport: (f32, f32), + theme: &Theme, + flags: EditFlags, + on_action: F, + on_dismiss: Msg, +) -> ContextMenuSpec +where + Msg: Clone + 'static, + F: Fn(EditAction) -> Msg + Send + Sync + 'static, +{ + let (items, actions) = entries(flags); + let actions = Arc::new(actions); + let on_action = Arc::new(on_action); + let on_pick: Arc Msg + Send + Sync> = Arc::new(move |i: usize| { + // `i` siempre cae en un ítem-acción habilitado (los separadores y + // deshabilitados no enganchan click). El `SelectAll` de relleno de + // los separadores nunca se alcanza. + let a = actions.get(i).copied().unwrap_or(EditAction::SelectAll); + (on_action)(a) + }); + + ContextMenuSpec { + anchor, + viewport, + header: Some("Edición".to_string()), + items, + active: usize::MAX, + on_pick, + on_dismiss, + palette: ContextMenuPalette::from_theme(theme), + } +} + +/// Aplica una [`EditAction`] al `EditorState`. Reutiliza +/// `apply_key_with_clipboard` (sintetizando la tecla equivalente) para +/// heredar exactamente el mismo comportamiento — incluido el bookkeeping +/// de parseo incremental — que el atajo de teclado. Devuelve el +/// [`ApplyResult`] para que el caller decida si persistir el cambio. +pub fn apply(state: &mut EditorState, action: EditAction, clipboard: &mut dyn Clipboard) -> ApplyResult { + match action { + EditAction::SelectAll => { + state.select_all(); + ApplyResult::CursorMoved + } + EditAction::Undo => state.apply_key_with_clipboard(&ctrl_char("z"), clipboard), + EditAction::Redo => state.apply_key_with_clipboard(&ctrl_char("y"), clipboard), + EditAction::Cut => state.apply_key_with_clipboard(&ctrl_char("x"), clipboard), + EditAction::Copy => state.apply_key_with_clipboard(&ctrl_char("c"), clipboard), + EditAction::Paste => state.apply_key_with_clipboard(&ctrl_char("v"), clipboard), + EditAction::Delete => state.apply_key_with_clipboard(&named(NamedKey::Delete), clipboard), + } +} + +fn ctrl_char(s: &str) -> KeyEvent { + KeyEvent { + key: Key::Character(s.into()), + state: KeyState::Pressed, + text: Some(s.to_string()), + modifiers: Modifiers { + ctrl: true, + ..Modifiers::default() + }, + repeat: false, + } +} + +fn named(k: NamedKey) -> KeyEvent { + KeyEvent { + key: Key::Named(k), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers::default(), + repeat: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_widget_text_editor::MemClipboard; + + fn lleno() -> EditorState { + let mut s = EditorState::new(); + s.set_text("hola mundo"); + s + } + + #[test] + fn select_all_y_copy_llevan_todo_al_clipboard() { + let mut s = lleno(); + let r = apply(&mut s, EditAction::SelectAll, &mut MemClipboard::new()); + assert_eq!(r, ApplyResult::CursorMoved); + assert!(s.has_selection()); + let mut clip = MemClipboard::new(); + apply(&mut s, EditAction::Copy, &mut clip); + assert_eq!(clip.get().as_deref(), Some("hola mundo")); + } + + #[test] + fn cut_borra_y_copia() { + let mut s = lleno(); + s.select_all(); + let mut clip = MemClipboard::new(); + let r = apply(&mut s, EditAction::Cut, &mut clip); + assert_eq!(r, ApplyResult::Changed); + assert!(s.is_empty()); + assert_eq!(clip.get().as_deref(), Some("hola mundo")); + } + + #[test] + fn paste_inserta_del_clipboard() { + let mut s = EditorState::new(); + let mut clip = MemClipboard::with("XYZ"); + apply(&mut s, EditAction::Paste, &mut clip); + assert_eq!(s.text(), "XYZ"); + } + + #[test] + fn flags_sin_seleccion_deshabilitan_copiar() { + let s = lleno(); + let flags = EditFlags::from_editor(&s, false); + assert!(!flags.has_selection); + let items = edit_menu_items(flags); + // "Cortar" es el primer ítem tras el separador (índice 3). + assert!(!items[3].enabled, "Cortar debería estar deshabilitado sin selección"); + } + + #[test] + fn step_y_action_at_saltan_separadores_y_deshabilitados() { + let mut s = lleno(); + s.select_all(); + let flags = EditFlags::from_editor(&s, false); + // Desde "ninguna fila", bajar cae en la primera seleccionable (Deshacer + // está gris sin historial; Cortar=3 es el primer habilitado real). + let first = edit_menu_step(flags, usize::MAX, 1); + assert!(edit_menu_action_at(flags, first).is_some()); + // El separador (índice 2) nunca da acción. + assert_eq!(edit_menu_action_at(flags, 2), None); + // Avanzar y retroceder vuelve a una fila con acción válida. + let next = edit_menu_step(flags, first, 1); + assert!(edit_menu_action_at(flags, next).is_some()); + } + + #[test] + fn masked_deshabilita_copiar_aun_con_seleccion() { + let mut s = lleno(); + s.select_all(); + let flags = EditFlags::from_editor(&s, true); + let items = edit_menu_items(flags); + assert!(!items[4].enabled, "Copiar debería estar gris en campo enmascarado"); + } +} diff --git a/widgets/empty/Cargo.toml b/widgets/empty/Cargo.toml new file mode 100644 index 0000000..7042e6c --- /dev/null +++ b/widgets/empty/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-empty" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-empty — empty-state con icono grande, título y descripción opcional. Reemplaza pantallas en blanco crudas con orientación al usuario." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-icons = { workspace = true } diff --git a/widgets/empty/src/lib.rs b/widgets/empty/src/lib.rs new file mode 100644 index 0000000..8bb2568 --- /dev/null +++ b/widgets/empty/src/lib.rs @@ -0,0 +1,112 @@ +//! `llimphi-widget-empty` — empty state con icono, título y descripción. +//! +//! Patrón para reemplazar pantallas en blanco con orientación: cuando +//! una lista no tiene items, un editor no tiene archivo abierto, una +//! búsqueda no arrojó resultados — en vez de fondo plano, mostrar +//! un icono grande apagado + título + descripción corta + (opcional) +//! botón de acción primaria. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_icons::{icon_view, Icon}; +use llimphi_theme::{alpha, Theme}; + +/// Paleta del empty state — colores apagados para no competir con la +/// UI principal. +#[derive(Debug, Clone, Copy)] +pub struct EmptyPalette { + pub fg_icon: Color, + pub fg_title: Color, + pub fg_desc: Color, +} + +impl EmptyPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + fg_icon: with_alpha8(t.fg_muted, alpha::HINT), + fg_title: t.fg_muted, + fg_desc: with_alpha8(t.fg_muted, alpha::DISABLED), + } + } +} + +fn with_alpha8(c: Color, a: u8) -> Color { + let [r, g, b, _] = c.components; + use llimphi_ui::llimphi_raster::peniko::color::AlphaColor; + AlphaColor::new([r, g, b, a as f32 / 255.0]) +} + +/// Construye el empty state. La app llama desde su `view()` cuando +/// detecta el caso vacío: +/// +/// ```ignore +/// if model.items.is_empty() { +/// return empty_view(Icon::File, "Sin archivos abiertos", +/// Some("Abrí uno con Ctrl+O para empezar."), +/// &palette); +/// } +/// ``` +pub fn empty_view( + icon: Icon, + title: impl Into, + description: Option<&str>, + palette: &EmptyPalette, +) -> View { + let icon_cell = View::new(Style { + size: Size { + width: length(72.0_f32), + height: length(72.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![icon_view(icon, palette.fg_icon, 1.4)]); + + let title_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(title.into(), 15.5, palette.fg_title, Alignment::Center); + + let mut children = vec![icon_cell, title_view]; + if let Some(desc) = description { + children.push( + View::new(Style { + size: Size { + width: length(360.0_f32), + height: length(40.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(desc.to_string(), 12.0, palette.fg_desc, Alignment::Center), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Size { + width: length(0.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .children(children) +} diff --git a/widgets/field/Cargo.toml b/widgets/field/Cargo.toml new file mode 100644 index 0000000..9179ae6 --- /dev/null +++ b/widgets/field/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-field" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-field — wrapper de formulario: label arriba + slot del input + descripción/error abajo. Patrón estándar para formularios accesibles." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/field/src/lib.rs b/widgets/field/src/lib.rs new file mode 100644 index 0000000..a847c3b --- /dev/null +++ b/widgets/field/src/lib.rs @@ -0,0 +1,127 @@ +//! `llimphi-widget-field` — wrapper de formulario para inputs. +//! +//! Patrón estándar de campo: +//! ```text +//! Nombre del campo (label — bold, fg_text) +//! [ input control aquí ] (slot — viene como View) +//! Descripción o error abajo (helper — fg_muted o fg_destructive) +//! ``` +//! +//! El widget no implementa el input — lo recibe como `View` y lo +//! envuelve. Esto permite usarlo con `text-input`, `text-area`, `switch`, +//! `segmented` o cualquier otro control. + +#![forbid(unsafe_code)] + +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::View; +use llimphi_theme::Theme; + +#[derive(Debug, Clone, Copy)] +pub struct FieldPalette { + pub fg_label: Color, + pub fg_helper: Color, + pub fg_error: Color, + pub fg_required: Color, +} + +impl FieldPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + fg_label: t.fg_text, + fg_helper: t.fg_muted, + fg_error: t.fg_destructive, + fg_required: t.fg_destructive, + } + } +} + +/// Spec del field. `helper` y `error` son mutuamente excluyentes — +/// si hay error, se renderiza el error (rojo); si no, el helper. +pub struct FieldSpec { + pub label: String, + /// El input/control concreto (text-input, switch, segmented, etc). + pub control: View, + /// Marca el field como requerido — agrega un asterisco al label. + pub required: bool, + /// Texto explicativo debajo del control. `None` para omitirlo. + pub helper: Option, + /// Mensaje de error — gana sobre `helper` cuando está presente. + pub error: Option, + pub palette: FieldPalette, +} + +const LABEL_H: f32 = 16.0; +const HELPER_H: f32 = 16.0; +const GAP_LABEL: f32 = 4.0; +const GAP_HELPER: f32 = 4.0; +const LABEL_FONT: f32 = 11.5; +const HELPER_FONT: f32 = 10.5; + +pub fn field_view(spec: FieldSpec) -> View { + let FieldSpec { + label, + control, + required, + helper, + error, + palette, + } = spec; + + // Label: nombre + (asterisco si required). + let label_text = if required { format!("{label} *") } else { label }; + let label_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(LABEL_H), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(label_text, LABEL_FONT, palette.fg_label, Alignment::Start); + + // Helper / error — error gana si presente. + let helper_text = error.clone().or(helper.clone()); + let helper_color = if error.is_some() { palette.fg_error } else { palette.fg_helper }; + + let mut children: Vec> = vec![label_view, spacer(GAP_LABEL), control]; + if let Some(t) = helper_text { + children.push(spacer(GAP_HELPER)); + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(HELPER_H), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(t, HELPER_FONT, helper_color, Alignment::Start), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: llimphi_ui::llimphi_layout::taffy::prelude::auto(), + }, + ..Default::default() + }) + .children(children) +} + +fn spacer(h: f32) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(h), + }, + flex_shrink: 0.0, + ..Default::default() + }) +} diff --git a/widgets/gallery/Cargo.toml b/widgets/gallery/Cargo.toml new file mode 100644 index 0000000..4640b7c --- /dev/null +++ b/widgets/gallery/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "llimphi-widget-gallery" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-gallery — app demo que pinta todos los widgets de llimphi en una sola ventana. Pensado como referencia visual y como smoke test al introducir cambios al theme o a los widgets." + +[[bin]] +name = "llimphi-widget-gallery" +path = "src/main.rs" + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-app-header = { workspace = true } +llimphi-widget-banner = { workspace = true } +llimphi-widget-button = { workspace = true } +llimphi-widget-card = { workspace = true } +llimphi-widget-list = { workspace = true } +llimphi-widget-splitter = { workspace = true } +llimphi-widget-stat-card = { workspace = true } +llimphi-widget-tabs = { workspace = true } +llimphi-widget-theme-switcher = { workspace = true } +llimphi-widget-text-input = { workspace = true } +llimphi-widget-tiled = { workspace = true } +llimphi-widget-tree = { workspace = true } +llimphi-widget-menubar = { workspace = true } +llimphi-widget-context-menu = { workspace = true } +app-bus = { workspace = true } diff --git a/widgets/gallery/LEEME.md b/widgets/gallery/LEEME.md new file mode 100644 index 0000000..d94b8f8 --- /dev/null +++ b/widgets/gallery/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-gallery + +> Grid virtualizada de cards para [llimphi](../../README.md). + +Layout responsive con columnas auto-fit; cada card es `View` libre. Reutiliza [`card`](../card/README.md). diff --git a/widgets/gallery/README.md b/widgets/gallery/README.md new file mode 100644 index 0000000..eaf56e0 --- /dev/null +++ b/widgets/gallery/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-gallery + +> Virtualized card grid for [llimphi](../../README.md). + +Responsive layout with auto-fit columns; each card is a free `View`. Reuses [`card`](../card/README.md). diff --git a/widgets/gallery/src/main.rs b/widgets/gallery/src/main.rs new file mode 100644 index 0000000..af16bc4 --- /dev/null +++ b/widgets/gallery/src/main.rs @@ -0,0 +1,569 @@ +//! `llimphi-widget-gallery` — todos los widgets de Llimphi en una sola +//! ventana. Útil como referencia visual y smoke test al cambiar el +//! theme o cualquier widget. +//! +//! Corré con: `cargo run -p llimphi-widget-gallery --release`. + +use std::sync::Arc; + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, DragPhase, Handle, View}; +use llimphi_widget_app_header::{app_header, AppHeaderPalette}; +use llimphi_widget_banner::{banner_view, BannerKind}; +use llimphi_widget_button::{button_view, ButtonPalette}; +use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec}; +use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette}; +use llimphi_widget_stat_card::{stat_card_view, StatCardPalette}; +use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec}; +use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState}; +use llimphi_widget_theme_switcher::theme_switcher_view; +use llimphi_widget_tiled::{tiled_view_reorderable, TileSpec, TiledPalette}; +use llimphi_widget_context_menu::{ + context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec, +}; +use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H}; + +use app_bus::{AppMenu, Menu, MenuItem}; + +#[derive(Clone)] +enum Msg { + EditKey(llimphi_ui::KeyEvent), + SelectRow(usize), + SelectTab(usize), + ClickAction(u32), + ResizeOuter(f32), + SwapTile { from: usize, to: usize }, + ChangeTheme(Theme), + CycleTheme, + // --- Barra de menú + contextual --- + MenuOpen(Option), + MenuCommand(String), + CloseMenus, + ContextMenuOpen(f32, f32), +} + +struct Model { + text: TextInputState, + list_sel: usize, + tab: usize, + last_action: Option, + left_w: f32, + tile_order: Vec, + theme: Theme, + /// Índice del menú raíz abierto en la barra (None = ninguno). + menu_open: Option, + /// Posición (en coords de ventana) del menú contextual, si está abierto. + context_menu: Option<(f32, f32)>, +} + +struct Gallery; + +impl App for Gallery { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · widget gallery" + } + + fn initial_size() -> (u32, u32) { + (1280, 820) + } + + fn init(_: &Handle) -> Model { + Model { + text: TextInputState::new(), + list_sel: 0, + tab: 0, + last_action: None, + left_w: 380.0, + tile_order: vec![0, 1, 2, 3], + theme: Theme::dark(), + menu_open: None, + context_menu: None, + } + } + + fn on_key(_: &Model, e: &llimphi_ui::KeyEvent) -> Option { + Some(Msg::EditKey(e.clone())) + } + + fn update(model: Model, msg: Msg, handle: &Handle) -> Model { + let mut m = model; + match msg { + Msg::EditKey(ev) => { + m.text.apply_key(&ev); + } + Msg::SelectRow(i) => m.list_sel = i, + Msg::SelectTab(i) => m.tab = i, + Msg::ClickAction(id) => m.last_action = Some(id), + Msg::ResizeOuter(dx) => m.left_w = (m.left_w + dx).clamp(220.0, 800.0), + Msg::SwapTile { from, to } => { + if from != to && from < m.tile_order.len() && to < m.tile_order.len() { + m.tile_order.swap(from, to); + } + } + Msg::ChangeTheme(t) => m.theme = t, + Msg::CycleTheme => m.theme = Theme::next_after(m.theme.name), + Msg::MenuOpen(which) => { + m.menu_open = which; + // Abrir un menú raíz cierra cualquier contextual. + m.context_menu = None; + } + Msg::CloseMenus => { + m.menu_open = None; + m.context_menu = None; + } + Msg::ContextMenuOpen(x, y) => { + m.menu_open = None; + m.context_menu = Some((x, y)); + } + Msg::MenuCommand(cmd) => { + m.menu_open = None; + m.context_menu = None; + handle_menu_command(&cmd, &mut m, handle); + } + } + m + } + + fn view(model: &Model) -> View { + let theme = model.theme; + let menu = app_menu(); + let menubar = menubar_view(&menubar_spec(&menu, model)); + let header_palette = AppHeaderPalette::from_theme(&theme); + let btn_palette = ButtonPalette::from_theme(&theme); + let list_palette = ListPalette::from_theme(&theme); + let splitter_palette = SplitterPalette::from_theme(&theme); + let tabs_palette = TabsPalette::from_theme(&theme); + let stat_palette = StatCardPalette::from_theme(&theme); + let input_palette = TextInputPalette::from_theme(&theme); + + // --- Header con acción a la derecha --- + let header = app_header( + format!( + "llimphi widget gallery · última acción: {}", + match model.last_action { + Some(i) => format!("button #{i}"), + None => "ninguna".to_string(), + } + ), + vec![ + { + let mut btn = button_view("acción A", &btn_palette, Msg::ClickAction(1)); + btn.style.size = Size { + width: length(110.0_f32), + height: length(28.0_f32), + }; + btn + }, + { + let mut btn = button_view("acción B", &btn_palette, Msg::ClickAction(2)); + btn.style.size = Size { + width: length(110.0_f32), + height: length(28.0_f32), + }; + btn + }, + theme_switcher_view::(&theme, Msg::ChangeTheme), + ], + &header_palette, + ); + + // --- Panel izquierdo: lista virtualizada --- + let entries = (0..40) + .map(|i| format!("entry {:02}", i)) + .collect::>(); + let visible_rows: Vec> = entries + .iter() + .enumerate() + .take(20) + .map(|(i, label)| ListRow { + label: label.clone(), + selected: i == model.list_sel, + on_click: Msg::SelectRow(i), + }) + .collect(); + let list = list_view(ListSpec { + rows: visible_rows, + total: entries.len(), + caption: Some(format!("{} entradas", entries.len())), + truncated_hint: Some(format!("… y {} más", entries.len() - 20)), + row_height: 22.0, + palette: list_palette, + }); + + // --- Panel derecho: tabs con stat cards + banners + input + tiled --- + let tiled_palette = TiledPalette::from_theme(&theme); + let tab_content = match model.tab { + 0 => stats_pane(&theme, &stat_palette), + 1 => alerts_pane(), + 2 => input_pane(&model.text, &input_palette, &theme), + _ => tiled_pane(&theme, &tiled_palette, &model.tile_order), + }; + let tabs = tabs_view(TabsSpec { + labels: vec!["Stats".into(), "Banners".into(), "Input".into(), "Tiled".into()], + active: model.tab, + on_select: Msg::SelectTab, + content: tab_content, + tab_height: 32.0, + palette: tabs_palette, + tab_width: Some(120.0), + }); + + let body = splitter_two( + Direction::Row, + list, + PaneSize::Fixed(model.left_w), + tabs, + PaneSize::Flex, + |phase, dx| match phase { + DragPhase::Move => Some(Msg::ResizeOuter(dx)), + DragPhase::End => None, + }, + &splitter_palette, + ); + + 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) + // Origen (0,0) ⇒ las coords locales del right-click son coords de + // ventana, listas para anclar el contextual. + .on_right_click_at(|x, y, _, _| Some(Msg::ContextMenuOpen(x, y))) + .children(vec![menubar, header, body]) + } + + fn view_overlay(model: &Model) -> Option> { + // Prioridad: contextual sobre el dropdown del menú principal. + if let Some((x, y)) = model.context_menu { + return Some(context_menu_for_gallery(model, x, y)); + } + let menu = app_menu(); + menubar_overlay(&menubar_spec(&menu, model)) + } +} + +// --------------------------------------------------------------------- +// Menú principal (barra) + contextual +// --------------------------------------------------------------------- + +/// Viewport para clampear overlays. La gallery no trackea el tamaño real +/// de ventana, así que usamos las constantes de `initial_size()`. +fn viewport_of(_model: &Model) -> (f32, f32) { + let (w, h) = Gallery::initial_size(); + (w as f32, h as f32) +} + +/// `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`. +fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> { + MenuBarSpec { + menu, + open: model.menu_open, + theme: &model.theme, + viewport: viewport_of(model), + height: MENU_H, + on_open: Arc::new(Msg::MenuOpen), + on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())), + } +} + +/// Menú principal de la vitrina. Archivo / Ver / Ayuda — sólo comandos que +/// mapean a `Msg` reales. No hay "Editar": el único campo de texto es el +/// `text_input` del tab Input, que ya recibe teclas por `on_key`. +fn app_menu() -> AppMenu { + AppMenu::new() + .menu( + Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")), + ) + .menu( + Menu::new("Ver") + .item(MenuItem::new("Cambiar tema", "view.theme")) + .item(MenuItem::new("Pestaña: Stats", "view.tab.0").separated()) + .item(MenuItem::new("Pestaña: Banners", "view.tab.1")) + .item(MenuItem::new("Pestaña: Input", "view.tab.2")) + .item(MenuItem::new("Pestaña: Tiled", "view.tab.3")), + ) + .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about"))) +} + +/// Traduce un command id (de la barra o del contextual) al `Msg` real y lo +/// aplica. `file.quit` cierra el proceso directo (no hay diálogo). +fn handle_menu_command(cmd: &str, m: &mut Model, handle: &Handle) { + match cmd { + "file.quit" => std::process::exit(0), + // Reusa el Msg de ciclo de tema en vez de duplicar la lógica. + "view.theme" => handle.dispatch(Msg::CycleTheme), + "view.tab.0" => m.tab = 0, + "view.tab.1" => m.tab = 1, + "view.tab.2" => m.tab = 2, + "view.tab.3" => m.tab = 3, + // "help.about" y desconocidos: no-op (sin diálogo todavía). + _ => {} + } +} + +/// Menú contextual de la vitrina. No hay objeto seleccionable en un canvas: +/// el "ítem seleccionado" es la entrada resaltada de la lista izquierda, así +/// que el contextual la nombra y ofrece navegar las pestañas + cambiar tema. +fn context_menu_for_gallery(model: &Model, x: f32, y: f32) -> View { + let header = format!("entrada seleccionada: {:02}", model.list_sel); + + let items = vec![ + ContextMenuItem::action("Cambiar tema"), + ContextMenuItem::separator(), + ContextMenuItem::action("Pestaña: Stats"), + ContextMenuItem::action("Pestaña: Banners"), + ContextMenuItem::action("Pestaña: Input"), + ContextMenuItem::action("Pestaña: Tiled"), + ]; + // Mapeo índice de item → command id de `handle_menu_command`. + let cmds: Vec<&'static str> = vec![ + "view.theme", + "", + "view.tab.0", + "view.tab.1", + "view.tab.2", + "view.tab.3", + ]; + let on_pick: Arc Msg + Send + Sync> = Arc::new(move |i: usize| { + Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string()) + }); + + context_menu_view(ContextMenuSpec { + anchor: (x, y), + viewport: viewport_of(model), + header: Some(header), + items, + active: usize::MAX, + on_pick, + on_dismiss: Msg::CloseMenus, + palette: ContextMenuPalette::from_theme(&model.theme), + }) +} + +// --------------------------------------------------------------------- +// Paneles de tabs +// --------------------------------------------------------------------- + +fn stats_pane(theme: &Theme, palette: &StatCardPalette) -> View { + let valid = Color::from_rgba8(94, 184, 124, 255); + let warn = Color::from_rgba8(238, 178, 53, 255); + let danger = Color::from_rgba8(225, 84, 75, 255); + + let row = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(160.0_f32), + }, + gap: Size { + width: length(12.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![ + wrap_card_cell(stat_card_view::( + "Coherencia", + "247", + "átomos válidos", + valid, + &[], + palette, + )), + wrap_card_cell(stat_card_view::( + "Por evaluar", + "12", + "esperando re-cómputo", + warn, + &[], + palette, + )), + wrap_card_cell(stat_card_view::( + "En conflicto", + "3", + "contradicen su origen", + danger, + &[ + "puerta_amanecer".into(), + "muelle_soledad".into(), + "viento_nuevo".into(), + ], + palette, + )), + ]); + + let _ = theme; + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.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() + }) + .children(vec![row]) +} + +fn wrap_card_cell(view: View) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![view]) +} + +fn alerts_pane() -> View { + 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(10.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() + }) + .children(vec![ + banner_view(BannerKind::Info, "info: gallery iniciada con widgets verdes"), + banner_view(BannerKind::Success, "success: 12 widgets cargados ok"), + banner_view(BannerKind::Warning, "warning: el tema light aún tiene contraste subóptimo"), + banner_view(BannerKind::Error, "error: ningún error real — sólo un demo"), + ]) +} + +fn input_pane(state: &TextInputState, palette: &TextInputPalette, theme: &Theme) -> View { + let label = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + ..Default::default() + }) + .text_aligned( + "Probá tipear acá:".to_string(), + 12.0, + theme.fg_muted, + Alignment::Start, + ); + let input = text_input_view( + state, + "lo que sea", + true, // siempre focado en este demo + palette, + Msg::ClickAction(0), + ); + + 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: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(20.0_f32), + bottom: length(20.0_f32), + }, + ..Default::default() + }) + .children(vec![label, input]) +} + +fn tiled_pane(theme: &Theme, palette: &TiledPalette, order: &[usize]) -> View { + let body = |text: &str, size: f32, color: Color, align: Alignment| -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + ..Default::default() + }) + .text_aligned(text.to_string(), size, color, align) + }; + + let make_tile = |id: usize| -> TileSpec { + match id { + 0 => TileSpec { + label: "logs".into(), + content: body( + "[12:01] boot\n[12:02] config ok\n[12:03] esperando…", + 12.0, + theme.fg_text, + Alignment::Start, + ), + }, + 1 => TileSpec { + label: "métricas".into(), + content: body("cpu 37%\nram 1.2 G\nnet 12 kB/s", 12.0, theme.fg_text, Alignment::Start), + }, + 2 => TileSpec { + label: "uptime".into(), + content: body("4d 12h", 26.0, theme.accent, Alignment::Center), + }, + _ => TileSpec { + label: "queue".into(), + content: body( + "pending 7\nin-flight 2\ndone 1842", + 12.0, + theme.fg_text, + Alignment::Start, + ), + }, + } + }; + + let tiles: Vec> = order.iter().map(|&id| make_tile(id)).collect(); + + tiled_view_reorderable( + tiles, + |from, to| Some(Msg::SwapTile { from, to }), + palette, + ) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/grid/Cargo.toml b/widgets/grid/Cargo.toml new file mode 100644 index 0000000..55619fb --- /dev/null +++ b/widgets/grid/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-grid" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-grid — grilla virtualizada 2D para Llimphi: celdas clicables en mosaico, selección, caption/hint opcionales, recorte de overflow. El caller hace la virtualización (calcula la ventana visible con `ventana_visible` y pasa sólo las celdas visibles); el widget las compone en filas. Base para galerías de miniaturas tipo gThumb/FastStone — agnóstico del contenido de la celda (el caller arma cada `View`: thumb, placeholder, lo que sea)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/grid/src/lib.rs b/widgets/grid/src/lib.rs new file mode 100644 index 0000000..72a61bc --- /dev/null +++ b/widgets/grid/src/lib.rs @@ -0,0 +1,467 @@ +//! `llimphi-widget-grid` — grilla virtualizada 2D para Llimphi. +//! +//! Hermano de [`llimphi-widget-list`], pero en mosaico: celdas clicables +//! dispuestas en `cols` columnas × filas, con selección, caption/hint +//! opcionales y recorte de overflow. Pensado como base para galerías de +//! miniaturas tipo gThumb / FastStone — capaz de listar miles de archivos +//! sin montar todo: el caller renderea **sólo la ventana visible**. +//! +//! Como `list`, el widget **no** scrollea por sí mismo. La virtualización +//! es del caller, que mantiene `scroll_fila` (primera fila de celdas +//! visible) en su estado y lo actualiza con la rueda (calco de +//! `nahual-file-explorer`). La diferencia con `list` es que en 2D el +//! cálculo de la ventana no es trivial — cuántas columnas caben depende +//! del ancho del viewport — así que este crate lo provee como función +//! pura testeable: [`ventana_visible`]. +//! +//! El widget es **agnóstico del contenido**: cada [`GridCell`] lleva un +//! `View` que el caller arma (un thumb `peniko::Image`, un skeleton +//! mientras decodifica, un ícono…). Así el pipeline de miniaturas (cola +//! async + cache) vive afuera y sólo llena la celda con imagen o +//! placeholder. +//! +//! Flujo típico del caller: +//! +//! ```ignore +//! let v = ventana_visible(total, viewport_w, viewport_h, scroll_fila, &metrics); +//! let cells: Vec> = (v.first..v.first + v.count) +//! .map(|i| GridCell { +//! content: thumb_o_placeholder(i), // el caller decide +//! label: Some(nombre(i)), +//! selected: i == seleccionado, +//! on_click: Msg::Seleccionar(i), +//! }) +//! .collect(); +//! let grid = grid_view(GridSpec { +//! cells, cols: v.cols, metrics, +//! caption: Some(format!("{total} imágenes")), +//! truncated_hint: (v.first + v.count < total) +//! .then(|| format!("… y {} más", total - (v.first + v.count))), +//! palette: GridPalette::default(), +//! }); +//! ``` + +#![forbid(unsafe_code)] + +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::View; + +/// Geometría de la grilla en pixels. `tile_h` debe incluir el alto del +/// label si el caller lo usa — el widget reserva una franja inferior para +/// él dentro de la celda. `gap` es el espacio entre celdas (y entre filas); +/// `pad` el margen interno del contenedor (cada lado). +#[derive(Debug, Clone, Copy)] +pub struct GridMetrics { + pub tile_w: f32, + pub tile_h: f32, + pub gap: f32, + pub pad: f32, +} + +impl Default for GridMetrics { + fn default() -> Self { + // Default ~thumb mediano estilo gThumb. + Self { + tile_w: 128.0, + tile_h: 148.0, // 128 imagen + ~20 label + gap: 8.0, + pad: 8.0, + } + } +} + +/// Resultado del cálculo de virtualización: qué celdas montar. `first` y +/// `count` delimitan el rango de índices `[first, first + count)` que el +/// caller debe renderear; `cols` cuántas columnas caben (para `grid_view`). +/// Los demás campos son informativos (scrollbars, "fila X de Y", clamping). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VisibleWindow { + /// Columnas que caben en el ancho del viewport (≥ 1). + pub cols: usize, + /// Índice del primer item a renderear. + pub first: usize, + /// Cantidad de items a renderear desde `first`. + pub count: usize, + /// Fila (0-based) del primer item visible — ya clampeada al rango. + pub first_row: usize, + /// Total de filas que ocupa la colección completa. + pub total_rows: usize, + /// Filas que entran en el alto del viewport (incluye 1 de margen). + pub filas_visibles: usize, +} + +/// Calcula la ventana visible de una grilla virtualizada. **Función pura.** +/// +/// - `total`: cantidad total de items. +/// - `viewport_w` / `viewport_h`: dimensiones del área de la grilla en px. +/// - `scroll_fila`: primera fila que el caller quiere ver arriba (se +/// clampa al rango válido; el caller no necesita pre-clampar). +/// - `m`: geometría (tile + gap + pad). +/// +/// El número de columnas se deriva del ancho: `cols = ⌊(ancho_útil + gap) +/// / (tile_w + gap)⌋`, mínimo 1. Las filas visibles incluyen una extra de +/// margen para que una fila parcial al borde no aparezca en blanco al +/// scrollear. +pub fn ventana_visible( + total: usize, + viewport_w: f32, + viewport_h: f32, + scroll_fila: usize, + m: &GridMetrics, +) -> VisibleWindow { + let paso_w = (m.tile_w + m.gap).max(1.0); + let paso_h = (m.tile_h + m.gap).max(1.0); + + let util_w = (viewport_w - 2.0 * m.pad + m.gap).max(0.0); + let cols = ((util_w / paso_w).floor() as usize).max(1); + + let total_rows = total.div_ceil(cols); + + let util_h = (viewport_h - 2.0 * m.pad + m.gap).max(0.0); + let filas_visibles = (util_h / paso_h).ceil() as usize + 1; + + let max_first_row = total_rows.saturating_sub(1); + let first_row = scroll_fila.min(max_first_row); + let first = first_row * cols; + let last_row = (first_row + filas_visibles).min(total_rows); + let count = (last_row * cols).min(total).saturating_sub(first); + + VisibleWindow { + cols, + first, + count, + first_row, + total_rows, + filas_visibles, + } +} + +/// Paleta de la grilla. Defaults dark con selección azulada (calco de +/// `ListPalette`). +#[derive(Debug, Clone, Copy)] +pub struct GridPalette { + pub bg_panel: Color, + pub bg_cell: Color, + pub bg_selected: Color, + pub fg_text: Color, + pub fg_muted: Color, +} + +impl Default for GridPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl GridPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_cell: t.bg_panel_alt, + bg_selected: t.bg_selected, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + } + } +} + +/// Una celda de la grilla. `content` es el `View` que el caller arma para +/// el cuerpo (thumb/placeholder/ícono); el widget lo centra y, debajo, +/// pinta `label` truncable si está. `on_click` se emite al clickear la +/// celda completa. +pub struct GridCell { + pub content: View, + pub label: Option, + pub selected: bool, + pub on_click: Msg, +} + +/// Especificación completa de la grilla a renderear. `cells` ya viene +/// recortada a la ventana visible (ver [`ventana_visible`]); `cols` es el +/// número de columnas de esa ventana. +pub struct GridSpec { + pub cells: Vec>, + pub cols: usize, + pub metrics: GridMetrics, + pub caption: Option, + pub truncated_hint: Option, + pub palette: GridPalette, +} + +/// Compone la grilla como un `View`. Agrupa `cells` en filas de +/// `cols` celdas y las apila. El contenedor recorta (`clip`) para que las +/// celdas no sangren a vecinos cuando el caller subestima el área. +pub fn grid_view(spec: GridSpec) -> View { + let GridSpec { + cells, + cols, + metrics, + caption, + truncated_hint, + palette, + } = spec; + let cols = cols.max(1); + + let mut children: Vec> = Vec::new(); + + if let Some(text) = caption { + children.push(barra_texto(text, 11.0, palette.fg_muted, 20.0)); + } + + // Agrupar en filas de `cols`. La última fila puede quedar incompleta. + let mut iter = cells.into_iter(); + loop { + let fila: Vec> = iter.by_ref().take(cols).collect(); + if fila.is_empty() { + break; + } + children.push(fila_view(fila, &metrics, &palette)); + } + + if let Some(text) = truncated_hint { + children.push(barra_texto(text, 10.0, palette.fg_muted, 16.0)); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(metrics.pad), + right: length(metrics.pad), + top: length(metrics.pad), + bottom: length(metrics.pad), + }, + gap: Size { + width: length(0.0_f32), + height: length(metrics.gap), + }, + ..Default::default() + }) + .fill(palette.bg_panel) + .clip(true) + .children(children) +} + +fn fila_view( + fila: Vec>, + m: &GridMetrics, + palette: &GridPalette, +) -> View { + let celdas: Vec> = fila + .into_iter() + .map(|c| celda_view(c, m, palette)) + .collect(); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(m.tile_h), + }, + gap: Size { + width: length(m.gap), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(celdas) +} + +fn celda_view( + cell: GridCell, + m: &GridMetrics, + palette: &GridPalette, +) -> View { + let bg = if cell.selected { + palette.bg_selected + } else { + palette.bg_cell + }; + + let mut hijos: Vec> = Vec::with_capacity(2); + // Cuerpo de la celda: el content del caller, centrado, ocupa el alto + // restante (tile_h menos la franja de label). + hijos.push( + View::new(Style { + flex_grow: 1.0, + size: Size { + width: percent(1.0_f32), + height: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .clip(true) + .children(vec![cell.content]), + ); + if let Some(label) = cell.label { + hijos.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + padding: Rect { + left: length(4.0_f32), + right: length(4.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .clip(true) + .text_aligned(label, 10.0, palette.fg_text, Alignment::Center), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: length(m.tile_w), + height: length(m.tile_h), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .clip(true) + .children(hijos) + .on_click(cell.on_click) +} + +fn barra_texto( + text: String, + size: f32, + color: Color, + height: f32, +) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(height), + }, + padding: Rect { + left: length(4.0_f32), + right: length(4.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(text, size, color, Alignment::Start) +} + +#[cfg(test)] +mod pruebas { + use super::*; + + fn metrics() -> GridMetrics { + GridMetrics { + tile_w: 80.0, + tile_h: 96.0, + gap: 8.0, + pad: 8.0, + } + } + + #[test] + fn cols_se_deriva_del_ancho() { + let m = metrics(); + // util_w = 400 - 16 + 8 = 392; paso = 88; 392/88 = 4.45 → 4. + let v = ventana_visible(100, 400.0, 300.0, 0, &m); + assert_eq!(v.cols, 4); + assert_eq!(v.total_rows, 25); + } + + #[test] + fn ancho_minimo_da_al_menos_una_columna() { + let m = metrics(); + let v = ventana_visible(10, 50.0, 300.0, 0, &m); + assert_eq!(v.cols, 1, "nunca menos de 1 columna"); + } + + #[test] + fn ventana_arriba_monta_filas_visibles_mas_margen() { + let m = metrics(); + // util_h = 300 - 16 + 8 = 292; paso_h = 104; ceil(292/104)=3; +1 = 4. + let v = ventana_visible(100, 400.0, 300.0, 0, &m); + assert_eq!(v.filas_visibles, 4); + assert_eq!(v.first, 0); + // 4 filas × 4 cols = 16 items. + assert_eq!(v.count, 16); + } + + #[test] + fn ventana_al_fondo_clampa_y_recorta_la_cola() { + let m = metrics(); + // 100 items, 4 cols → 25 filas. Pedir fila 22 (cerca del fondo). + let v = ventana_visible(100, 400.0, 300.0, 22, &m); + assert_eq!(v.first_row, 22); + assert_eq!(v.first, 88); + // last_row = min(22+4, 25) = 25 → count = min(100,100) - 88 = 12. + assert_eq!(v.count, 12); + } + + #[test] + fn scroll_mas_alla_del_fondo_se_clampa() { + let m = metrics(); + let v = ventana_visible(100, 400.0, 300.0, 999, &m); + // total_rows 25 → max_first_row 24. + assert_eq!(v.first_row, 24); + assert_eq!(v.first, 96); + // Sólo la última fila: 100 - 96 = 4 items. + assert_eq!(v.count, 4); + } + + #[test] + fn coleccion_vacia_no_monta_nada() { + let m = metrics(); + let v = ventana_visible(0, 400.0, 300.0, 0, &m); + assert!(v.cols >= 1); + assert_eq!(v.total_rows, 0); + assert_eq!(v.count, 0); + assert_eq!(v.first, 0); + } + + #[test] + fn ultima_fila_parcial_cuenta_completa_en_total_rows() { + let m = metrics(); + // 10 items, 4 cols → 3 filas (la última con 2). + let v = ventana_visible(10, 400.0, 1000.0, 0, &m); + assert_eq!(v.cols, 4); + assert_eq!(v.total_rows, 3); + // Viewport alto: entran todas. + assert_eq!(v.count, 10); + } + + #[test] + fn grid_view_agrupa_en_filas_sin_panicar() { + // Smoke: 7 celdas en 3 columnas → 3 filas (3+3+1). Sólo verifica + // que compone sin panicar y devuelve un View. + let cells: Vec> = (0..7) + .map(|i| GridCell { + content: View::new(Style::default()), + label: Some(format!("img{i}")), + selected: i == 2, + on_click: i, + }) + .collect(); + let _v = grid_view(GridSpec { + cells, + cols: 3, + metrics: metrics(), + caption: Some("7 imágenes".into()), + truncated_hint: None, + palette: GridPalette::default(), + }); + } +} diff --git a/widgets/list/Cargo.toml b/widgets/list/Cargo.toml new file mode 100644 index 0000000..4b8d546 --- /dev/null +++ b/widgets/list/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-list" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-list — lista vertical virtualizada para Llimphi: filas clicables, selección, caption opcional, recorte de overflow. El caller hace la virtualización (pasa sólo las filas visibles) y el widget las compone." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/list/LEEME.md b/widgets/list/LEEME.md new file mode 100644 index 0000000..053c036 --- /dev/null +++ b/widgets/list/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-list + +> Lista virtualizada para [llimphi](../../README.md). + +Renderiza sólo los items visibles. Selección single/multi, scroll programático, keyboard nav. diff --git a/widgets/list/README.md b/widgets/list/README.md new file mode 100644 index 0000000..1345bfb --- /dev/null +++ b/widgets/list/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-list + +> Virtualized list for [llimphi](../../README.md). + +Renders only visible items. Single/multi selection, programmatic scroll, keyboard nav. diff --git a/widgets/list/src/lib.rs b/widgets/list/src/lib.rs new file mode 100644 index 0000000..a5de315 --- /dev/null +++ b/widgets/list/src/lib.rs @@ -0,0 +1,201 @@ +//! `llimphi-widget-list` — lista vertical virtualizada. +//! +//! Compone una pila de filas con foco visual en la seleccionada y un Msg +//! por click. Pensado como bloque reusable para file explorers, árboles +//! lineales, paneles de log, listados de items, etc. +//! +//! El widget **no** maneja virtualización por sí mismo: el caller pasa +//! únicamente las filas que deberían renderearse (las visibles según su +//! propio `offset`/`scroll`). El widget se ocupa del resto: caption +//! opcional con el conteo, fondo de selección, hint "… y N más" cuando +//! `total > rows.len()`, y `clip` en el contenedor para que las filas no +//! sangren a vecinos. +//! +//! Ejemplo: +//! +//! ```ignore +//! let rows: Vec> = entries[offset..(offset + visible).min(entries.len())] +//! .iter() +//! .enumerate() +//! .map(|(i, e)| ListRow { +//! label: e.name.clone(), +//! selected: offset + i == selected, +//! on_click: Msg::Select(offset + i), +//! }) +//! .collect(); +//! +//! let panel = list_view(ListSpec { +//! rows, +//! total: entries.len(), +//! caption: Some(format!("{} entradas", entries.len())), +//! truncated_hint: (entries.len() > offset + rows.len()) +//! .then(|| format!("… y {} más", entries.len() - offset - rows.len())), +//! row_height: 22.0, +//! palette: ListPalette::default(), +//! }); +//! ``` + +#![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::View; + +/// Paleta de la lista. Los defaults son una variante dark con selección +/// azulada — equivalente conceptual a `nahual_theme` en su tema oscuro. +#[derive(Debug, Clone, Copy)] +pub struct ListPalette { + pub bg_panel: Color, + pub bg_selected: Color, + pub fg_text: Color, + pub fg_muted: Color, +} + +impl Default for ListPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl ListPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_selected: t.bg_selected, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + } + } +} + +/// Una fila a renderear. `selected` cambia el fondo; `on_click` se emite al +/// hacer click sobre cualquier parte de la fila. +pub struct ListRow { + pub label: String, + pub selected: bool, + pub on_click: Msg, +} + +/// Especificación completa de la lista a renderear. +pub struct ListSpec { + /// Filas a renderear, ya filtradas a la ventana visible. + pub rows: Vec>, + /// Total de items del modelo (usado para el caption — la lista + /// mostrada puede ser un subconjunto virtualizado). + pub total: usize, + /// Caption opcional arriba de las filas (p. ej. "120 entradas"). + pub caption: Option, + /// Mensaje opcional al pie ("… y 12 más") cuando hay items fuera de + /// la ventana visible. El caller decide qué texto usar. + pub truncated_hint: Option, + /// Altura de cada fila en pixels. + pub row_height: f32, + pub palette: ListPalette, +} + +/// Compone la lista como un `View`. El contenedor tiene `clip = true` +/// para evitar overflow visual cuando el llamador subestima el tamaño +/// disponible — las filas que excedan el área del panel se recortan. +pub fn list_view(spec: ListSpec) -> View { + let ListSpec { + rows, + total: _, + caption, + truncated_hint, + row_height, + palette, + } = spec; + + let mut children: Vec> = Vec::with_capacity(rows.len() + 2); + + if let Some(text) = caption { + children.push( + 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), + ..Default::default() + }) + .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start), + ); + } + + for row in rows { + children.push(row_view(row, row_height, &palette)); + } + + if let Some(text) = truncated_hint { + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_panel) + .clip(true) + .children(children) +} + +fn row_view(row: ListRow, height: f32, palette: &ListPalette) -> View { + let bg = if row.selected { + palette.bg_selected + } else { + palette.bg_panel + }; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(height), + }, + 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), + ..Default::default() + }) + .fill(bg) + .text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start) + .on_click(row.on_click) +} diff --git a/widgets/menubar/Cargo.toml b/widgets/menubar/Cargo.toml new file mode 100644 index 0000000..d71f673 --- /dev/null +++ b/widgets/menubar/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "llimphi-widget-menubar" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-menubar — barra de menú principal in-window (Archivo/Editar/Ver/Ayuda) que cualquier app Llimphi monta a partir de un app_bus::AppMenu. menubar_view() pinta la fila de títulos; menubar_overlay() el dropdown (vía context-menu) para App::view_overlay. Decoplado del Surface del launcher: sirve dentro de la ventana de cada app." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-button = { workspace = true } +llimphi-widget-context-menu = { workspace = true } +app-bus = { path = "../../../../shared/app-bus" } diff --git a/widgets/menubar/src/lib.rs b/widgets/menubar/src/lib.rs new file mode 100644 index 0000000..abd178d --- /dev/null +++ b/widgets/menubar/src/lib.rs @@ -0,0 +1,334 @@ +//! `llimphi-widget-menubar` — la barra de menú principal de una app. +//! +//! Toda app Llimphi declara un [`app_bus::AppMenu`] (Archivo / Editar / +//! Ver / Ayuda …) y lo monta in-window con este widget. Es el gemelo de +//! la barra global de [`launcher_llimphi`], pero vive **dentro** de la +//! ventana de la app — para las apps que corren standalone y no bajo el +//! shell del launcher. +//! +//! Sin estado, al estilo Llimphi: el `Model` del host lleva qué menú raíz +//! está abierto (`Option`); el widget aplana el `AppMenu` y emite +//! `Msg` en cada interacción. +//! +//! Dos entradas: +//! - [`menubar_view`] → la fila de títulos, para el tope de `App::view`. +//! - [`menubar_overlay`] → el dropdown del menú abierto, para +//! `App::view_overlay` (devolvé `None` si no hay nada abierto). +//! +//! El `command` de cada ítem es el id que la app entiende (convención +//! `menu.`, ver [`app_bus::AppMenu::standard`]); el widget lo +//! rebota por `on_command`. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use app_bus::{AppMenu, Menu}; +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_widget_button::{button_styled, ButtonPalette}; +use llimphi_widget_context_menu::{ + context_menu_view_ex, step_active, ContextMenuExtras, ContextMenuItem, ContextMenuPalette, + ContextMenuSpec, +}; + +type MsgFromMenu = Arc) -> Msg + Send + Sync>; +type MsgFromStr = Arc Msg + Send + Sync>; + +/// Todo lo que el render necesita. El host lo arma en cada `view()`. +pub struct MenuBarSpec<'a, Msg: Clone + 'static> { + /// El menú a pintar (típicamente `AppMenu::standard()` + menús propios). + pub menu: &'a AppMenu, + /// Índice del menú raíz abierto (estado del host). `None` = ninguno. + pub open: Option, + pub theme: &'a Theme, + /// Tamaño de la ventana — para clampear el dropdown. + pub viewport: (f32, f32), + /// Alto de la barra (px). Usar [`DEFAULT_HEIGHT`] si no hay razón. + pub height: f32, + /// Abrir/cerrar un menú raíz por índice (`None` = cerrar). + pub on_open: MsgFromMenu, + /// command id → Msg, al elegir un ítem. + pub on_command: MsgFromStr, +} + +/// Alto recomendado de la barra de menú. +pub const DEFAULT_HEIGHT: f32 = 30.0; + +fn title_palette(theme: &Theme) -> ButtonPalette { + ButtonPalette::from_theme(theme) +} + +fn title_palette_active(theme: &Theme) -> ButtonPalette { + let base = ButtonPalette::from_theme(theme); + ButtonPalette { + bg: theme.accent, + bg_hover: theme.accent, + fg: theme.bg_panel, + radius: base.radius, + } +} + +/// La fila de títulos (Archivo / Editar / …). Click sobre un título +/// togglea su dropdown vía `on_open`. El abierto se resalta con el accent. +/// `hover_switch = true` agrega `on_pointer_enter` a cada título para que, +/// con un menú ya abierto, pasar el mouse sobre otro título cambie de menú +/// (comportamiento clásico de barra de menú) — sólo se usa en el overlay, +/// donde los títulos quedan por encima del scrim y son hovereables. +fn titles_row(spec: &MenuBarSpec, hover_switch: bool) -> View { + let pal = title_palette(spec.theme); + let pal_on = title_palette_active(spec.theme); + + let mut titles: Vec> = Vec::with_capacity(spec.menu.menus.len()); + for (i, root) in spec.menu.menus.iter().enumerate() { + let open = spec.open == Some(i); + let target = if open { None } else { Some(i) }; + let mut title = button_styled( + root.label.clone(), + title_style(), + Alignment::Center, + if open { &pal_on } else { &pal }, + (spec.on_open)(target), + ); + // Con un menú abierto, hover sobre otro título lo abre. + if hover_switch && !open { + title = title.on_pointer_enter((spec.on_open)(Some(i))); + } + titles.push(title); + } + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(spec.height), + }, + flex_shrink: 0.0, + flex_direction: FlexDirection::Row, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(2.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(spec.theme.bg_panel_alt) + .children(titles) +} + +/// La barra de menú principal — primer hijo del column raíz de `view()`. +pub fn menubar_view(spec: &MenuBarSpec) -> View { + titles_row(spec, false) +} + +/// Aplana un menú raíz al par alineado `(items, commands)` que consume el +/// context-menu (los separadores `separator_before` se insertan como +/// filas y llevan `command = None`). Es la única fuente de verdad del +/// orden de filas — la navegación por teclado y el render comparten esto. +fn dropdown_items(root: &Menu) -> (Vec, Vec>) { + let mut items: Vec = Vec::new(); + let mut commands: Vec> = Vec::new(); + for (k, src) in root.items.iter().enumerate() { + if src.separator_before && k != 0 { + items.push(ContextMenuItem::separator()); + commands.push(None); + } + let mut cm = ContextMenuItem::action(src.label.clone()); + if let Some(s) = &src.shortcut { + cm = cm.with_shortcut(s.clone()); + } + if let Some(ic) = &src.icon { + cm = cm.icon(ic.clone()); + } + if !src.enabled { + cm = cm.disabled(); + } + items.push(cm); + commands.push(Some(src.command.clone())); + } + (items, commands) +} + +/// El dropdown del menú abierto, para `App::view_overlay`. `None` si no +/// hay menú abierto. Hospeda además una copia de la fila de títulos por +/// encima del scrim: así, con el menú abierto, mover el mouse a otro +/// título cambia de menú (hover-switch). +pub fn menubar_overlay(spec: &MenuBarSpec) -> Option> { + menubar_overlay_core(spec, usize::MAX, 1.0) +} + +/// Como [`menubar_overlay`] pero con `active` (fila resaltada por teclado; +/// `usize::MAX` = ninguna) y `appear` (0..1, animación de aparición — útil +/// para que el dropdown se deslice/funda al cambiar de menú por hover o +/// flechas). La app guarda el `active` y un `Tween` para el `appear`. +pub fn menubar_overlay_animated( + spec: &MenuBarSpec, + active: usize, + appear: f32, +) -> Option> { + menubar_overlay_core(spec, active, appear) +} + +fn menubar_overlay_core( + spec: &MenuBarSpec, + active: usize, + appear: f32, +) -> Option> { + let idx = spec.open?; + let root = spec.menu.menus.get(idx)?; + + let mut x = 6.0_f32; + for prev in spec.menu.menus.iter().take(idx) { + x += approx_title_width(&prev.label); + } + + let (items, commands) = dropdown_items(root); + + let on_command = spec.on_command.clone(); + let on_open = spec.on_open.clone(); + let commands = Arc::new(commands); + let on_pick: Arc Msg + Send + Sync> = Arc::new(move |i: usize| { + match commands.get(i).and_then(|c| c.clone()) { + Some(cmd) => (on_command)(&cmd), + None => (on_open)(None), + } + }); + + let dropdown = context_menu_view_ex( + ContextMenuSpec { + anchor: (x, spec.height), + viewport: spec.viewport, + header: Some(root.label.clone()), + items, + active, + on_pick, + on_dismiss: (spec.on_open)(None), + palette: ContextMenuPalette::from_theme(spec.theme), + }, + ContextMenuExtras { + appear, + ..ContextMenuExtras::default() + }, + ); + + // Fila de títulos por encima del scrim del dropdown: queda hovereable + // para cambiar de menú con el mouse. Absoluta al tope para no consumir + // el layout; se pinta después del dropdown ⇒ arriba en z-order ⇒ gana + // el hit-test. + let titles = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(0.0_f32), + right: auto(), + bottom: auto(), + }, + size: Size { + width: percent(1.0_f32), + height: length(spec.height), + }, + ..Default::default() + }) + .children(vec![titles_row(spec, true)]); + + Some( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![dropdown, titles]), + ) +} + +/// Navegación por teclado dentro del dropdown del menú `menu_idx`: dado el +/// `active` actual y la dirección (`+1` baja, `-1` sube), devuelve el +/// próximo índice de fila válido (saltea separadores y deshabilitados). +/// `usize::MAX` si no hay menú abierto o sin filas elegibles. +pub fn menubar_nav(menu: &AppMenu, menu_idx: usize, active: usize, dir: i32) -> usize { + let Some(root) = menu.menus.get(menu_idx) else { + return usize::MAX; + }; + let (items, _) = dropdown_items(root); + step_active(&items, active, dir) +} + +/// El `command` de la fila `active` del menú `menu_idx` (para ejecutar con +/// Enter). `None` si el índice no es una fila-acción. +pub fn menubar_command_at(menu: &AppMenu, menu_idx: usize, active: usize) -> Option { + let root = menu.menus.get(menu_idx)?; + let (_, commands) = dropdown_items(root); + commands.get(active).cloned().flatten() +} + +fn title_style() -> Style { + Style { + size: Size { + width: llimphi_ui::llimphi_layout::taffy::prelude::auto(), + height: length(24.0_f32), + }, + flex_shrink: 0.0, + 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), + justify_content: Some(JustifyContent::Center), + ..Default::default() + } +} + +/// Ancho aproximado de un título — mismo criterio que `launcher-llimphi` +/// para anclar el dropdown sin medir la fuente. +fn approx_title_width(label: &str) -> f32 { + label.chars().count() as f32 * 8.0 + 22.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_none_si_no_hay_abierto() { + let menu = AppMenu::standard(); + let spec = MenuBarSpec { + menu: &menu, + open: None, + theme: &Theme::dark(), + viewport: (800.0, 600.0), + height: DEFAULT_HEIGHT, + on_open: Arc::new(|_| 0u8), + on_command: Arc::new(|_| 1u8), + }; + assert!(menubar_overlay(&spec).is_none()); + } + + #[test] + fn overlay_some_si_hay_abierto() { + let menu = AppMenu::standard(); + let spec = MenuBarSpec { + menu: &menu, + open: Some(0), + theme: &Theme::dark(), + viewport: (800.0, 600.0), + height: DEFAULT_HEIGHT, + on_open: Arc::new(|_| 0u8), + on_command: Arc::new(|_| 1u8), + }; + assert!(menubar_overlay(&spec).is_some()); + } +} diff --git a/widgets/modal/Cargo.toml b/widgets/modal/Cargo.toml new file mode 100644 index 0000000..3251003 --- /dev/null +++ b/widgets/modal/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-modal" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-modal — diálogo genérico (título + body arbitrario + botones primary/cancel/destructive) con scrim y centrado. Para menús contextuales usar llimphi-widget-context-menu." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/modal/src/lib.rs b/widgets/modal/src/lib.rs new file mode 100644 index 0000000..79bc03d --- /dev/null +++ b/widgets/modal/src/lib.rs @@ -0,0 +1,319 @@ +//! `llimphi-widget-modal` — diálogo genérico centrado con scrim. +//! +//! Distinto del `context-menu` (chico, anclado a un click): el modal +//! ocupa una región central de tamaño configurable, presenta un título, +//! un cuerpo arbitrario (lo arma la app) y una barra de botones. +//! +//! Uso típico: +//! 1. La app guarda `Option` en su modelo. +//! 2. `view_overlay` devuelve `Some(modal_view(spec))` cuando hay +//! state, `None` cuando se cerró. +//! 3. La app captura `Esc` en `on_key` → cierra; `Enter` → primary. +//! +//! Tres severidades de botón: +//! - `Primary` — verde/accent, acción principal. +//! - `Cancel` — neutral, descarta. +//! - `Destructive` — rojo, acción irreversible (eliminar, etc). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_theme::{alpha, radius, Theme}; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta del modal. +#[derive(Debug, Clone, Copy)] +pub struct ModalPalette { + /// Color del scrim. El alpha se usa como **promedio** del vignette + /// radial: el centro (debajo del panel) queda ~25% más claro y las + /// esquinas ~40% más oscuras, manteniendo la densidad media igual a + /// lo que pidió el caller. Esto focaliza al modal sin "encerrarlo". + pub scrim: Color, + /// Firma visual del panel — gradient sutil + hairline accent en el + /// top edge. La que vuelve consistente el "look gioser" en todos + /// los modales y overlays. + pub panel: PanelStyle, + pub border: Color, + pub fg_title: Color, + pub fg_text: Color, + pub bg_btn: Color, + pub bg_btn_hover: Color, + pub fg_btn: Color, + pub bg_primary: Color, + pub fg_primary: Color, + pub bg_destructive: Color, + pub fg_destructive: Color, +} + +impl ModalPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM), + panel: PanelStyle::from_theme_large(t), + border: t.border, + fg_title: t.fg_text, + fg_text: t.fg_muted, + bg_btn: t.bg_button, + bg_btn_hover: t.bg_button_hover, + fg_btn: t.fg_text, + bg_primary: t.accent, + fg_primary: t.bg_app, + bg_destructive: t.fg_destructive, + fg_destructive: t.bg_app, + } + } +} + +/// Severidad de un botón del modal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonKind { + Primary, + Cancel, + Destructive, +} + +/// Spec de un botón. `label` se renderiza; `msg` se dispatcha al click. +#[derive(Clone)] +pub struct ModalButton { + pub label: String, + pub kind: ButtonKind, + pub msg: Msg, +} + +impl ModalButton { + pub fn primary(label: impl Into, msg: Msg) -> Self { + Self { label: label.into(), kind: ButtonKind::Primary, msg } + } + pub fn cancel(label: impl Into, msg: Msg) -> Self { + Self { label: label.into(), kind: ButtonKind::Cancel, msg } + } + pub fn destructive(label: impl Into, msg: Msg) -> Self { + Self { label: label.into(), kind: ButtonKind::Destructive, msg } + } +} + +/// Spec completo del modal. +pub struct ModalSpec { + pub title: String, + /// Cuerpo libre — la app construye un `View` con lo que quiera + /// mostrar (texto, form, lista). Se pinta entre título y botones. + pub body: View, + pub buttons: Vec>, + /// Tamaño del panel (clampea al viewport con margen). + pub size: (f32, f32), + pub viewport: (f32, f32), + /// Msg al hacer click en el scrim o presionar Esc (la app maneja + /// Esc en su `on_key`; este Msg es el del click). + pub on_dismiss: Msg, + pub palette: ModalPalette, +} + +const TITLE_H: f32 = 40.0; +const BUTTONS_H: f32 = 56.0; +const TITLE_FONT: f32 = 14.0; +const BTN_FONT: f32 = 12.5; +const PAD: f32 = 16.0; + +pub fn modal_view(spec: ModalSpec) -> View { + let ModalSpec { + title, + body, + buttons, + size, + viewport, + on_dismiss, + palette, + } = spec; + + let (w, h) = ( + size.0.min(viewport.0 - 32.0).max(200.0), + size.1.min(viewport.1 - 32.0).max(140.0), + ); + let x = ((viewport.0 - w) * 0.5).max(0.0); + let y = ((viewport.1 - h) * 0.5).max(0.0); + + // Header — título a la izquierda; al borde inferior, una línea + // separadora se logra con un nodo de 1px. + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(TITLE_H), + }, + padding: Rect { + left: length(PAD), + right: length(PAD), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(title, TITLE_FONT, palette.fg_title, Alignment::Start); + + let separator = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(1.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.border); + + // Body — flex_grow para ocupar todo el espacio sobrante. + let body_wrap = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: auto(), + }, + flex_grow: 1.0, + padding: Rect { + left: length(PAD), + right: length(PAD), + top: length(PAD), + bottom: length(PAD), + }, + ..Default::default() + }) + .children(vec![body]); + + // Botones — flex-row justify-end con gap. + let btn_children: Vec> = buttons + .into_iter() + .map(|b| button_view(b, &palette)) + .collect(); + let buttons_row = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(BUTTONS_H), + }, + flex_direction: FlexDirection::Row, + justify_content: Some(JustifyContent::FlexEnd), + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(PAD), + right: length(PAD), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(8.0_f32), + height: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(btn_children); + + let panel = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(w), + height: length(h), + }, + flex_direction: FlexDirection::Column, + ..Default::default() + }) + .paint_with(panel_signature_painter(palette.panel)) + .radius(palette.panel.radius) + .clip(true) + .children(vec![header, separator, body_wrap, buttons_row]); + + let scrim_base = palette.scrim; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Rect as KurboRect}; + use llimphi_ui::llimphi_raster::peniko::{color::AlphaColor, Fill, Gradient}; + + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + // Vignette: el centro toma alpha = base * 0.75 (más translúcido, + // deja ver lo que hay detrás del modal); las esquinas alpha = + // base * 1.4 (más sólido, oscurece los bordes). El promedio + // visual queda cerca de `base` original, así la densidad pedida + // por el caller se preserva. + let [r, g, b, base_a] = scrim_base.components; + let inner: Color = + AlphaColor::new([r, g, b, (base_a * 0.75).clamp(0.0, 1.0)]); + let outer: Color = + AlphaColor::new([r, g, b, (base_a * 1.4).clamp(0.0, 1.0)]); + + let cx = rect.x as f64 + rect.w as f64 * 0.5; + let cy = rect.y as f64 + rect.h as f64 * 0.5; + let diag_half = (((rect.w as f64).powi(2) + (rect.h as f64).powi(2)).sqrt() * 0.5) as f32; + let gradient = Gradient::new_radial(Point::new(cx, cy), diag_half) + .with_stops([inner, outer].as_slice()); + let full = KurboRect::new( + rect.x as f64, + rect.y as f64, + (rect.x + rect.w) as f64, + (rect.y + rect.h) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &full); + }) + .on_click(on_dismiss) + .children(vec![panel]) +} + +fn button_view(btn: ModalButton, palette: &ModalPalette) -> View { + let (bg, fg, hover) = match btn.kind { + ButtonKind::Primary => (palette.bg_primary, palette.fg_primary, brighten(palette.bg_primary, 0.15)), + ButtonKind::Cancel => (palette.bg_btn, palette.fg_btn, palette.bg_btn_hover), + ButtonKind::Destructive => (palette.bg_destructive, palette.fg_destructive, brighten(palette.bg_destructive, 0.15)), + }; + let label = btn.label.clone(); + View::new(Style { + size: Size { + width: length(label.chars().count() as f32 * 7.5 + 28.0), + height: length(32.0_f32), + }, + 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), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .hover_fill(hover) + .radius(radius::SM) + .text_aligned(label, BTN_FONT, fg, Alignment::Center) + .on_click(btn.msg) +} + +/// Aclara un color sumando `delta` a cada componente RGB. Útil para +/// derivar un hover state del color base sin tener que definirlo aparte. +fn brighten(c: Color, delta: f32) -> Color { + let [r, g, b, a] = c.components; + use llimphi_ui::llimphi_raster::peniko::color::AlphaColor; + AlphaColor::new([ + (r + delta).clamp(0.0, 1.0), + (g + delta).clamp(0.0, 1.0), + (b + delta).clamp(0.0, 1.0), + a, + ]) +} diff --git a/widgets/navigator/Cargo.toml b/widgets/navigator/Cargo.toml new file mode 100644 index 0000000..954ee0f --- /dev/null +++ b/widgets/navigator/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "llimphi-widget-navigator" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-navigator — navegador data-agnóstico de nodos (Mónada/Dir/Archivo) en dos modos conmutables: árbol y grafo; click selecciona, right-click abre." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-tree = { workspace = true } +llimphi-widget-nodegraph = { workspace = true } + +[dev-dependencies] +llimphi-widget-segmented = { workspace = true } diff --git a/widgets/navigator/examples/navigator_demo.rs b/widgets/navigator/examples/navigator_demo.rs new file mode 100644 index 0000000..a5fb121 --- /dev/null +++ b/widgets/navigator/examples/navigator_demo.rs @@ -0,0 +1,222 @@ +//! Showcase de `llimphi-widget-navigator`: un bosque de "Mónadas" con sus +//! archivos, conmutable entre **árbol** y **grafo** con un control +//! segmentado. Click selecciona; click en el chevron expande/colapsa; +//! right-click "abre" (acá sólo registra el id en el header). +//! +//! Corré con: +//! `cargo run -p llimphi-widget-navigator --example navigator_demo --release`. + +use std::collections::HashSet; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_theme::Theme; +use llimphi_widget_navigator::{ + navigator_view, NavId, NavKind, NavMode, NavNode, NavPalette, NavSpec, +}; +use llimphi_widget_segmented::{segmented_view, SegmentedPalette}; + +#[derive(Clone)] +enum Msg { + Toggle(NavId), + Select(NavId), + Open(NavId), + SetMode(usize), +} + +struct Model { + expanded: HashSet, + selected: Option, + mode: NavMode, + last_open: Option, +} + +struct Showcase; + +/// Bosque de demo: tres Mónadas (clusters de nouser), cada una con sus +/// archivos miembros. +fn forest() -> Vec { + vec![ + NavNode::branch( + 1, + "src · código rust", + NavKind::Monad, + vec![ + NavNode::leaf(11, "lib.rs", NavKind::File), + NavNode::leaf(12, "config.rs", NavKind::File), + NavNode::branch( + 13, + "widgets/", + NavKind::Dir, + vec![ + NavNode::leaf(131, "tree.rs", NavKind::File), + NavNode::leaf(132, "navigator.rs", NavKind::File), + ], + ), + ], + ), + NavNode::branch( + 2, + "docs · markdown", + NavKind::Monad, + vec![ + NavNode::leaf(21, "README.md", NavKind::File), + NavNode::leaf(22, "SDD.md", NavKind::File), + ], + ), + NavNode::branch( + 3, + "assets · imágenes", + NavKind::Monad, + vec![ + NavNode::leaf(31, "logo.png", NavKind::File), + NavNode::leaf(32, "icon.svg", NavKind::File), + ], + ), + ] +} + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · navigator showcase" + } + + fn initial_size() -> (u32, u32) { + (520, 680) + } + + fn init(_: &Handle) -> Model { + let mut expanded = HashSet::new(); + expanded.insert(1); + expanded.insert(13); + Model { + expanded, + selected: None, + mode: NavMode::Tree, + last_open: None, + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::Toggle(id) => { + if !m.expanded.remove(&id) { + m.expanded.insert(id); + } + } + Msg::Select(id) => m.selected = Some(id), + Msg::Open(id) => m.last_open = Some(id), + Msg::SetMode(i) => m.mode = NavMode::from_index(i), + } + m + } + + fn view(model: &Model) -> View { + let theme = Theme::dark(); + let palette = NavPalette::from_theme(&theme); + + // Toggle de modo. + let toggle = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(36.0_f32), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(6.0_f32), + bottom: length(2.0_f32), + }, + ..Default::default() + }) + .children(vec![segmented_view( + &NavMode::LABELS, + model.mode.index(), + Msg::SetMode, + &SegmentedPalette::from_theme(&theme), + )]); + + let roots = forest(); + let nav = navigator_view( + NavSpec { + roots: &roots, + mode: model.mode, + selected: model.selected, + palette, + guides: true, + }, + { + let expanded = model.expanded.clone(); + move |id| expanded.contains(&id) + }, + Msg::Toggle, + Msg::Select, + Some(Msg::Open), + ); + + let nav_pane = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![nav]); + + let status = format!( + "modo: {} · sel: {} · abrir (right-click): {}", + match model.mode { + NavMode::Tree => "árbol", + NavMode::Graph => "grafo", + }, + model + .selected + .map(|i| i.to_string()) + .unwrap_or_else(|| "—".into()), + model + .last_open + .map(|i| i.to_string()) + .unwrap_or_else(|| "—".into()), + ); + let footer = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(26.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .text_aligned(status, 12.0, theme.fg_muted, Alignment::Start); + + 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![toggle, nav_pane, footer]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/navigator/src/lib.rs b/widgets/navigator/src/lib.rs new file mode 100644 index 0000000..bec3718 --- /dev/null +++ b/widgets/navigator/src/lib.rs @@ -0,0 +1,626 @@ +//! `llimphi-widget-navigator` — navegador **data-agnóstico** de nodos en +//! dos modos conmutables: **árbol** (`tree`) y **grafo** (`nodegraph`). +//! +//! Nació para que `pata` muestre las **Mónadas** de nouser y sus archivos +//! en un sidebar, pero el widget no sabe de nouser: recibe un bosque de +//! [`NavNode`]s (id opaco + label + [`NavKind`] + hijos) y emite `Msg`s al +//! interactuar. El caller mapea cada `id` a lo suyo (un `MonadId`, un path) +//! y decide qué hacer al seleccionar/abrir. +//! +//! Igual que el resto de widgets Llimphi, es **render-only y stateless**: +//! el estado (qué nodos están expandidos, cuál está seleccionado, en qué +//! modo está) vive en el `Model` del App; el widget sólo pinta y avisa. +//! +//! - **Árbol** — reusa [`llimphi_widget_tree`]. El navegador aplana el +//! bosque respetando `is_expanded`, dibuja un icono por [`NavKind`] entre +//! el chevron y el label, y cablea toggle / select / context por fila. +//! - **Grafo** — reusa [`llimphi_widget_nodegraph`]. Coloca los nodos +//! visibles en columnas por profundidad, con cables de **contención** +//! (padre→hijo). El nodo seleccionado se resalta; arrastrar un nodo lo +//! selecciona; el right-click abre el menú contextual. +//! +//! ```ignore +//! navigator_view( +//! NavSpec { roots: &model.nodes, mode: model.mode, +//! selected: model.selected, palette, guides: true }, +//! |id| model.expanded.contains(&id), +//! Msg::Toggle, Msg::Select, Some(Msg::Open), +//! ) +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; +use llimphi_theme::Theme; +use llimphi_widget_nodegraph::{ + nodegraph_view_styled, NodeId, NodeSpec, NodeTint, NodegraphMetrics, NodegraphPalette, Wire, +}; +use llimphi_widget_tree::{tree_view, TreePalette, TreeRow, TreeSpec}; + +/// Identificador opaco de un nodo. El caller lo asigna y lo recibe de vuelta +/// sin que el widget lo interprete (típicamente un índice a su propio mapa +/// `id → MonadId | PathBuf`). +pub type NavId = u64; + +/// Naturaleza de un nodo — sólo para elegir su icono y tinte. El widget no +/// asume semántica de dominio más allá de esto. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavKind { + /// Una Mónada (cluster semántico de nouser). Diamante de acento. + Monad, + /// Una agrupación intermedia (carpeta lógica, categoría). Cuadrado. + Group, + /// Un directorio del filesystem. Cuadrado tenue. + Dir, + /// Un archivo hoja. Punto. + File, + /// Cualquier otra cosa. Punto tenue. + Other, +} + +/// Un nodo del bosque que el navegador pinta. La jerarquía es explícita +/// (`children`); el navegador la aplana según el estado de expansión. +#[derive(Debug, Clone)] +pub struct NavNode { + pub id: NavId, + pub label: String, + pub kind: NavKind, + pub children: Vec, +} + +impl NavNode { + /// Un nodo hoja (sin hijos). + pub fn leaf(id: NavId, label: impl Into, kind: NavKind) -> Self { + Self { + id, + label: label.into(), + kind, + children: Vec::new(), + } + } + + /// Un nodo con hijos. + pub fn branch( + id: NavId, + label: impl Into, + kind: NavKind, + children: Vec, + ) -> Self { + Self { + id, + label: label.into(), + kind, + children, + } + } + + /// `true` si tiene al menos un hijo. + pub fn has_children(&self) -> bool { + !self.children.is_empty() + } +} + +/// Modo de visualización del navegador. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavMode { + /// Árbol indentado con expand/collapse. + Tree, + /// Grafo de nodos con cables de contención. + Graph, +} + +impl NavMode { + /// Etiquetas para un control segmentado (en el mismo orden que + /// [`NavMode::index`] / [`NavMode::from_index`]). + pub const LABELS: [&'static str; 2] = ["Árbol", "Grafo"]; + + /// El otro modo (para un botón de toggle simple). + pub fn toggled(self) -> Self { + match self { + NavMode::Tree => NavMode::Graph, + NavMode::Graph => NavMode::Tree, + } + } + + /// Índice 0/1 — para alimentar un control segmentado. + pub fn index(self) -> usize { + match self { + NavMode::Tree => 0, + NavMode::Graph => 1, + } + } + + /// Recupera el modo desde un índice de control segmentado (≥1 = grafo). + pub fn from_index(i: usize) -> Self { + if i == 0 { + NavMode::Tree + } else { + NavMode::Graph + } + } +} + +/// Paleta del navegador: hereda las de tree y nodegraph del [`Theme`] +/// semántico, más los tintes por [`NavKind`] para los iconos. +#[derive(Debug, Clone, Copy)] +pub struct NavPalette { + pub tree: TreePalette, + pub graph: NodegraphPalette, + pub accent: Color, + pub monad: Color, + pub group: Color, + pub dir: Color, + pub file: Color, + pub other: Color, +} + +impl Default for NavPalette { + fn default() -> Self { + Self::from_theme(&Theme::dark()) + } +} + +impl NavPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + tree: TreePalette::from_theme(t), + graph: NodegraphPalette::from_theme(t), + accent: t.accent, + monad: t.accent, + group: t.fg_text, + dir: t.fg_muted, + file: t.fg_text, + other: t.fg_muted, + } + } + + /// El color del icono de un nodo según su clase. + pub fn kind_color(&self, kind: NavKind) -> Color { + match kind { + NavKind::Monad => self.monad, + NavKind::Group => self.group, + NavKind::Dir => self.dir, + NavKind::File => self.file, + NavKind::Other => self.other, + } + } +} + +/// Lo que el navegador necesita saber para pintar, sin los callbacks. +pub struct NavSpec<'a> { + /// Las raíces del bosque a mostrar. + pub roots: &'a [NavNode], + /// Modo activo. + pub mode: NavMode, + /// Nodo seleccionado (resaltado en ambos modos). `None` = ninguno. + pub selected: Option, + /// Paleta. + pub palette: NavPalette, + /// Dibujar líneas guía de indentación en el árbol. + pub guides: bool, +} + +/// Alto de fila del árbol / paso vertical del grafo. +const ROW_H: f32 = 24.0; +/// Tamaño del icono de clase (px). +const ICON_PX: f32 = 14.0; + +/// Compone el navegador. Los callbacks se identifican por [`NavId`]: +/// - `is_expanded(id)` → si un nodo rama está abierto (sólo árbol); +/// - `on_toggle(id)` → al click en el chevron (sólo árbol); +/// - `on_select(id)` → al click en la fila (árbol) o al arrastrar el nodo +/// (grafo); +/// - `on_context(id)` → al right-click (ambos modos); `None` = sin menú. +pub fn navigator_view( + spec: NavSpec, + is_expanded: FExp, + on_toggle: FTog, + on_select: FSel, + on_context: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FExp: Fn(NavId) -> bool, + FTog: Fn(NavId) -> Msg, + FSel: Fn(NavId) -> Msg + Send + Sync + 'static, + FCtx: Fn(NavId) -> Msg, +{ + match spec.mode { + NavMode::Tree => tree_mode(spec, is_expanded, on_toggle, on_select, on_context), + NavMode::Graph => graph_mode(spec, is_expanded, on_select, on_context), + } +} + +// ===================================================================== +// Árbol +// ===================================================================== + +fn tree_mode( + spec: NavSpec, + is_expanded: FExp, + on_toggle: FTog, + on_select: FSel, + on_context: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FExp: Fn(NavId) -> bool, + FTog: Fn(NavId) -> Msg, + FSel: Fn(NavId) -> Msg, + FCtx: Fn(NavId) -> Msg, +{ + let mut rows: Vec> = Vec::new(); + for root in spec.roots { + push_rows( + root, + 0, + &spec, + &is_expanded, + &on_toggle, + &on_select, + &on_context, + &mut rows, + ); + } + tree_view(TreeSpec { + rows, + row_height: ROW_H, + indent_px: 14.0, + palette: spec.palette.tree, + guides: spec.guides, + }) +} + +#[allow(clippy::too_many_arguments)] +fn push_rows( + node: &NavNode, + depth: usize, + spec: &NavSpec, + is_expanded: &FExp, + on_toggle: &FTog, + on_select: &FSel, + on_context: &Option, + out: &mut Vec>, +) where + Msg: Clone + Send + Sync + 'static, + FExp: Fn(NavId) -> bool, + FTog: Fn(NavId) -> Msg, + FSel: Fn(NavId) -> Msg, + FCtx: Fn(NavId) -> Msg, +{ + let has_children = node.has_children(); + let expanded = has_children && is_expanded(node.id); + let icon = kind_icon_view::(node.kind, spec.palette.kind_color(node.kind)); + let mut row = TreeRow::new( + node.label.clone(), + depth, + has_children, + expanded, + spec.selected == Some(node.id), + on_toggle(node.id), + on_select(node.id), + ) + .with_icon(icon); + if let Some(ctx) = on_context.as_ref().map(|f| f(node.id)) { + row = row.with_context(ctx); + } + out.push(row); + + if expanded { + for child in &node.children { + push_rows( + child, depth + 1, spec, is_expanded, on_toggle, on_select, on_context, out, + ); + } + } +} + +// ===================================================================== +// Grafo +// ===================================================================== + +/// Un nodo visible aplanado para el grafo: su id, su label/kind y la posición +/// (índice) de su padre en la lista (`None` = raíz). +struct FlatNode { + id: NavId, + label: String, + kind: NavKind, + depth: usize, + parent: Option, + has_children: bool, +} + +fn flatten_for_graph bool>( + roots: &[NavNode], + is_expanded: &FExp, +) -> Vec { + let mut out = Vec::new(); + for root in roots { + walk_graph(root, 0, None, is_expanded, &mut out); + } + out +} + +fn walk_graph bool>( + node: &NavNode, + depth: usize, + parent: Option, + is_expanded: &FExp, + out: &mut Vec, +) { + let has_children = node.has_children(); + let me = out.len(); + out.push(FlatNode { + id: node.id, + label: node.label.clone(), + kind: node.kind, + depth, + parent, + has_children, + }); + if has_children && is_expanded(node.id) { + for child in &node.children { + walk_graph(child, depth + 1, Some(me), is_expanded, out); + } + } +} + +fn graph_mode( + spec: NavSpec, + is_expanded: FExp, + on_select: FSel, + on_context: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FExp: Fn(NavId) -> bool, + FSel: Fn(NavId) -> Msg + Send + Sync + 'static, + FCtx: Fn(NavId) -> Msg, +{ + let flat = flatten_for_graph(spec.roots, &is_expanded); + let metrics = NodegraphMetrics { + node_width: 150.0, + ..NodegraphMetrics::default() + }; + + // Layout: columna por profundidad, una fila por nodo visible. + const MARGIN: f32 = 24.0; + const COL_GAP: f32 = 36.0; + const ROW_GAP: f32 = 12.0; + let node_h = metrics.node_height(1, 1); + let col_w = metrics.node_width + COL_GAP; + + let mut nodes: Vec = Vec::with_capacity(flat.len()); + let mut wires: Vec = Vec::new(); + let ids: Vec = flat.iter().map(|f| f.id).collect(); + + for (i, f) in flat.iter().enumerate() { + let inputs = if f.parent.is_some() { + vec![String::new()] + } else { + Vec::new() + }; + let outputs = if f.has_children { + vec![String::new()] + } else { + Vec::new() + }; + // Prefijo del icono en el label (el nodegraph no tiene slot de icono; + // un glifo simple por clase basta para distinguirlos de un vistazo). + let label = format!("{} {}", kind_glyph(f.kind), f.label); + nodes.push(NodeSpec { + id: i as NodeId, + label, + x: MARGIN + f.depth as f32 * col_w, + y: MARGIN + i as f32 * (node_h + ROW_GAP), + inputs, + outputs, + }); + if let Some(p) = f.parent { + wires.push(Wire { + from_node: p as NodeId, + from_output: 0, + to_node: i as NodeId, + to_input: 0, + }); + } + } + + // Arrastrar un nodo lo selecciona (al soltar). El grafo no reposiciona + // por arrastre — el layout es derivado, no editable. + let drag_ids = ids.clone(); + let on_drag = move |id: NodeId, phase: DragPhase, _dx: f32, _dy: f32| match phase { + DragPhase::End => drag_ids + .get(id as usize) + .map(|nav_id| on_select(*nav_id)), + DragPhase::Move => None, + }; + // Sin conexiones: la contención es fija. + let on_connect = |_: NodeId, _: u16, _: NodeId, _: u16| None; + + // Right-click → menú contextual (evaluado en build, por nodo). + let ctx_ids = &ids; + let on_right: Option Option>> = on_context.map(|f| { + let f = move |id: NodeId| ctx_ids.get(id as usize).map(|nav_id| f(*nav_id)); + Box::new(f) as Box Option> + }); + + // Resaltado del nodo seleccionado. + let sel_idx = spec + .selected + .and_then(|sid| ids.iter().position(|id| *id == sid)); + let accent = spec.palette.accent; + let tint = move |id: NodeId| -> Option { + if Some(id as usize) == sel_idx { + Some(NodeTint { + bg_title: Some(accent), + ..NodeTint::default() + }) + } else { + None + } + }; + + nodegraph_view_styled( + &nodes, + &wires, + &spec.palette.graph, + &metrics, + on_drag, + on_connect, + on_right, + Some(&tint as &dyn Fn(NodeId) -> Option), + None, + ) +} + +/// Glifo ASCII-ish por clase para el label del grafo. +fn kind_glyph(kind: NavKind) -> &'static str { + match kind { + NavKind::Monad => "◈", + NavKind::Group => "▣", + NavKind::Dir => "▸", + NavKind::File => "·", + NavKind::Other => "·", + } +} + +// ===================================================================== +// Icono vectorial por clase (para el árbol) +// ===================================================================== + +/// Un mini-canvas con el icono de la clase, tinte `color`. Diamante para +/// Mónada, cuadrado para grupo/dir, círculo para archivo. +fn kind_icon_view(kind: NavKind, color: Color) -> View { + View::new(Style { + size: Size { + width: llimphi_ui::llimphi_layout::taffy::prelude::length(ICON_PX), + height: llimphi_ui::llimphi_layout::taffy::prelude::length(ICON_PX), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let r = (rect.w.min(rect.h) as f64 * 0.34).max(1.5); + match kind { + NavKind::Monad => { + // Diamante (cuadrado a 45°). + let mut p = BezPath::new(); + p.move_to(Point::new(cx, cy - r)); + p.line_to(Point::new(cx + r, cy)); + p.line_to(Point::new(cx, cy + r)); + p.line_to(Point::new(cx - r, cy)); + p.close_path(); + scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &p); + } + NavKind::Group | NavKind::Dir => { + let sq = RoundedRect::new(cx - r, cy - r, cx + r, cy + r, 2.0); + scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &sq); + } + NavKind::File | NavKind::Other => { + let dot = (rect.w.min(rect.h) as f64 * 0.22).max(1.0); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + color, + None, + &Circle::new((cx, cy), dot), + ); + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Debug, PartialEq)] + enum Msg { + Toggle(NavId), + Select(NavId), + Open(NavId), + } + + fn forest() -> Vec { + vec![NavNode::branch( + 1, + "Mónada src", + NavKind::Monad, + vec![ + NavNode::leaf(11, "lib.rs", NavKind::File), + NavNode::leaf(12, "main.rs", NavKind::File), + ], + )] + } + + #[test] + fn navmode_toggle_e_indices() { + assert_eq!(NavMode::Tree.toggled(), NavMode::Graph); + assert_eq!(NavMode::Graph.toggled(), NavMode::Tree); + assert_eq!(NavMode::Tree.index(), 0); + assert_eq!(NavMode::from_index(1), NavMode::Graph); + assert_eq!(NavMode::from_index(0), NavMode::Tree); + } + + #[test] + fn navnode_constructores() { + let n = NavNode::leaf(1, "x", NavKind::File); + assert!(!n.has_children()); + let b = NavNode::branch(2, "y", NavKind::Monad, vec![n]); + assert!(b.has_children()); + assert_eq!(b.children.len(), 1); + } + + #[test] + fn flatten_grafo_respeta_expansion() { + let roots = forest(); + // Colapsado: sólo la raíz. + let collapsed = flatten_for_graph(&roots, &|_| false); + assert_eq!(collapsed.len(), 1); + assert_eq!(collapsed[0].id, 1); + assert!(collapsed[0].parent.is_none()); + assert!(collapsed[0].has_children); + // Expandido: raíz + 2 hijos, con parent = índice 0. + let expanded = flatten_for_graph(&roots, &|id| id == 1); + assert_eq!(expanded.len(), 3); + assert_eq!(expanded[1].parent, Some(0)); + assert_eq!(expanded[2].parent, Some(0)); + assert_eq!(expanded[1].depth, 1); + } + + #[test] + fn navigator_view_construye_en_ambos_modos() { + // No paniquea construyendo el View en cada modo (smoke). + let roots = forest(); + let palette = NavPalette::default(); + for mode in [NavMode::Tree, NavMode::Graph] { + let _v: View = navigator_view( + NavSpec { + roots: &roots, + mode, + selected: Some(1), + palette, + guides: true, + }, + |id| id == 1, + Msg::Toggle, + Msg::Select, + Some(Msg::Open), + ); + } + } +} diff --git a/widgets/nodegraph/Cargo.toml b/widgets/nodegraph/Cargo.toml new file mode 100644 index 0000000..edc5d0e --- /dev/null +++ b/widgets/nodegraph/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-widget-nodegraph" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-nodegraph — lienzo de nodos con pins y cables Bezier. Reusable por pluma (DAG), nakui (fórmulas yupay), tullpu (ajustes no destructivos), dominium (sistemas), takiy (cadena de audio)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[[example]] +name = "nodegraph_demo" +path = "examples/nodegraph_demo.rs" diff --git a/widgets/nodegraph/LEEME.md b/widgets/nodegraph/LEEME.md new file mode 100644 index 0000000..d65279c --- /dev/null +++ b/widgets/nodegraph/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-nodegraph + +> Lienzo de nodos + cables Bezier para [llimphi](../../README.md). + +Cada nodo es `View` libre con puertos in/out. Aristas curvas Bezier. Drag de nodos, pan/zoom del canvas, conectar puertos. Usado por `pluma-notebook-graph-llimphi`, `nakui-explorer-llimphi`, `iniy-explorer-llimphi`. diff --git a/widgets/nodegraph/README.md b/widgets/nodegraph/README.md new file mode 100644 index 0000000..f374697 --- /dev/null +++ b/widgets/nodegraph/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-nodegraph + +> Node canvas + Bezier wires for [llimphi](../../README.md). + +Each node is a free `View` with in/out ports. Bezier curve edges. Node drag, canvas pan/zoom, port connect. Used by `pluma-notebook-graph-llimphi`, `nakui-explorer-llimphi`, `iniy-explorer-llimphi`. diff --git a/widgets/nodegraph/examples/nodegraph_demo.rs b/widgets/nodegraph/examples/nodegraph_demo.rs new file mode 100644 index 0000000..a78117a --- /dev/null +++ b/widgets/nodegraph/examples/nodegraph_demo.rs @@ -0,0 +1,197 @@ +//! Showcase de `llimphi-widget-nodegraph`. Cuatro nodos pre-conectados +//! representando una cadena de audio (`Source → Filter → Mixer → +//! Output`) y un `LFO` huérfano para que el usuario lo conecte +//! arrastrando desde su pin de salida hasta el `mod` del filtro. +//! +//! - Arrastrá la title bar de cualquier nodo para moverlo. +//! - Arrastrá desde un pin de salida (lado derecho) y soltá sobre un +//! pin de entrada (lado izquierdo) de otro nodo para conectar. +//! +//! Corré con: `cargo run -p llimphi-widget-nodegraph --example +//! nodegraph_demo --release`. + +use llimphi_theme::Theme; +use llimphi_ui::{App, DragPhase, Handle, View}; +use llimphi_widget_nodegraph::{ + nodegraph_view, NodeId, NodeSpec, NodegraphMetrics, NodegraphPalette, PinIdx, Wire, +}; + +#[derive(Clone)] +enum Msg { + DragNode { + id: NodeId, + // El demo no diferencia Move/End; lo dejamos en el Msg por si + // un caller real quiere persistir layout solo en End. + #[allow(dead_code)] + phase: DragPhase, + dx: f32, + dy: f32, + }, + Connect { + from_node: NodeId, + from_pin: PinIdx, + to_node: NodeId, + to_pin: PinIdx, + }, +} + +struct Model { + nodes: Vec, + wires: Vec, +} + +const ID_SOURCE: NodeId = 1; +const ID_FILTER: NodeId = 2; +const ID_MIXER: NodeId = 3; +const ID_OUTPUT: NodeId = 4; +const ID_LFO: NodeId = 5; + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · nodegraph showcase (drag títulos, arrastrá pin → pin)" + } + + fn initial_size() -> (u32, u32) { + (1100, 720) + } + + fn init(_: &Handle) -> Model { + Model { + nodes: vec![ + NodeSpec { + id: ID_SOURCE, + label: "Source".into(), + x: 60.0, + y: 80.0, + inputs: vec![], + outputs: vec!["out".into()], + }, + NodeSpec { + id: ID_FILTER, + label: "Filter".into(), + x: 290.0, + y: 80.0, + inputs: vec!["in".into(), "mod".into()], + outputs: vec!["out".into()], + }, + NodeSpec { + id: ID_MIXER, + label: "Mixer".into(), + x: 520.0, + y: 80.0, + inputs: vec!["a".into(), "b".into()], + outputs: vec!["out".into()], + }, + NodeSpec { + id: ID_OUTPUT, + label: "Output".into(), + x: 750.0, + y: 80.0, + inputs: vec!["in".into()], + outputs: vec![], + }, + NodeSpec { + id: ID_LFO, + label: "LFO".into(), + x: 290.0, + y: 260.0, + inputs: vec![], + outputs: vec!["out".into()], + }, + ], + wires: vec![ + Wire { + from_node: ID_SOURCE, + from_output: 0, + to_node: ID_FILTER, + to_input: 0, + }, + Wire { + from_node: ID_FILTER, + from_output: 0, + to_node: ID_MIXER, + to_input: 0, + }, + Wire { + from_node: ID_MIXER, + from_output: 0, + to_node: ID_OUTPUT, + to_input: 0, + }, + ], + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::DragNode { id, phase: _, dx, dy } => { + if let Some(n) = m.nodes.iter_mut().find(|n| n.id == id) { + n.x += dx; + n.y += dy; + if n.x < 0.0 { + n.x = 0.0; + } + if n.y < 0.0 { + n.y = 0.0; + } + } + } + Msg::Connect { + from_node, + from_pin, + to_node, + to_pin, + } => { + if from_node == to_node { + return m; + } + let exists = m.wires.iter().any(|w| { + w.from_node == from_node + && w.from_output == from_pin + && w.to_node == to_node + && w.to_input == to_pin + }); + if !exists { + m.wires.push(Wire { + from_node, + from_output: from_pin, + to_node, + to_input: to_pin, + }); + } + } + } + m + } + + fn view(model: &Model) -> View { + let theme = Theme::dark(); + let palette = NodegraphPalette::from_theme(&theme); + let metrics = NodegraphMetrics::default(); + nodegraph_view( + &model.nodes, + &model.wires, + &palette, + &metrics, + |id, phase, dx, dy| Some(Msg::DragNode { id, phase, dx, dy }), + |from_node, from_pin, to_node, to_pin| { + Some(Msg::Connect { + from_node, + from_pin, + to_node, + to_pin, + }) + }, + ) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/nodegraph/src/lib.rs b/widgets/nodegraph/src/lib.rs new file mode 100644 index 0000000..7af7238 --- /dev/null +++ b/widgets/nodegraph/src/lib.rs @@ -0,0 +1,718 @@ +//! `llimphi-widget-nodegraph` — lienzo de nodos con pins y cables +//! Bezier sobre Llimphi. +//! +//! Modelo declarativo de un grafo dirigido: cada frame, el caller pasa +//! la lista actual de [`NodeSpec`]s + [`Wire`]s y el widget pinta: +//! +//! - el lienzo (fondo lleno); +//! - cada nodo como un rect con título arriba y pins a los lados +//! (entradas a la izquierda, salidas a la derecha); +//! - los cables entre `(node_a, output_pin_a)` y `(node_b, input_pin_b)` +//! como Bezier cúbicas con tangentes horizontales (mismo look que +//! `pluma-editor-llimphi::multilienzo_editor::carril_editor`). +//! +//! El widget no mantiene estado: el caller acumula posición de nodos + +//! cables en su `Model` y le pasa handlers para los dos eventos +//! interactivos: +//! +//! - **mover un nodo** — `on_drag_node(node_id, phase, dx, dy)` se +//! invoca al arrastrar la title bar de un nodo. El handler suma el +//! delta a la posición persistida. +//! - **conectar dos pins** — al arrastrar desde un pin de salida y +//! soltar sobre un pin de entrada, `on_connect(from_node, from_out, +//! to_node, to_in)` se invoca para que el caller materialice el +//! `Wire` en su modelo. +//! +//! Reusable por: pluma (visualizador DAG), nakui (fórmulas yupay), +//! tullpu (ajustes no destructivos), dominium (sistemas), takiy +//! (cadena de audio), pluma-notebook (kernel-DAG visual). + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, Position, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{DragPhase, View}; + +/// Identificador opaco de un nodo. El caller asigna estos valores; el +/// widget los pasa de vuelta sin interpretarlos. +pub type NodeId = u32; +/// Índice del pin dentro de la lista `inputs` o `outputs` del nodo. +pub type PinIdx = u16; + +/// Especificación de un nodo del grafo. El caller construye uno por +/// nodo en cada `view`. Las posiciones son en pixels relativas al rect +/// del lienzo. +#[derive(Debug, Clone)] +pub struct NodeSpec { + pub id: NodeId, + pub label: String, + /// Esquina superior-izquierda del nodo, en coordenadas del lienzo. + pub x: f32, + pub y: f32, + /// Labels de los pins de entrada. Cantidad = altura mínima del nodo. + pub inputs: Vec, + /// Labels de los pins de salida. + pub outputs: Vec, +} + +/// Cable entre el pin de salida de un nodo y el pin de entrada de otro. +/// El widget no valida ciclos ni direcciones — esa política vive en el +/// caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Wire { + pub from_node: NodeId, + pub from_output: PinIdx, + pub to_node: NodeId, + pub to_input: PinIdx, +} + +/// Tinte opcional de un nodo resaltado. Cada campo `Some` sobrescribe +/// el color correspondiente de la paleta global para *ese* nodo; los +/// `None` heredan la paleta. Sirve para que el caller marque un subgrafo +/// (p.ej. el cono afectado por un morfismo) sin tocar el resto. +#[derive(Debug, Clone, Copy, Default)] +pub struct NodeTint { + pub bg_node: Option, + pub bg_title: Option, + pub fg_title: Option, +} + +/// Paleta del lienzo. Hereda del [`llimphi_theme::Theme`] semántico. +#[derive(Debug, Clone, Copy)] +pub struct NodegraphPalette { + pub bg_canvas: Color, + pub bg_node: Color, + pub bg_title: Color, + pub fg_title: Color, + pub fg_pin_label: Color, + pub pin_input: Color, + pub pin_output: Color, + pub pin_drop_hover: Color, + pub wire: Color, + pub border: Color, +} + +impl Default for NodegraphPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl NodegraphPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_canvas: t.bg_app, + bg_node: t.bg_panel, + bg_title: t.bg_panel_alt, + fg_title: t.fg_text, + fg_pin_label: t.fg_muted, + pin_input: t.accent, + pin_output: t.accent, + pin_drop_hover: t.bg_selected, + wire: t.accent, + border: t.border, + } + } +} + +/// Geometría del nodo y de los pins. +#[derive(Debug, Clone, Copy)] +pub struct NodegraphMetrics { + pub node_width: f32, + pub title_height: f32, + pub pin_row_height: f32, + pub pin_radius: f32, + pub pin_label_size: f32, + pub title_text_size: f32, + pub wire_stroke: f32, + pub node_radius: f64, +} + +impl Default for NodegraphMetrics { + fn default() -> Self { + Self { + node_width: 160.0, + title_height: 22.0, + pin_row_height: 18.0, + pin_radius: 5.0, + pin_label_size: 10.0, + title_text_size: 11.0, + wire_stroke: 1.6, + node_radius: 4.0, + } + } +} + +impl NodegraphMetrics { + /// Alto total del rect que ocupa un nodo con `n_in` entradas y + /// `n_out` salidas. El cuerpo crece con el lado que tenga más pins. + pub fn node_height(&self, n_in: usize, n_out: usize) -> f32 { + let rows = n_in.max(n_out).max(1) as f32; + self.title_height + rows * self.pin_row_height + 6.0 + } + + /// Centro Y absoluto de un pin de entrada del nodo cuyo top-left es + /// `(_x, node_y)`. Sirve también para outputs (misma alineación). + pub fn input_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 { + node_y + + self.title_height + + 3.0 + + (pin as f32 + 0.5) * self.pin_row_height + } + + pub fn output_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 { + self.input_pin_y(node_y, pin) + } +} + +type DragNodeFn = + Arc Option + Send + Sync>; +type ConnectFn = Arc< + dyn Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync, +>; + +/// Codifica `(node_id, pin_idx)` en el `u64` que viaja como payload del +/// drag de un pin. 32 bits superiores = node_id, 16 bits inferiores = +/// pin_idx. +#[inline] +fn encode_payload(node: NodeId, pin: PinIdx) -> u64 { + ((node as u64) << 32) | (pin as u64) +} + +#[inline] +fn decode_payload(payload: u64) -> (NodeId, PinIdx) { + let node = (payload >> 32) as NodeId; + let pin = (payload & 0xFFFF) as PinIdx; + (node, pin) +} + +/// Construye el lienzo de nodos. `on_drag_node` se invoca con el delta +/// del cursor cuando el usuario arrastra la title bar de un nodo (las +/// fases `Move` y `End` se reenvían tal cual). `on_connect` se invoca +/// cuando el usuario suelta un cable iniciado en un pin de salida +/// sobre un pin de entrada de otro nodo. +pub fn nodegraph_view( + nodes: &[NodeSpec], + wires: &[Wire], + palette: &NodegraphPalette, + metrics: &NodegraphMetrics, + on_drag_node: FDrag, + on_connect: FConnect, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option + Send + Sync + 'static, + FConnect: + Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync + 'static, +{ + nodegraph_view_ex:: Option>( + nodes, + wires, + palette, + metrics, + on_drag_node, + on_connect, + None, + ) +} + +/// Variante extendida con un handler opcional de click derecho sobre +/// la title bar de cada nodo. Permite a la app montar acciones por-nodo +/// (estilo "ejecutar desde aquí" en un notebook reactivo, o "duplicar +/// este nodo" en un editor de cadena de audio) sin esperar a que el +/// widget tenga un menú contextual propio. +/// +/// `on_right_click_node` se evalúa una vez por nodo al construir la +/// vista — si devuelve `Some(msg)`, el runtime emite ese `Msg` al hacer +/// right-click sobre la title bar; `None` deja al nodo sin acción +/// contextual. +pub fn nodegraph_view_ex( + nodes: &[NodeSpec], + wires: &[Wire], + palette: &NodegraphPalette, + metrics: &NodegraphMetrics, + on_drag_node: FDrag, + on_connect: FConnect, + on_right_click_node: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option + Send + Sync + 'static, + FConnect: + Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync + 'static, + FRight: Fn(NodeId) -> Option, +{ + nodegraph_view_styled( + nodes, + wires, + palette, + metrics, + on_drag_node, + on_connect, + on_right_click_node, + None, + None, + ) +} + +/// Variante con realce: además de los handlers, acepta dos closures de +/// estilo evaluados en construcción —`node_tint(id)` tiñe nodos puntuales +/// y `wire_tint(&Wire)` recolorea cables— para que el caller marque un +/// subgrafo (cono afectado, ruta crítica, celda con error…) sin tocar la +/// paleta global. Ambos `None` = render idéntico a [`nodegraph_view`]. +#[allow(clippy::too_many_arguments)] +pub fn nodegraph_view_styled( + nodes: &[NodeSpec], + wires: &[Wire], + palette: &NodegraphPalette, + metrics: &NodegraphMetrics, + on_drag_node: FDrag, + on_connect: FConnect, + on_right_click_node: Option, + node_tint: Option<&dyn Fn(NodeId) -> Option>, + wire_tint: Option<&dyn Fn(&Wire) -> Option>, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option + Send + Sync + 'static, + FConnect: + Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync + 'static, + FRight: Fn(NodeId) -> Option, +{ + let on_drag: DragNodeFn = Arc::new(on_drag_node); + let on_connect: ConnectFn = Arc::new(on_connect); + + let painted = precompute_wires(nodes, wires, metrics, palette.wire, wire_tint); + let stroke_px = metrics.wire_stroke; + + let mut children: Vec> = Vec::with_capacity(nodes.len() + 1); + + // Capa 0 — cables (van detrás de los nodos). + children.push(wires_layer(painted, stroke_px)); + + // Capa 1..N — nodos. + for node in nodes { + let right_click_msg = on_right_click_node + .as_ref() + .and_then(|f| f(node.id)); + let tint = node_tint.and_then(|f| f(node.id)); + children.push(node_view( + node, + palette, + metrics, + &on_drag, + &on_connect, + right_click_msg, + tint, + )); + } + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_canvas) + .clip(true) + .children(children) +} + +#[derive(Debug, Clone, Copy)] +struct WirePainted { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + color: Color, +} + +fn precompute_wires( + nodes: &[NodeSpec], + wires: &[Wire], + metrics: &NodegraphMetrics, + default_color: Color, + wire_tint: Option<&dyn Fn(&Wire) -> Option>, +) -> Vec { + let mut out = Vec::with_capacity(wires.len()); + for w in wires { + let from = nodes.iter().find(|n| n.id == w.from_node); + let to = nodes.iter().find(|n| n.id == w.to_node); + if let (Some(a), Some(b)) = (from, to) { + let x1 = a.x + metrics.node_width; + let y1 = metrics.output_pin_y(a.y, w.from_output); + let x2 = b.x; + let y2 = metrics.input_pin_y(b.y, w.to_input); + let color = wire_tint.and_then(|f| f(w)).unwrap_or(default_color); + out.push(WirePainted { + x1, + y1, + x2, + y2, + color, + }); + } + } + out +} + +fn wires_layer(wires: Vec, stroke_px: f32) -> View +where + Msg: Clone + 'static, +{ + let nodo = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(0.0_f32), + right: length(0.0_f32), + bottom: length(0.0_f32), + }, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }); + if wires.is_empty() { + return nodo; + } + nodo.paint_with(move |scene, _ts, rect| { + let stroke = Stroke::new(stroke_px as f64); + for w in &wires { + // Bezier cúbica con tangentes horizontales — mismo patrón + // que las hebras de pluma-editor-llimphi. + let dx = ((w.x2 - w.x1).abs().max(40.0) * 0.5) as f64; + let x1 = (rect.x + w.x1) as f64; + let y1 = (rect.y + w.y1) as f64; + let x2 = (rect.x + w.x2) as f64; + let y2 = (rect.y + w.y2) as f64; + let mut path = BezPath::new(); + path.move_to((x1, y1)); + path.curve_to((x1 + dx, y1), (x2 - dx, y2), (x2, y2)); + scene.stroke(&stroke, Affine::IDENTITY, w.color, None, &path); + } + }) +} + +fn node_view( + node: &NodeSpec, + palette: &NodegraphPalette, + metrics: &NodegraphMetrics, + on_drag: &DragNodeFn, + on_connect: &ConnectFn, + on_right_click_msg: Option, + tint: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let n_in = node.inputs.len(); + let n_out = node.outputs.len(); + let height = metrics.node_height(n_in, n_out); + + // Colores efectivos: el tinte sobrescribe la paleta por-campo. + let tint = tint.unwrap_or_default(); + let bg_node = tint.bg_node.unwrap_or(palette.bg_node); + let bg_title = tint.bg_title.unwrap_or(palette.bg_title); + let fg_title = tint.fg_title.unwrap_or(palette.fg_title); + + let node_id = node.id; + let drag = on_drag.clone(); + let mut title_bar = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(metrics.title_height), + }, + flex_shrink: 0.0, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(bg_title) + .text_aligned( + node.label.clone(), + metrics.title_text_size, + fg_title, + Alignment::Start, + ) + .draggable(move |phase, dx, dy| (drag)(node_id, phase, dx, dy)); + + if let Some(msg) = on_right_click_msg { + title_bar = title_bar.on_right_click(msg); + } + + let mut pin_layer_children: Vec> = Vec::with_capacity(n_in + n_out); + for (i, label) in node.inputs.iter().enumerate() { + pin_layer_children.push(pin_view( + node_id, + i as PinIdx, + PinKind::Input, + label, + palette, + metrics, + on_connect.clone(), + )); + } + for (i, label) in node.outputs.iter().enumerate() { + pin_layer_children.push(pin_view( + node_id, + i as PinIdx, + PinKind::Output, + label, + palette, + metrics, + on_connect.clone(), + )); + } + let pin_layer = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(metrics.title_height), + right: length(0.0_f32), + bottom: length(0.0_f32), + }, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + ..Default::default() + }) + .children(pin_layer_children); + + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(node.x), + top: length(node.y), + right: length(0.0_f32), + bottom: length(0.0_f32), + }, + size: Size { + width: length(metrics.node_width), + height: length(height), + }, + ..Default::default() + }) + .fill(bg_node) + .radius(metrics.node_radius) + .children(vec![title_bar, pin_layer]) +} + +#[derive(Debug, Clone, Copy)] +enum PinKind { + Input, + Output, +} + +fn pin_view( + node_id: NodeId, + pin_idx: PinIdx, + kind: PinKind, + label: &str, + palette: &NodegraphPalette, + metrics: &NodegraphMetrics, + on_connect: ConnectFn, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let y_top = pin_idx as f32 * metrics.pin_row_height; + let row_h = metrics.pin_row_height; + let r = metrics.pin_radius; + let diam = r * 2.0; + + let (pin_left, pin_right, dot_color, label_align) = match kind { + PinKind::Input => ( + Some(length(-r)), + None, + palette.pin_input, + Alignment::Start, + ), + PinKind::Output => ( + None, + Some(length(-r)), + palette.pin_output, + Alignment::End, + ), + }; + + let mut dot = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: pin_left.unwrap_or_else(|| length(0.0_f32)), + top: length((row_h - diam) * 0.5), + right: pin_right.unwrap_or_else(|| length(0.0_f32)), + bottom: length(0.0_f32), + }, + size: Size { + width: length(diam), + height: length(diam), + }, + ..Default::default() + }) + .fill(dot_color) + .radius(r as f64); + + match kind { + PinKind::Output => { + dot = dot + .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None) + .drag_payload(encode_payload(node_id, pin_idx)); + } + PinKind::Input => { + let to_node = node_id; + let to_pin = pin_idx; + let cb = on_connect.clone(); + dot = dot + .on_drop(move |payload: u64| { + let (from_node, from_pin) = decode_payload(payload); + (cb)(from_node, from_pin, to_node, to_pin) + }) + .drop_hover_fill(palette.pin_drop_hover); + } + } + + let label_view = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(diam + 4.0), + top: length(0.0), + right: length(diam + 4.0), + bottom: length(0.0), + }, + size: Size { + width: Dimension::auto(), + height: length(row_h), + }, + ..Default::default() + }) + .text_aligned( + label.to_string(), + metrics.pin_label_size, + palette.fg_pin_label, + label_align, + ); + + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0), + top: length(y_top), + right: length(0.0), + bottom: length(0.0_f32), + }, + size: Size { + width: percent(1.0_f32), + height: length(row_h), + }, + ..Default::default() + }) + .children(vec![label_view, dot]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_roundtrip() { + for (n, p) in [ + (0u32, 0u16), + (1, 0), + (0, 1), + (42, 7), + (u32::MAX, u16::MAX), + (123_456, 65_535), + ] { + let enc = encode_payload(n, p); + let (n2, p2) = decode_payload(enc); + assert_eq!((n, p), (n2, p2), "payload {enc} → ({n2}, {p2})"); + } + } + + #[test] + fn metrics_node_height_grows_with_max_side() { + let m = NodegraphMetrics::default(); + assert_eq!(m.node_height(3, 1), m.node_height(1, 3)); + let min = m.title_height + m.pin_row_height + 6.0; + assert_eq!(m.node_height(0, 0), min); + } + + #[test] + fn pin_y_progression() { + let m = NodegraphMetrics::default(); + let y0 = m.input_pin_y(100.0, 0); + let y1 = m.input_pin_y(100.0, 1); + let y2 = m.input_pin_y(100.0, 2); + assert!(y1 - y0 > 0.0, "pins crecen hacia abajo"); + assert!((y2 - y1) - (y1 - y0) < 1e-3, "espaciado uniforme"); + } + + #[test] + fn precompute_skips_dangling_wires() { + let nodes = vec![NodeSpec { + id: 1, + label: "solo".into(), + x: 0.0, + y: 0.0, + inputs: vec!["in".into()], + outputs: vec!["out".into()], + }]; + let wires = vec![Wire { + from_node: 99, + from_output: 0, + to_node: 1, + to_input: 0, + }]; + let m = NodegraphMetrics::default(); + let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None); + assert!(pre.is_empty()); + } + + #[test] + fn precompute_resolves_existing_wires() { + let nodes = vec![ + NodeSpec { + id: 1, + label: "a".into(), + x: 0.0, + y: 0.0, + inputs: vec![], + outputs: vec!["out".into()], + }, + NodeSpec { + id: 2, + label: "b".into(), + x: 200.0, + y: 50.0, + inputs: vec!["in".into()], + outputs: vec![], + }, + ]; + let wires = vec![Wire { + from_node: 1, + from_output: 0, + to_node: 2, + to_input: 0, + }]; + let m = NodegraphMetrics::default(); + let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None); + assert_eq!(pre.len(), 1); + assert!((pre[0].x1 - m.node_width).abs() < 1e-3); + assert!((pre[0].x2 - 200.0).abs() < 1e-3); + } +} diff --git a/widgets/panel/Cargo.toml b/widgets/panel/Cargo.toml new file mode 100644 index 0000000..e5a3e1e --- /dev/null +++ b/widgets/panel/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-panel" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-panel — firma visual transversal: gradiente vertical casi imperceptible + hairline accent en el top edge. Helper paint_with + wrapper panel_view. La capa que vuelve reconocible al sistema sin cargar." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/panel/src/lib.rs b/widgets/panel/src/lib.rs new file mode 100644 index 0000000..be2fe76 --- /dev/null +++ b/widgets/panel/src/lib.rs @@ -0,0 +1,239 @@ +//! `llimphi-widget-panel` — firma visual transversal de los paneles gioser. +//! +//! Aporta dos detalles que aplicados consistentemente vuelven al sistema +//! reconocible sin que se note "diseñado": +//! +//! 1. **Gradiente vertical casi imperceptible** — el fondo del panel no +//! es un color sólido sino una interpolación lineal entre una versión +//! ligeramente más clara (top) y una ligeramente más oscura (bot) del +//! color base. La diferencia es ~4% en valor — invisible al primer +//! vistazo pero el ojo lo registra como "tallado" en vez de "pintado". +//! +//! 2. **Hairline accent en el top edge** — una línea horizontal de 1px +//! en el color accent del theme, al ~30% de alpha, justo en el borde +//! superior del panel. Funciona como "hilo de identidad" que cose +//! todos los paneles del sistema: aparece en modales, dropdowns, +//! cards, sidebars; siempre el mismo grosor, siempre el mismo color. +//! +//! ## API +//! +//! - [`PanelStyle`] — bundle de tokens (color base, accent, radio, +//! alpha del hairline, fuerza del gradiente). +//! - [`panel_signature_painter`] — `Fn` para `View::paint_with`. Útil si +//! ya tenés un View configurado y querés sumarle la firma sin envolver. +//! - [`panel_view`] — convenience: arma el View completo con la firma +//! aplicada, recibe los hijos como `Vec>`. +//! +//! ## Cuándo usarlo +//! +//! - SÍ: modales, dropdowns, cards prominentes, columnas de layout, +//! shortcuts-help, paneles flotantes. +//! - NO: chips, badges, toasts, items de lista (la firma es para +//! superficies grandes; en piezas chiquitas es ruido). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Rect as KurboRect, RoundedRect}; +use llimphi_ui::llimphi_raster::peniko::{color::AlphaColor, Color, Fill, Gradient}; +use llimphi_ui::{PaintRect, View}; +use llimphi_theme::{alpha, radius, Theme}; + +/// Token bundle de la firma visual. +#[derive(Debug, Clone, Copy)] +pub struct PanelStyle { + /// Color base del panel (típico: `theme.bg_panel`). + pub bg_base: Color, + /// Color del hairline (típico: `theme.accent`). + pub accent: Color, + /// Radio de las esquinas (típico: `radius::MD` para cards, `radius::LG` + /// para modales/overlays). + pub radius: f64, + /// Alpha del hairline (0.0–1.0). Por debajo de 0.20 se pierde; por + /// encima de 0.45 se vuelve dominante. Default 0.30. + pub hairline_alpha: f32, + /// Fuerza del gradiente — cada componente RGB se desplaza ±gradient + /// (en escala 0.0–1.0). 0.04 = 4% = imperceptible-pero-presente. + /// Subir más sólo si el theme es muy claro y el efecto no llega. + pub gradient_strength: f32, +} + +impl PanelStyle { + /// Estilo estándar para cards / sidebars / paneles medianos. + pub fn from_theme(t: &Theme) -> Self { + Self { + bg_base: t.bg_panel, + accent: t.accent, + radius: radius::MD, + hairline_alpha: alpha::SCRIM as f32 / 255.0 * 1.2, // ~0.30 + gradient_strength: 0.04, + } + } + + /// Variante para superficies grandes — modales, splash, overlays. + /// Esquinas más generosas, gradiente y hairline un toque más marcados. + pub fn from_theme_large(t: &Theme) -> Self { + Self { + bg_base: t.bg_panel, + accent: t.accent, + radius: radius::LG, + hairline_alpha: 0.35, + gradient_strength: 0.05, + } + } + + /// Variante neutra — sin hairline (panels que no deben llevar la + /// "firma" porque son piezas auxiliares). Mantiene el gradiente. + pub fn neutral(t: &Theme) -> Self { + Self { + bg_base: t.bg_panel, + accent: t.accent, + radius: radius::MD, + hairline_alpha: 0.0, + gradient_strength: 0.03, + } + } + + /// Color del top del gradiente: base aclarada. + pub fn bg_top(&self) -> Color { + shift(self.bg_base, self.gradient_strength) + } + + /// Color del bottom del gradiente: base oscurecida. + pub fn bg_bot(&self) -> Color { + shift(self.bg_base, -self.gradient_strength) + } +} + +/// Devuelve la closure de pintura que aplica la firma sobre el rect del +/// nodo. Pasarla a `View::paint_with` para sumar la firma a un View +/// existente. El View NO debe tener `.fill(...)` setteado — el gradient +/// reemplaza el fill sólido. +/// +/// Nota: el View debe llamar `.radius(style.radius)` en sí mismo si quiere +/// que clip/hit-test/borders respeten las esquinas. La firma pinta el +/// gradiente como `RoundedRect` con el mismo `radius`, así que la +/// silueta visual es consistente independientemente del clipping. +pub fn panel_signature_painter( + style: PanelStyle, +) -> impl Fn(&mut llimphi_ui::llimphi_raster::vello::Scene, &mut llimphi_ui::llimphi_text::Typesetter, PaintRect) + + Send + + Sync + + 'static { + move |scene, _ts, rect| { + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + + // === 1) Gradiente vertical en RoundedRect === + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let rr = RoundedRect::new(x0, y0, x1, y1, style.radius); + let gradient = Gradient::new_linear( + Point::new(x0, y0), + Point::new(x0, y1), + ) + .with_stops([style.bg_top(), style.bg_bot()].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr); + + // === 2) Hairline accent en el top edge === + // Se acorta horizontalmente para no chocar con las esquinas + // redondeadas — queda inscrito en el "techo recto" del panel. + if style.hairline_alpha > 0.0 && rect.w > style.radius as f32 * 2.0 + 4.0 { + let hairline_color = with_alpha_mul(style.accent, style.hairline_alpha); + let hairline = KurboRect::new( + x0 + style.radius, + y0, + x1 - style.radius, + y0 + 1.0, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, hairline_color, None, &hairline); + } + } +} + +/// Convenience: arma un `View` con la firma aplicada y los `children` +/// adentro. Equivalente a: +/// +/// ```ignore +/// View::new(Style { size: full, ..Default::default() }) +/// .paint_with(panel_signature_painter(style)) +/// .radius(style.radius) +/// .clip(true) +/// .children(children) +/// ``` +/// +/// Para layouts custom (size específico, padding, flex direction), usar +/// `panel_signature_painter` directamente y construir el View a mano. +pub fn panel_view( + children: Vec>, + style: PanelStyle, +) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(panel_signature_painter(style)) + .radius(style.radius) + .clip(true) + .children(children) +} + +// ===================================================================== +// Helpers internos +// ===================================================================== + +/// Desplaza cada componente RGB de `c` por `delta` (positivo aclara, +/// negativo oscurece). Clampea en [0,1]. El alpha queda intacto. +fn shift(c: Color, delta: f32) -> Color { + let [r, g, b, a] = c.components; + AlphaColor::new([ + (r + delta).clamp(0.0, 1.0), + (g + delta).clamp(0.0, 1.0), + (b + delta).clamp(0.0, 1.0), + a, + ]) +} + +fn with_alpha_mul(c: Color, mult: f32) -> Color { + let [r, g, b, a] = c.components; + AlphaColor::new([r, g, b, a * mult]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bg_top_is_brighter_than_bg_bot() { + let t = Theme::dark(); + let s = PanelStyle::from_theme(&t); + let top = s.bg_top(); + let bot = s.bg_bot(); + // El top debe tener cada canal RGB ≥ al del bot (es más claro). + for i in 0..3 { + assert!(top.components[i] >= bot.components[i], + "canal {i}: top {} < bot {}", top.components[i], bot.components[i]); + } + } + + #[test] + fn neutral_style_has_no_hairline() { + let t = Theme::dark(); + let s = PanelStyle::neutral(&t); + assert_eq!(s.hairline_alpha, 0.0); + } + + #[test] + fn shift_clamps_to_unit() { + let c = Color::from_rgba8(250, 250, 250, 255); + let bright = shift(c, 0.5); + assert!(bright.components[0] <= 1.0); + assert!(bright.components[1] <= 1.0); + } +} diff --git a/widgets/panes/Cargo.toml b/widgets/panes/Cargo.toml new file mode 100644 index 0000000..0e5d73c --- /dev/null +++ b/widgets/panes/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-panes" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-panes — árbol de paneles BSP estilo tmux: hojas opacas (`View`) que se parten horizontal/vertical, se cierran, enfocan y redimensionan arrastrando divisores. La base para montar cualquier componente de gioser en un layout intercambiable." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/panes/examples/panes_demo.rs b/widgets/panes/examples/panes_demo.rs new file mode 100644 index 0000000..d4fb11c --- /dev/null +++ b/widgets/panes/examples/panes_demo.rs @@ -0,0 +1,319 @@ +//! Demo de `llimphi-widget-panes` — "tmux de componentes gioser". +//! +//! Dos tipos de panel heterogéneos (Contador y Notas) conviviendo en un +//! mismo árbol BSP que se parte horizontal/vertical, se cierra, se enfoca +//! (click) y se redimensiona (arrastrando los divisores). Prueba de punta +//! a punta de que componentes distintos se montan en un layout +//! intercambiable con splits resizables. +//! +//! Correr: `cargo run -p llimphi-widget-panes --example panes_demo --release` + +use std::collections::HashMap; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::{App, DragPhase, Handle, View}; +use llimphi_theme::Theme; +use llimphi_widget_panes::{panes_view, Axis, Layout, PaneId, PanesPalette, Side}; + +struct Demo; + +#[derive(Clone)] +enum Msg { + Focus(PaneId), + Split(Axis), + Close, + Resize(Vec, f32), + Inc(PaneId), + Dec(PaneId), + AddNote(PaneId), +} + +enum Kind { + Counter(i64), + Notes(Vec), +} + +struct Pane { + title: String, + kind: Kind, +} + +struct Model { + layout: Layout, + panes: HashMap, + focused: PaneId, + next_id: PaneId, + theme: Theme, +} + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "panes — tmux de componentes gioser" + } + + fn init(_: &Handle) -> Model { + let mut panes = HashMap::new(); + panes.insert( + 1, + Pane { + title: "Contador".into(), + kind: Kind::Counter(0), + }, + ); + panes.insert( + 2, + Pane { + title: "Notas".into(), + kind: Kind::Notes(vec!["arrastrá el divisor del medio →".into()]), + }, + ); + let mut layout = Layout::single(1); + layout.split(1, 2, Axis::Horizontal); + Model { + layout, + panes, + focused: 1, + next_id: 3, + theme: Theme::dark(), + } + } + + fn update(mut model: Model, msg: Msg, _: &Handle) -> Model { + match msg { + Msg::Focus(id) => model.focused = id, + Msg::Split(axis) => { + let id = model.next_id; + model.next_id += 1; + let kind = if id % 2 == 0 { + Kind::Counter(0) + } else { + Kind::Notes(vec![]) + }; + let title = match &kind { + Kind::Counter(_) => "Contador".to_string(), + Kind::Notes(_) => "Notas".to_string(), + }; + model.panes.insert(id, Pane { title, kind }); + model.layout.split(model.focused, id, axis); + model.focused = id; + } + Msg::Close => { + if model.layout.count() > 1 { + let target = model.focused; + let (nl, removed) = model.layout.clone().without(target); + if removed { + model.layout = nl; + model.panes.remove(&target); + model.focused = model.layout.first_leaf(); + } + } + } + Msg::Resize(path, d) => model.layout.resize(&path, d), + Msg::Inc(id) => { + if let Some(Pane { + kind: Kind::Counter(n), + .. + }) = model.panes.get_mut(&id) + { + *n += 1; + } + } + Msg::Dec(id) => { + if let Some(Pane { + kind: Kind::Counter(n), + .. + }) = model.panes.get_mut(&id) + { + *n -= 1; + } + } + Msg::AddNote(id) => { + if let Some(Pane { + kind: Kind::Notes(v), + .. + }) = model.panes.get_mut(&id) + { + let n = v.len() + 1; + v.push(format!("nota #{n}")); + } + } + } + model + } + + fn view(model: &Model) -> View { + let t = &model.theme; + 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(t.bg_panel) + .children(vec![ + button("Split →", Msg::Split(Axis::Horizontal), t), + button("Split ↓", Msg::Split(Axis::Vertical), t), + button("Cerrar", Msg::Close, t), + View::new(Style { + flex_grow: 1.0, + ..Default::default() + }), + label( + format!("foco #{} · {} paneles", model.focused, model.layout.count()), + 13.0, + t.fg_muted, + ), + ]); + + let palette = PanesPalette::from_theme(t); + let panes = &model.panes; + let theme = t; + let area = panes_view( + &model.layout, + model.focused, + move |id| render_pane(panes, theme, id), + |path, phase, d| { + let _ = phase; + Some(Msg::Resize(path, d)) + }, + Msg::Focus, + &palette, + ); + + let area_wrap = View::new(Style { + flex_grow: 1.0, + size: Size { + width: percent(1.0), + height: percent(1.0), + }, + min_size: Size { + width: length(0.0), + height: length(0.0), + }, + ..Default::default() + }) + .children(vec![area]); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0), + height: percent(1.0), + }, + ..Default::default() + }) + .fill(t.bg_app) + .children(vec![toolbar, area_wrap]) + } +} + +fn render_pane(panes: &HashMap, t: &Theme, id: PaneId) -> View { + let Some(pane) = panes.get(&id) else { + return label("(panel vacío)".to_string(), 14.0, t.fg_muted); + }; + + let header = label(format!("{} #{id}", pane.title), 13.0, t.fg_text); + + let body = match &pane.kind { + Kind::Counter(n) => View::new(Style { + flex_direction: FlexDirection::Column, + gap: Size { + width: length(8.0), + height: length(8.0), + }, + ..Default::default() + }) + .children(vec![ + label(format!("{n}"), 44.0, t.accent), + View::new(Style { + flex_direction: FlexDirection::Row, + gap: Size { + width: length(8.0), + height: length(8.0), + }, + ..Default::default() + }) + .children(vec![ + button("−", Msg::Dec(id), t), + button("+", Msg::Inc(id), t), + ]), + ]), + Kind::Notes(v) => { + let mut lines: Vec> = v + .iter() + .map(|s| label(format!("• {s}"), 14.0, t.fg_text)) + .collect(); + lines.push(button("+ nota", Msg::AddNote(id), t)); + View::new(Style { + flex_direction: FlexDirection::Column, + gap: Size { + width: length(6.0), + height: length(6.0), + }, + ..Default::default() + }) + .children(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![header, body]) +} + +fn button(text: &str, msg: Msg, t: &Theme) -> View { + 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 label( + text: String, + size: f32, + color: llimphi_ui::llimphi_raster::peniko::Color, +) -> View { + View::new(Style::default()).text(text, size, color) +} + +fn uniform(px: f32) -> Rect { + Rect { + left: length(px), + right: length(px), + top: length(px), + bottom: length(px), + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/panes/src/lib.rs b/widgets/panes/src/lib.rs new file mode 100644 index 0000000..cecee75 --- /dev/null +++ b/widgets/panes/src/lib.rs @@ -0,0 +1,505 @@ +//! `llimphi-widget-panes` — árbol de paneles BSP estilo tmux. +//! +//! La pieza que faltaba para "montar cualquier componente de gioser en un +//! layout intercambiable con splits resizables". El widget NO conoce los +//! dominios: hospeda hojas opacas (`View`) en un árbol binario que el +//! usuario parte (horizontal/vertical), cierra, enfoca (click) y +//! redimensiona (arrastrando los divisores). tmux, pero in-process y sobre +//! el bucle Elm de Llimphi. +//! +//! No confundir con `llimphi-widget-panel` (el chrome de UN panel con +//! título): esto es el árbol de N panes. +//! +//! ## Modelo +//! +//! - [`Layout`] es la **estructura** del árbol (qué hoja vive dónde, con +//! qué ratio cada split). Vive en el `Model` del host y se manipula con +//! [`Layout::split`], [`Layout::without`] y [`Layout::resize`]. +//! - El **contenido** de cada hoja lo provee el host vía un closure +//! `FnMut(PaneId) -> View` que se invoca al construir la vista — +//! por eso puede tomar prestado el `Model` (no necesita ser `'static`). +//! - El handler de resize sí se guarda en el árbol de vistas (lo agarra el +//! divisor draggable), así que ése debe ser `'static + Send + Sync`. El +//! de focus se evalúa al construir (porque `on_click` toma el `Msg` por +//! valor), así que no tiene esa restricción. +//! +//! ## Por qué no `Box` +//! +//! Igual que el resto del repo: el host mantiene un `enum` de sus tipos de +//! panel y hace dispatch estático. El widget es genérico sobre `Msg`; el +//! host decide cómo materializar cada hoja. Cero downcasting. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; + +/// Identificador estable de un panel. El host lo asigna (un contador +/// monótono basta) y lo usa como llave hacia su propio estado. +pub type PaneId = u64; + +/// Eje del split. `Horizontal` pone los panes lado a lado (divisor +/// vertical, se arrastra en X); `Vertical` los apila (divisor horizontal, +/// se arrastra en Y). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis { + Horizontal, + Vertical, +} + +/// Rama de un split, usada para direccionar un nodo dentro del árbol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + First, + Second, +} + +/// Árbol binario de paneles. `Leaf` es un panel; `Split` divide el espacio +/// entre dos subárboles con un `ratio` (fracción que ocupa el primero). +#[derive(Debug, Clone, PartialEq)] +pub enum Layout { + Leaf(PaneId), + Split { + axis: Axis, + /// Fracción del eje que ocupa el subárbol `first` (0..1). + ratio: f32, + first: Box, + second: Box, + }, +} + +impl Layout { + /// Árbol de un solo panel. + pub fn single(id: PaneId) -> Self { + Layout::Leaf(id) + } + + /// Cantidad de hojas (paneles) en el árbol. + pub fn count(&self) -> usize { + match self { + Layout::Leaf(_) => 1, + Layout::Split { first, second, .. } => first.count() + second.count(), + } + } + + /// Lista de todas las hojas, en orden de aparición (izq→der / arr→ab). + pub fn leaves(&self) -> Vec { + let mut out = Vec::new(); + self.collect_leaves(&mut out); + out + } + + fn collect_leaves(&self, out: &mut Vec) { + match self { + Layout::Leaf(id) => out.push(*id), + Layout::Split { first, second, .. } => { + first.collect_leaves(out); + second.collect_leaves(out); + } + } + } + + /// `true` si la hoja existe en el árbol. + pub fn contains(&self, id: PaneId) -> bool { + match self { + Layout::Leaf(x) => *x == id, + Layout::Split { first, second, .. } => first.contains(id) || second.contains(id), + } + } + + /// Primera hoja (la de más arriba/izquierda). Útil para reenfocar tras + /// cerrar un panel. + pub fn first_leaf(&self) -> PaneId { + match self { + Layout::Leaf(id) => *id, + Layout::Split { first, .. } => first.first_leaf(), + } + } + + /// Parte la hoja `target` en dos: `target` queda en `Side::First` y la + /// nueva hoja `new` en `Side::Second`, con ratio 0.5. Devuelve `true` + /// si encontró el target. + pub fn split(&mut self, target: PaneId, new: PaneId, axis: Axis) -> bool { + match self { + Layout::Leaf(id) if *id == target => { + *self = Layout::Split { + axis, + ratio: 0.5, + first: Box::new(Layout::Leaf(target)), + second: Box::new(Layout::Leaf(new)), + }; + true + } + Layout::Leaf(_) => false, + Layout::Split { first, second, .. } => { + first.split(target, new, axis) || second.split(target, new, axis) + } + } + } + + /// Devuelve el árbol sin la hoja `target`, colapsando el split padre en + /// el hermano sobreviviente. El `bool` indica si removió algo. Quitar la + /// única hoja raíz es no-op (devuelve el árbol intacto, `false`). + pub fn without(self, target: PaneId) -> (Layout, bool) { + match self { + Layout::Leaf(id) => (Layout::Leaf(id), false), + Layout::Split { + axis, + ratio, + first, + second, + } => { + if matches!(*first, Layout::Leaf(t) if t == target) { + return (*second, true); + } + if matches!(*second, Layout::Leaf(t) if t == target) { + return (*first, true); + } + let (nf, rf) = first.without(target); + if rf { + return ( + Layout::Split { + axis, + ratio, + first: Box::new(nf), + second, + }, + true, + ); + } + let (ns, rs) = second.without(target); + ( + Layout::Split { + axis, + ratio, + first: Box::new(nf), + second: Box::new(ns), + }, + rs, + ) + } + } + } + + /// Ajusta el ratio del split direccionado por `path` (camino de raíz a + /// ese nodo). `delta` se suma al ratio, clamp a [0.05, 0.95]. + pub fn resize(&mut self, path: &[Side], delta: f32) { + match self { + Layout::Split { + ratio, + first, + second, + .. + } => match path.split_first() { + None => *ratio = (*ratio + delta).clamp(0.05, 0.95), + Some((Side::First, rest)) => first.resize(rest, delta), + Some((Side::Second, rest)) => second.resize(rest, delta), + }, + Layout::Leaf(_) => {} + } + } +} + +/// Ratio movido por píxel arrastrado. No conocemos el tamaño en px del +/// contenedor en tiempo de `view` (limitación conocida de Llimphi, la +/// misma raíz por la que no hay `View::map`), así que aproximamos con una +/// sensibilidad fija. El clamp en [`Layout::resize`] evita degenerar. +const RESIZE_SENSITIVITY: f32 = 1.0 / 600.0; + +/// Paleta del árbol de paneles. +#[derive(Debug, Clone, Copy)] +pub struct PanesPalette { + pub bg: Color, + pub border: Color, + pub focus_border: Color, + pub divider: Color, + pub divider_hover: Color, + pub thickness: f32, +} + +impl Default for PanesPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl PanesPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_app, + border: t.border, + focus_border: t.accent, + divider: t.border, + divider_hover: t.accent, + thickness: 6.0, + } + } +} + +/// Renderiza el árbol de paneles. +/// +/// - `leaf` materializa el contenido de cada hoja; se llama una vez por +/// panel mientras se construye la vista (puede tomar prestado el host). +/// - `on_resize` recibe el camino al split, la fase del drag y el delta de +/// ratio; devolver `Some(msg)` dispara el `update` (el host llama +/// [`Layout::resize`]). +/// - `on_focus` produce el msg al hacer click en un panel. +pub fn panes_view( + layout: &Layout, + focused: PaneId, + mut leaf: impl FnMut(PaneId) -> View, + on_resize: impl Fn(Vec, DragPhase, f32) -> Option + Send + Sync + 'static, + on_focus: impl Fn(PaneId) -> Msg, + palette: &PanesPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let on_resize: Arc, DragPhase, f32) -> Option + Send + Sync> = + Arc::new(on_resize); + render( + layout, + focused, + &mut leaf, + &on_resize, + &on_focus, + Vec::new(), + palette, + ) +} + +#[allow(clippy::too_many_arguments)] +fn render( + layout: &Layout, + focused: PaneId, + leaf: &mut dyn FnMut(PaneId) -> View, + on_resize: &Arc, DragPhase, f32) -> Option + Send + Sync>, + on_focus: &dyn Fn(PaneId) -> Msg, + path: Vec, + palette: &PanesPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + match layout { + Layout::Leaf(id) => { + let id = *id; + let content = leaf(id); + let is_focused = id == focused; + let border_col = if is_focused { + palette.focus_border + } else { + palette.border + }; + let border_w = if is_focused { 2.0 } else { 1.0 }; + + // Caja interior (fondo del panel) con el contenido del host. + let inner = View::new(Style { + flex_grow: 1.0, + flex_direction: FlexDirection::Column, + size: full(), + min_size: zero(), + ..Default::default() + }) + .fill(palette.bg) + .children(vec![content]); + + // Marco: no hay `stroke`, así que el borde es un contenedor + // relleno con un padding del grosor → simula el trazo. + View::new(Style { + flex_direction: FlexDirection::Column, + size: full(), + min_size: zero(), + padding: uniform(border_w), + ..Default::default() + }) + .fill(border_col) + .on_click(on_focus(id)) + .children(vec![inner]) + } + Layout::Split { + axis, + ratio, + first, + second, + } => { + let flex_dir = match axis { + Axis::Horizontal => FlexDirection::Row, + Axis::Vertical => FlexDirection::Column, + }; + + let mut p1 = path.clone(); + p1.push(Side::First); + let mut p2 = path.clone(); + p2.push(Side::Second); + + let a = render(first, focused, leaf, on_resize, on_focus, p1, palette); + let b = render(second, focused, leaf, on_resize, on_focus, p2, palette); + + let pane_a = grow_pane(a, *ratio); + let pane_b = grow_pane(b, 1.0 - *ratio); + let divider = divider_view(*axis, palette, on_resize.clone(), path.clone()); + + View::new(Style { + flex_direction: flex_dir, + size: full(), + min_size: zero(), + ..Default::default() + }) + .children(vec![pane_a, divider, pane_b]) + } + } +} + +fn grow_pane(view: View, grow: f32) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + View::new(Style { + flex_grow: grow.max(0.01), + flex_shrink: 1.0, + flex_basis: length(0.0), + size: full(), + min_size: zero(), + ..Default::default() + }) + .children(vec![view]) +} + +fn divider_view( + axis: Axis, + palette: &PanesPalette, + on_resize: Arc, DragPhase, f32) -> Option + Send + Sync>, + path: Vec, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let (width, height) = match axis { + Axis::Horizontal => (length(palette.thickness), percent(1.0_f32)), + Axis::Vertical => (percent(1.0_f32), length(palette.thickness)), + }; + View::new(Style { + size: Size { width, height }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.divider) + .hover_fill(palette.divider_hover) + .draggable(move |phase, dx, dy| { + let main = match axis { + Axis::Horizontal => dx, + Axis::Vertical => dy, + }; + (on_resize)(path.clone(), phase, main * RESIZE_SENSITIVITY) + }) +} + +fn full() -> Size { + Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + } +} + +fn zero() -> Size { + Size { + width: length(0.0_f32), + height: length(0.0_f32), + } +} + +fn uniform(px: f32) -> Rect { + Rect { + left: length(px), + right: length(px), + top: length(px), + bottom: length(px), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_has_one_leaf() { + let l = Layout::single(1); + assert_eq!(l.count(), 1); + assert_eq!(l.leaves(), vec![1]); + assert_eq!(l.first_leaf(), 1); + } + + #[test] + fn split_creates_two_leaves() { + let mut l = Layout::single(1); + assert!(l.split(1, 2, Axis::Horizontal)); + assert_eq!(l.count(), 2); + assert_eq!(l.leaves(), vec![1, 2]); + assert!(l.contains(2)); + } + + #[test] + fn split_missing_target_is_noop() { + let mut l = Layout::single(1); + assert!(!l.split(99, 2, Axis::Vertical)); + assert_eq!(l.count(), 1); + } + + #[test] + fn nested_split_then_close_collapses() { + let mut l = Layout::single(1); + l.split(1, 2, Axis::Horizontal); + l.split(2, 3, Axis::Vertical); // 2 se parte en [2 / 3] + assert_eq!(l.leaves(), vec![1, 2, 3]); + + let (l, removed) = l.without(3); + assert!(removed); + assert_eq!(l.leaves(), vec![1, 2]); + + let (l, removed) = l.without(1); + assert!(removed); + assert_eq!(l.leaves(), vec![2]); + + let (l, removed) = l.without(2); + assert!(!removed); + assert_eq!(l.leaves(), vec![2]); + } + + #[test] + fn resize_adjusts_ratio_with_clamp() { + let mut l = Layout::single(1); + l.split(1, 2, Axis::Horizontal); + l.resize(&[], 0.2); + if let Layout::Split { ratio, .. } = &l { + assert!((ratio - 0.7).abs() < 1e-6); + } else { + panic!("esperaba split"); + } + l.resize(&[], -10.0); + if let Layout::Split { ratio, .. } = &l { + assert!((ratio - 0.05).abs() < 1e-6); + } + } + + #[test] + fn resize_nested_path() { + let mut l = Layout::single(1); + l.split(1, 2, Axis::Horizontal); + l.split(2, 3, Axis::Vertical); + l.resize(&[Side::Second], 0.1); + if let Layout::Split { second, .. } = &l { + if let Layout::Split { ratio, .. } = second.as_ref() { + assert!((ratio - 0.6).abs() < 1e-6); + return; + } + } + panic!("estructura inesperada"); + } +} diff --git a/widgets/progress/Cargo.toml b/widgets/progress/Cargo.toml new file mode 100644 index 0000000..db8b579 --- /dev/null +++ b/widgets/progress/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-progress" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-progress — barras de progreso lineales y radiales determinadas (0.0-1.0). Para indeterminadas usar llimphi-widget-spinner." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/progress/src/lib.rs b/widgets/progress/src/lib.rs new file mode 100644 index 0000000..7858be9 --- /dev/null +++ b/widgets/progress/src/lib.rs @@ -0,0 +1,126 @@ +//! `llimphi-widget-progress` — progreso determinado, lineal o radial. +//! +//! Determinado = la app conoce el porcentaje (`0.0..=1.0`). Para +//! progreso indeterminado (la op está corriendo, no sé cuánto falta), +//! usar `llimphi-widget-spinner`. +//! +//! Dos formas: +//! - [`linear_progress_view`] — barra horizontal con relleno proporcional. +//! - [`radial_progress_view`] — anillo cuya porción llena indica el avance. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Position, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Arc, Cap, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_theme::radius; + +/// Barra horizontal: una pista (`track`) con un fill proporcional al +/// `progress` (0.0..=1.0) pintado encima. +pub fn linear_progress_view( + progress: f32, + track_color: Color, + fill_color: Color, + height_px: f32, +) -> View { + let p = progress.clamp(0.0, 1.0); + let fill_radius = radius::XS; + let fill = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(0.0_f32), + right: llimphi_ui::llimphi_layout::taffy::prelude::auto(), + bottom: length(0.0_f32), + }, + size: Size { + width: percent(p), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(fill_color) + .radius(fill_radius) + .paint_with(move |scene, _ts, rect| { + // Gloss superior sobre la porción rellena — la barra deja de + // leerse como un rect plano y se siente como una luz que avanza. + // Mismo patrón que button/badge (P6). + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, fill_radius); + let top = Color::from_rgba8(255, 255, 255, 50); + let bot = Color::from_rgba8(255, 255, 255, 0); + let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); + }); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(height_px), + }, + ..Default::default() + }) + .fill(track_color) + .radius(radius::XS) + .children(vec![fill]) +} + +/// Anillo cuya porción angular llena indica el avance. Empieza desde +/// arriba (12 en punto) y gira en sentido horario, igual que la +/// convención de relojes y muchos progress radiales. +pub fn radial_progress_view( + progress: f32, + track_color: Color, + fill_color: Color, + stroke_width_ratio: f32, +) -> View { + let p = progress.clamp(0.0, 1.0); + let sw = stroke_width_ratio; + 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| { + let side = rect.w.min(rect.h) as f64; + if side <= 0.0 { + return; + } + let cx = rect.x as f64 + rect.w as f64 * 0.5; + let cy = rect.y as f64 + rect.h as f64 * 0.5; + let stroke_w = (side * sw as f64).max(1.0); + let radius = (side - stroke_w) * 0.5; + let stroke = Stroke::new(stroke_w).with_caps(Cap::Round); + + // Track completo (anillo gris). + let track = Arc::new((cx, cy), (radius, radius), 0.0, std::f64::consts::TAU, 0.0); + scene.stroke(&stroke, Affine::IDENTITY, track_color, None, &track); + + // Arco lleno — arranca en -π/2 (12 en punto) y barre `p * 2π` + // en sentido horario (positivo en el sistema y-down de vello). + if p > 0.0 { + let theta0 = -std::f64::consts::FRAC_PI_2; + let sweep = std::f64::consts::TAU * p as f64; + let fill_arc = Arc::new((cx, cy), (radius, radius), theta0, sweep, 0.0); + scene.stroke(&stroke, Affine::IDENTITY, fill_color, None, &fill_arc); + } + }) +} diff --git a/widgets/scroll/Cargo.toml b/widgets/scroll/Cargo.toml new file mode 100644 index 0000000..1aea1a6 --- /dev/null +++ b/widgets/scroll/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-scroll" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-scroll — área de scroll vertical reutilizable: viewport clipeado + contenido desplazado + barra arrastrable. Stateless (el offset vive en el Model); rueda autocontenida vía View::on_scroll. Helpers puros: clamp_offset, ensure_visible, approach (scroll suave)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/scroll/src/lib.rs b/widgets/scroll/src/lib.rs new file mode 100644 index 0000000..9e054b1 --- /dev/null +++ b/widgets/scroll/src/lib.rs @@ -0,0 +1,326 @@ +//! `llimphi-widget-scroll` — área de scroll vertical reutilizable. +//! +//! Hasta ahora cada app rearmaba el scroll a mano: `App::on_wheel` + un +//! offset en el `Model` + `clip` + virtualización. Este widget empaqueta +//! ese patrón en un solo builder, **sin estado propio** (el offset sigue +//! viviendo en el `Model`, fiel al bucle Elm): +//! +//! - **viewport clipeado** de alto fijo (`viewport_len`), +//! - **contenido desplazado** `-offset` px (overflow recortado), +//! - **barra de scroll arrastrable** a la derecha (sólo si el contenido +//! excede el viewport), +//! - **rueda autocontenida** vía [`View::on_scroll`]: girar la rueda con +//! el cursor sobre el área emite un `Msg` sin que la app rutee nada por +//! su `on_wheel` global. +//! +//! El caller debe conocer el **alto total del contenido** (`content_len`) +//! y el **alto visible** (`viewport_len`) — igual que `list`/`grid` ya +//! piden la ventana visible. Para contenido de filas uniformes es +//! `n_filas * alto_fila`. +//! +//! ## Convención del callback `on_scroll` +//! +//! `on_scroll` recibe el **delta en px** a sumar al offset (no el offset +//! absoluto): tanto la rueda como el arrastre de la barra emiten deltas, +//! y el caller acumula + clampea en su `update` con [`clamp_offset`]. Es +//! la misma idea que el `splitter` (el handler de drag se reusa durante +//! todo el arrastre, así que un offset absoluto capturado se quedaría +//! viejo; el delta-por-evento siempre es correcto). +//! +//! ```ignore +//! // view: +//! scroll_y( +//! model.offset, +//! model.rows.len() as f32 * ROW_H, +//! panel_h, +//! lista_view, +//! Msg::ScrollBy, // Fn(f32) -> Msg, arg = delta px +//! &ScrollPalette::default(), +//! ) +//! // update: +//! Msg::ScrollBy(d) => { +//! m.offset = clamp_offset(m.offset + d, content_len, viewport_len); +//! } +//! ``` +//! +//! Para llevar una selección a la vista (teclado), ver [`ensure_visible`]; +//! para scroll suave/inercia, ver [`approach`]. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, Position, Rect, Size, Style}, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; + +/// Alto mínimo del thumb en px — para que no desaparezca con contenido +/// muy largo. +const MIN_THUMB: f32 = 28.0; +/// Px de desplazamiento por "línea" de rueda. Aproxima el step de scroll +/// de un editor (≈3 líneas de texto). +pub const DEFAULT_LINE_PX: f32 = 48.0; +/// Ancho de la barra de scroll en px. +pub const DEFAULT_BAR_WIDTH: f32 = 10.0; + +/// Colores de la barra de scroll. +#[derive(Debug, Clone, Copy)] +pub struct ScrollPalette { + /// Canal de fondo (track). + pub track: Color, + /// Pulgar (thumb) en reposo. + pub thumb: Color, + /// Pulgar al pasar el cursor. + pub thumb_hover: Color, + /// Ancho de la barra y px por línea de rueda. + pub bar_width: f32, + pub line_px: f32, +} + +impl Default for ScrollPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl ScrollPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + track: t.bg_panel_alt, + thumb: t.border, + thumb_hover: t.accent, + bar_width: DEFAULT_BAR_WIDTH, + line_px: DEFAULT_LINE_PX, + } + } +} + +/// Máximo offset posible: cuánto se puede desplazar antes de que el final +/// del contenido toque el borde inferior del viewport. `0` si el contenido +/// entra entero. +pub fn max_offset(content_len: f32, viewport_len: f32) -> f32 { + (content_len - viewport_len).max(0.0) +} + +/// Acota `offset` a `[0, max_offset]`. El caller lo usa en su `update` +/// tras sumar el delta de [`scroll_y`]. +pub fn clamp_offset(offset: f32, content_len: f32, viewport_len: f32) -> f32 { + offset.clamp(0.0, max_offset(content_len, viewport_len)) +} + +/// Devuelve el offset que deja **visible** el intervalo vertical +/// `[item_top, item_top + item_h]` dentro de un viewport de alto +/// `viewport_len`, partiendo de `offset`. Si ya está visible, lo devuelve +/// sin cambios. Pensado para "llevar la selección a la vista" al navegar +/// con teclado (flechas, Page Up/Down). El resultado se acota a `≥ 0`; el +/// caller puede clampear arriba con [`clamp_offset`] si lo necesita. +pub fn ensure_visible(offset: f32, viewport_len: f32, item_top: f32, item_h: f32) -> f32 { + if item_top < offset { + // El item arranca por encima del viewport: subí hasta su tope. + item_top.max(0.0) + } else if item_top + item_h > offset + viewport_len { + // El item termina por debajo: bajá hasta que su fondo toque el borde. + (item_top + item_h - viewport_len).max(0.0) + } else { + offset + } +} + +/// Un paso de aproximación exponencial de `current` hacia `target` +/// (scroll suave / inercia). `factor ∈ (0, 1]`: 1.0 salta de una, 0.2 +/// desliza suave. Cuando la diferencia cae por debajo de 0.5 px aterriza +/// exacto en `target` (evita el "casi-llega" infinito). El caller lo +/// dispara por frame vía `Handle::spawn_periodic` guardando `target` en +/// su `Model`. +pub fn approach(current: f32, target: f32, factor: f32) -> f32 { + let f = factor.clamp(0.0, 1.0); + let next = current + (target - current) * f; + if (target - next).abs() < 0.5 { + target + } else { + next + } +} + +/// Geometría del thumb: `(altura, posición_y)` dentro del track de alto +/// `viewport_len`, y `offset_por_px` (cuánto offset de contenido equivale +/// a 1 px de arrastre del thumb). Público para tests y para callers que +/// quieran pintar su propia barra. +pub fn thumb_geometry(offset: f32, content_len: f32, viewport_len: f32) -> (f32, f32, f32) { + let max_off = max_offset(content_len, viewport_len); + if max_off <= 0.0 || content_len <= 0.0 { + return (viewport_len, 0.0, 0.0); + } + let ratio = (viewport_len / content_len).clamp(0.0, 1.0); + let thumb_h = (viewport_len * ratio).clamp(MIN_THUMB.min(viewport_len), viewport_len); + let travel = (viewport_len - thumb_h).max(0.0); + let thumb_y = if max_off > 0.0 { + (offset / max_off).clamp(0.0, 1.0) * travel + } else { + 0.0 + }; + let offset_per_px = if travel > 0.0 { max_off / travel } else { 0.0 }; + (thumb_h, thumb_y, offset_per_px) +} + +/// Área de scroll vertical. `offset` es el desplazamiento actual (px, ya +/// clampeado por el caller). `content_len`/`viewport_len` el alto total y +/// visible. `content` se desplaza `-offset` y se recorta al viewport. +/// `on_scroll(delta_px)` se invoca con el delta a sumar al offset (rueda +/// y arrastre de barra); el caller acumula con [`clamp_offset`]. +pub fn scroll_y( + offset: f32, + content_len: f32, + viewport_len: f32, + content: View, + on_scroll: F, + palette: &ScrollPalette, +) -> View +where + // `Msg` no necesita `Send + Sync`: los closures de rueda/arrastre + // capturan el `Arc`, no un `Msg`. Sólo se exige + // `Clone` (para montar el `View`) y `'static`. + Msg: Clone + 'static, + F: Fn(f32) -> Msg + Send + Sync + 'static, +{ + let on_scroll = Arc::new(on_scroll); + + // Contenido desplazado: nodo absoluto anclado a left/right (toma el + // ancho del viewport) con top = -offset y alto natural. El overflow se + // recorta por el `clip` del viewport. + let content_wrap = View::new(Style { + position: Position::Absolute, + inset: Rect { + top: length(-offset), + left: length(0.0), + right: length(0.0), + bottom: auto(), + }, + ..Default::default() + }) + .children(vec![content]); + + let mut children = vec![content_wrap]; + + // Barra: sólo si hay overflow. + if max_offset(content_len, viewport_len) > 0.0 { + let (thumb_h, thumb_y, offset_per_px) = + thumb_geometry(offset, content_len, viewport_len); + + let on_thumb = on_scroll.clone(); + let thumb = View::new(Style { + position: Position::Absolute, + inset: Rect { + top: length(thumb_y), + right: length(0.0), + left: auto(), + bottom: auto(), + }, + size: Size { + width: length(palette.bar_width), + height: length(thumb_h), + }, + ..Default::default() + }) + .fill(palette.thumb) + .hover_fill(palette.thumb_hover) + .radius((palette.bar_width * 0.5) as f64) + .draggable(move |phase, _dx, dy| match phase { + // Cada Move trae el delta de px de pantalla del thumb; lo + // convertimos a delta de offset de contenido. + DragPhase::Move => Some((on_thumb)(dy * offset_per_px)), + DragPhase::End => None, + }); + + let track = View::new(Style { + position: Position::Absolute, + inset: Rect { + top: length(0.0), + right: length(0.0), + bottom: length(0.0), + left: auto(), + }, + size: Size { + width: length(palette.bar_width), + height: auto(), + }, + ..Default::default() + }) + .fill(palette.track) + .children(vec![thumb]); + + children.push(track); + } + + // Viewport: alto fijo, ancho del padre, contenido recortado, rueda + // local. Position::Relative para ser el bloque contenedor de los + // hijos absolutos. + let line_px = palette.line_px; + let on_wheel = on_scroll; + View::new(Style { + position: Position::Relative, + size: Size { + width: percent(1.0), + height: length(viewport_len), + }, + ..Default::default() + }) + .clip(true) + .on_scroll(move |_dx, dy| Some((on_wheel)(dy * line_px))) + .children(children) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn max_y_clamp() { + assert_eq!(max_offset(1000.0, 300.0), 700.0); + assert_eq!(max_offset(200.0, 300.0), 0.0); // entra entero + assert_eq!(clamp_offset(-50.0, 1000.0, 300.0), 0.0); + assert_eq!(clamp_offset(9999.0, 1000.0, 300.0), 700.0); + assert_eq!(clamp_offset(400.0, 1000.0, 300.0), 400.0); + } + + #[test] + fn ensure_visible_arriba_abajo_y_sin_cambio() { + let vp = 300.0; + // Item por encima del offset → subir hasta su tope. + assert_eq!(ensure_visible(500.0, vp, 100.0, 20.0), 100.0); + // Item por debajo del fondo visible → bajar lo justo. + assert_eq!(ensure_visible(0.0, vp, 400.0, 20.0), 120.0); // 400+20-300 + // Item ya visible → sin cambios. + assert_eq!(ensure_visible(50.0, vp, 100.0, 20.0), 50.0); + // Nunca negativo. + assert_eq!(ensure_visible(50.0, vp, -10.0, 20.0), 0.0); + } + + #[test] + fn approach_aterriza_exacto() { + // Se acerca pero no salta. + let a = approach(0.0, 100.0, 0.25); + assert!(a > 0.0 && a < 100.0); + // Diferencia < 0.5 px → aterriza exacto. + assert_eq!(approach(99.8, 100.0, 0.25), 100.0); + // factor 1.0 salta de una. + assert_eq!(approach(0.0, 100.0, 1.0), 100.0); + } + + #[test] + fn thumb_proporcional_y_topes() { + // Contenido entra entero → thumb cubre todo, sin travel. + let (h, y, opp) = thumb_geometry(0.0, 200.0, 300.0); + assert_eq!((h, y, opp), (300.0, 0.0, 0.0)); + // Contenido 3× viewport → thumb ≈ 1/3 (clampeado a MIN_THUMB). + let (h, y, _) = thumb_geometry(0.0, 900.0, 300.0); + assert!((h - 100.0).abs() < 0.01); + assert_eq!(y, 0.0); + // En el máximo offset, el thumb toca el fondo del track. + let max = max_offset(900.0, 300.0); + let (h2, y2, _) = thumb_geometry(max, 900.0, 300.0); + assert!((y2 + h2 - 300.0).abs() < 0.01); + } +} diff --git a/widgets/segmented/Cargo.toml b/widgets/segmented/Cargo.toml new file mode 100644 index 0000000..a6b0efe --- /dev/null +++ b/widgets/segmented/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-segmented" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-segmented — control de opciones mutuamente exclusivas (radio horizontal). Para 2-5 opciones en línea." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/segmented/src/lib.rs b/widgets/segmented/src/lib.rs new file mode 100644 index 0000000..ddbe843 --- /dev/null +++ b/widgets/segmented/src/lib.rs @@ -0,0 +1,143 @@ +//! `llimphi-widget-segmented` — control de opciones mutuamente exclusivas. +//! +//! N opciones horizontales con UNA activa. Patrón iOS/macOS para +//! alternativas radio-style cuando son pocas (2-5) y caben en línea. +//! Si son más, usar un `tabs` o un dropdown. +//! +//! Render-only: la app guarda `selected: usize` en el modelo y +//! dispatcha `Msg::SelectSegment(usize)` al click. + +#![forbid(unsafe_code)] + +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::View; +use llimphi_theme::{radius, Theme}; + +/// Paleta del control. +#[derive(Debug, Clone, Copy)] +pub struct SegmentedPalette { + pub bg_track: Color, + pub bg_active: Color, + pub fg_active: Color, + pub fg_inactive: Color, + pub fg_hover: Color, +} + +impl SegmentedPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + bg_track: t.bg_button, + bg_active: t.bg_panel, + fg_active: t.fg_text, + fg_inactive: t.fg_muted, + fg_hover: t.fg_text, + } + } +} + +/// Construye el control. `labels` son los textos visibles; `selected` +/// es el índice activo (0-based). `make_msg(i)` se llama al click. +pub fn segmented_view( + labels: &[&str], + selected: usize, + make_msg: F, + palette: &SegmentedPalette, +) -> View +where + Msg: Clone + 'static, + F: Fn(usize) -> Msg, +{ + let children: Vec> = labels + .iter() + .enumerate() + .map(|(i, label)| segment_view(i, label, i == selected, make_msg(i), palette)) + .collect(); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + padding: Rect { + left: length(2.0_f32), + right: length(2.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + gap: Size { + width: length(2.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_track) + .radius(radius::SM) + .children(children) +} + +fn segment_view( + _idx: usize, + label: &str, + is_active: bool, + msg: Msg, + palette: &SegmentedPalette, +) -> View { + let (bg, fg) = if is_active { + (Some(palette.bg_active), palette.fg_active) + } else { + (None, palette.fg_inactive) + }; + + let seg_radius = radius::XS; + let mut node = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .radius(seg_radius) + .text_aligned(label.to_string(), 11.5, fg, Alignment::Center) + .on_click(msg); + + if let Some(c) = bg { + node = node.fill(c).paint_with(move |scene, _ts, rect| { + // Gloss superior sólo en el segmento activo — refuerza + // "esto está seleccionado" con la misma firma de button (P6). + // Los segmentos inactivos quedan planos para que el contraste + // sea inequívoco. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, seg_radius); + let top = Color::from_rgba8(255, 255, 255, 28); + let bot = Color::from_rgba8(255, 255, 255, 0); + let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); + }); + } + node +} diff --git a/widgets/shortcuts-help/Cargo.toml b/widgets/shortcuts-help/Cargo.toml new file mode 100644 index 0000000..fe41b1f --- /dev/null +++ b/widgets/shortcuts-help/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-shortcuts-help" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-shortcuts-help — overlay '?' que muestra los atajos de teclado del contexto actual, agrupados por categoría." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/shortcuts-help/src/lib.rs b/widgets/shortcuts-help/src/lib.rs new file mode 100644 index 0000000..6471f03 --- /dev/null +++ b/widgets/shortcuts-help/src/lib.rs @@ -0,0 +1,282 @@ +//! `llimphi-widget-shortcuts-help` — overlay de atajos de teclado. +//! +//! Convención "press ? for help": cuando el usuario aprieta `?`, +//! aparece un panel centrado con todos los atajos del contexto actual +//! agrupados por categoría. Cualquier tecla cierra (la app maneja eso). +//! +//! La app construye un `ShortcutsHelpSpec` con grupos y entries, lo +//! guarda en su modelo cuando se abre, y lo devuelve desde +//! `view_overlay`. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_theme::{alpha, radius, Theme}; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta del overlay. +#[derive(Debug, Clone, Copy)] +pub struct ShortcutsHelpPalette { + pub scrim: Color, + /// Firma del panel (gradient + hairline accent en top edge). + pub panel: PanelStyle, + pub border: Color, + pub fg_title: Color, + pub fg_group: Color, + pub fg_desc: Color, + pub fg_key: Color, + pub bg_key: Color, +} + +impl ShortcutsHelpPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM), + panel: PanelStyle::from_theme_large(t), + border: t.border, + fg_title: t.fg_text, + fg_group: t.accent, + fg_desc: t.fg_text, + fg_key: t.fg_text, + bg_key: t.bg_button, + } + } +} + +/// Una entrada de atajo: combinación de teclas + descripción de qué hace. +#[derive(Debug, Clone)] +pub struct ShortcutEntry { + /// La combinación tal como aparece (ej. `"Ctrl+S"`, `"⌘K ⌘P"`, `"?"`). + pub keys: String, + pub description: String, +} + +impl ShortcutEntry { + pub fn new(keys: impl Into, description: impl Into) -> Self { + Self { keys: keys.into(), description: description.into() } + } +} + +/// Grupo de atajos con un título (ej. "Edición", "Navegación"). +#[derive(Debug, Clone)] +pub struct ShortcutGroup { + pub title: String, + pub entries: Vec, +} + +impl ShortcutGroup { + pub fn new(title: impl Into, entries: Vec) -> Self { + Self { title: title.into(), entries } + } +} + +/// Spec completo del overlay. +pub struct ShortcutsHelpSpec { + pub title: String, + pub groups: Vec, + pub viewport: (f32, f32), + pub on_dismiss: Msg, + pub palette: ShortcutsHelpPalette, +} + +const PANEL_W: f32 = 480.0; +const TITLE_FONT: f32 = 16.0; +const GROUP_FONT: f32 = 11.5; +const ENTRY_FONT: f32 = 12.0; +const ENTRY_H: f32 = 22.0; +const GROUP_H: f32 = 24.0; +const TITLE_H: f32 = 40.0; +const PAD: f32 = 20.0; + +pub fn shortcuts_help_view(spec: ShortcutsHelpSpec) -> View { + let ShortcutsHelpSpec { title, groups, viewport, on_dismiss, palette } = spec; + + // Altura del panel — suma de header + grupos. + let body_h: f32 = groups + .iter() + .map(|g| GROUP_H + g.entries.len() as f32 * ENTRY_H + 8.0) + .sum(); + let panel_h = (TITLE_H + body_h + PAD * 2.0).min(viewport.1 - 32.0); + let panel_w = PANEL_W.min(viewport.0 - 32.0); + let x = ((viewport.0 - panel_w) * 0.5).max(0.0); + let y = ((viewport.1 - panel_h) * 0.5).max(0.0); + + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(TITLE_H), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(title, TITLE_FONT, palette.fg_title, Alignment::Start); + + let mut body_children: Vec> = Vec::with_capacity(groups.len() * 6); + for group in &groups { + body_children.push(group_header_view(&group.title, &palette)); + for entry in &group.entries { + body_children.push(entry_view(entry, &palette)); + } + } + let body = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: auto(), + }, + flex_grow: 1.0, + ..Default::default() + }) + .children(body_children); + + let panel = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(panel_w), + height: length(panel_h), + }, + flex_direction: FlexDirection::Column, + padding: Rect { + left: length(PAD), + right: length(PAD), + top: length(PAD), + bottom: length(PAD), + }, + ..Default::default() + }) + .paint_with(panel_signature_painter(palette.panel)) + .radius(palette.panel.radius) + .clip(true) + .children(vec![header, body]); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.scrim) + .on_click(on_dismiss) + .children(vec![panel]) +} + +fn group_header_view( + title: &str, + palette: &ShortcutsHelpPalette, +) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(GROUP_H), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(8.0_f32), + bottom: length(2.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned( + title.to_uppercase(), + GROUP_FONT, + palette.fg_group, + Alignment::Start, + ) +} + +fn entry_view( + entry: &ShortcutEntry, + palette: &ShortcutsHelpPalette, +) -> View { + let desc = 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( + entry.description.clone(), + ENTRY_FONT, + palette.fg_desc, + Alignment::Start, + ); + + let key_radius = radius::XS; + let keys = View::new(Style { + size: Size { + width: length(140.0_f32), + height: length(ENTRY_H - 6.0), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::FlexEnd), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_key) + .radius(key_radius) + .paint_with(move |scene, _ts, rect| { + // Gloss superior — el chip de teclado se lee como tecla con + // luz cayendo desde el top, no como rect plano. Mismo patrón + // que button (P6) — todo chip clicable o tipo-tecla comparte + // la firma. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, key_radius); + let top = Color::from_rgba8(255, 255, 255, 28); + let bot = Color::from_rgba8(255, 255, 255, 0); + let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); + }) + .text_aligned(entry.keys.clone(), ENTRY_FONT - 1.0, palette.fg_key, Alignment::End); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(ENTRY_H), + }, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(10.0_f32), + height: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![desc, keys]) +} diff --git a/widgets/skeleton/Cargo.toml b/widgets/skeleton/Cargo.toml new file mode 100644 index 0000000..95a562b --- /dev/null +++ b/widgets/skeleton/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-skeleton" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-skeleton — bloque animado con shimmer para placeholders de contenido en carga. Alternativa a spinner cuando se conoce la forma del contenido (lista de N items, card con título+texto+imagen)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/skeleton/src/lib.rs b/widgets/skeleton/src/lib.rs new file mode 100644 index 0000000..1e39743 --- /dev/null +++ b/widgets/skeleton/src/lib.rs @@ -0,0 +1,145 @@ +//! `llimphi-widget-skeleton` — placeholder de carga con shimmer. +//! +//! Cuando una pantalla está cargando contenido cuya forma es predecible +//! (ej. una lista de 5 cards, un avatar+nombre+timestamp), un skeleton +//! es más informativo que un spinner: el usuario ya ve QUÉ vendrá, +//! sólo no tiene los valores reales todavía. +//! +//! El brillo (shimmer) viene de una **banda de gradiente que cruza** el +//! rect de izquierda a derecha cíclicamente. Los stops son +//! `[low, high, low]` sobre una franja del ~50% del ancho, con `Extend::Pad` +//! por default — fuera de la banda el rect queda en `low`, dentro el +//! `high` pinta el destello. Es el patrón canónico de Material/Apple/ +//! sistemas modernos, más legible que la oscilación uniforme previa. +//! +//! Como `spinner`, requiere que la app fuerce redraws periódicos para +//! que la animación corra (típico: `Handle::spawn_periodic(50ms, …)` +//! mientras hay skeletons visibles). + +#![forbid(unsafe_code)] + +use std::time::Instant; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + Position, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_theme::{radius, Theme}; + +/// Paleta del skeleton — dos tonos entre los que oscila. +#[derive(Debug, Clone, Copy)] +pub struct SkeletonPalette { + pub low: Color, + pub high: Color, +} + +impl SkeletonPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + low: t.bg_panel_alt, + high: t.bg_button_hover, + } + } +} + +/// Período del shimmer en segundos — un ciclo completo de la banda +/// cruzando el rect. 1.4s es el sweet spot: rápido para señalar +/// "esto se está cargando", lento para no marear. +const SHIMMER_CYCLE_SECS: f32 = 1.4; +/// Ancho de la banda como fracción del ancho del rect. 50% da una +/// transición suave; bajar a 30% da un destello más puntual. +const SHIMMER_BAND_FRAC: f64 = 0.5; +/// Ancho mínimo absoluto de la banda — evita que en skeletons cortos +/// (avatares chicos, line skeletons de ~80px) el destello sea un +/// pixel apretado. +const SHIMMER_BAND_MIN_PX: f64 = 40.0; + +/// Bloque rectangular animado. La altura y forma viene del `Style` +/// que pasa el caller — el skeleton sólo aporta el `fill` animado. +/// +/// Devuelve un `View` con `paint_with` que pinta una banda de +/// gradiente atravesando el rect. Para usarlo dentro de un layout +/// con tamaño definido, envolvelo en un contenedor con el `Style` +/// adecuado. +pub fn skeleton_view(palette: &SkeletonPalette) -> View { + let started = Instant::now(); + let p = *palette; + 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| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + + // Progress del ciclo en [0, 1). + let elapsed = started.elapsed().as_secs_f32(); + let progress = (elapsed / SHIMMER_CYCLE_SECS).fract() as f64; + + // Banda: ancho relativo al rect (con floor mínimo) que arranca + // a la izquierda del rect y termina a la derecha. Distancia + // total recorrida = rect.w + band_w, así el destello entra y + // sale por completo. + let rect_w = rect.w as f64; + let band_w = (rect_w * SHIMMER_BAND_FRAC).max(SHIMMER_BAND_MIN_PX); + let travel = rect_w + band_w; + let band_left = rect.x as f64 - band_w + progress * travel; + let band_right = band_left + band_w; + let cy = (rect.y + rect.h * 0.5) as f64; + + // Single fill: gradient lineal con stops [low, high, low]. Fuera + // de [band_left, band_right] el Extend::Pad (default de peniko) + // extiende los stops endpoint — ambos `low` — así el resto del + // rect queda en `low` sin necesidad de un fill base separado. + let rr = RoundedRect::new( + rect.x as f64, + rect.y as f64, + (rect.x + rect.w) as f64, + (rect.y + rect.h) as f64, + radius::SM, + ); + let gradient = Gradient::new_linear( + Point::new(band_left, cy), + Point::new(band_right, cy), + ) + .with_stops([p.low, p.high, p.low].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr); + }) +} + +/// Caja con tamaño explícito (ancho + alto en px) + skeleton adentro. +/// Helper para casos comunes: line skeleton (`skeleton_line_view(160)`). +pub fn skeleton_box_view( + width_px: f32, + height_px: f32, + palette: &SkeletonPalette, +) -> View { + View::new(Style { + size: Size { + width: length(width_px), + height: length(height_px), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![skeleton_view(palette)]) +} + +/// Línea horizontal típica para texto en carga (height fijo ~12px). +pub fn skeleton_line_view( + width_px: f32, + palette: &SkeletonPalette, +) -> View { + skeleton_box_view(width_px, 12.0, palette) +} + diff --git a/widgets/slider/Cargo.toml b/widgets/slider/Cargo.toml new file mode 100644 index 0000000..ba415b4 --- /dev/null +++ b/widgets/slider/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-widget-slider" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-slider — slider horizontal con etiqueta + track draggable + valor numérico. El track es un fillbar (sin pulgar): cambia el ancho relleno según la fracción `(value-min)/(max-min)`. El drag emite el delta de valor (no pixels) en cada `Move`, listo para reentrar al update." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[[example]] +name = "slider_demo" +path = "examples/slider_demo.rs" diff --git a/widgets/slider/LEEME.md b/widgets/slider/LEEME.md new file mode 100644 index 0000000..a3ef331 --- /dev/null +++ b/widgets/slider/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-slider + +> Slider con tick marks para [llimphi](../../README.md). + +Horizontal/vertical. Range custom, snap-to-ticks opcional, label de valor en vivo. Continuous y stepped variants. diff --git a/widgets/slider/README.md b/widgets/slider/README.md new file mode 100644 index 0000000..43a2bba --- /dev/null +++ b/widgets/slider/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-slider + +> Slider with tick marks for [llimphi](../../README.md). + +Horizontal/vertical. Custom range, optional snap-to-ticks, live value label. Continuous and stepped variants. diff --git a/widgets/slider/examples/slider_demo.rs b/widgets/slider/examples/slider_demo.rs new file mode 100644 index 0000000..fcd50d1 --- /dev/null +++ b/widgets/slider/examples/slider_demo.rs @@ -0,0 +1,130 @@ +//! Showcase de `llimphi-widget-slider`: tres sliders sobre un Model que +//! acumula deltas en vivo. Corré con: +//! +//! ```text +//! cargo run -p llimphi-widget-slider --example slider_demo +//! ``` + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, DragPhase, Handle, View}; +use llimphi_widget_slider::{slider_view, SliderPalette}; + +#[derive(Clone, Debug)] +enum Msg { + EditPsique(f32), + EditMateria(f32), + EditPoder(f32), +} + +struct Model { + psique: f32, + materia: f32, + poder: f32, +} + +struct Demo; + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · slider demo" + } + + fn initial_size() -> (u32, u32) { + (520, 280) + } + + fn init(_: &Handle) -> Model { + Model { psique: 0.0, materia: 0.5, poder: -0.25 } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::EditPsique(dv) => m.psique = (m.psique + dv).clamp(-1.0, 1.0), + Msg::EditMateria(dv) => m.materia = (m.materia + dv).clamp(-1.0, 1.0), + Msg::EditPoder(dv) => m.poder = (m.poder + dv).clamp(-1.0, 1.0), + } + m + } + + fn view(model: &Model) -> View { + let theme = Theme::dark(); + let palette = SliderPalette::from_theme(&theme); + + let header = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, + ..Default::default() + }) + .text_aligned( + "ajustá los sliders — el Model acumula deltas en vivo".to_string(), + 13.0, + theme.fg_text, + Alignment::Start, + ); + + let psique = slider_view( + "psique", + model.psique, + -1.0, + 1.0, + &palette, + |phase, dv| match phase { + DragPhase::Move => Some(Msg::EditPsique(dv)), + DragPhase::End => None, + }, + ); + let materia = slider_view( + "materia", + model.materia, + -1.0, + 1.0, + &palette, + |phase, dv| match phase { + DragPhase::Move => Some(Msg::EditMateria(dv)), + DragPhase::End => None, + }, + ); + let poder = slider_view( + "poder", + model.poder, + -1.0, + 1.0, + &palette, + |phase, dv| match phase { + DragPhase::Move => Some(Msg::EditPoder(dv)), + DragPhase::End => None, + }, + ); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(16.0_f32), + bottom: length(16.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(8.0_f32), + }, + align_items: Some(AlignItems::Stretch), + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![header, psique, materia, poder]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/slider/src/lib.rs b/widgets/slider/src/lib.rs new file mode 100644 index 0000000..5fb6c4b --- /dev/null +++ b/widgets/slider/src/lib.rs @@ -0,0 +1,254 @@ +//! `llimphi-widget-slider` — slider horizontal con label + track + valor. +//! +//! Pattern análogo a `llimphi-widget-splitter`: el widget no mantiene +//! estado. El caller guarda el valor actual en su `Model` y le pasa un +//! handler `Fn(DragPhase, f32) -> Option` que recibe **el delta de +//! valor** (no el delta de pixels) entre eventos consecutivos. El widget +//! traduce internamente `dx_pixels` a `dv` usando `track_width`. +//! +//! Visualmente es un *fillbar*: el track entero es draggable y se rellena +//! una fracción proporcional a `(value - min) / (max - min)`. No hay +//! pulgar separado — el límite entre relleno y vacío es el indicador. +//! +//! Layout fila: +//! +//! ```text +//! [ label_width ] [ ████░░░░░░ ] [ value_width ] +//! "psique" 0.4 / 1.0 " 0.40" +//! ``` +//! +//! Uso típico (sliders sobre `LayerMods` de un Concepto): +//! +//! ```ignore +//! slider_view( +//! "psique", +//! model.selected.mods.psique, +//! -1.0, 1.0, +//! &palette, +//! |phase, dv| match phase { +//! DragPhase::Move => Some(Msg::EditMod(Layer::Psique, dv)), +//! DragPhase::End => None, +//! }, +//! ) +//! ``` + +#![forbid(unsafe_code)] + +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::{DragPhase, View}; + +/// Paleta del slider. Las dimensiones también viajan acá porque definen +/// el layout fila — el caller no toca el `Style` del slider directamente. +#[derive(Debug, Clone, Copy)] +pub struct SliderPalette { + pub track: Color, + pub track_filled: Color, + pub track_hover: Color, + pub fg_label: Color, + pub fg_value: Color, + pub radius: f64, + /// Alto total del widget en pixels. + pub row_height: f32, + /// Ancho fijo del bloque del label (a la izquierda). + pub label_width: f32, + /// Ancho fijo del bloque del valor numérico (a la derecha). + pub value_width: f32, + /// Ancho fijo del track draggable (al medio). Único valor que el + /// widget usa para convertir dx_pixels → dv_value. + pub track_width: f32, + /// Grosor (alto) del track en pixels. + pub track_thickness: f32, +} + +impl Default for SliderPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl SliderPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + track: t.bg_button, + track_filled: t.accent, + track_hover: t.bg_button_hover, + fg_label: t.fg_muted, + fg_value: t.fg_text, + radius: 3.0, + row_height: 22.0, + label_width: 80.0, + value_width: 56.0, + track_width: 120.0, + track_thickness: 6.0, + } + } +} + +/// Compone un slider horizontal: label + track-fillbar draggable + valor. +/// +/// `value`, `min`, `max` son sólo para presentación visual y conversión +/// `dx → dv`; el caller mantiene el estado y aplica el delta en su +/// `update`. El handler recibe `(DragPhase, delta_value)`; devolver +/// `None` deja el drag activo sin emitir Msg. +pub fn slider_view( + label: impl Into, + value: f32, + min: f32, + max: f32, + palette: &SliderPalette, + on_change: F, +) -> View +where + Msg: Clone + Send + Sync + 'static, + F: Fn(DragPhase, f32) -> Option + Send + Sync + 'static, +{ + let range = (max - min).max(f32::EPSILON); + let ratio = ((value - min) / range).clamp(0.0, 1.0); + let track_width = palette.track_width.max(1.0); + + // Drag: dx_pixels → dv_value. Escala FIJA (no depende del valor actual). + let span = max - min; + let handler = move |phase: DragPhase, dx: f32, _dy: f32| -> Option { + let dv = dx * span / track_width; + on_change(phase, dv) + }; + + // Bloque del label. + let label_view = View::new(Style { + size: Size { + width: length(palette.label_width), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(label.into(), 12.0, palette.fg_label, Alignment::Start); + + // Track draggable: fill = track bg, hijo = porción rellena (accent). + let filled_radius = palette.radius; + let filled = View::new(Style { + size: Size { + width: percent(ratio), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.track_filled) + .radius(filled_radius) + .paint_with(move |scene, _ts, rect| { + // Gloss superior sobre la stripe accent — la barra se lee como + // luz que avanza, no como rect plano. Mismo patrón button/progress + // (P6/P7). Alpha bajo (40) porque el track es muy delgado (6px + // default) y un sheen fuerte le mete glitter. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, filled_radius); + let top = Color::from_rgba8(255, 255, 255, 40); + let bot = Color::from_rgba8(255, 255, 255, 0); + let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); + }); + + let track = View::new(Style { + size: Size { + width: length(track_width), + height: length(palette.track_thickness), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.track) + .hover_fill(palette.track_hover) + .radius(palette.radius) + .draggable(handler) + .children(vec![filled]); + + // Wrapper del track para centrarlo verticalmente sobre la fila. + let track_cell = View::new(Style { + size: Size { + width: length(track_width), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![track]); + + // Bloque del valor. + let value_text = format_value(value); + let value_view = View::new(Style { + size: Size { + width: length(palette.value_width), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(value_text, 12.0, palette.fg_value, Alignment::End); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(palette.row_height), + }, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(8.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![label_view, track_cell, value_view]) +} + +/// Formato uniforme para los valores: 2 decimales con signo explícito si +/// la magnitud es chica, 1 decimal si es grande. Cabe en `value_width: 56`. +fn format_value(v: f32) -> String { + let abs = v.abs(); + if abs >= 1000.0 { + format!("{v:.0}") + } else if abs >= 10.0 { + format!("{v:.1}") + } else { + format!("{v:+.2}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_value_pretty_for_three_regimes() { + assert_eq!(format_value(0.34), "+0.34"); + assert_eq!(format_value(-0.10), "-0.10"); + assert_eq!(format_value(42.5), "42.5"); + assert_eq!(format_value(1234.0), "1234"); + } +} diff --git a/widgets/spinner/Cargo.toml b/widgets/spinner/Cargo.toml new file mode 100644 index 0000000..21b90d2 --- /dev/null +++ b/widgets/spinner/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-spinner" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-spinner — spinner circular animado por reloj absoluto (no requiere ticks del modelo). Stroke gradient circular. Default 24×24 pero escalable." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/spinner/src/lib.rs b/widgets/spinner/src/lib.rs new file mode 100644 index 0000000..9d3c192 --- /dev/null +++ b/widgets/spinner/src/lib.rs @@ -0,0 +1,72 @@ +//! `llimphi-widget-spinner` — spinner circular animado por reloj absoluto. +//! +//! El paint usa `Instant::now()` para calcular el ángulo de rotación, +//! así no hace falta que la app guarde un tween ni dispatchee ticks: +//! cuando llimphi-ui rasterize un frame (porque algo cambió en el +//! modelo o porque la app pidió un repaint), el spinner se ve girando. +//! +//! **Nota**: el spinner sólo se anima si HAY frames. Una app idle no +//! repintará por sí sola — usar `Handle::spawn_periodic(50ms, …)` +//! mientras el spinner esté visible para forzar redraw. O conectar +//! el spinner a un `Tween` y leer su `progress()` desde la `view`. +//! +//! Diseño visual: arco de 270° con stroke variable (más grueso al +//! frente del giro, más fino atrás) para dar sensación de aceleración. + +#![forbid(unsafe_code)] + +use std::time::Instant; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{percent, Size, Style}, + Position, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Arc, Cap, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; + +/// Construye el `View` que pinta un spinner circular animado dentro +/// del rect del padre. +/// +/// - `color`: tinte del arco (típico: `theme.accent`). +/// - `stroke_width_ratio`: grosor del arco como fracción del lado +/// menor (0.10 = 10%). Default razonable es `0.12`. +/// - `speed_rev_per_sec`: revoluciones por segundo. Default `1.0`. +pub fn spinner_view( + color: Color, + stroke_width_ratio: f32, + speed_rev_per_sec: f32, +) -> View { + // Anchor temporal: arrancamos el reloj al construir el View. Como + // la closure se evalúa por frame, cada repintado calcula `elapsed` + // contra este origen — sin tween, sin model state. + let started = Instant::now(); + let sw = stroke_width_ratio; + let speed = speed_rev_per_sec; + 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| { + let side = rect.w.min(rect.h) as f64; + if side <= 0.0 { + return; + } + let cx = rect.x as f64 + rect.w as f64 * 0.5; + let cy = rect.y as f64 + rect.h as f64 * 0.5; + let stroke_w = (side * sw as f64).max(1.0); + let radius = (side - stroke_w) * 0.5; + let elapsed = started.elapsed().as_secs_f64(); + // Ángulo de inicio del arco — gira completamente cada `1/speed` s. + let theta0 = elapsed * speed as f64 * std::f64::consts::TAU; + // Arco de 270° (= 3π/2 rad) — la "abertura" sugiere movimiento. + let sweep = std::f64::consts::PI * 1.5; + let arc = Arc::new((cx, cy), (radius, radius), theta0, sweep, 0.0); + let stroke = Stroke::new(stroke_w).with_caps(Cap::Round); + scene.stroke(&stroke, Affine::IDENTITY, color, None, &arc); + }) +} diff --git a/widgets/splash/Cargo.toml b/widgets/splash/Cargo.toml new file mode 100644 index 0000000..b0bf4b7 --- /dev/null +++ b/widgets/splash/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-splash" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-splash — splash de arranque gioser: cuatro cuadrantes (unanchay/yachay/ruway/ukupacha) animados con tween de entrada secuencial. Identidad visual del SO." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-motion = { workspace = true } diff --git a/widgets/splash/src/lib.rs b/widgets/splash/src/lib.rs new file mode 100644 index 0000000..c73c2b5 --- /dev/null +++ b/widgets/splash/src/lib.rs @@ -0,0 +1,283 @@ +//! `llimphi-widget-splash` — splash de arranque gioser. +//! +//! Identidad visual del SO al boot: cuatro cuadrantes ordenados como +//! una cruz andina, cada uno con su nombre quechua y color simbólico, +//! que **entran en secuencia** con un tween de fade+escala. +//! +//! Los cuadrantes (en orden de entrada): +//! 1. `unanchay` — PERCIBIR — cyan (índigo claro) +//! 2. `yachay` — CONOCER — verde aurora +//! 3. `ruway` — HACER — naranja sunset +//! 4. `ukupacha` — RAÍZ — púrpura profundo +//! +//! Cada cuadrante hace fade-in + slight scale-up, con un offset de +//! `motion::NORMAL / 2` entre uno y el siguiente. La app pasa un +//! `Instant` de inicio y el splash calcula las fases relativas — no +//! requiere ningún tween del modelo. +//! +//! Cuando el splash termina (todos visibles), la app puede: +//! - mantenerlo unos segundos más como pantalla de carga, +//! - hacer un fade-out completo cuando el sistema esté listo, +//! - o reemplazarlo por la UI principal. + +#![forbid(unsafe_code)] + +use std::time::Instant; + +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::View; +use llimphi_motion::motion; + +/// Datos de un cuadrante: nombre quechua, glosa breve y color. +#[derive(Debug, Clone, Copy)] +pub struct Quadrant { + pub name: &'static str, + pub gloss: &'static str, + pub color: Color, +} + +/// Los cuatro cuadrantes canónicos, en orden de entrada al splash. +pub fn quadrants() -> [Quadrant; 4] { + [ + Quadrant { + name: "unanchay", + gloss: "PERCIBIR", + color: Color::from_rgba8(110, 160, 230, 255), + }, + Quadrant { + name: "yachay", + gloss: "CONOCER", + color: Color::from_rgba8(110, 220, 180, 255), + }, + Quadrant { + name: "ruway", + gloss: "HACER", + color: Color::from_rgba8(232, 160, 90, 255), + }, + Quadrant { + name: "ukupacha", + gloss: "RAÍZ", + color: Color::from_rgba8(160, 110, 220, 255), + }, + ] +} + +/// Construye el splash. `started_at` es el `Instant` de origen — el +/// splash calcula las fases relativas. La app puede llamar `animate(handle, +/// motion::SLOW * 3, …)` para forzar repaints durante la animación. +/// +/// `bg`: color de fondo (típico: `theme.bg_app`). +/// `fg_text`: color del título/glosa. +pub fn splash_view( + started_at: Instant, + bg: Color, + fg_text: Color, +) -> View { + let elapsed = started_at.elapsed().as_secs_f32(); + let stagger = motion::NORMAL.as_secs_f32() * 0.45; + let per_quad = motion::NORMAL.as_secs_f32(); + let quads = quadrants(); + + let cells: Vec> = quads + .iter() + .enumerate() + .map(|(i, q)| { + let local_t = ((elapsed - i as f32 * stagger) / per_quad).clamp(0.0, 1.0); + let eased = motion::ease_out_cubic(local_t); + quadrant_cell(q, eased, fg_text) + }) + .collect(); + + // 2×2 grid: row 0 = unanchay + yachay; row 1 = ruway + ukupacha. + let row = |a: View, b: View| -> View { + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: percent(0.5_f32), + }, + gap: Size { + width: length(12.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![a, b]) + }; + let mut iter = cells.into_iter(); + let r0 = row(iter.next().unwrap(), iter.next().unwrap()); + let r1 = row(iter.next().unwrap(), iter.next().unwrap()); + + let grid = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: length(420.0_f32), + height: length(280.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(12.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![r0, r1]); + + // Título "gioser" debajo, también fade-in pero al final. + let title_t = ((elapsed - 4.0 * stagger) / per_quad).clamp(0.0, 1.0); + let title_alpha = motion::ease_out_cubic(title_t); + let title = View::new(Style { + size: Size { + width: length(420.0_f32), + height: length(32.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned("gioser", 22.0, fg_text, Alignment::Center) + .alpha(title_alpha); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Size { + width: length(0.0_f32), + height: length(28.0_f32), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .children(vec![grid, title]) +} + +fn quadrant_cell( + quad: &Quadrant, + progress: f32, + fg_text: Color, +) -> View { + // El cuadrante "entra" con fade y un leve drift desde abajo (10px). + // El drift lo representamos con un padding-top que tiende a cero; + // como llimphi no expone translate por nodo (sólo position absolute), + // metemos el contenido en un wrapper con padding decreciente. + let drift = (1.0 - progress) * 10.0; + + let name = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(quad.name, 16.0, fg_text, Alignment::Center); + + let gloss = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(quad.gloss, 10.0, quad.color, Alignment::Center); + + let inner = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Size { + width: length(0.0_f32), + height: length(6.0_f32), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(drift), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![name, gloss]); + + // Fondo del cuadrante con gradient vertical en el color semántico: + // alpha 50 arriba → alpha 12 abajo. Da volumen al cuadrante (más + // intenso cerca del accent strip del top) y un efecto "halo descendente" + // que ayuda a leer la cruz andina como cuatro luces que emergen del + // centro. Antes: alpha 30 uniforme. + let border = with_alpha8(quad.color, 90); + let bg_top = with_alpha8(quad.color, 50); + let bg_bot = with_alpha8(quad.color, 12); + + let cell_radius = llimphi_theme::radius::MD; + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let rr = RoundedRect::new(x0, y0, x1, y1, cell_radius); + let gradient = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y1)) + .with_stops([bg_top, bg_bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr); + }) + .radius(cell_radius) + .clip(true) + .alpha(progress) + .children(vec![ + // Línea accent superior — 2px del color del cuadrante a alta + // intensidad, ancla del gradiente que cae. + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(2.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(border), + inner, + ]) +} + +fn with_alpha8(c: Color, a: u8) -> Color { + let [r, g, b, _] = c.components; + use llimphi_ui::llimphi_raster::peniko::color::AlphaColor; + AlphaColor::new([r, g, b, a as f32 / 255.0]) +} diff --git a/widgets/splitter/Cargo.toml b/widgets/splitter/Cargo.toml new file mode 100644 index 0000000..af931f0 --- /dev/null +++ b/widgets/splitter/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-splitter" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-splitter — split container con divisor draggable. Análogo Llimphi al `nahual-widget-splitter` GPUI: dos panes, divisor sólido del ancho del thickness configurable, drag emite Msg con el delta del eje principal." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/splitter/LEEME.md b/widgets/splitter/LEEME.md new file mode 100644 index 0000000..bd99b68 --- /dev/null +++ b/widgets/splitter/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-splitter + +> Splitter horizontal/vertical para [llimphi](../../README.md). + +Divide el espacio entre dos hijos con un handle arrastrable. Min/max sizes por hijo; doble-click para reset. diff --git a/widgets/splitter/README.md b/widgets/splitter/README.md new file mode 100644 index 0000000..8140406 --- /dev/null +++ b/widgets/splitter/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-splitter + +> Horizontal/vertical splitter for [llimphi](../../README.md). + +Divides space between two children with a draggable handle. Per-child min/max sizes; double-click to reset. diff --git a/widgets/splitter/examples/splitter_demo.rs b/widgets/splitter/examples/splitter_demo.rs new file mode 100644 index 0000000..45e1133 --- /dev/null +++ b/widgets/splitter/examples/splitter_demo.rs @@ -0,0 +1,126 @@ +//! Showcase de `llimphi-widget-splitter`: dos splits anidados +//! draggables (Row con Column adentro). +//! +//! Corré con: `cargo run -p llimphi-widget-splitter --example showcase --release`. +//! +//! Probá: agarrá el divisor vertical y arrastralo izquierda/derecha +//! para resizar el pane izquierdo; agarrá el divisor horizontal de la +//! derecha para resizar el pane superior derecho. + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{percent, Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, DragPhase, Handle, View}; +use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette}; + +#[derive(Clone)] +enum Msg { + ResizeOuter(f32), + ResizeInner(f32), +} + +struct Model { + left_w: f32, + top_h: f32, +} + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · splitter showcase" + } + + fn initial_size() -> (u32, u32) { + (1100, 720) + } + + fn init(_: &Handle) -> Model { + Model { + left_w: 320.0, + top_h: 240.0, + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::ResizeOuter(dx) => { + m.left_w = (m.left_w + dx).clamp(120.0, 800.0); + } + Msg::ResizeInner(dy) => { + m.top_h = (m.top_h + dy).clamp(80.0, 600.0); + } + } + m + } + + fn view(model: &Model) -> View { + let palette = SplitterPalette::default(); + + let left = pane("izquierdo", Color::from_rgba8(28, 36, 50, 255)); + let top_right = pane( + &format!("arriba · {:.0} px", model.top_h), + Color::from_rgba8(38, 50, 70, 255), + ); + let bottom_right = pane( + "abajo · flex", + Color::from_rgba8(48, 36, 60, 255), + ); + + let right = splitter_two( + Direction::Column, + top_right, + PaneSize::Fixed(model.top_h), + bottom_right, + PaneSize::Flex, + |phase, dy| match phase { + DragPhase::Move => Some(Msg::ResizeInner(dy)), + DragPhase::End => None, + }, + &palette, + ); + + splitter_two( + Direction::Row, + left, + PaneSize::Fixed(model.left_w), + right, + PaneSize::Flex, + |phase, dx| match phase { + DragPhase::Move => Some(Msg::ResizeOuter(dx)), + DragPhase::End => None, + }, + &palette, + ) + } +} + +fn pane(label: &str, bg: Color) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(bg) + .text_aligned( + label.to_string(), + 18.0, + Color::from_rgba8(220, 230, 240, 255), + Alignment::Center, + ) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/splitter/src/lib.rs b/widgets/splitter/src/lib.rs new file mode 100644 index 0000000..ee73a92 --- /dev/null +++ b/widgets/splitter/src/lib.rs @@ -0,0 +1,174 @@ +//! `llimphi-widget-splitter` — split container con divisor draggable. +//! +//! Análogo Llimphi al `nahual-widget-splitter` GPUI: dos panes con un +//! divisor entre medio que el usuario arrastra para reasignar el tamaño. +//! El widget no mantiene estado: el caller acumula el tamaño de un pane +//! en su `Model` y le pasa el valor actual + un handler `Fn(DragPhase, +//! f32) -> Option` que materializa el delta en un Msg de update. +//! +//! Uso típico (dos panes, izquierdo fijo y derecho flex): +//! +//! ```ignore +//! splitter_two( +//! Direction::Row, +//! left_view, +//! PaneSize::Fixed(model.left_size), +//! right_view, +//! PaneSize::Flex, +//! |phase, dx| match phase { +//! DragPhase::Move => Some(Msg::ResizeLeft(dx)), +//! DragPhase::End => Some(Msg::PersistLayout), +//! }, +//! &SplitterPalette::default(), +//! ) +//! ``` + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; + +/// Dirección del split. `Row` apila los panes horizontalmente +/// (divisor vertical, drag horizontal); `Column` los apila verticalmente. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Row, + Column, +} + +/// Tamaño de un pane sobre el eje principal del split. +#[derive(Debug, Clone, Copy)] +pub enum PaneSize { + /// Ancho/alto fijo en pixels. El otro pane se ajusta con `flex_grow`. + Fixed(f32), + /// Toma todo el espacio sobrante (`flex_grow = 1`). + Flex, +} + +/// Paleta del divisor. Cambia de color al hover para señalar +/// "agarrame y arrastrá". +#[derive(Debug, Clone, Copy)] +pub struct SplitterPalette { + pub divider: Color, + pub divider_hover: Color, + pub thickness: f32, +} + +impl Default for SplitterPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl SplitterPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + divider: t.border, + divider_hover: t.accent, + thickness: 6.0, + } + } +} + +/// Split de dos panes con divisor draggable entre medio. `on_resize` +/// se invoca con el delta del eje principal (positivo → divisor se +/// mueve a la derecha/abajo). +pub fn splitter_two( + direction: Direction, + a: View, + a_size: PaneSize, + b: View, + b_size: PaneSize, + on_resize: F, + palette: &SplitterPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, + F: Fn(DragPhase, f32) -> Option + Send + Sync + 'static, +{ + let flex_dir = match direction { + Direction::Row => FlexDirection::Row, + Direction::Column => FlexDirection::Column, + }; + + // El divisor sólo necesita Msg en el eje principal — escondemos el + // otro detrás del closure. + let on_resize = Arc::new(on_resize); + let cb_dir = direction; + let cb = on_resize.clone(); + let divider = divider_view::(direction, palette, move |phase, dx, dy| { + let main = match cb_dir { + Direction::Row => dx, + Direction::Column => dy, + }; + (cb)(phase, main) + }); + + let pane_a = wrap_pane(a, direction, a_size); + let pane_b = wrap_pane(b, direction, b_size); + + View::new(Style { + flex_direction: flex_dir, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![pane_a, divider, pane_b]) +} + +fn wrap_pane(view: View, direction: Direction, size: PaneSize) -> View { + let (width, height, flex_grow) = match (direction, size) { + (Direction::Row, PaneSize::Fixed(px)) => (length(px), percent(1.0_f32), 0.0), + (Direction::Row, PaneSize::Flex) => (Dimension::auto(), percent(1.0_f32), 1.0), + (Direction::Column, PaneSize::Fixed(px)) => (percent(1.0_f32), length(px), 0.0), + (Direction::Column, PaneSize::Flex) => (percent(1.0_f32), Dimension::auto(), 1.0), + }; + View::new(Style { + size: Size { width, height }, + flex_grow, + flex_shrink: 0.0, + min_size: Size { + width: length(0.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![view]) +} + +fn divider_view( + direction: Direction, + palette: &SplitterPalette, + handler: impl Fn(DragPhase, f32, f32) -> Option + Send + Sync + 'static, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let (width, height) = match direction { + Direction::Row => (length(palette.thickness), percent(1.0_f32)), + Direction::Column => (percent(1.0_f32), length(palette.thickness)), + }; + View::new(Style { + size: Size { width, height }, + flex_shrink: 0.0, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.divider) + .hover_fill(palette.divider_hover) + .draggable(handler) +} diff --git a/widgets/stat-card/Cargo.toml b/widgets/stat-card/Cargo.toml new file mode 100644 index 0000000..f63af4c --- /dev/null +++ b/widgets/stat-card/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-stat-card" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-stat-card — tarjeta de dashboard con label chico + valor grande + descripción + accent vertical. Análogo Llimphi al `nahual-widget-stat-card` GPUI." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-card = { workspace = true } diff --git a/widgets/stat-card/LEEME.md b/widgets/stat-card/LEEME.md new file mode 100644 index 0000000..4fa05fd --- /dev/null +++ b/widgets/stat-card/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-stat-card + +> Card para métricas para [llimphi](../../README.md). + +Label + valor grande + sub-label + sparkline opcional. Variante `compact` y `wide`. Usado por `cosmos-card`, `chasqui-card`, `arje-card`, etc. diff --git a/widgets/stat-card/README.md b/widgets/stat-card/README.md new file mode 100644 index 0000000..92be197 --- /dev/null +++ b/widgets/stat-card/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-stat-card + +> Card for metrics for [llimphi](../../README.md). + +Label + large value + sub-label + optional sparkline. `compact` and `wide` variants. Used by `cosmos-card`, `chasqui-card`, `arje-card`, etc. diff --git a/widgets/stat-card/src/lib.rs b/widgets/stat-card/src/lib.rs new file mode 100644 index 0000000..557667e --- /dev/null +++ b/widgets/stat-card/src/lib.rs @@ -0,0 +1,144 @@ +//! `llimphi-widget-stat-card` — tarjeta de dashboard con accent. +//! +//! Compone (sobre `llimphi-widget-card`): +//! - **Border-l-4** con un color de accent que el caller decide. +//! - **Label** chico arriba en el color del accent. +//! - **Value** grande (28 px) en el color principal del texto. +//! - **Description** chica en el color tenue. +//! - **Listing opcional** de items recientes con sub-header +//! `"recent (N):"`. +//! +//! Análogo Llimphi al `nahual-widget-stat-card` GPUI. Pensado para +//! dashboards estilo `minga-explorer`, `brahman-broker-explorer`. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_widget_card::{card_view, CardOptions, CardPalette}; + +/// Paleta del stat-card. `accent` se setea por instancia (verde/rojo/ +/// ámbar etc.), los otros vienen del theme. +#[derive(Debug, Clone, Copy)] +pub struct StatCardPalette { + pub bg: Color, + pub fg_text: Color, + pub fg_muted: Color, +} + +impl Default for StatCardPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl StatCardPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_panel, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + } + } +} + +/// Compone un stat-card. +/// +/// - `label`: header chico en color `accent`. +/// - `value`: texto principal grande. +/// - `description`: línea chica tenue debajo del value. +/// - `accent`: color del border-l + del label. +/// - `recent_items`: si no vacío, agrega "recent (N):" + una fila por +/// item. +pub fn stat_card_view( + label: &str, + value: impl Into, + description: &str, + accent: Color, + recent_items: &[String], + palette: &StatCardPalette, +) -> View { + let label_row = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .text_aligned(label.to_string(), 11.0, accent, Alignment::Start); + + let value_row = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(36.0_f32), + }, + ..Default::default() + }) + .text_aligned(value.into(), 28.0, palette.fg_text, Alignment::Start); + + let desc_row = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .text_aligned( + description.to_string(), + 11.0, + palette.fg_muted, + Alignment::Start, + ); + + let mut children: Vec> = vec![label_row, value_row, desc_row]; + + if !recent_items.is_empty() { + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(6.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned( + format!("recent ({}):", recent_items.len()), + 10.0, + palette.fg_muted, + Alignment::Start, + ), + ); + for it in recent_items { + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .text_aligned(it.clone(), 11.0, palette.fg_text, Alignment::Start), + ); + } + } + + card_view( + children, + CardOptions { + accent: Some(accent), + ..Default::default() + }, + &CardPalette { bg: palette.bg }, + ) +} diff --git a/widgets/status-bar/Cargo.toml b/widgets/status-bar/Cargo.toml new file mode 100644 index 0000000..bcfcee2 --- /dev/null +++ b/widgets/status-bar/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-widget-status-bar" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-status-bar — barra inferior con segmentos left/center/right configurables. Cada segmento puede llevar icono opcional y handler de click." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-icons = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/status-bar/src/lib.rs b/widgets/status-bar/src/lib.rs new file mode 100644 index 0000000..5e9bc53 --- /dev/null +++ b/widgets/status-bar/src/lib.rs @@ -0,0 +1,242 @@ +//! `llimphi-widget-status-bar` — barra de estado inferior. +//! +//! Patrón clásico de IDEs/editores: barra delgada en el borde inferior +//! de la ventana con tres regiones (left/center/right). Cada región +//! tiene N segmentos, cada uno puede llevar icono + texto + handler de +//! click opcional. +//! +//! Útil para mostrar: rama git activa, posición del cursor, tipo de +//! archivo, modo (insert/normal), notificaciones pendientes, etc. + +#![forbid(unsafe_code)] + +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::View; +use llimphi_icons::{icon_view, Icon}; +use llimphi_theme::Theme; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta de la barra de estado. +#[derive(Debug, Clone, Copy)] +pub struct StatusBarPalette { + pub bg: Color, + pub fg: Color, + pub fg_muted: Color, + pub bg_hover: Color, + pub border: Color, + /// Firma visual de la barra: gradient sutil + hairline accent en su + /// top edge — el hairline funciona como "techo" que separa la barra + /// de la zona de contenido. `None` cae al fill plano + border top + /// del modo previo (back-compat). + pub signature: Option, +} + +impl StatusBarPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + bg: t.bg_panel_alt, + fg: t.fg_text, + fg_muted: t.fg_muted, + bg_hover: t.bg_row_hover, + border: t.border, + signature: Some(PanelStyle { + radius: 0.0, + bg_base: t.bg_panel_alt, + ..PanelStyle::from_theme(t) + }), + } + } +} + +/// Un segmento de la barra. `icon` y `on_click` son opcionales. +#[derive(Clone)] +pub struct StatusSegment { + pub text: String, + pub icon: Option, + pub on_click: Option, + /// Si `true`, usa `fg` en vez de `fg_muted` — útil para destacar + /// estados importantes (ej. "modificado"). + pub emphasized: bool, +} + +impl StatusSegment { + pub fn text(text: impl Into) -> Self { + Self { + text: text.into(), + icon: None, + on_click: None, + emphasized: false, + } + } + pub fn with_icon(mut self, icon: Icon) -> Self { + self.icon = Some(icon); + self + } + pub fn clickable(mut self, msg: Msg) -> Self { + self.on_click = Some(msg); + self + } + pub fn emphasized(mut self) -> Self { + self.emphasized = true; + self + } +} + +const BAR_H: f32 = 22.0; +const SEG_GAP: f32 = 14.0; +const FONT_SIZE: f32 = 11.0; +const ICON_SIZE: f32 = 12.0; + +pub fn status_bar_view( + left: Vec>, + center: Vec>, + right: Vec>, + palette: &StatusBarPalette, +) -> View { + let make_region = |segs: Vec>, justify: JustifyContent| -> View { + let children: Vec> = segs + .into_iter() + .map(|s| segment_view(s, palette)) + .collect(); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + justify_content: Some(justify), + gap: Size { + width: length(SEG_GAP), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(children) + }; + + let left_region = make_region(left, JustifyContent::FlexStart); + let center_region = make_region(center, JustifyContent::Center); + let right_region = make_region(right, JustifyContent::FlexEnd); + + // Modo con firma: la barra trae su propio hairline accent en el top + // edge — reemplaza el border plano del modo previo. + let bar_style = Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(BAR_H), + }, + 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() + }; + + if let Some(style) = palette.signature { + return View::new(bar_style) + .paint_with(panel_signature_painter(style)) + .children(vec![left_region, center_region, right_region]); + } + + // Back-compat: fill plano + border top 1px en el wrapper column. + let border = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(1.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.border); + + let bar = View::new(bar_style) + .fill(palette.bg) + .children(vec![left_region, center_region, right_region]); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: length(BAR_H + 1.0), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![border, bar]) +} + +fn segment_view( + seg: StatusSegment, + palette: &StatusBarPalette, +) -> View { + let fg = if seg.emphasized { palette.fg } else { palette.fg_muted }; + let approx_w = seg.text.chars().count() as f32 * 6.0 + + if seg.icon.is_some() { ICON_SIZE + 4.0 } else { 0.0 } + + 12.0; + + let mut children: Vec> = Vec::with_capacity(2); + if let Some(icon) = seg.icon { + children.push( + View::new(Style { + size: Size { + width: length(ICON_SIZE), + height: length(ICON_SIZE), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![icon_view(icon, fg, 1.4)]), + ); + } + children.push( + 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(seg.text.clone(), FONT_SIZE, fg, Alignment::Start), + ); + + let mut node = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: length(approx_w), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(4.0_f32), + height: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(children); + + if let Some(msg) = seg.on_click { + node = node.hover_fill(palette.bg_hover).on_click(msg); + } + node +} diff --git a/widgets/switch/Cargo.toml b/widgets/switch/Cargo.toml new file mode 100644 index 0000000..d02deda --- /dev/null +++ b/widgets/switch/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-switch" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-switch — toggle binario on/off (track + thumb) con paleta del theme. Para preferencias, modos y feature flags." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/switch/src/lib.rs b/widgets/switch/src/lib.rs new file mode 100644 index 0000000..c5ec516 --- /dev/null +++ b/widgets/switch/src/lib.rs @@ -0,0 +1,152 @@ +//! `llimphi-widget-switch` — toggle binario (track + thumb). +//! +//! Render-only: la app guarda el `bool` en su modelo y dispatcha el +//! Msg de toggle al click. Visualmente: +//! - Track horizontal (40×22 default) con color del estado activo. +//! - Thumb circular (18px) que se posiciona a la izquierda (off) o +//! derecha (on) del track. +//! +//! Para animar la transición, la app puede guardar un `Tween` con +//! el progreso 0→1 y leerlo desde `view` para interpolar la posición +//! del thumb. Sin tween la transición es instantánea — funcional pero +//! menos elegante. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, Position, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_theme::Theme; + +/// Paleta del switch. +#[derive(Debug, Clone, Copy)] +pub struct SwitchPalette { + pub track_off: Color, + pub track_on: Color, + pub thumb: Color, +} + +impl SwitchPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + track_off: t.bg_button, + track_on: t.accent, + thumb: t.fg_text, + } + } +} + +const TRACK_W: f32 = 40.0; +const TRACK_H: f32 = 22.0; +const THUMB_R: f32 = 9.0; // radio en px → diámetro 18 +const PAD: f32 = 2.0; + +/// Construye un switch. `progress` en `[0.0, 1.0]` indica la +/// posición animada del thumb (0 = off, 1 = on). Para la transición +/// instantánea usar `if state { 1.0 } else { 0.0 }`. +/// +/// `on_toggle` se dispatcha al click; la app actualiza su `bool` y +/// (opcionalmente) lanza un `Tween` que actualiza `progress` por frame. +pub fn switch_view( + progress: f32, + on_toggle: Msg, + palette: &SwitchPalette, +) -> View { + let p = progress.clamp(0.0, 1.0); + + // Track color interpola entre off y on según progress. + let track_color = lerp_color(palette.track_off, palette.track_on, p); + + // Thumb absolute dentro del track. Range del centro: PAD+THUMB_R a TRACK_W-PAD-THUMB_R. + let min_x = PAD; + let max_x = TRACK_W - PAD - THUMB_R * 2.0; + let thumb_x = min_x + (max_x - min_x) * p; + let thumb_y = (TRACK_H - THUMB_R * 2.0) * 0.5; + + let thumb = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(thumb_x), + top: length(thumb_y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(THUMB_R * 2.0), + height: length(THUMB_R * 2.0), + }, + ..Default::default() + }) + .fill(palette.thumb) + .radius(THUMB_R as f64) + .paint_with(move |scene, _ts, rect| { + // Highlight radial pequeño en cuadrante superior — el thumb se + // lee como esfera, no como círculo plano. Mismo patrón que el + // dot del badge (P6). + use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.32) as f64; + let r = (rect.w as f64 * 0.18).max(1.0); + let highlight = Color::from_rgba8(255, 255, 255, 70); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + highlight, + None, + &Circle::new((cx, cy), r), + ); + }); + + let track_radius = (TRACK_H * 0.5) as f64; + View::new(Style { + size: Size { + width: length(TRACK_W), + height: length(TRACK_H), + }, + ..Default::default() + }) + .fill(track_color) + .radius(track_radius) + .paint_with(move |scene, _ts, rect| { + // Gloss superior en el track — pill con luz cayendo desde arriba. + // El track interpola color (off/on) en el fill, el gloss queda + // estable encima en ambos estados. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, track_radius); + let top = Color::from_rgba8(255, 255, 255, 28); + let bot = Color::from_rgba8(255, 255, 255, 0); + let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); + }) + .on_click(on_toggle) + .children(vec![thumb]) +} + +fn lerp_color(a: Color, b: Color, t: f32) -> Color { + let [r0, g0, b0, a0] = a.components; + let [r1, g1, b1, a1] = b.components; + use llimphi_ui::llimphi_raster::peniko::color::AlphaColor; + AlphaColor::new([ + r0 + (r1 - r0) * t, + g0 + (g1 - g0) * t, + b0 + (b1 - b0) * t, + a0 + (a1 - a0) * t, + ]) +} diff --git a/widgets/tabs/Cargo.toml b/widgets/tabs/Cargo.toml new file mode 100644 index 0000000..d01a2e7 --- /dev/null +++ b/widgets/tabs/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-tabs" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-tabs — tira de tabs + área de contenido. Análogo Llimphi al `nahual-widget-tabs` GPUI. El caller mantiene el índice activo en el `Model` y le da al widget las labels + el view del tab activo." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/tabs/LEEME.md b/widgets/tabs/LEEME.md new file mode 100644 index 0000000..cff8999 --- /dev/null +++ b/widgets/tabs/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-tabs + +> Tabs con cierre para [llimphi](../../README.md). + +Pestañas horizontales arrastrables, botón "+", close por pestaña. Activa por keyboard (Ctrl+Tab). Usado por `nada`, `pluma`, `puriy`. diff --git a/widgets/tabs/README.md b/widgets/tabs/README.md new file mode 100644 index 0000000..076c525 --- /dev/null +++ b/widgets/tabs/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-tabs + +> Closeable tabs for [llimphi](../../README.md). + +Draggable horizontal tabs, "+" button, per-tab close. Keyboard active (Ctrl+Tab). Used by `nada`, `pluma`, `puriy`. diff --git a/widgets/tabs/examples/tabs_demo.rs b/widgets/tabs/examples/tabs_demo.rs new file mode 100644 index 0000000..25c44d6 --- /dev/null +++ b/widgets/tabs/examples/tabs_demo.rs @@ -0,0 +1,136 @@ +//! Showcase de `llimphi-widget-tabs`: 3 tabs con contenido distinto +//! cada uno. Hover en los tabs inactivos cambia el bg. +//! +//! Corré con: `cargo run -p llimphi-widget-tabs --example showcase --release`. + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec}; + +#[derive(Clone)] +enum Msg { + SelectTab(usize), +} + +struct Model { + active: usize, +} + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · tabs showcase" + } + + fn initial_size() -> (u32, u32) { + (900, 600) + } + + fn init(_: &Handle) -> Model { + Model { active: 0 } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::SelectTab(i) => m.active = i, + } + m + } + + fn view(model: &Model) -> View { + let body = match model.active { + 0 => content_pane( + "General", + "Acá vivirían los settings principales del módulo.\n\ + El click cambia de tab; el hover sobre tabs inactivos\n\ + ilumina el fondo levemente.", + Color::from_rgba8(220, 230, 245, 255), + ), + 1 => content_pane( + "Avanzado", + "Variables esotéricas, banderas experimentales.\n\ + Probablemente no las toques.", + Color::from_rgba8(200, 220, 240, 255), + ), + _ => content_pane( + "Logs", + "[12:01:33] arranqué\n[12:01:34] cargué config\n\ + [12:01:35] esperando eventos…", + Color::from_rgba8(180, 195, 215, 255), + ), + }; + + tabs_view(TabsSpec { + labels: vec!["General".into(), "Avanzado".into(), "Logs".into()], + active: model.active, + on_select: Msg::SelectTab, + content: body, + tab_height: 36.0, + palette: TabsPalette::default(), + tab_width: Some(160.0), + }) + } +} + +fn content_pane(title: &str, body: &str, fg: Color) -> View { + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(36.0_f32), + }, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(8.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Start), + ..Default::default() + }) + .text_aligned( + format!("# {title}"), + 18.0, + Color::from_rgba8(220, 230, 245, 255), + Alignment::Start, + ); + + let body_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(0.0_f32), + bottom: length(20.0_f32), + }, + ..Default::default() + }) + .text_aligned(body.to_string(), 13.0, fg, Alignment::Start); + + View::new(Style { + flex_direction: llimphi_ui::llimphi_layout::taffy::FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![header, body_view]) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/tabs/src/lib.rs b/widgets/tabs/src/lib.rs new file mode 100644 index 0000000..0abc6dd --- /dev/null +++ b/widgets/tabs/src/lib.rs @@ -0,0 +1,271 @@ +//! `llimphi-widget-tabs` — tira de tabs + área de contenido. +//! +//! Análogo Llimphi al `nahual-widget-tabs` GPUI. El widget no mantiene +//! estado interno: el `Model` del App lleva el índice activo, le pasa al +//! widget las labels + el `View` del tab activo, y maneja el Msg de +//! cambio de tab. +//! +//! Uso típico: +//! +//! ```ignore +//! tabs_view( +//! TabsSpec { +//! labels: vec!["General".into(), "Avanzado".into(), "Logs".into()], +//! active: model.active_tab, +//! on_select: |i| Msg::SelectTab(i), +//! content: render_active_tab(model), +//! palette: TabsPalette::default(), +//! } +//! ) +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, Dimension, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; + +/// Ancho mínimo de un tab cuando `tab_width` es `None` — evita que los +/// tabs cortos (un nombre de 4 chars) se vean apretados contra los +/// vecinos. Si se especifica `tab_width: Some(px)`, se ignora. +const DEFAULT_MIN_TAB_WIDTH: f32 = 120.0; +/// Separación horizontal entre tabs — deja ver el `bg_bar` como hilo +/// fino, suaviza el bloque sólido de antes. +const TAB_GAP: f32 = 2.0; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta del tab bar. +#[derive(Debug, Clone, Copy)] +pub struct TabsPalette { + pub bg_bar: Color, + pub bg_tab_inactive: Color, + pub bg_tab_hover: Color, + pub bg_tab_active: Color, + pub fg_text: Color, + pub fg_text_active: Color, + /// Línea bajo el tab activo (acento). Si es `None` no se dibuja. + pub accent: Option, + /// Firma visual del área de contenido (sólo gradient — el accent + /// del tab activo justo encima ya cumple el rol del hairline). `None` + /// cae al fill plano de `bg_tab_active` (back-compat). + pub content_signature: Option, +} + +impl Default for TabsPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TabsPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_bar: t.bg_panel_alt, + bg_tab_inactive: t.bg_panel, + bg_tab_hover: t.bg_row_hover, + bg_tab_active: t.bg_app, + fg_text: t.fg_muted, + fg_text_active: t.fg_text, + accent: Some(t.accent), + content_signature: Some(PanelStyle { + radius: 0.0, + bg_base: t.bg_app, + ..PanelStyle::neutral(t) + }), + } + } +} + +/// Especificación de los tabs. `labels.len()` define cuántos tabs; el +/// `Msg` por click se construye con `on_select(idx)`. +pub struct TabsSpec { + pub labels: Vec, + pub active: usize, + /// Function from tab index to Msg. Se invoca una vez por tab en `view`. + pub on_select: F, + /// Contenido del tab activo. El widget lo coloca debajo de la barra. + pub content: View, + pub tab_height: f32, + pub palette: TabsPalette, + /// Ancho de cada tab. `None` = tamaño según contenido (auto). + pub tab_width: Option, +} + +/// Compone la barra de tabs + área de contenido. La función `on_select` +/// se consume — se invoca una vez por tab para construir su Msg. +pub fn tabs_view(spec: TabsSpec) -> View +where + Msg: Clone + 'static, + F: Fn(usize) -> Msg, +{ + let TabsSpec { + labels, + active, + on_select, + content, + tab_height, + palette, + tab_width, + } = spec; + + let mut bar_children: Vec> = Vec::with_capacity(labels.len() + 1); + for (i, label) in labels.iter().enumerate() { + bar_children.push(tab_button( + label, + i == active, + tab_height, + tab_width, + &palette, + on_select(i), + )); + } + // Spacer al final: empuja los tabs al inicio y rellena el resto del + // ancho con el bg_bar. + bar_children.push( + View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(tab_height), + }, + flex_grow: 1.0, + ..Default::default() + }) + .fill(palette.bg_bar), + ); + + let bar = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(tab_height + accent_thickness(&palette)), + }, + // No comprimir verticalmente cuando el contenido del tab activo + // pide percent(1.0): si no, el column padre reparte overflow y + // come la altura del tab strip. + flex_shrink: 0.0, + gap: Size { + width: length(TAB_GAP), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_bar) + .children(bar_children); + + let content_style = Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }; + let content_wrap = match palette.content_signature { + Some(style) => View::new(content_style) + .paint_with(panel_signature_painter(style)) + .children(vec![content]), + None => View::new(content_style) + .fill(palette.bg_tab_active) + .children(vec![content]), + }; + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![bar, content_wrap]) +} + +fn tab_button( + label: &str, + active: bool, + height: f32, + width: Option, + palette: &TabsPalette, + on_click: Msg, +) -> View { + let (bg, fg) = if active { + (palette.bg_tab_active, palette.fg_text_active) + } else { + (palette.bg_tab_inactive, palette.fg_text) + }; + let w = match width { + Some(px) => length(px), + None => Dimension::auto(), + }; + // Cuando el tab es auto-width, garantizamos min para que un label + // corto («main.rs», 7 chars) no apriete al vecino. + let min_w = match width { + Some(_) => auto(), + None => length(DEFAULT_MIN_TAB_WIDTH), + }; + + let label_view = View::new(Style { + size: Size { + width: w, + height: length(height), + }, + min_size: Size { width: min_w, height: auto() }, + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(bg) + .hover_fill(palette.bg_tab_hover) + .text_aligned(label.to_string(), 13.0, fg, Alignment::Center) + .on_click(on_click); + + // Línea de acento bajo el tab activo. Para inactivos se dibuja con el + // bg_bar (transparente al ojo). + let accent_color = match (palette.accent, active) { + (Some(c), true) => c, + _ => palette.bg_bar, + }; + let accent = View::new(Style { + size: Size { + width: w, + height: length(accent_thickness(palette)), + }, + min_size: Size { width: min_w, height: auto() }, + ..Default::default() + }) + .fill(accent_color); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: w, + height: length(height + accent_thickness(palette)), + }, + min_size: Size { width: min_w, height: auto() }, + // Cuando hay muchos tabs y el ancho total excede la bar, no + // comprimir cada tab — preferimos overflow a verlos como una + // lasca delgada. (Eventualmente: scroll horizontal.) + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![label_view, accent]) +} + +fn accent_thickness(palette: &TabsPalette) -> f32 { + if palette.accent.is_some() { + 2.0 + } else { + 0.0 + } +} diff --git a/widgets/text-area/Cargo.toml b/widgets/text-area/Cargo.toml new file mode 100644 index 0000000..916faba --- /dev/null +++ b/widgets/text-area/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-text-area" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-text-area — input de texto multilínea para Llimphi: estado plano (String con \\n), apply_key con Enter→\\n + Backspace + caracteres imprimibles, render multilínea con caret bloque al final del último renglón." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/text-area/LEEME.md b/widgets/text-area/LEEME.md new file mode 100644 index 0000000..0cba865 --- /dev/null +++ b/widgets/text-area/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-area + +> Textarea multi-line para [llimphi](../../README.md). + +Sin highlight (eso es `text-editor`). Wrap configurable, char count, placeholder. diff --git a/widgets/text-area/README.md b/widgets/text-area/README.md new file mode 100644 index 0000000..92f0f60 --- /dev/null +++ b/widgets/text-area/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-area + +> Multi-line textarea for [llimphi](../../README.md). + +No highlight (that's `text-editor`). Configurable wrap, char count, placeholder. diff --git a/widgets/text-area/src/lib.rs b/widgets/text-area/src/lib.rs new file mode 100644 index 0000000..f480c03 --- /dev/null +++ b/widgets/text-area/src/lib.rs @@ -0,0 +1,261 @@ +//! `llimphi-widget-text-area` — input de texto multilínea para Llimphi. +//! +//! Versión multilínea del [`llimphi-widget-text-input`]. Mismo contrato Elm +//! (estado en el `Model`, `apply_key` desde el `update`, view con foco), +//! pero acepta `\n` como contenido válido: Enter inserta salto de línea +//! en lugar de "submit". El llamador decide cómo commitear (típicamente +//! Ctrl+Enter o un botón ✓ aparte). +//! +//! El render aprovecha que `View::text_aligned` ya hace layout multilínea +//! vía parley (line wrap por `max_width`, saltos `\n` respetados). +//! +//! Limitaciones del PMV (heredadas del text-input): sin posicionamiento +//! del cursor con flechas, sin selección, sin copy/paste, sin IME. El +//! caret se simula como un bloque sólido al final del texto. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; + +/// Paleta del text-area — mismos slots que el text-input. +#[derive(Debug, Clone, Copy)] +pub struct TextAreaPalette { + pub bg: Color, + pub bg_focus: Color, + pub border: Color, + pub border_focus: Color, + pub fg_text: Color, + pub fg_placeholder: Color, +} + +impl Default for TextAreaPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TextAreaPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_input, + bg_focus: t.bg_input_focus, + border: t.border, + border_focus: t.border_focus, + fg_text: t.fg_text, + fg_placeholder: t.fg_placeholder, + } + } +} + +/// Estado del text-area. Vive en el `Model`; `apply_key` se llama desde +/// el `update` para ediciones por tecla. +#[derive(Debug, Clone, Default)] +pub struct TextAreaState { + text: String, +} + +impl TextAreaState { + pub fn new() -> Self { + Self::default() + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + pub fn clear(&mut self) { + self.text.clear(); + } + + pub fn set_text(&mut self, s: impl Into) { + self.text = s.into(); + } + + /// Cantidad de líneas (≥ 1, mismo criterio que `str::lines` + 1 si + /// el texto termina en `\n`). + pub fn line_count(&self) -> usize { + if self.text.is_empty() { + return 1; + } + let mut n = self.text.lines().count(); + if self.text.ends_with('\n') { + n += 1; + } + n.max(1) + } + + /// Aplica una tecla al estado. Devuelve `true` si cambió el contenido. + /// + /// Maneja: Backspace, Enter (inserta `\n`), e inserción de + /// caracteres imprimibles vía `event.text`. NO maneja: Tab (lo + /// dejamos al caller — típicamente cambio de foco o indent), + /// Escape, flechas. + pub fn apply_key(&mut self, event: &KeyEvent) -> bool { + if event.state != KeyState::Pressed { + return false; + } + match &event.key { + Key::Named(NamedKey::Backspace) => self.text.pop().is_some(), + Key::Named(NamedKey::Enter) => { + self.text.push('\n'); + true + } + _ => { + let Some(text) = event.text.as_ref() else { + return false; + }; + // Filtramos caracteres de control — el `\n` lo metemos + // sólo desde NamedKey::Enter para tener un único path. + if text.is_empty() || text.chars().any(|c| c.is_control()) { + return false; + } + self.text.push_str(text); + true + } + } + } +} + +/// Render del text-area. `body_height` es el alto disponible del bloque +/// (el widget no calcula altura automática; el caller decide). Con foco +/// se pinta un caret bloque al final del texto. +pub fn text_area_view( + state: &TextAreaState, + placeholder: &str, + focused: bool, + body_height: f32, + palette: &TextAreaPalette, + on_focus: Msg, +) -> View { + let is_empty = state.is_empty(); + let display = if is_empty { + placeholder.to_string() + } else { + state.text.clone() + }; + let text_color = if is_empty { palette.fg_placeholder } else { palette.fg_text }; + let (bg, border) = if focused { + (palette.bg_focus, palette.border_focus) + } else { + (palette.bg, palette.border) + }; + + let inner = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(body_height), + }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(4.0_f32), + bottom: length(4.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .text_aligned(display, 12.0, text_color, Alignment::Start); + + // Wrapper que pinta el borde como fill del padre (1 px alrededor + // del inner gracias al padding del padre). + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(body_height + 2.0), + }, + padding: Rect { + left: length(1.0_f32), + right: length(1.0_f32), + top: length(1.0_f32), + bottom: length(1.0_f32), + }, + ..Default::default() + }) + .fill(border) + .on_click(on_focus) + .children(vec![inner]) +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_ui::Modifiers; + + fn k(named: NamedKey) -> KeyEvent { + KeyEvent { + key: Key::Named(named), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers::default(), + repeat: false, + } + } + + fn k_text(s: &str) -> KeyEvent { + KeyEvent { + key: Key::Character(s.into()), + state: KeyState::Pressed, + text: Some(s.to_owned()), + modifiers: Modifiers::default(), + repeat: false, + } + } + + #[test] + fn enter_inserta_salto_de_linea() { + let mut s = TextAreaState::new(); + s.apply_key(&k_text("a")); + s.apply_key(&k(NamedKey::Enter)); + s.apply_key(&k_text("b")); + assert_eq!(s.text(), "a\nb"); + assert_eq!(s.line_count(), 2); + } + + #[test] + fn backspace_borra_el_salto_y_une_lineas() { + let mut s = TextAreaState::new(); + s.set_text("a\nb"); + s.apply_key(&k(NamedKey::Backspace)); + s.apply_key(&k(NamedKey::Backspace)); + assert_eq!(s.text(), "a"); + } + + #[test] + fn line_count_vacio_es_uno() { + let s = TextAreaState::new(); + assert_eq!(s.line_count(), 1); + } + + #[test] + fn line_count_cuenta_trailing_newline() { + let mut s = TextAreaState::new(); + s.set_text("a\nb\n"); + assert_eq!(s.line_count(), 3); + } + + #[test] + fn caracteres_de_control_se_filtran() { + let mut s = TextAreaState::new(); + s.apply_key(&k_text("\t")); + assert!(s.is_empty()); + } + + #[test] + fn set_text_roundtrip() { + let mut s = TextAreaState::new(); + s.set_text("hola\nmundo"); + assert_eq!(s.text(), "hola\nmundo"); + s.clear(); + assert!(s.is_empty()); + } +} diff --git a/widgets/text-editor-core/Cargo.toml b/widgets/text-editor-core/Cargo.toml new file mode 100644 index 0000000..9a4be5b --- /dev/null +++ b/widgets/text-editor-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "llimphi-widget-text-editor-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-text-editor-core — núcleo agnóstico del editor de código: rope buffer (ropey), cursor + selección, undo/redo, bracket matching, find, diagnostics y syntax highlighting (tree-sitter). Sin dependencias de render — reutilizable en TUI/web/headless. La capa Llimphi (state + view) vive en `llimphi-widget-text-editor`." + +[dependencies] +ropey = { workspace = true } +tree-sitter = { workspace = true } +tree-sitter-rust = { workspace = true } +tree-sitter-python = { workspace = true } +# peniko sólo aporta el tipo de color (peniko::Color) para SyntaxPalette; +# es un crate de tipos sin GPU — no arrastra wgpu/vello. Versión alineada +# con la que expone vello 0.5 (ver workspace root). +peniko = "0.4" diff --git a/widgets/text-editor-core/src/bracket.rs b/widgets/text-editor-core/src/bracket.rs new file mode 100644 index 0000000..7cc5e1c --- /dev/null +++ b/widgets/text-editor-core/src/bracket.rs @@ -0,0 +1,165 @@ +//! Matching de paréntesis/corchetes/llaves bajo el cursor. +//! +//! Si el carácter inmediatamente *antes* o *en* el caret es un bracket +//! abridor o cerrador, busca su par contando profundidad y devuelve las +//! dos posiciones. Útil para el visor (resaltar ambas). +//! +//! Restricciones del PMV: no diferencia brackets dentro de strings ni +//! comentarios — el tokenizer del bloque de highlight (tree-sitter) lo +//! resolverá mejor en una pasada futura. Para WAT/JSON/Lisp esto basta. + +use crate::buffer::Buffer; +use crate::cursor::{Cursor, Pos}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Forward, + Backward, +} + +/// Pares reconocidos. +const PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}')]; + +fn pair_of(c: char) -> Option<(char, char, Direction)> { + for &(o, cl) in PAIRS { + if c == o { + return Some((o, cl, Direction::Forward)); + } + if c == cl { + return Some((o, cl, Direction::Backward)); + } + } + None +} + +/// Si el caret toca un bracket, devuelve `(pos_del_bracket, pos_del_par)`. +pub fn find_bracket_pair(buf: &Buffer, cursor: &Cursor) -> Option<(Pos, Pos)> { + let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col); + + // Probamos en `caret` y `caret-1` — un caret "entre" dos chars puede + // tocar al de la izquierda visualmente. + let candidates: [Option; 2] = [ + Some(caret_off).filter(|&o| o < buf.len_chars()), + caret_off.checked_sub(1), + ]; + + for opt in candidates { + let Some(off) = opt else { continue }; + let Some(ch) = buf.char_at(off) else { continue }; + let Some((open, close, dir)) = pair_of(ch) else { continue }; + let mate = match dir { + Direction::Forward => find_forward(buf, off + 1, open, close), + Direction::Backward => find_backward(buf, off, open, close), + }; + if let Some(mate_off) = mate { + let a = buf.offset_to_pos(off); + let b = buf.offset_to_pos(mate_off); + return Some((Pos::new(a.0, a.1), Pos::new(b.0, b.1))); + } + } + None +} + +fn find_forward(buf: &Buffer, from: usize, open: char, close: char) -> Option { + let mut depth = 1usize; + let mut off = from; + let len = buf.len_chars(); + while off < len { + match buf.char_at(off) { + Some(c) if c == open => depth += 1, + Some(c) if c == close => { + depth -= 1; + if depth == 0 { + return Some(off); + } + } + _ => {} + } + off += 1; + } + None +} + +fn find_backward(buf: &Buffer, before: usize, open: char, close: char) -> Option { + if before == 0 { + return None; + } + let mut depth = 1usize; + let mut off = before; + while off > 0 { + off -= 1; + match buf.char_at(off) { + Some(c) if c == close => depth += 1, + Some(c) if c == open => { + depth -= 1; + if depth == 0 { + return Some(off); + } + } + _ => {} + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empareja_paren_simple() { + let b = Buffer::from_str("(a)"); + let c = Cursor::at(0, 0); // caret antes del '(' + let (a, m) = find_bracket_pair(&b, &c).unwrap(); + assert_eq!(a, Pos::new(0, 0)); + assert_eq!(m, Pos::new(0, 2)); + } + + #[test] + fn empareja_desde_el_lado_derecho() { + let b = Buffer::from_str("(a)"); + let c = Cursor::at(0, 3); // caret después del ')' + let (a, m) = find_bracket_pair(&b, &c).unwrap(); + assert_eq!(a, Pos::new(0, 2)); // ')' + assert_eq!(m, Pos::new(0, 0)); // '(' + } + + #[test] + fn anidados_respeta_profundidad() { + let b = Buffer::from_str("((a))"); + let c = Cursor::at(0, 0); // primer '(' + let (_, m) = find_bracket_pair(&b, &c).unwrap(); + assert_eq!(m, Pos::new(0, 4)); // último ')' + } + + #[test] + fn empareja_brackets_y_llaves() { + let b = Buffer::from_str("[a]"); + assert!(find_bracket_pair(&b, &Cursor::at(0, 0)).is_some()); + + let b2 = Buffer::from_str("{a}"); + assert!(find_bracket_pair(&b2, &Cursor::at(0, 0)).is_some()); + } + + #[test] + fn caret_lejos_de_bracket_devuelve_none() { + let b = Buffer::from_str("hola"); + let c = Cursor::at(0, 2); + assert!(find_bracket_pair(&b, &c).is_none()); + } + + #[test] + fn bracket_sin_par_devuelve_none() { + let b = Buffer::from_str("(a"); + let c = Cursor::at(0, 0); + assert!(find_bracket_pair(&b, &c).is_none()); + } + + #[test] + fn multilinea_pasa_saltos() { + let b = Buffer::from_str("(\n a\n)"); + let c = Cursor::at(0, 0); + let (_, m) = find_bracket_pair(&b, &c).unwrap(); + assert_eq!(m, Pos::new(2, 0)); + } +} diff --git a/widgets/text-editor-core/src/buffer.rs b/widgets/text-editor-core/src/buffer.rs new file mode 100644 index 0000000..f998ece --- /dev/null +++ b/widgets/text-editor-core/src/buffer.rs @@ -0,0 +1,255 @@ +//! Buffer del editor — wrapper fino sobre [`ropey::Rope`] con las +//! conversiones de coordenadas que el resto del crate usa. +//! +//! Coordenadas: +//! - `char_offset`: índice de carácter (no byte) en el buffer entero. +//! - `(line, col)`: línea (0-based) + columna en chars dentro de esa línea. +//! +//! Convenciones: +//! - Las líneas son las que define `Rope::lines()` — un `\n` separa +//! líneas; la última línea puede o no terminar en `\n` (en cuyo caso +//! hay una línea vacía extra después). +//! - `col` cuenta chars, no graphemes ni bytes. Para CJK ancho doble +//! el render decidirá el ancho visual; el cursor avanza en chars. + +use ropey::Rope; + +#[derive(Debug, Clone)] +pub struct Buffer { + rope: Rope, +} + +impl Default for Buffer { + fn default() -> Self { + Self::new() + } +} + +impl Buffer { + pub fn new() -> Self { + Self { rope: Rope::new() } + } + + pub fn from_str(s: &str) -> Self { + Self { rope: Rope::from_str(s) } + } + + pub fn text(&self) -> String { + self.rope.to_string() + } + + pub fn len_chars(&self) -> usize { + self.rope.len_chars() + } + + pub fn len_lines(&self) -> usize { + self.rope.len_lines().max(1) + } + + pub fn is_empty(&self) -> bool { + self.rope.len_chars() == 0 + } + + /// Devuelve la línea `n` como `String` (incluye su trailing `\n` si + /// no es la última). Si `n` está fuera de rango devuelve `""`. + pub fn line(&self, n: usize) -> String { + if n >= self.rope.len_lines() { + return String::new(); + } + self.rope.line(n).to_string() + } + + /// Cantidad de chars en la línea `n` **sin contar** el `\n` terminal. + pub fn line_len_chars(&self, n: usize) -> usize { + if n >= self.rope.len_lines() { + return 0; + } + let line = self.rope.line(n); + let mut len = line.len_chars(); + // Quitamos el `\n` final si lo hay. + if len > 0 && line.char(len - 1) == '\n' { + len -= 1; + } + len + } + + /// Convierte `char_offset` global a `(line, col)`. + pub fn offset_to_pos(&self, offset: usize) -> (usize, usize) { + let off = offset.min(self.rope.len_chars()); + let line = self.rope.char_to_line(off); + let line_start = self.rope.line_to_char(line); + (line, off - line_start) + } + + /// Convierte `(line, col)` a `char_offset`. Clampea `line` y `col` + /// para no panicear con coordenadas fuera de rango. + pub fn pos_to_offset(&self, line: usize, col: usize) -> usize { + let line = line.min(self.rope.len_lines().saturating_sub(1)); + let line_start = self.rope.line_to_char(line); + let line_chars = self.line_len_chars(line); + let col = col.min(line_chars); + line_start + col + } + + /// Carácter en `char_offset`. `None` si está fuera de rango. + pub fn char_at(&self, offset: usize) -> Option { + if offset >= self.rope.len_chars() { + return None; + } + Some(self.rope.char(offset)) + } + + /// Slice `[start..end)` como `String`. Clampea para no panicear. + pub fn slice(&self, start: usize, end: usize) -> String { + let len = self.rope.len_chars(); + let s = start.min(len); + let e = end.min(len).max(s); + self.rope.slice(s..e).to_string() + } + + /// Inserta `s` en `offset`. Clampea `offset`. + pub fn insert(&mut self, offset: usize, s: &str) { + let off = offset.min(self.rope.len_chars()); + self.rope.insert(off, s); + } + + /// Borra `[start..end)`. Clampea ambos. + pub fn delete(&mut self, start: usize, end: usize) { + let len = self.rope.len_chars(); + let s = start.min(len); + let e = end.min(len).max(s); + if s == e { + return; + } + self.rope.remove(s..e); + } + + pub fn set_text(&mut self, s: &str) { + self.rope = Rope::from_str(s); + } + + pub fn replace_all(&mut self, s: &str) { + self.set_text(s); + } + + /// Convierte char_offset → byte_offset. tree-sitter trabaja en bytes + /// (UTF-8); el editor en chars. Esto las conecta. + pub fn char_to_byte(&self, char_offset: usize) -> usize { + let off = char_offset.min(self.rope.len_chars()); + self.rope.char_to_byte(off) + } + + /// Línea (0-based) que contiene el char_offset dado. + pub fn char_to_line(&self, char_offset: usize) -> usize { + let off = char_offset.min(self.rope.len_chars()); + self.rope.char_to_line(off) + } + + /// Byte_offset del primer char de la línea `n`. + pub fn line_to_byte(&self, line: usize) -> usize { + let line = line.min(self.rope.len_lines()); + self.rope.line_to_byte(line) + } + + /// Devuelve el rango `[start_col..col)` que contiene el "word" actual + /// — desde el último carácter no-de-palabra hasta `col`, en la línea + /// `line`. Útil para autocompletion (smart-replace del prefijo). + pub fn current_word_prefix(&self, line: usize, col: usize) -> (usize, String) { + let line_text = self.line(line); + let chars: Vec = line_text + .chars() + .filter(|c| *c != '\n') + .collect(); + let end = col.min(chars.len()); + let mut start = end; + while start > 0 && is_word_char(chars[start - 1]) { + start -= 1; + } + let prefix: String = chars[start..end].iter().collect(); + (start, prefix) + } +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_buffer_has_one_line() { + let b = Buffer::new(); + assert_eq!(b.len_lines(), 1); + assert_eq!(b.line_len_chars(0), 0); + } + + #[test] + fn pos_offset_roundtrip() { + let b = Buffer::from_str("hola\nmundo\nfin"); + let cases = [(0usize, 0usize), (0, 4), (1, 0), (1, 5), (2, 3)]; + for (line, col) in cases { + let off = b.pos_to_offset(line, col); + assert_eq!(b.offset_to_pos(off), (line, col)); + } + } + + #[test] + fn line_len_excludes_trailing_newline() { + let b = Buffer::from_str("hola\nfin"); + assert_eq!(b.line_len_chars(0), 4); // "hola" sin \n + assert_eq!(b.line_len_chars(1), 3); // "fin" + } + + #[test] + fn insert_and_delete_modify_text() { + let mut b = Buffer::from_str("ab"); + b.insert(1, "X"); + assert_eq!(b.text(), "aXb"); + b.delete(1, 2); + assert_eq!(b.text(), "ab"); + } + + #[test] + fn slice_clampea() { + let b = Buffer::from_str("hola"); + assert_eq!(b.slice(0, 100), "hola"); + assert_eq!(b.slice(50, 100), ""); + assert_eq!(b.slice(2, 1), ""); // end < start clampea + } + + #[test] + fn current_word_prefix_basic() { + let b = Buffer::from_str("let hola_mundo = 1;"); + // Caret en col 14 (después de la 'o' de "hola_mundo"). + let (start, p) = b.current_word_prefix(0, 14); + assert_eq!(start, 4); + assert_eq!(p, "hola_mundo"); + } + + #[test] + fn current_word_prefix_en_inicio_es_vacio() { + let b = Buffer::from_str("hola"); + let (start, p) = b.current_word_prefix(0, 0); + assert_eq!(start, 0); + assert!(p.is_empty()); + } + + #[test] + fn current_word_prefix_caret_despues_de_no_word() { + let b = Buffer::from_str("foo.bar"); + let (start, p) = b.current_word_prefix(0, 4); + // El '.' no es word; el prefijo empieza ahí. + assert_eq!(start, 4); + assert!(p.is_empty()); + } + + #[test] + fn pos_to_offset_clampea_col() { + let b = Buffer::from_str("ab\ncd"); + // col fuera de rango → fin de línea + assert_eq!(b.pos_to_offset(0, 99), 2); + assert_eq!(b.pos_to_offset(1, 99), 5); + } +} diff --git a/widgets/text-editor-core/src/clipboard.rs b/widgets/text-editor-core/src/clipboard.rs new file mode 100644 index 0000000..efb11d5 --- /dev/null +++ b/widgets/text-editor-core/src/clipboard.rs @@ -0,0 +1,50 @@ +//! Clipboard abstracto. El editor no quiere acoplarse a un backend de +//! SO concreto (X11 / Wayland / macOS / Windows), así que define el +//! trait y entrega un mock para tests. La impl real (vía `arboard`) +//! vive del lado del caller — típicamente la app embebida en +//! `nada` o el visor del notebook. + +/// Backend de clipboard. `set` mete texto; `get` lo lee. Cualquiera de +/// los dos puede fallar (sin display, headless CI, race con otro +/// programa) — `None` / no-op silencioso es válido. +pub trait Clipboard: Send { + fn get(&mut self) -> Option; + fn set(&mut self, s: &str); +} + +/// Clipboard de memoria — útil para tests y como fallback cuando el +/// sistema no expone uno. +#[derive(Debug, Default, Clone)] +pub struct MemClipboard { + content: Option, +} + +impl MemClipboard { + pub fn new() -> Self { + Self::default() + } + pub fn with(s: impl Into) -> Self { + Self { content: Some(s.into()) } + } +} + +impl Clipboard for MemClipboard { + fn get(&mut self) -> Option { + self.content.clone() + } + fn set(&mut self, s: &str) { + self.content = Some(s.to_owned()); + } +} + +/// "No clipboard" — `set` descarta, `get` devuelve `None`. Útil cuando +/// el caller quiere desactivar copy/paste explícitamente. +#[derive(Debug, Default, Clone, Copy)] +pub struct NullClipboard; + +impl Clipboard for NullClipboard { + fn get(&mut self) -> Option { + None + } + fn set(&mut self, _: &str) {} +} diff --git a/widgets/text-editor-core/src/cursor.rs b/widgets/text-editor-core/src/cursor.rs new file mode 100644 index 0000000..cf21501 --- /dev/null +++ b/widgets/text-editor-core/src/cursor.rs @@ -0,0 +1,325 @@ +//! Cursor + selección. Coordenadas en `(line, col)` (col en chars). +//! +//! Un [`Cursor`] tiene siempre una posición `caret` y opcionalmente un +//! `anchor`: si están en distintos puntos, hay una **selección**. +//! Movimiento sin `shift` colapsa la selección al caret nuevo; +//! movimiento con `shift` extiende desde el `anchor`. + +use crate::buffer::Buffer; + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} +fn is_ws(c: char) -> bool { + c.is_whitespace() && c != '\n' +} + +/// Posición lógica del cursor — (línea, columna en chars). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Pos { + pub line: usize, + pub col: usize, +} + +impl Pos { + pub const fn new(line: usize, col: usize) -> Self { + Self { line, col } + } + pub const ORIGIN: Pos = Pos { line: 0, col: 0 }; +} + +/// Selección activa (anchor + caret). El rango efectivo es +/// `(min(anchor,caret), max(anchor,caret))` en orden de offset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Selection { + pub anchor: Pos, + pub caret: Pos, +} + +impl Selection { + pub fn new(anchor: Pos, caret: Pos) -> Self { + Self { anchor, caret } + } + pub fn is_empty(&self) -> bool { + self.anchor == self.caret + } +} + +/// Cursor: caret + (opcional) anchor cuando hay selección. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cursor { + pub caret: Pos, + pub anchor: Option, + /// Columna "deseada" — preserva la posición horizontal al saltar + /// entre líneas de distinto largo. Se setea al mover horizontal + /// y se respeta al mover vertical. + pub desired_col: usize, +} + +impl Default for Cursor { + fn default() -> Self { + Self::new() + } +} + +impl Cursor { + pub fn new() -> Self { + Self { caret: Pos::ORIGIN, anchor: None, desired_col: 0 } + } + + pub fn at(line: usize, col: usize) -> Self { + Self { caret: Pos::new(line, col), anchor: None, desired_col: col } + } + + pub fn selection(&self) -> Option { + self.anchor.map(|a| Selection::new(a, self.caret)) + } + + pub fn has_selection(&self) -> bool { + self.anchor.map_or(false, |a| a != self.caret) + } + + /// Rango efectivo `(start, end)` en `char_offset` global. Si no hay + /// selección, ambos son el caret. + pub fn selection_range(&self, buf: &Buffer) -> (usize, usize) { + let caret_off = buf.pos_to_offset(self.caret.line, self.caret.col); + match self.anchor { + None => (caret_off, caret_off), + Some(a) => { + let anchor_off = buf.pos_to_offset(a.line, a.col); + if anchor_off <= caret_off { + (anchor_off, caret_off) + } else { + (caret_off, anchor_off) + } + } + } + } + + /// Colapsa la selección dejando el caret donde está. + pub fn collapse(&mut self) { + self.anchor = None; + } + + /// Asegura que `anchor = caret` si `extending` es true y no había + /// anchor; si es false, colapsa. + pub fn set_extending(&mut self, extending: bool) { + match (extending, self.anchor) { + (true, None) => self.anchor = Some(self.caret), + (true, Some(_)) => {} + (false, _) => self.anchor = None, + } + } + + // ----- Movimiento por chars ----- + + pub fn move_left(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + if self.caret.col > 0 { + self.caret.col -= 1; + } else if self.caret.line > 0 { + self.caret.line -= 1; + self.caret.col = buf.line_len_chars(self.caret.line); + } + self.desired_col = self.caret.col; + } + + pub fn move_right(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + let line_len = buf.line_len_chars(self.caret.line); + if self.caret.col < line_len { + self.caret.col += 1; + } else if self.caret.line + 1 < buf.len_lines() { + self.caret.line += 1; + self.caret.col = 0; + } + self.desired_col = self.caret.col; + } + + pub fn move_up(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + if self.caret.line == 0 { + self.caret.col = 0; + } else { + self.caret.line -= 1; + self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); + } + } + + pub fn move_down(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + if self.caret.line + 1 >= buf.len_lines() { + self.caret.col = buf.line_len_chars(self.caret.line); + } else { + self.caret.line += 1; + self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); + } + } + + pub fn move_home(&mut self, _buf: &Buffer, extending: bool) { + self.set_extending(extending); + // Atajo: ir al inicio del primer non-whitespace; segundo Home + // iría al 0 — por ahora siempre al 0. + self.caret.col = 0; + self.desired_col = 0; + } + + pub fn move_end(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + self.caret.col = buf.line_len_chars(self.caret.line); + self.desired_col = self.caret.col; + } + + pub fn move_page_up(&mut self, buf: &Buffer, extending: bool, page: usize) { + self.set_extending(extending); + self.caret.line = self.caret.line.saturating_sub(page); + self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); + } + + pub fn move_page_down(&mut self, buf: &Buffer, extending: bool, page: usize) { + self.set_extending(extending); + self.caret.line = (self.caret.line + page).min(buf.len_lines().saturating_sub(1)); + self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); + } + + pub fn move_doc_start(&mut self, _buf: &Buffer, extending: bool) { + self.set_extending(extending); + self.caret = Pos::ORIGIN; + self.desired_col = 0; + } + + pub fn move_doc_end(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + let last_line = buf.len_lines().saturating_sub(1); + self.caret = Pos::new(last_line, buf.line_len_chars(last_line)); + self.desired_col = self.caret.col; + } + + // ----- Word movement ----- + + /// Movimiento por palabra a la izquierda — salta whitespace, después + /// caracteres de palabra (alfanumérico + `_`). + pub fn move_word_left(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + let mut off = buf.pos_to_offset(self.caret.line, self.caret.col); + while off > 0 && buf.char_at(off - 1).map_or(false, is_ws) { + off -= 1; + } + while off > 0 && buf.char_at(off - 1).map_or(false, is_word) { + off -= 1; + } + let (l, c) = buf.offset_to_pos(off); + self.caret = Pos::new(l, c); + self.desired_col = c; + } + + pub fn move_word_right(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + let len = buf.len_chars(); + let mut off = buf.pos_to_offset(self.caret.line, self.caret.col); + while off < len && buf.char_at(off).map_or(false, is_word) { + off += 1; + } + while off < len && buf.char_at(off).map_or(false, is_ws) { + off += 1; + } + let (l, c) = buf.offset_to_pos(off); + self.caret = Pos::new(l, c); + self.desired_col = c; + } + + // ----- Setters ----- + + pub fn set_caret(&mut self, buf: &Buffer, pos: Pos) { + let line = pos.line.min(buf.len_lines().saturating_sub(1)); + let col = pos.col.min(buf.line_len_chars(line)); + self.caret = Pos::new(line, col); + self.desired_col = col; + self.anchor = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn buf() -> Buffer { + Buffer::from_str("hola\nmundo\nfin") + } + + #[test] + fn cursor_new_is_origin() { + let c = Cursor::new(); + assert_eq!(c.caret, Pos::ORIGIN); + assert!(!c.has_selection()); + } + + #[test] + fn move_right_atraviesa_lineas() { + let b = buf(); + let mut c = Cursor::at(0, 4); // fin de "hola" + c.move_right(&b, false); + assert_eq!(c.caret, Pos::new(1, 0)); // inicio de "mundo" + } + + #[test] + fn move_left_retrocede_a_linea_anterior() { + let b = buf(); + let mut c = Cursor::at(1, 0); + c.move_left(&b, false); + assert_eq!(c.caret, Pos::new(0, 4)); + } + + #[test] + fn move_up_preserva_desired_col() { + let b = Buffer::from_str("abcdefgh\nxy\nlmnop"); + let mut c = Cursor::at(0, 7); + c.move_down(&b, false); + // "xy" sólo tiene 2 chars; el cursor se pega a col=2 + assert_eq!(c.caret, Pos::new(1, 2)); + // pero al bajar de nuevo, el desired (7) reanima. + c.move_down(&b, false); + assert_eq!(c.caret, Pos::new(2, 5)); // "lmnop" tiene 5 + } + + #[test] + fn shift_arrow_inicia_seleccion() { + let b = buf(); + let mut c = Cursor::at(0, 0); + c.move_right(&b, true); + c.move_right(&b, true); + assert!(c.has_selection()); + let (s, e) = c.selection_range(&b); + assert_eq!((s, e), (0, 2)); + } + + #[test] + fn arrow_sin_shift_colapsa() { + let b = buf(); + let mut c = Cursor::at(0, 0); + c.move_right(&b, true); + c.move_right(&b, true); + c.move_right(&b, false); + assert!(!c.has_selection()); + } + + #[test] + fn home_end_son_locales_a_la_linea() { + let b = buf(); + let mut c = Cursor::at(1, 2); + c.move_home(&b, false); + assert_eq!(c.caret, Pos::new(1, 0)); + c.move_end(&b, false); + assert_eq!(c.caret, Pos::new(1, 5)); + } + + #[test] + fn doc_start_y_end() { + let b = buf(); + let mut c = Cursor::at(1, 2); + c.move_doc_end(&b, false); + assert_eq!(c.caret, Pos::new(2, 3)); + c.move_doc_start(&b, false); + assert_eq!(c.caret, Pos::ORIGIN); + } +} diff --git a/widgets/text-editor-core/src/diagnostics.rs b/widgets/text-editor-core/src/diagnostics.rs new file mode 100644 index 0000000..20c9441 --- /dev/null +++ b/widgets/text-editor-core/src/diagnostics.rs @@ -0,0 +1,78 @@ +//! Diagnósticos del editor — espejo minimal del shape de `lsp-types` +//! sin depender del crate. Pensado para que un client LSP (rust-analyzer, +//! pylsp, etc.) lo poble desde fuera; el render del editor los pinta +//! como subrayado bajo el rango. +//! +//! El client real vive aparte (proceso + JSON-RPC) — este módulo sólo +//! define el shape de los datos y el helper para renderizarlos. + +use crate::cursor::Pos; + +/// Severidad — mismos valores y orden que en LSP (1 = Error es el más alto). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + Error, + Warning, + Information, + Hint, +} + +/// Rango cerrado de un diagnostic. `end` exclusivo en `col`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiagnosticRange { + pub start: Pos, + pub end: Pos, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnostic { + pub range: DiagnosticRange, + pub severity: Severity, + /// Mensaje humano corto — el render lo trunca para mostrar al hover/ + /// en una mini popup futura. En esta versión solo se usa el rango. + pub message: String, + /// Source del diagnostic — "rust-analyzer", "pylsp", "clippy", etc. + /// `None` si no se conoce. + pub source: Option, +} + +impl Diagnostic { + pub fn error(line_start: usize, col_start: usize, line_end: usize, col_end: usize, message: impl Into) -> Self { + Self { + range: DiagnosticRange { + start: Pos::new(line_start, col_start), + end: Pos::new(line_end, col_end), + }, + severity: Severity::Error, + message: message.into(), + source: None, + } + } + pub fn warning(line_start: usize, col_start: usize, line_end: usize, col_end: usize, message: impl Into) -> Self { + Self { + range: DiagnosticRange { + start: Pos::new(line_start, col_start), + end: Pos::new(line_end, col_end), + }, + severity: Severity::Warning, + message: message.into(), + source: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constructors_funcionan() { + let e = Diagnostic::error(1, 2, 1, 5, "boom"); + assert_eq!(e.severity, Severity::Error); + assert_eq!(e.range.start, Pos::new(1, 2)); + assert_eq!(e.range.end, Pos::new(1, 5)); + + let w = Diagnostic::warning(0, 0, 0, 10, "ojo"); + assert_eq!(w.severity, Severity::Warning); + } +} diff --git a/widgets/text-editor-core/src/find.rs b/widgets/text-editor-core/src/find.rs new file mode 100644 index 0000000..5a97d00 --- /dev/null +++ b/widgets/text-editor-core/src/find.rs @@ -0,0 +1,168 @@ +//! Búsqueda en el buffer. PMV: case-insensitive opcional, sin regex, +//! sin replace. La UI del prompt vive en el caller (típicamente una +//! barra arriba del editor); este módulo sólo provee: +//! +//! - [`FindState`] con el query actual + dirección + flag case-sensitive. +//! - [`find_next`] / [`find_prev`] que devuelven la próxima/anterior +//! match desde el caret del editor. +//! - [`all_matches`] para que el render resalte cada ocurrencia. + +use crate::buffer::Buffer; +use crate::cursor::{Cursor, Pos}; + +/// Configuración de búsqueda del editor. +#[derive(Debug, Clone, Default)] +pub struct FindState { + pub query: String, + pub case_sensitive: bool, +} + +impl FindState { + pub fn new() -> Self { + Self::default() + } + pub fn with_query(query: impl Into) -> Self { + Self { query: query.into(), case_sensitive: false } + } + pub fn is_active(&self) -> bool { + !self.query.is_empty() + } +} + +/// Devuelve todas las ocurrencias del query en el buffer como +/// `(start_offset, end_offset)` en char offsets. Vacío si query vacío. +pub fn all_matches(buf: &Buffer, find: &FindState) -> Vec<(usize, usize)> { + if find.query.is_empty() { + return Vec::new(); + } + let hay = buf.text(); + let (hay_search, needle_search) = if find.case_sensitive { + (hay.clone(), find.query.clone()) + } else { + (hay.to_lowercase(), find.query.to_lowercase()) + }; + + // Buscamos en bytes; convertimos a char_offsets al devolver. + let mut out: Vec<(usize, usize)> = Vec::new(); + let mut byte_start = 0; + while let Some(pos) = hay_search[byte_start..].find(&needle_search) { + let byte_match = byte_start + pos; + let char_start = hay[..byte_match].chars().count(); + let char_end = char_start + find.query.chars().count(); + out.push((char_start, char_end)); + byte_start = byte_match + needle_search.len().max(1); + } + out +} + +/// Encuentra la próxima ocurrencia con `start >= caret_off` (la match +/// **en** el caret cuenta, no la saltea). Para avanzar a la siguiente +/// real, el caller mueve el caret al `end` de la match anterior y +/// vuelve a llamar. Wrap-around al fin del buffer → primera match. +pub fn find_next(buf: &Buffer, find: &FindState, cursor: &Cursor) -> Option<(Pos, Pos)> { + let matches = all_matches(buf, find); + if matches.is_empty() { + return None; + } + let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col); + let next = matches + .iter() + .find(|(s, _)| *s >= caret_off) + .copied() + .or_else(|| matches.first().copied())?; + Some(positions_of(buf, next)) +} + +/// Como [`find_next`] pero en reverso. +pub fn find_prev(buf: &Buffer, find: &FindState, cursor: &Cursor) -> Option<(Pos, Pos)> { + let matches = all_matches(buf, find); + if matches.is_empty() { + return None; + } + let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col); + let prev = matches + .iter() + .rev() + .find(|(_, e)| *e < caret_off) + .copied() + .or_else(|| matches.last().copied())?; + Some(positions_of(buf, prev)) +} + +fn positions_of(buf: &Buffer, (start, end): (usize, usize)) -> (Pos, Pos) { + let (sl, sc) = buf.offset_to_pos(start); + let (el, ec) = buf.offset_to_pos(end); + (Pos::new(sl, sc), Pos::new(el, ec)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_matches_vacio_devuelve_vacio() { + let b = Buffer::from_str("hola hola"); + let f = FindState::new(); + assert!(all_matches(&b, &f).is_empty()); + } + + #[test] + fn all_matches_encuentra_todas() { + let b = Buffer::from_str("ab cd ab ef ab"); + let f = FindState::with_query("ab"); + let m = all_matches(&b, &f); + assert_eq!(m, vec![(0, 2), (6, 8), (12, 14)]); + } + + #[test] + fn case_insensitive_por_default() { + let b = Buffer::from_str("Hola HOLA hola"); + let f = FindState::with_query("hola"); + assert_eq!(all_matches(&b, &f).len(), 3); + } + + #[test] + fn case_sensitive_filtra() { + let b = Buffer::from_str("Hola HOLA hola"); + let f = FindState { query: "hola".into(), case_sensitive: true }; + assert_eq!(all_matches(&b, &f).len(), 1); + } + + #[test] + fn find_next_wrap_al_final() { + let b = Buffer::from_str("ab cd ab"); + let f = FindState::with_query("ab"); + let c = Cursor::at(0, 8); // al final + let (a, _) = find_next(&b, &f, &c).unwrap(); + assert_eq!(a, Pos::new(0, 0)); // wrap al primero + } + + #[test] + fn find_prev_wrap_al_principio() { + let b = Buffer::from_str("ab cd ab"); + let f = FindState::with_query("ab"); + let c = Cursor::at(0, 0); + let (a, _) = find_prev(&b, &f, &c).unwrap(); + assert_eq!(a, Pos::new(0, 6)); // wrap al último + } + + #[test] + fn find_next_devuelve_match_en_el_caret() { + let b = Buffer::from_str("ab ab ab"); + let f = FindState::with_query("ab"); + let c = Cursor::at(0, 0); + let (a, _) = find_next(&b, &f, &c).unwrap(); + assert_eq!(a, Pos::new(0, 0)); + } + + #[test] + fn find_next_avanza_si_caret_va_al_fin_de_match_anterior() { + let b = Buffer::from_str("ab ab ab"); + let f = FindState::with_query("ab"); + let mut c = Cursor::at(0, 0); + let (_, end1) = find_next(&b, &f, &c).unwrap(); + c.caret = end1; // (0, 2) — fin de la primera + let (a2, _) = find_next(&b, &f, &c).unwrap(); + assert_eq!(a2, Pos::new(0, 3)); + } +} diff --git a/widgets/text-editor-core/src/highlight.rs b/widgets/text-editor-core/src/highlight.rs new file mode 100644 index 0000000..57b58f2 --- /dev/null +++ b/widgets/text-editor-core/src/highlight.rs @@ -0,0 +1,590 @@ +//! Syntax highlighting. Cada `Language` produce una `Vec`: +//! por línea, una secuencia ordenada de `(start_col, end_col, TokenKind)` +//! que cubre toda la línea. El renderer pinta cada span con el color +//! que el [`SyntaxPalette`] mapea desde el `TokenKind`. +//! +//! - **Rust / Python**: tree-sitter parseando el buffer entero (ineficiente +//! pero adecuado para celdas de notebook ≤ ~1k LOC). Las queries se +//! compilan una vez por `Language`. +//! - **WAT**: tokenizer en Rust puro (LISP-like: paren, `$`-prefijo, +//! strings, números, keywords típicos del subset MVP). +//! - **Plain**: un solo span por línea con `TokenKind::Other`. + +use peniko::Color; + +/// Lenguajes soportados — la matriz se extiende sumando un variant + +/// una rama en [`Highlighter::tokenize_line`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Language { + Plain, + Rust, + Python, + Wat, +} + +impl Language { + /// Heurística: derivar el `Language` del `language` del `CellKind`. + pub fn from_cell_language(s: &str) -> Self { + match s.to_ascii_lowercase().as_str() { + "rust" | "rs" => Language::Rust, + "python" | "py" => Language::Python, + "wasm" | "wat" => Language::Wat, + _ => Language::Plain, + } + } +} + +/// Categorías de token — lo suficientemente granular para colores +/// distintos sin saturar el theme. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenKind { + Keyword, + Type, + Function, + String, + Number, + Comment, + Operator, + Punctuation, + Identifier, + Other, +} + +/// Un span dentro de una línea: `[start_col..end_col)` de la línea, +/// más su categoría. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Span { + pub start_col: usize, + pub end_col: usize, + pub kind: TokenKind, +} + +/// Paleta de colores por categoría — el theme la deriva. +#[derive(Debug, Clone, Copy)] +pub struct SyntaxPalette { + pub keyword: Color, + pub typ: Color, + pub function: Color, + pub string: Color, + pub number: Color, + pub comment: Color, + pub operator: Color, + pub punctuation: Color, + pub identifier: Color, + pub other: Color, +} + +impl SyntaxPalette { + pub fn color(&self, k: TokenKind) -> Color { + match k { + TokenKind::Keyword => self.keyword, + TokenKind::Type => self.typ, + TokenKind::Function => self.function, + TokenKind::String => self.string, + TokenKind::Number => self.number, + TokenKind::Comment => self.comment, + TokenKind::Operator => self.operator, + TokenKind::Punctuation => self.punctuation, + TokenKind::Identifier => self.identifier, + TokenKind::Other => self.other, + } + } +} + +// El constructor `dark_default(theme)` — única pieza que dependía de +// `llimphi_theme` — vive ahora en `llimphi-widget-text-editor` +// (`syntax_palette_dark`), para que este núcleo no arrastre el stack de +// render. Aquí queda sólo el modelo de color puro (peniko::Color). + +// Pool thread-local de parsers tree-sitter. Reconstruir el parser +// (con `set_language`) es caro; reusarlo entre highlights del mismo +// lenguaje es un ahorro grande. `tree_sitter::Parser` no es Send/ +// Sync ni Clone, así que vive en thread-local — un parser por +// lenguaje por thread. +thread_local! { + static PARSER_POOL: std::cell::RefCell> + = std::cell::RefCell::new(std::collections::HashMap::new()); + /// Cache del último árbol parseado por lenguaje. Se pasa como hint + /// al siguiente `parse(source, Some(&old_tree))`. El "verdadero + /// incremental" (aplicar `InputEdit`s al tree antes de reparsear) + /// ya está cableado: `EditorState` acumula los edits por delta y + /// llama a [`apply_pending_edits`] antes de cada highlight, de modo + /// que tree-sitter sólo reconstruye los subtrees afectados. + static TREE_CACHE: std::cell::RefCell> + = std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Invalida el árbol cached para `language` — el caller lo invoca al +/// hacer `set_text` o cambios masivos donde el hint puede confundir +/// más que ayudar. No es estrictamente necesario, pero es defensivo. +pub fn invalidate_tree_cache(language: Language) { + TREE_CACHE.with(|c| { + c.borrow_mut().remove(&language); + }); +} + +/// Aplica una lista de `InputEdit` al tree cached del `language`. +/// Llamarlo ANTES de `parse(source, Some(&old_tree))` activa el modo +/// incremental real de tree-sitter — solo reconstruye los subtrees +/// afectados por las edits. +pub fn apply_pending_edits(language: Language, edits: &[tree_sitter::InputEdit]) { + if edits.is_empty() { + return; + } + TREE_CACHE.with(|c| { + let mut c = c.borrow_mut(); + if let Some(tree) = c.get_mut(&language) { + for e in edits { + tree.edit(e); + } + } + }); +} + +/// Highlighter — fina capa sin estado mutable propio. La parser real +/// vive en el pool thread-local. +pub struct Highlighter { + language: Language, +} + +impl Highlighter { + pub fn new(language: Language) -> Self { + Self { language } + } + + pub fn language(&self) -> Language { + self.language + } + + pub fn set_language(&mut self, language: Language) { + self.language = language; + } + + /// Tokeniza el `source` entero y devuelve los spans por línea. + /// `result.len() == source.lines().count().max(1)`. + pub fn highlight(&mut self, source: &str) -> Vec> { + match self.language { + Language::Plain => plain_lines(source), + Language::Wat => highlight_wat(source), + Language::Rust => self.highlight_treesitter(source, rust_kind), + Language::Python => self.highlight_treesitter(source, python_kind), + } + } + + fn highlight_treesitter( + &mut self, + source: &str, + kind_of: fn(&str) -> Option, + ) -> Vec> { + let language = self.language; + // Parsea con hint del tree previo si lo hay. tree-sitter puede + // reusar subtrees por hash incluso sin InputEdits aplicados. + let tree_opt = PARSER_POOL.with(|pool| { + let mut pool = pool.borrow_mut(); + let parser = pool.entry(language).or_insert_with(|| { + make_ts_parser(language).unwrap_or_else(tree_sitter::Parser::new) + }); + let old = TREE_CACHE.with(|c| c.borrow().get(&language).cloned()); + parser.parse(source, old.as_ref()) + }); + let Some(tree) = tree_opt else { + return plain_lines(source); + }; + // Guarda el nuevo árbol para la próxima invocación. + TREE_CACHE.with(|c| { + c.borrow_mut().insert(language, tree.clone()); + }); + + // Por línea: recopilamos spans de los nodos *named* tipados que + // matchean kind_of. Luego rellenamos los huecos con `Other`. + let line_count = source.lines().count().max(1) + + (if source.ends_with('\n') { 1 } else { 0 }); + let mut per_line: Vec> = vec![Vec::new(); line_count.max(1)]; + + let mut stack: Vec = vec![tree.root_node()]; + while let Some(node) = stack.pop() { + if node.child_count() == 0 { + // hoja: tomamos el tipo del nodo (token). + let kind = node.kind(); + if let Some(tk) = kind_of(kind) { + let start = node.start_position(); + let end = node.end_position(); + // Sólo manejamos tokens single-line (los multi-line + // como block strings se splitean por línea). + if start.row == end.row { + if let Some(line) = per_line.get_mut(start.row) { + line.push(Span { + start_col: start.column, + end_col: end.column, + kind: tk, + }); + } + } else { + // Multi-line: marca cada línea entera como ese kind. + // Aproximación; suficiente para strings multi-línea. + for row in start.row..=end.row { + if let Some(line) = per_line.get_mut(row) { + let line_text = + source.lines().nth(row).unwrap_or(""); + let s = if row == start.row { start.column } else { 0 }; + let e = + if row == end.row { end.column } else { line_text.chars().count() }; + line.push(Span { start_col: s, end_col: e, kind: tk }); + } + } + } + } + } else { + for i in (0..node.child_count()).rev() { + if let Some(c) = node.child(i) { + stack.push(c); + } + } + } + } + + // Por cada línea: ordena, fusiona overlapping, rellena huecos. + let mut result: Vec> = Vec::with_capacity(per_line.len()); + for (row, mut spans) in per_line.into_iter().enumerate() { + let line_text = source.lines().nth(row).unwrap_or(""); + spans.sort_by_key(|s| s.start_col); + result.push(fill_gaps(spans, line_text.chars().count())); + } + result + } +} + +fn make_ts_parser(language: Language) -> Option { + let mut parser = tree_sitter::Parser::new(); + let lang: tree_sitter::Language = match language { + Language::Rust => tree_sitter_rust::LANGUAGE.into(), + Language::Python => tree_sitter_python::LANGUAGE.into(), + _ => return None, + }; + parser.set_language(&lang).ok()?; + Some(parser) +} + +/// Mapeo de tree-sitter node `kind` → TokenKind para Rust. +fn rust_kind(kind: &str) -> Option { + // Lista deliberadamente acotada al subset común; nodos no listados + // caen como Identifier/Other vía fill_gaps. + match kind { + // Keywords + "fn" | "let" | "mut" | "const" | "static" | "if" | "else" | "match" + | "for" | "while" | "loop" | "break" | "continue" | "return" | "use" + | "mod" | "pub" | "impl" | "trait" | "struct" | "enum" | "type" + | "where" | "as" | "in" | "ref" | "move" | "self" | "Self" | "crate" + | "super" | "async" | "await" | "dyn" | "unsafe" | "extern" => { + Some(TokenKind::Keyword) + } + // Tipos primitivos + "primitive_type" => Some(TokenKind::Type), + // Literales + "string_literal" | "raw_string_literal" | "char_literal" | "string_content" => { + Some(TokenKind::String) + } + "integer_literal" | "float_literal" | "boolean_literal" => Some(TokenKind::Number), + // Comentarios + "line_comment" | "block_comment" => Some(TokenKind::Comment), + _ => None, + } +} + +/// Mapeo para Python. +fn python_kind(kind: &str) -> Option { + match kind { + "def" | "class" | "if" | "elif" | "else" | "for" | "while" | "return" + | "import" | "from" | "as" | "in" | "is" | "not" | "and" | "or" + | "with" | "try" | "except" | "finally" | "raise" | "yield" | "pass" + | "break" | "continue" | "global" | "nonlocal" | "lambda" | "True" + | "False" | "None" | "async" | "await" => Some(TokenKind::Keyword), + "string" | "string_start" | "string_content" | "string_end" => Some(TokenKind::String), + "integer" | "float" | "true" | "false" | "none" => Some(TokenKind::Number), + "comment" => Some(TokenKind::Comment), + _ => None, + } +} + +// --------------------------------------------------------------------- +// WAT — tokenizer en Rust puro (sin tree-sitter). +// --------------------------------------------------------------------- + +fn highlight_wat(source: &str) -> Vec> { + let mut out: Vec> = Vec::new(); + for line in iterate_lines(source) { + out.push(tokenize_wat_line(line)); + } + out +} + +fn iterate_lines(source: &str) -> Vec<&str> { + let mut out: Vec<&str> = source.lines().collect(); + if source.ends_with('\n') || source.is_empty() { + out.push(""); + } + if out.is_empty() { + out.push(""); + } + out +} + +fn tokenize_wat_line(line: &str) -> Vec { + let mut out: Vec = Vec::new(); + let chars: Vec = line.chars().collect(); + let len = chars.len(); + let mut i = 0usize; + + while i < len { + let c = chars[i]; + + if c.is_whitespace() { + let start = i; + while i < len && chars[i].is_whitespace() { + i += 1; + } + out.push(Span { start_col: start, end_col: i, kind: TokenKind::Other }); + continue; + } + + // Comentario línea `;; ...` + if c == ';' && i + 1 < len && chars[i + 1] == ';' { + out.push(Span { start_col: i, end_col: len, kind: TokenKind::Comment }); + break; + } + + // Paren + if c == '(' || c == ')' { + out.push(Span { start_col: i, end_col: i + 1, kind: TokenKind::Punctuation }); + i += 1; + continue; + } + + // String "..." + if c == '"' { + let start = i; + i += 1; + while i < len { + let cc = chars[i]; + if cc == '\\' && i + 1 < len { + i += 2; + continue; + } + if cc == '"' { + i += 1; + break; + } + i += 1; + } + out.push(Span { start_col: start, end_col: i, kind: TokenKind::String }); + continue; + } + + // Identificador `$nombre` + if c == '$' { + let start = i; + i += 1; + while i < len && is_wat_ident_char(chars[i]) { + i += 1; + } + out.push(Span { start_col: start, end_col: i, kind: TokenKind::Identifier }); + continue; + } + + // Número (entero/hex/float — simplificado: empieza con dígito o -dígito). + if c.is_ascii_digit() || (c == '-' && i + 1 < len && chars[i + 1].is_ascii_digit()) { + let start = i; + if c == '-' { + i += 1; + } + while i < len { + let cc = chars[i]; + if cc.is_ascii_digit() || cc == '.' || cc == 'x' || cc.is_ascii_hexdigit() { + i += 1; + } else { + break; + } + } + out.push(Span { start_col: start, end_col: i, kind: TokenKind::Number }); + continue; + } + + // Word: keyword o identificador + if is_wat_word_start(c) { + let start = i; + while i < len && is_wat_ident_char(chars[i]) { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + let kind = wat_word_kind(&word); + out.push(Span { start_col: start, end_col: i, kind }); + continue; + } + + // Otros (operadores como `.`) + out.push(Span { start_col: i, end_col: i + 1, kind: TokenKind::Operator }); + i += 1; + } + + fill_gaps(out, len) +} + +fn is_wat_word_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' +} +fn is_wat_ident_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '/' | ':' | '<' | '=' | '>' | '?' | '@' | '\\' | '^' | '`' | '|' | '~') +} + +fn wat_word_kind(w: &str) -> TokenKind { + const KEYWORDS: &[&str] = &[ + "module", "func", "param", "result", "local", "import", "export", + "memory", "data", "table", "elem", "type", "global", "start", "block", + "loop", "if", "then", "else", "end", "br", "br_if", "br_table", + "return", "call", "call_indirect", + ]; + const TYPES: &[&str] = &["i32", "i64", "f32", "f64", "v128", "funcref", "externref", "anyref"]; + + if KEYWORDS.contains(&w) { + TokenKind::Keyword + } else if TYPES.contains(&w) { + TokenKind::Type + } else if w.contains('.') { + // Instrucciones tipo `i32.const`, `local.get`, etc. + TokenKind::Function + } else { + TokenKind::Identifier + } +} + +// --------------------------------------------------------------------- +// Plain + utilities +// --------------------------------------------------------------------- + +fn plain_lines(source: &str) -> Vec> { + let mut out: Vec> = Vec::new(); + for line in iterate_lines(source) { + let len = line.chars().count(); + out.push(vec![Span { start_col: 0, end_col: len, kind: TokenKind::Other }]); + } + out +} + +/// Rellena los huecos entre spans con `Other` para cubrir `[0..line_len)`. +fn fill_gaps(spans: Vec, line_len: usize) -> Vec { + if spans.is_empty() { + return vec![Span { start_col: 0, end_col: line_len, kind: TokenKind::Other }]; + } + let mut out: Vec = Vec::with_capacity(spans.len() * 2); + let mut cursor = 0usize; + for s in spans { + if s.start_col > cursor { + out.push(Span { start_col: cursor, end_col: s.start_col, kind: TokenKind::Other }); + } + // Clampea overlaps con el anterior. + if s.end_col > cursor { + let start_col = s.start_col.max(cursor); + out.push(Span { start_col, end_col: s.end_col, kind: s.kind }); + cursor = s.end_col; + } + } + if cursor < line_len { + out.push(Span { start_col: cursor, end_col: line_len, kind: TokenKind::Other }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_devuelve_un_span_por_linea() { + let mut h = Highlighter::new(Language::Plain); + let r = h.highlight("hola\nmundo"); + assert_eq!(r.len(), 2); + assert_eq!(r[0].len(), 1); + assert_eq!(r[0][0].kind, TokenKind::Other); + } + + #[test] + fn wat_paren_es_punctuation() { + let mut h = Highlighter::new(Language::Wat); + let r = h.highlight("(module)"); + let line = &r[0]; + let paren = line.iter().find(|s| s.kind == TokenKind::Punctuation).unwrap(); + assert_eq!(paren.start_col, 0); + assert_eq!(paren.end_col, 1); + } + + #[test] + fn wat_keyword_module_clasifica_como_keyword() { + let mut h = Highlighter::new(Language::Wat); + let r = h.highlight("(module)"); + let kw = r[0].iter().find(|s| s.kind == TokenKind::Keyword).unwrap(); + assert_eq!(kw.start_col, 1); + assert_eq!(kw.end_col, 7); + } + + #[test] + fn wat_tipo_i32_es_type() { + let mut h = Highlighter::new(Language::Wat); + let r = h.highlight("(result i32)"); + assert!(r[0].iter().any(|s| s.kind == TokenKind::Type)); + } + + #[test] + fn wat_string_y_comment() { + let mut h = Highlighter::new(Language::Wat); + let r = h.highlight(r#"(data "hola") ;; comentario"#); + assert!(r[0].iter().any(|s| s.kind == TokenKind::String)); + assert!(r[0].iter().any(|s| s.kind == TokenKind::Comment)); + } + + #[test] + fn wat_instruction_dotted_es_function() { + let mut h = Highlighter::new(Language::Wat); + let r = h.highlight("i32.const 42"); + assert!(r[0].iter().any(|s| s.kind == TokenKind::Function)); + assert!(r[0].iter().any(|s| s.kind == TokenKind::Number)); + } + + #[test] + fn rust_keyword_fn() { + let mut h = Highlighter::new(Language::Rust); + let r = h.highlight("fn main() {}"); + // El span de "fn" debe estar marcado como keyword. + assert!(r[0].iter().any(|s| s.kind == TokenKind::Keyword)); + } + + #[test] + fn python_keyword_def() { + let mut h = Highlighter::new(Language::Python); + let r = h.highlight("def f():\n return 1"); + assert!(r[0].iter().any(|s| s.kind == TokenKind::Keyword)); + // "return" en la línea 2. + assert!(r[1].iter().any(|s| s.kind == TokenKind::Keyword)); + } + + #[test] + fn fill_gaps_rellena_y_clampea() { + let spans = vec![ + Span { start_col: 2, end_col: 4, kind: TokenKind::Keyword }, + Span { start_col: 6, end_col: 9, kind: TokenKind::String }, + ]; + let filled = fill_gaps(spans, 10); + // [Other 0..2] [Keyword 2..4] [Other 4..6] [String 6..9] [Other 9..10] + assert_eq!(filled.len(), 5); + assert_eq!(filled[0].kind, TokenKind::Other); + assert_eq!(filled[4].kind, TokenKind::Other); + } + + #[test] + fn from_cell_language_mapea_aliases() { + assert_eq!(Language::from_cell_language("rust"), Language::Rust); + assert_eq!(Language::from_cell_language("rs"), Language::Rust); + assert_eq!(Language::from_cell_language("py"), Language::Python); + assert_eq!(Language::from_cell_language("wat"), Language::Wat); + assert_eq!(Language::from_cell_language("desconocido"), Language::Plain); + } +} diff --git a/widgets/text-editor-core/src/lib.rs b/widgets/text-editor-core/src/lib.rs new file mode 100644 index 0000000..d28fb8c --- /dev/null +++ b/widgets/text-editor-core/src/lib.rs @@ -0,0 +1,40 @@ +//! `llimphi-widget-text-editor-core` — núcleo agnóstico del editor de código. +//! +//! Capas finas y **puras** (sin IO, sin Llimphi, sin GPU) sobre [`ropey`]: +//! +//! - [`buffer`] — wrapper de `Rope` con conversiones (línea, col) ↔ char_offset. +//! - [`cursor`] — `Cursor` + `Selection`; movimiento por char/word/line/page. +//! - [`ops`] — operaciones puras de edición sobre `(Buffer, Cursor) → (Buffer, Cursor)`. +//! - [`undo`] — pila reversible: cada operación se registra como `EditDelta`. +//! - [`bracket`] — matching de paréntesis/llaves/corchetes. +//! - [`find`] — búsqueda incremental sobre el buffer. +//! - [`diagnostics`] — modelo de diagnósticos (errores/warnings) por rango. +//! - [`clipboard`] — abstracción de portapapeles (mem/null) sin tocar el SO. +//! - [`highlight`] — syntax highlighting con tree-sitter (Rust/Python/WAT/Plain). +//! +//! Único acoplamiento externo: [`peniko::Color`] en [`highlight::SyntaxPalette`] +//! — un tipo de color, no el stack de render. Eso deja el núcleo reutilizable +//! desde un TUI, una mini-REPL, un text-input single-line, un backend web, etc. +//! La capa visual (state + view sobre Llimphi) vive en +//! `llimphi-widget-text-editor`, que re-exporta todo este núcleo. + +#![forbid(unsafe_code)] + +pub mod bracket; +pub mod buffer; +pub mod clipboard; +pub mod cursor; +pub mod diagnostics; +pub mod find; +pub mod highlight; +pub mod ops; +pub mod undo; + +pub use buffer::Buffer; +pub use clipboard::{Clipboard, MemClipboard, NullClipboard}; +pub use cursor::{Cursor, Pos, Selection}; +pub use diagnostics::{Diagnostic, DiagnosticRange, Severity}; +pub use find::{all_matches, find_next, find_prev, FindState}; +pub use highlight::{Highlighter, Language, Span, SyntaxPalette, TokenKind}; +pub use ops::{indent_str, EditDelta}; +pub use undo::UndoStack; diff --git a/widgets/text-editor-core/src/ops.rs b/widgets/text-editor-core/src/ops.rs new file mode 100644 index 0000000..0381d17 --- /dev/null +++ b/widgets/text-editor-core/src/ops.rs @@ -0,0 +1,384 @@ +//! Operaciones de edición. Cada una toma `&mut Buffer + &mut Cursor` y +//! devuelve un [`EditDelta`] reversible que la pila de undo guarda. +//! +//! El delta es minimal: el rango `[start..end)` que se reemplazó + el +//! texto que estaba antes + el texto nuevo. Aplicado en reversa, +//! restaura el estado anterior exactamente. + +use crate::buffer::Buffer; +use crate::cursor::{Cursor, Pos}; + +/// Delta atómico de edición — útil para undo/redo y log de cambios. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EditDelta { + pub start: usize, + pub removed: String, + pub inserted: String, + /// Caret antes de la operación (para restaurarlo en undo). + pub cursor_before: Cursor, + /// Caret después de la operación. + pub cursor_after: Cursor, +} + +impl EditDelta { + /// Aplica el delta a `(buf, cursor)`. + pub fn apply(&self, buf: &mut Buffer, cursor: &mut Cursor) { + let end = self.start + self.removed.chars().count(); + buf.delete(self.start, end); + if !self.inserted.is_empty() { + buf.insert(self.start, &self.inserted); + } + *cursor = self.cursor_after; + } + + /// Aplica el inverso (undo). + pub fn undo(&self, buf: &mut Buffer, cursor: &mut Cursor) { + let end = self.start + self.inserted.chars().count(); + buf.delete(self.start, end); + if !self.removed.is_empty() { + buf.insert(self.start, &self.removed); + } + *cursor = self.cursor_before; + } +} + +/// Genera la string de indentación según la config. +pub fn indent_str(tab_to_spaces: bool, indent_size: usize) -> String { + if tab_to_spaces { + " ".repeat(indent_size) + } else { + "\t".to_string() + } +} + +/// Reemplaza la selección activa por `text`. Si no hay selección, +/// inserta `text` en el caret. Devuelve el delta resultante. +pub fn replace_selection( + buf: &mut Buffer, + cursor: &mut Cursor, + text: &str, +) -> EditDelta { + let before = *cursor; + let (start, end) = cursor.selection_range(buf); + let removed = buf.slice(start, end); + + if start != end { + buf.delete(start, end); + } + if !text.is_empty() { + buf.insert(start, text); + } + + let new_off = start + text.chars().count(); + let (line, col) = buf.offset_to_pos(new_off); + cursor.caret = Pos::new(line, col); + cursor.desired_col = col; + cursor.anchor = None; + + EditDelta { + start, + removed, + inserted: text.to_string(), + cursor_before: before, + cursor_after: *cursor, + } +} + +/// Borra hacia atrás (Backspace). Si hay selección, la borra; si no, +/// borra el char antes del caret. Devuelve `None` si no había nada que +/// borrar (cursor al inicio + sin selección). +pub fn delete_backward(buf: &mut Buffer, cursor: &mut Cursor) -> Option { + if cursor.has_selection() { + return Some(replace_selection(buf, cursor, "")); + } + let before = *cursor; + let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col); + if caret_off == 0 { + return None; + } + let removed = buf.slice(caret_off - 1, caret_off); + buf.delete(caret_off - 1, caret_off); + let (line, col) = buf.offset_to_pos(caret_off - 1); + cursor.caret = Pos::new(line, col); + cursor.desired_col = col; + Some(EditDelta { + start: caret_off - 1, + removed, + inserted: String::new(), + cursor_before: before, + cursor_after: *cursor, + }) +} + +/// Borra hacia adelante (Delete). +pub fn delete_forward(buf: &mut Buffer, cursor: &mut Cursor) -> Option { + if cursor.has_selection() { + return Some(replace_selection(buf, cursor, "")); + } + let before = *cursor; + let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col); + if caret_off >= buf.len_chars() { + return None; + } + let removed = buf.slice(caret_off, caret_off + 1); + buf.delete(caret_off, caret_off + 1); + Some(EditDelta { + start: caret_off, + removed, + inserted: String::new(), + cursor_before: before, + cursor_after: *cursor, + }) +} + +/// Inserta un salto de línea con **indentación automática**: copia los +/// whitespace iniciales del renglón actual al renglón nuevo. +pub fn insert_newline_auto_indent(buf: &mut Buffer, cursor: &mut Cursor) -> EditDelta { + let current_line = buf.line(cursor.caret.line); + let indent: String = current_line + .chars() + .take_while(|c| *c == ' ' || *c == '\t') + .collect(); + let text = format!("\n{indent}"); + replace_selection(buf, cursor, &text) +} + +/// Inserta un tab (o `indent_size` spaces según config). Si hay +/// selección **multilínea**, indenta cada línea de la selección. +pub fn indent_or_insert_tab( + buf: &mut Buffer, + cursor: &mut Cursor, + tab_to_spaces: bool, + indent_size: usize, +) -> EditDelta { + let indent = indent_str(tab_to_spaces, indent_size); + + // Sin selección o selección en una sola línea → inserta indent. + let multi_line = match cursor.selection() { + Some(sel) => sel.anchor.line != sel.caret.line, + None => false, + }; + if !multi_line { + return replace_selection(buf, cursor, &indent); + } + + // Selección multilínea: indenta cada línea afectada por el rango. + let before = *cursor; + let sel = cursor.selection().expect("multi_line implica selección"); + let first = sel.anchor.line.min(sel.caret.line); + let last = sel.anchor.line.max(sel.caret.line); + + let mut start_global = buf.pos_to_offset(first, 0); + let removed = String::new(); + let mut inserted = String::new(); + for line in first..=last { + let line_start = buf.pos_to_offset(line, 0); + buf.insert(line_start, &indent); + inserted.push_str(&indent); + let _ = start_global; // (sin uso; se mantiene por simetría) + start_global = buf.pos_to_offset(first, 0); + } + + // Mantenemos la selección extendida sobre las líneas indentadas. + let n_added = indent.chars().count(); + let new_anchor = Pos::new(sel.anchor.line, sel.anchor.col + n_added); + let new_caret = Pos::new(sel.caret.line, sel.caret.col + n_added); + cursor.anchor = Some(new_anchor); + cursor.caret = new_caret; + cursor.desired_col = new_caret.col; + + EditDelta { + start: start_global, + removed, + inserted, + cursor_before: before, + cursor_after: *cursor, + } +} + +/// Quita un nivel de indent del renglón actual (o de cada línea si hay +/// selección multilínea). Devuelve `None` si nada cambió. +pub fn dedent( + buf: &mut Buffer, + cursor: &mut Cursor, + tab_to_spaces: bool, + indent_size: usize, +) -> Option { + let before = *cursor; + let (first, last) = match cursor.selection() { + Some(sel) => ( + sel.anchor.line.min(sel.caret.line), + sel.anchor.line.max(sel.caret.line), + ), + None => (cursor.caret.line, cursor.caret.line), + }; + + let mut total_removed = 0usize; + let mut removed_text = String::new(); + let start_offset = buf.pos_to_offset(first, 0); + + for line in first..=last { + let line_str = buf.line(line); + let mut n = 0usize; + let mut chars = line_str.chars(); + if tab_to_spaces { + for _ in 0..indent_size { + if chars.next() == Some(' ') { + n += 1; + } else { + break; + } + } + } else if chars.next() == Some('\t') { + n = 1; + } + if n == 0 { + continue; + } + let line_start = buf.pos_to_offset(line, 0); + removed_text.push_str(&buf.slice(line_start, line_start + n)); + buf.delete(line_start, line_start + n); + total_removed += n; + } + + if total_removed == 0 { + return None; + } + + // Cursor: clampea col al nuevo line_len. + let caret_line = cursor.caret.line; + let caret_col = cursor + .caret + .col + .saturating_sub(if caret_line >= first && caret_line <= last { + // Cuánto se removió de esta línea (varía); aproximamos al + // common case de mismo n por línea. Si fuera distinto el + // visual queda OK porque clampea. + removed_text.chars().count() / (last - first + 1).max(1) + } else { + 0 + }); + cursor.caret.col = caret_col.min(buf.line_len_chars(caret_line)); + cursor.desired_col = cursor.caret.col; + + if let Some(anchor) = cursor.anchor.as_mut() { + if anchor.line >= first && anchor.line <= last { + anchor.col = anchor + .col + .saturating_sub(removed_text.chars().count() / (last - first + 1).max(1)) + .min(buf.line_len_chars(anchor.line)); + } + } + + Some(EditDelta { + start: start_offset, + removed: removed_text, + inserted: String::new(), + cursor_before: before, + cursor_after: *cursor, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replace_selection_sin_seleccion_inserta() { + let mut b = Buffer::from_str("ab"); + let mut c = Cursor::at(0, 1); + let d = replace_selection(&mut b, &mut c, "X"); + assert_eq!(b.text(), "aXb"); + assert_eq!(c.caret, Pos::new(0, 2)); + assert_eq!(d.removed, ""); + assert_eq!(d.inserted, "X"); + } + + #[test] + fn replace_selection_con_seleccion_reemplaza() { + let mut b = Buffer::from_str("hola mundo"); + let mut c = Cursor { caret: Pos::new(0, 9), anchor: Some(Pos::new(0, 5)), desired_col: 9 }; + replace_selection(&mut b, &mut c, "luna"); + assert_eq!(b.text(), "hola lunao"); + } + + #[test] + fn backspace_borra_char() { + let mut b = Buffer::from_str("hola"); + let mut c = Cursor::at(0, 4); + delete_backward(&mut b, &mut c); + assert_eq!(b.text(), "hol"); + assert_eq!(c.caret, Pos::new(0, 3)); + } + + #[test] + fn backspace_en_inicio_no_hace_nada() { + let mut b = Buffer::from_str("a"); + let mut c = Cursor::at(0, 0); + assert!(delete_backward(&mut b, &mut c).is_none()); + } + + #[test] + fn delete_forward_borra_char() { + let mut b = Buffer::from_str("ab"); + let mut c = Cursor::at(0, 0); + delete_forward(&mut b, &mut c); + assert_eq!(b.text(), "b"); + } + + #[test] + fn newline_copia_indent_del_renglon_anterior() { + let mut b = Buffer::from_str(" hola"); + let mut c = Cursor::at(0, 8); + insert_newline_auto_indent(&mut b, &mut c); + assert_eq!(b.text(), " hola\n "); + assert_eq!(c.caret, Pos::new(1, 4)); + } + + #[test] + fn tab_inserta_spaces() { + let mut b = Buffer::from_str("ab"); + let mut c = Cursor::at(0, 1); + indent_or_insert_tab(&mut b, &mut c, true, 4); + assert_eq!(b.text(), "a b"); + assert_eq!(c.caret, Pos::new(0, 5)); + } + + #[test] + fn tab_con_seleccion_multilinea_indenta_cada_linea() { + let mut b = Buffer::from_str("a\nb\nc"); + let mut c = Cursor { + anchor: Some(Pos::new(0, 0)), + caret: Pos::new(2, 1), + desired_col: 1, + }; + indent_or_insert_tab(&mut b, &mut c, true, 2); + assert_eq!(b.text(), " a\n b\n c"); + } + + #[test] + fn dedent_quita_indent_del_renglon() { + let mut b = Buffer::from_str(" hola"); + let mut c = Cursor::at(0, 8); + dedent(&mut b, &mut c, true, 4); + assert_eq!(b.text(), "hola"); + } + + #[test] + fn dedent_sin_indent_devuelve_none() { + let mut b = Buffer::from_str("hola"); + let mut c = Cursor::at(0, 0); + assert!(dedent(&mut b, &mut c, true, 4).is_none()); + } + + #[test] + fn delta_undo_restaura_estado() { + let mut b = Buffer::from_str("hola"); + let mut c = Cursor::at(0, 4); + let d = replace_selection(&mut b, &mut c, "!"); + assert_eq!(b.text(), "hola!"); + d.undo(&mut b, &mut c); + assert_eq!(b.text(), "hola"); + assert_eq!(c.caret, Pos::new(0, 4)); + } +} diff --git a/widgets/text-editor-core/src/undo.rs b/widgets/text-editor-core/src/undo.rs new file mode 100644 index 0000000..6de8ad4 --- /dev/null +++ b/widgets/text-editor-core/src/undo.rs @@ -0,0 +1,133 @@ +//! Pila de undo/redo basada en [`EditDelta`]. +//! +//! API simple: `push(delta)` añade al historial y limpia el stack de +//! redo; `undo`/`redo` aplican o reaplican deltas existentes. No +//! coalesce inserciones consecutivas — cada keystroke es un delta; +//! para una UX más fina, el llamador puede agrupar deltas relacionados +//! (ej. cada secuencia de chars imprimibles hasta whitespace). + +use crate::buffer::Buffer; +use crate::cursor::Cursor; +use crate::ops::EditDelta; + +const DEFAULT_CAPACITY: usize = 256; + +#[derive(Debug, Clone, Default)] +pub struct UndoStack { + /// Deltas aplicados, en orden cronológico. El `Vec::last` es el + /// próximo candidato a deshacer. + done: Vec, + /// Deltas deshechos disponibles para redo (en orden inverso del + /// `undo`: el último deshecho es el primero a rehacer). + undone: Vec, + capacity: usize, +} + +impl UndoStack { + pub fn new() -> Self { + Self::with_capacity(DEFAULT_CAPACITY) + } + pub fn with_capacity(capacity: usize) -> Self { + Self { + done: Vec::with_capacity(capacity.min(64)), + undone: Vec::new(), + capacity, + } + } + + /// Registra un delta. Limpia el stack de redo (la rama alternativa + /// se pierde, como en todo editor estándar). + pub fn push(&mut self, delta: EditDelta) { + self.done.push(delta); + self.undone.clear(); + if self.done.len() > self.capacity { + // Truncamos por el extremo viejo. + let drop = self.done.len() - self.capacity; + self.done.drain(0..drop); + } + } + + pub fn can_undo(&self) -> bool { + !self.done.is_empty() + } + pub fn can_redo(&self) -> bool { + !self.undone.is_empty() + } + + pub fn undo(&mut self, buf: &mut Buffer, cursor: &mut Cursor) -> bool { + let Some(delta) = self.done.pop() else { + return false; + }; + delta.undo(buf, cursor); + self.undone.push(delta); + true + } + + pub fn redo(&mut self, buf: &mut Buffer, cursor: &mut Cursor) -> bool { + let Some(delta) = self.undone.pop() else { + return false; + }; + delta.apply(buf, cursor); + self.done.push(delta); + true + } + + pub fn clear(&mut self) { + self.done.clear(); + self.undone.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ops::replace_selection; + + #[test] + fn undo_y_redo_son_simetricos() { + let mut b = Buffer::from_str("a"); + let mut c = Cursor::at(0, 1); + let mut st = UndoStack::new(); + + st.push(replace_selection(&mut b, &mut c, "b")); + st.push(replace_selection(&mut b, &mut c, "c")); + assert_eq!(b.text(), "abc"); + + assert!(st.undo(&mut b, &mut c)); + assert_eq!(b.text(), "ab"); + assert!(st.undo(&mut b, &mut c)); + assert_eq!(b.text(), "a"); + + assert!(st.redo(&mut b, &mut c)); + assert_eq!(b.text(), "ab"); + assert!(st.redo(&mut b, &mut c)); + assert_eq!(b.text(), "abc"); + } + + #[test] + fn push_limpia_redo() { + let mut b = Buffer::from_str("a"); + let mut c = Cursor::at(0, 1); + let mut st = UndoStack::new(); + st.push(replace_selection(&mut b, &mut c, "b")); + st.undo(&mut b, &mut c); + assert!(st.can_redo()); + st.push(replace_selection(&mut b, &mut c, "X")); + assert!(!st.can_redo()); + } + + #[test] + fn capacity_descartan_viejos() { + let mut b = Buffer::from_str(""); + let mut c = Cursor::at(0, 0); + let mut st = UndoStack::with_capacity(2); + for ch in ["a", "b", "c"] { + st.push(replace_selection(&mut b, &mut c, ch)); + } + // Sólo deberían quedar los últimos 2 deltas; el undo del primero + // (cuando ya no está) no debería hacer nada. + st.undo(&mut b, &mut c); + st.undo(&mut b, &mut c); + assert!(!st.undo(&mut b, &mut c)); + } +} diff --git a/widgets/text-editor-lsp/Cargo.toml b/widgets/text-editor-lsp/Cargo.toml new file mode 100644 index 0000000..a7009ba --- /dev/null +++ b/widgets/text-editor-lsp/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "llimphi-widget-text-editor-lsp" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-text-editor-lsp — trait LspClient + NoopLspClient como foundation. El cliente real (rust-analyzer/pylsp con tokio + jsonrpc) queda como TODO para una sesión dedicada." + +[dependencies] +llimphi-widget-text-editor = { workspace = true } +lsp-types = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] + diff --git a/widgets/text-editor-lsp/LEEME.md b/widgets/text-editor-lsp/LEEME.md new file mode 100644 index 0000000..867c27c --- /dev/null +++ b/widgets/text-editor-lsp/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-editor-lsp + +> [`text-editor`](../text-editor/README.md) + LSP para [llimphi](../../README.md). + +Wrapper que conecta el editor a un servidor LSP (rust-analyzer, pyright, ...). Hover, goto-definition, autocomplete, diagnostics inline, formatter al guardar. diff --git a/widgets/text-editor-lsp/README.md b/widgets/text-editor-lsp/README.md new file mode 100644 index 0000000..8be0f1b --- /dev/null +++ b/widgets/text-editor-lsp/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-editor-lsp + +> [`text-editor`](../text-editor/README.md) + LSP for [llimphi](../../README.md). + +Wrapper connecting the editor to an LSP server (rust-analyzer, pyright, ...). Hover, goto-definition, autocomplete, inline diagnostics, format on save. diff --git a/widgets/text-editor-lsp/src/client.rs b/widgets/text-editor-lsp/src/client.rs new file mode 100644 index 0000000..20e21dd --- /dev/null +++ b/widgets/text-editor-lsp/src/client.rs @@ -0,0 +1,501 @@ +use super::*; + +pub struct RustAnalyzerClient { + /// Diagnostics activos por path. Lo escribe la task reader. + state: SharedState, + /// Sender al writer task. `None` si el spawn falló (modo no-op). + tx: Option>, + /// Contador monotónico de request IDs. + next_id: i64, + /// Versiones por documento — el server las requiere en didChange. + versions: HashMap, + /// Runtime tokio dedicado — vive todo lo que viva el client. + /// `None` si el spawn falló. + _runtime: Option>, +} + +impl RustAnalyzerClient { + /// Spawn `rust-analyzer` en `workspace_root`. Si el binary no está + /// en PATH, devuelve un client en modo no-op (sin error). + pub fn start(workspace_root: PathBuf) -> Self { + Self::with_command(workspace_root, "rust-analyzer") + } + + /// Como `start` pero permite indicar el binary (`pylsp`, etc.). + pub fn with_command(workspace_root: PathBuf, command: &str) -> Self { + let state: SharedState = Arc::new(Mutex::new(SharedInner::default())); + let runtime = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + { + Ok(rt) => Arc::new(rt), + Err(_) => { + return Self { + state, + tx: None, + next_id: 1, + versions: HashMap::new(), + _runtime: None, + }; + } + }; + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + let state_clone = state.clone(); + let workspace_root_clone = workspace_root.clone(); + let command_string = command.to_string(); + + runtime.spawn(async move { + if let Err(e) = run_server(workspace_root_clone, command_string, rx, state_clone).await + { + eprintln!("lsp: server task terminó con error: {e}"); + } + }); + + let mut client = Self { + state, + tx: Some(tx), + next_id: 1, + versions: HashMap::new(), + _runtime: Some(runtime), + }; + client.send_initialize(&workspace_root); + client + } + + fn send_initialize(&mut self, root: &Path) { + let id = self.alloc_id(); + let params = serde_json::json!({ + "processId": std::process::id(), + "rootUri": format!("file://{}", root.display()), + "capabilities": { + "textDocument": { + "publishDiagnostics": { "relatedInformation": false } + } + }, + "clientInfo": { "name": "llimphi-text-editor-lsp", "version": "0.1.0" } + }); + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": params + }); + self.send_raw(req.to_string()); + // El handshake termina con la notification `initialized` que + // mandamos sin esperar la response — el reader la procesará. + let notif = serde_json::json!({ + "jsonrpc": "2.0", + "method": "initialized", + "params": {} + }); + self.send_raw(notif.to_string()); + } + + fn alloc_id(&mut self) -> i64 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn send_raw(&self, msg: String) { + if let Some(tx) = &self.tx { + let _ = tx.send(msg); + } + } + + fn lsp_language_id(language: &str) -> &str { + match language { + "rust" | "rs" => "rust", + "python" | "py" => "python", + other => other, + } + } +} + +impl LspClient for RustAnalyzerClient { + fn diagnostics(&self, path: &Path) -> Vec { + self.state + .lock() + .ok() + .and_then(|s| s.diagnostics.get(path).cloned()) + .unwrap_or_default() + } + + fn request_completions(&mut self, path: &Path, line: usize, col: usize) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_completion_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/completion", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_completions(&self) -> Vec { + self.state + .lock() + .map(|s| s.completions.clone()) + .unwrap_or_default() + } + + fn clear_completions(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.completions.clear(); + } + } + + fn request_hover(&mut self, path: &Path, line: usize, col: usize) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_hover_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/hover", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_hover(&self) -> Option { + self.state.lock().ok().and_then(|s| s.hover.clone()) + } + + fn clear_hover(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.hover = None; + } + } + + fn request_definition(&mut self, path: &Path, line: usize, col: usize) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_definition_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/definition", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_definition(&self) -> Option { + self.state.lock().ok().and_then(|s| s.definition.clone()) + } + + fn clear_definition(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.definition = None; + } + } + + fn request_formatting(&mut self, path: &Path, tab_size: u32, insert_spaces: bool) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_formatting_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/formatting", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "options": { + "tabSize": tab_size, + "insertSpaces": insert_spaces + } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_text_edits(&self) -> Vec { + self.state.lock().map(|s| s.text_edits.clone()).unwrap_or_default() + } + + fn clear_text_edits(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.text_edits.clear(); + } + } + + fn request_signature_help(&mut self, path: &Path, line: usize, col: usize) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_signature_help_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/signatureHelp", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_signature_help(&self) -> Option { + self.state.lock().ok().and_then(|s| s.signature_help.clone()) + } + + fn clear_signature_help(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.signature_help = None; + } + } + + fn request_references(&mut self, path: &Path, line: usize, col: usize, include_decl: bool) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_references_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/references", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col }, + "context": { "includeDeclaration": include_decl } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_references(&self) -> Vec { + self.state.lock().map(|s| s.references.clone()).unwrap_or_default() + } + + fn clear_references(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.references.clear(); + } + } + + fn request_rename(&mut self, path: &Path, line: usize, col: usize, new_name: &str) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_rename_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/rename", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col }, + "newName": new_name + } + }); + self.send_raw(req.to_string()); + } + + fn latest_workspace_edit(&self) -> std::collections::HashMap> { + self.state.lock().map(|s| s.workspace_edit.clone()).unwrap_or_default() + } + + fn clear_workspace_edit(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.workspace_edit.clear(); + } + } + + fn request_document_symbols(&mut self, path: &Path) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_document_symbols_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/documentSymbol", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_document_symbols(&self) -> Vec { + self.state.lock().map(|s| s.document_symbols.clone()).unwrap_or_default() + } + + fn clear_document_symbols(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.document_symbols.clear(); + } + } + + fn did_open(&mut self, path: &Path, language: &str, text: &str) { + self.versions.insert(path.to_path_buf(), 1); + let notif = serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": format!("file://{}", path.display()), + "languageId": Self::lsp_language_id(language), + "version": 1, + "text": text, + } + } + }); + self.send_raw(notif.to_string()); + } + + fn did_change(&mut self, path: &Path, new_text: &str) { + let version = { + let v = self.versions.entry(path.to_path_buf()).or_insert(1); + *v += 1; + *v + }; + // Full-document change. Más eficiente sería incremental, pero + // requiere trackear los EditDeltas del editor — futuro. + let notif = serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didChange", + "params": { + "textDocument": { + "uri": format!("file://{}", path.display()), + "version": version, + }, + "contentChanges": [{ "text": new_text }] + } + }); + self.send_raw(notif.to_string()); + } + + fn did_close(&mut self, path: &Path) { + self.versions.remove(path); + let notif = serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didClose", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) } + } + }); + self.send_raw(notif.to_string()); + if let Ok(mut s) = self.state.lock() { + s.diagnostics.remove(path); + } + } +} + +// --------------------------------------------------------------------- +// Task tokio que corre el server + bombea I/O +// --------------------------------------------------------------------- + +async fn run_server( + _workspace_root: PathBuf, + command: String, + mut rx: tokio::sync::mpsc::UnboundedReceiver, + state: SharedState, +) -> std::io::Result<()> { + use std::process::Stdio; + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::process::Command; + + let mut child = match Command::new(&command) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + eprintln!("lsp: no pude spawn `{command}`: {e}"); + return Ok(()); + } + }; + + let stdin = child.stdin.take().expect("stdin piped"); + let stdout = child.stdout.take().expect("stdout piped"); + + // Writer task: consume el rx y manda al stdin con headers LSP. + let writer = tokio::spawn(async move { + let mut stdin = stdin; + while let Some(msg) = rx.recv().await { + let header = format!("Content-Length: {}\r\n\r\n", msg.len()); + if stdin.write_all(header.as_bytes()).await.is_err() { + break; + } + if stdin.write_all(msg.as_bytes()).await.is_err() { + break; + } + let _ = stdin.flush().await; + } + }); + + // Reader task: parsea mensajes del stdout, procesa publishDiagnostics. + let reader = tokio::spawn({ + let state = state.clone(); + async move { + let mut reader = BufReader::new(stdout); + loop { + let mut content_length: Option = None; + // Headers — terminan con línea vacía. + loop { + let mut line = String::new(); + match reader.read_line(&mut line).await { + Ok(0) => return, // EOF + Ok(_) => {} + Err(_) => return, + } + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + break; + } + if let Some(rest) = line.strip_prefix("Content-Length:") { + if let Ok(n) = rest.trim().parse::() { + content_length = Some(n); + } + } + } + let Some(len) = content_length else { continue }; + let mut buf = vec![0u8; len]; + if reader.read_exact(&mut buf).await.is_err() { + return; + } + let Ok(json) = serde_json::from_slice::(&buf) else { + continue; + }; + if json.get("method").and_then(|m| m.as_str()) + == Some("textDocument/publishDiagnostics") + { + handle_publish_diagnostics(&json, &state); + } else if let Some(id) = json.get("id").and_then(|i| i.as_i64()) { + handle_response(id, &json, &state); + } + } + } + }); + + // Esperamos a que se cierre cualquiera de los dos lados o el child. + tokio::select! { + _ = writer => {} + _ = reader => {} + _ = child.wait() => {} + } + let _ = child.kill().await; + Ok(()) +} diff --git a/widgets/text-editor-lsp/src/lib.rs b/widgets/text-editor-lsp/src/lib.rs new file mode 100644 index 0000000..37e16e1 --- /dev/null +++ b/widgets/text-editor-lsp/src/lib.rs @@ -0,0 +1,275 @@ +//! `llimphi-widget-text-editor-lsp` — cliente LSP para alimentar +//! diagnostics al editor. +//! +//! Implementación real basada en `tokio::process::Command` + +//! `lsp-types` + JSON-RPC sobre stdin/stdout del language server. +//! +//! Flujo: +//! +//! 1. `RustAnalyzerClient::start(workspace_root)` spawn `rust-analyzer` +//! (o el binary que se le pase con `with_command`) y arranca dos +//! tasks tokio: +//! - **writer**: consume mensajes del `mpsc::Sender`, los serializa +//! con headers `Content-Length: N\r\n\r\n` y los manda al stdin. +//! - **reader**: parsea el stdout del server (mismo formato), +//! atiende `textDocument/publishDiagnostics` y guarda los +//! diagnostics en el state compartido. +//! 2. El handshake `initialize` se envía sincronicamente desde `start` +//! y se espera la respuesta antes de mandar `initialized` + +//! procesar más mensajes. +//! 3. `did_open` / `did_change` / `did_close` mandan las notifications +//! correspondientes — sin esperar respuesta. +//! 4. `diagnostics(path)` lee del state sin contactar al server. +//! +//! El client maneja **una sola conexión por instancia**. Para +//! multi-proyecto el caller crea varios clients. +//! +//! Si el server no se puede spawnear (binary no instalado), el client +//! cae en modo no-op transparentemente — `diagnostics` devuelve vacío. + +#![forbid(unsafe_code)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use llimphi_widget_text_editor::{Diagnostic, DiagnosticRange, Pos, Severity}; + +/// Item de completion — mirror minimal de `lsp_types::CompletionItem`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompletionItem { + pub label: String, + /// Texto a insertar. Si `None`, se usa `label`. + pub insert_text: Option, + /// Tipo del símbolo según LSP (Function, Variable, etc.) — para + /// mostrar un ícono. Aquí lo guardamos como string corto. + pub kind: Option, + /// Documentación corta — el primer renglón típicamente. + pub detail: Option, +} + +impl CompletionItem { + pub fn text_to_insert(&self) -> &str { + self.insert_text.as_deref().unwrap_or(self.label.as_str()) + } +} + +/// Contrato que un client LSP debe cumplir para alimentar al editor. +pub trait LspClient: Send { + fn diagnostics(&self, path: &Path) -> Vec; + fn did_open(&mut self, path: &Path, language: &str, text: &str); + fn did_change(&mut self, path: &Path, new_text: &str); + fn did_close(&mut self, path: &Path); + /// Dispara una petición de completions en `(line, col)` del path. + /// Fire-and-forget; la respuesta se lee con `latest_completions`. + fn request_completions(&mut self, path: &Path, line: usize, col: usize); + /// Última lista de completions recibida (cualquier path/pos). + /// Vacío hasta que el server responda. El client la limpia cuando + /// el caller llama `clear_completions`. + fn latest_completions(&self) -> Vec; + /// Borra el cache de completions — útil al cerrar el popup. + fn clear_completions(&mut self); + /// Dispara textDocument/hover. Fire-and-forget; el caller polla + /// `latest_hover` para leer la respuesta. + fn request_hover(&mut self, path: &Path, line: usize, col: usize); + /// Última hover info recibida (cualquier path/pos). + fn latest_hover(&self) -> Option; + /// Borra el cache de hover. + fn clear_hover(&mut self); + /// Dispara textDocument/definition. Fire-and-forget; el caller + /// polla `latest_definition`. + fn request_definition(&mut self, path: &Path, line: usize, col: usize); + /// Última definition recibida (path destino + pos de inicio). + fn latest_definition(&self) -> Option; + fn clear_definition(&mut self); + /// Dispara textDocument/formatting. Cuando llega la response, el + /// caller polla `latest_text_edits` y los aplica al buffer. + fn request_formatting(&mut self, path: &Path, tab_size: u32, insert_spaces: bool); + /// Última lista de TextEdits recibida (de formatting o rename). + fn latest_text_edits(&self) -> Vec; + fn clear_text_edits(&mut self); + /// Dispara textDocument/signatureHelp. Cuando llega, el popup + /// muestra la firma activa con el parámetro current resaltado. + fn request_signature_help(&mut self, path: &Path, line: usize, col: usize); + fn latest_signature_help(&self) -> Option; + fn clear_signature_help(&mut self); + /// Dispara textDocument/references. `include_decl` controla si la + /// declaración misma aparece en los resultados. + fn request_references(&mut self, path: &Path, line: usize, col: usize, include_decl: bool); + fn latest_references(&self) -> Vec; + fn clear_references(&mut self); + /// Dispara textDocument/rename con `new_name` como nuevo identificador. + fn request_rename(&mut self, path: &Path, line: usize, col: usize, new_name: &str); + /// Última WorkspaceEdit recibida (rename o code actions). Mapeado a + /// `path → Vec` por simplicidad. + fn latest_workspace_edit(&self) -> std::collections::HashMap>; + fn clear_workspace_edit(&mut self); + + /// Dispara textDocument/documentSymbol. La respuesta llega + /// asincrónica; el caller la recoge con [`latest_document_symbols`]. + fn request_document_symbols(&mut self, path: &Path); + /// Última respuesta de documentSymbol — lista plana flattening del + /// árbol jerárquico que devuelve el server. Orden: top-down, + /// children en orden de aparición. `depth` refleja la profundidad + /// para que el caller indente visualmente. + fn latest_document_symbols(&self) -> Vec; + fn clear_document_symbols(&mut self); +} + +/// Una entrada flattening del árbol `DocumentSymbol` del LSP. Espejo +/// mínimo que evita arrastrar `lsp_types::SymbolKind` a los hosts — +/// `kind` viene ya como string corta (`"fn"`, `"struct"`, `"method"`, …). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentSymbolEntry { + pub name: String, + pub kind: String, + pub line: usize, + pub col: usize, + pub container: Option, + pub depth: u32, +} + +/// Info de signatureHelp activa. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignatureHelpInfo { + /// Firma activa (label completa, ej. "fn foo(x: i32, y: String) -> u64"). + pub label: String, + /// Documentación de la firma activa. + pub doc: Option, + /// Índice del parámetro current (0-based). + pub active_param: usize, + /// Labels de los parámetros — para resaltar el activo. + pub param_labels: Vec, +} + +/// Edit estilo LSP: reemplazar el rango `[start..end)` por `new_text`. +/// Para apply: ordenar desc por `start` y aplicar uno por uno. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextEdit { + pub start_line: usize, + pub start_col: usize, + pub end_line: usize, + pub end_col: usize, + pub new_text: String, +} + +/// Resultado de un goto-definition: archivo destino + posición. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefinitionLocation { + pub path: PathBuf, + pub line: usize, + pub col: usize, +} + +/// Información de hover — espejo simplificado de `lsp_types::Hover`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HoverInfo { + /// Markdown / plaintext del símbolo bajo el cursor. El render del + /// caller lo muestra tal cual (sin parsear markdown todavía). + pub contents: String, +} + +/// Stub que no hace nada — útil cuando no hay LSP configurado o para tests. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopLspClient; + +impl LspClient for NoopLspClient { + fn diagnostics(&self, _: &Path) -> Vec { + Vec::new() + } + fn did_open(&mut self, _: &Path, _: &str, _: &str) {} + fn did_change(&mut self, _: &Path, _: &str) {} + fn did_close(&mut self, _: &Path) {} + fn request_completions(&mut self, _: &Path, _: usize, _: usize) {} + fn latest_completions(&self) -> Vec { + Vec::new() + } + fn clear_completions(&mut self) {} + fn request_hover(&mut self, _: &Path, _: usize, _: usize) {} + fn latest_hover(&self) -> Option { + None + } + fn clear_hover(&mut self) {} + fn request_definition(&mut self, _: &Path, _: usize, _: usize) {} + fn latest_definition(&self) -> Option { + None + } + fn clear_definition(&mut self) {} + fn request_formatting(&mut self, _: &Path, _: u32, _: bool) {} + fn latest_text_edits(&self) -> Vec { + Vec::new() + } + fn clear_text_edits(&mut self) {} + fn request_signature_help(&mut self, _: &Path, _: usize, _: usize) {} + fn latest_signature_help(&self) -> Option { + None + } + fn clear_signature_help(&mut self) {} + fn request_references(&mut self, _: &Path, _: usize, _: usize, _: bool) {} + fn latest_references(&self) -> Vec { + Vec::new() + } + fn clear_references(&mut self) {} + fn request_rename(&mut self, _: &Path, _: usize, _: usize, _: &str) {} + fn latest_workspace_edit(&self) -> std::collections::HashMap> { + std::collections::HashMap::new() + } + fn clear_workspace_edit(&mut self) {} + fn request_document_symbols(&mut self, _: &Path) {} + fn latest_document_symbols(&self) -> Vec { + Vec::new() + } + fn clear_document_symbols(&mut self) {} +} + +// --------------------------------------------------------------------- +// Rust-analyzer client real +// --------------------------------------------------------------------- + +/// State compartido: paths → versión + diagnostics actuales + última +/// lista de completions recibida. +#[derive(Default)] +struct SharedInner { + diagnostics: HashMap>, + /// Última respuesta de completions — sobreescribe cualquier + /// request previo. El caller decide cuándo limpiar. + completions: Vec, + /// Última hover info recibida. + hover: Option, + /// Última definition recibida. + definition: Option, + /// Última lista de TextEdits (formatting / rename). + text_edits: Vec, + /// Última signature help. + signature_help: Option, + /// Última lista de references. + references: Vec, + /// Última WorkspaceEdit (de rename). Mapeo path → edits. + workspace_edit: HashMap>, + /// Última lista de document symbols (flattened del árbol que devuelve + /// el server). Se sobreescribe en cada request. + document_symbols: Vec, + /// IDs de requests pendientes para distinguir responses; el reader + /// usa estos sets para routear cada response al handler correcto. + pending_completion_ids: std::collections::HashSet, + pending_hover_ids: std::collections::HashSet, + pending_definition_ids: std::collections::HashSet, + pending_formatting_ids: std::collections::HashSet, + pending_signature_help_ids: std::collections::HashSet, + pending_references_ids: std::collections::HashSet, + pending_rename_ids: std::collections::HashSet, + pending_document_symbols_ids: std::collections::HashSet, +} + +type SharedState = Arc>; + +// Cliente y protocolo partidos del monolito (regla dura #1, 1660 LOC): +// `client` (RustAnalyzerClient + impls), `protocol` (parsers/handlers JSON-RPC). +mod client; +mod protocol; + +pub use client::RustAnalyzerClient; +pub(crate) use protocol::*; + +#[cfg(test)] +mod tests; diff --git a/widgets/text-editor-lsp/src/protocol.rs b/widgets/text-editor-lsp/src/protocol.rs new file mode 100644 index 0000000..f30df0c --- /dev/null +++ b/widgets/text-editor-lsp/src/protocol.rs @@ -0,0 +1,515 @@ +//! Parsers y handlers JSON-RPC de las respuestas/notificaciones LSP. + +use super::*; + +pub(crate) fn handle_publish_diagnostics(json: &serde_json::Value, state: &SharedState) { + let Some(params) = json.get("params") else { return }; + let Some(uri) = params.get("uri").and_then(|u| u.as_str()) else { return }; + let path = match uri.strip_prefix("file://") { + Some(p) => PathBuf::from(p), + None => return, + }; + let Some(diags_arr) = params.get("diagnostics").and_then(|d| d.as_array()) else { + return; + }; + let diagnostics: Vec = diags_arr + .iter() + .filter_map(parse_lsp_diagnostic) + .collect(); + if let Ok(mut s) = state.lock() { + s.diagnostics.insert(path, diagnostics); + } +} + +/// Routea una response del server al handler correspondiente según +/// qué set de pendientes la contenía. +pub(crate) fn handle_response(id: i64, json: &serde_json::Value, state: &SharedState) { + let flags = { + let Ok(mut s) = state.lock() else { return }; + ( + s.pending_completion_ids.remove(&id), + s.pending_hover_ids.remove(&id), + s.pending_definition_ids.remove(&id), + s.pending_formatting_ids.remove(&id), + s.pending_signature_help_ids.remove(&id), + s.pending_references_ids.remove(&id), + s.pending_rename_ids.remove(&id), + s.pending_document_symbols_ids.remove(&id), + ) + }; + let (was_completion, was_hover, was_def, was_fmt, was_sig, was_refs, was_rename, was_doc_sym) = + flags; + if was_completion { + handle_completion_response(json, state); + } + if was_hover { + handle_hover_response(json, state); + } + if was_def { + handle_definition_response(json, state); + } + if was_fmt { + handle_text_edits_response(json, state); + } + if was_sig { + handle_signature_help_response(json, state); + } + if was_refs { + handle_references_response(json, state); + } + if was_rename { + handle_rename_response(json, state); + } + if was_doc_sym { + handle_document_symbols_response(json, state); + } +} + +/// Parsea la respuesta de `textDocument/documentSymbol`. Devuelve dos +/// formatos posibles según la versión del server: +/// +/// - `DocumentSymbol[]` (jerárquico, moderno) — el que usa rust-analyzer. +/// - `SymbolInformation[]` (plano, legacy) — fallback razonable. +/// +/// Ambos se flatten a `Vec` con depth para que el +/// caller pueda indentar visualmente. +pub(crate) fn handle_document_symbols_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + if let Ok(mut s) = state.lock() { + s.document_symbols.clear(); + } + return; + } + let mut out: Vec = Vec::new(); + if let Some(arr) = result.as_array() { + for item in arr { + // Distingue por la presencia de "selectionRange" (sólo en + // DocumentSymbol). SymbolInformation tiene "location" en + // su lugar. + if item.get("selectionRange").is_some() { + flatten_document_symbol(item, None, 0, &mut out); + } else if item.get("location").is_some() { + if let Some(entry) = parse_symbol_information(item) { + out.push(entry); + } + } + } + } + if let Ok(mut s) = state.lock() { + s.document_symbols = out; + } +} + +/// Flatten recursivo de `DocumentSymbol`. `parent` es el nombre del +/// contenedor (para que `container` quede poblado en métodos/campos). +pub(crate) fn flatten_document_symbol( + node: &serde_json::Value, + parent: Option<&str>, + depth: u32, + out: &mut Vec, +) { + let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("?").to_string(); + let kind_num = node.get("kind").and_then(|v| v.as_u64()).unwrap_or(0); + let kind = symbol_kind_label(kind_num); + // `selectionRange.start` es la pos del identificador (lo que el + // usuario quiere ver al saltar). `range.start` apuntaría al `{` de + // la definición — menos útil para outline. + let (line, col) = node + .get("selectionRange") + .and_then(|r| r.get("start")) + .and_then(parse_position) + .or_else(|| node.get("range").and_then(|r| r.get("start")).and_then(parse_position)) + .unwrap_or((0, 0)); + out.push(DocumentSymbolEntry { + name: name.clone(), + kind, + line, + col, + container: parent.map(|s| s.to_string()), + depth, + }); + if let Some(children) = node.get("children").and_then(|c| c.as_array()) { + for child in children { + flatten_document_symbol(child, Some(&name), depth + 1, out); + } + } +} + +pub(crate) fn parse_symbol_information(item: &serde_json::Value) -> Option { + let name = item.get("name")?.as_str()?.to_string(); + let kind_num = item.get("kind").and_then(|v| v.as_u64()).unwrap_or(0); + let location = item.get("location")?; + let (line, col) = location.get("range").and_then(|r| r.get("start")).and_then(parse_position)?; + let container = item + .get("containerName") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + Some(DocumentSymbolEntry { + name, + kind: symbol_kind_label(kind_num), + line, + col, + container, + depth: 0, + }) +} + +pub(crate) fn parse_position(p: &serde_json::Value) -> Option<(usize, usize)> { + let line = p.get("line")?.as_u64()? as usize; + let col = p.get("character")?.as_u64()? as usize; + Some((line, col)) +} + +/// Mapea el `SymbolKind` numérico del LSP a la etiqueta corta que el +/// outline pinta. Sólo cubre las que el usuario suele ver — el resto +/// va a `"sym"`. Lista canónica: +pub(crate) fn symbol_kind_label(kind: u64) -> String { + match kind { + 2 => "mod", + 5 => "class", + 6 => "method", + 7 => "property", + 8 => "field", + 9 => "ctor", + 10 => "enum", + 11 => "iface", + 12 => "fn", + 13 => "var", + 14 => "const", + 15 => "str", + 18 => "arr", + 22 => "variant", + 23 => "struct", + 26 => "type", + _ => "sym", + } + .to_string() +} + +pub(crate) fn handle_rename_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + return; + } + let mut map: HashMap> = HashMap::new(); + // changes: { uri → TextEdit[] } + if let Some(changes) = result.get("changes").and_then(|c| c.as_object()) { + for (uri, edits_val) in changes { + let Some(path) = uri.strip_prefix("file://").map(PathBuf::from) else { continue }; + let Some(arr) = edits_val.as_array() else { continue }; + let edits: Vec = arr.iter().filter_map(parse_text_edit).collect(); + map.insert(path, edits); + } + } + // documentChanges: [{ textDocument: { uri }, edits: [...] }] — más nuevo. + if let Some(docs) = result.get("documentChanges").and_then(|c| c.as_array()) { + for doc in docs { + let Some(uri) = doc + .get("textDocument") + .and_then(|t| t.get("uri")) + .and_then(|u| u.as_str()) + else { + continue; + }; + let Some(path) = uri.strip_prefix("file://").map(PathBuf::from) else { continue }; + let Some(arr) = doc.get("edits").and_then(|e| e.as_array()) else { continue }; + let edits: Vec = arr.iter().filter_map(parse_text_edit).collect(); + map.entry(path).or_default().extend(edits); + } + } + if let Ok(mut s) = state.lock() { + s.workspace_edit = map; + } +} + +pub(crate) fn handle_references_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + if let Ok(mut s) = state.lock() { + s.references.clear(); + } + return; + } + let Some(arr) = result.as_array() else { return }; + let refs: Vec = arr.iter().filter_map(parse_location).collect(); + if let Ok(mut s) = state.lock() { + s.references = refs; + } +} + +/// Parsea una `Location` LSP: { uri, range } → DefinitionLocation. +pub(crate) fn parse_location(loc: &serde_json::Value) -> Option { + let uri = loc.get("uri")?.as_str()?; + let path = uri.strip_prefix("file://").map(PathBuf::from)?; + let range = loc.get("range")?; + let start = range.get("start")?; + let line = start.get("line")?.as_u64()? as usize; + let col = start.get("character")?.as_u64()? as usize; + Some(DefinitionLocation { path, line, col }) +} + +pub(crate) fn handle_signature_help_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + if let Ok(mut s) = state.lock() { + s.signature_help = None; + } + return; + } + let info = parse_signature_help(result); + if let Ok(mut s) = state.lock() { + s.signature_help = info; + } +} + +pub(crate) fn parse_signature_help(result: &serde_json::Value) -> Option { + let sigs = result.get("signatures")?.as_array()?; + if sigs.is_empty() { + return None; + } + let active_sig = result.get("activeSignature").and_then(|n| n.as_u64()).unwrap_or(0) as usize; + let sig = sigs.get(active_sig).or_else(|| sigs.first())?; + let label = sig.get("label")?.as_str()?.to_string(); + let doc = sig + .get("documentation") + .map(stringify_hover_contents) + .filter(|s| !s.is_empty()); + let active_param = sig + .get("activeParameter") + .or_else(|| result.get("activeParameter")) + .and_then(|n| n.as_u64()) + .unwrap_or(0) as usize; + let param_labels = sig + .get("parameters") + .and_then(|p| p.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|p| { + let lbl = p.get("label")?; + if let Some(s) = lbl.as_str() { + Some(s.to_string()) + } else if let Some(arr2) = lbl.as_array() { + let s = arr2.first()?.as_u64()? as usize; + let e = arr2.get(1)?.as_u64()? as usize; + label.get(s..e).map(String::from) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + Some(SignatureHelpInfo { label, doc, active_param, param_labels }) +} + +pub(crate) fn handle_text_edits_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + return; + } + let Some(arr) = result.as_array() else { return }; + let edits: Vec = arr.iter().filter_map(parse_text_edit).collect(); + if let Ok(mut s) = state.lock() { + s.text_edits = edits; + } +} + +pub(crate) fn parse_text_edit(v: &serde_json::Value) -> Option { + let range = v.get("range")?; + let start = range.get("start")?; + let end = range.get("end")?; + let start_line = start.get("line")?.as_u64()? as usize; + let start_col = start.get("character")?.as_u64()? as usize; + let end_line = end.get("line")?.as_u64()? as usize; + let end_col = end.get("character")?.as_u64()? as usize; + let new_text = v.get("newText")?.as_str()?.to_string(); + Some(TextEdit { start_line, start_col, end_line, end_col, new_text }) +} + +pub(crate) fn handle_definition_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + return; + } + // `result` puede ser: + // - Location { uri, range } + // - Location[] + // - LocationLink[] { targetUri, targetSelectionRange } + // Tomamos la primera location en cualquier caso. + let loc_value = if result.is_array() { + result.as_array().and_then(|a| a.first()).cloned() + } else { + Some(result.clone()) + }; + let Some(loc) = loc_value else { return }; + + let (uri, range) = if let Some(u) = loc.get("uri") { + (u, loc.get("range")) + } else if let Some(u) = loc.get("targetUri") { + ( + u, + loc.get("targetSelectionRange").or_else(|| loc.get("targetRange")), + ) + } else { + return; + }; + let Some(uri) = uri.as_str() else { return }; + let path = match uri.strip_prefix("file://") { + Some(p) => PathBuf::from(p), + None => return, + }; + let Some(range) = range else { return }; + let Some(start) = range.get("start") else { return }; + let line = start.get("line").and_then(|n| n.as_u64()).unwrap_or(0) as usize; + let col = start.get("character").and_then(|n| n.as_u64()).unwrap_or(0) as usize; + if let Ok(mut s) = state.lock() { + s.definition = Some(DefinitionLocation { path, line, col }); + } +} + +pub(crate) fn handle_completion_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + let items_arr = if let Some(arr) = result.as_array() { + arr.clone() + } else if let Some(items) = result.get("items").and_then(|i| i.as_array()) { + items.clone() + } else { + return; + }; + let completions: Vec = items_arr.iter().filter_map(parse_completion).collect(); + if let Ok(mut s) = state.lock() { + s.completions = completions; + } +} + +pub(crate) fn handle_hover_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + if let Ok(mut s) = state.lock() { + s.hover = None; + } + return; + } + let info = parse_hover(result); + if let Ok(mut s) = state.lock() { + s.hover = info; + } +} + +/// `contents` en LSP puede ser: +/// - String +/// - { kind: "markdown"|"plaintext", value: String } +/// - Array de los anteriores (deprecated pero algunos servers lo mandan) +/// - { language: ..., value: ... } (legacy MarkedString) +pub(crate) fn parse_hover(result: &serde_json::Value) -> Option { + let contents = result.get("contents")?; + let text = stringify_hover_contents(contents); + if text.is_empty() { + None + } else { + Some(HoverInfo { contents: text }) + } +} + +pub(crate) fn stringify_hover_contents(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Object(map) => { + // { kind, value } o { language, value } + map.get("value") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string() + } + serde_json::Value::Array(arr) => arr + .iter() + .map(stringify_hover_contents) + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +pub(crate) fn parse_completion(v: &serde_json::Value) -> Option { + let label = v.get("label")?.as_str()?.to_string(); + let insert_text = v + .get("insertText") + .and_then(|s| s.as_str()) + .map(String::from); + let kind = v + .get("kind") + .and_then(|k| k.as_u64()) + .map(|n| completion_kind_label(n).to_string()); + let detail = v + .get("detail") + .and_then(|d| d.as_str()) + .map(String::from); + Some(CompletionItem { label, insert_text, kind, detail }) +} + +/// Etiqueta corta para el CompletionItemKind de LSP (1..25). +pub(crate) fn completion_kind_label(k: u64) -> &'static str { + match k { + 1 => "Text", + 2 => "Method", + 3 => "Function", + 4 => "Ctor", + 5 => "Field", + 6 => "Var", + 7 => "Class", + 8 => "Iface", + 9 => "Mod", + 10 => "Prop", + 11 => "Unit", + 12 => "Value", + 13 => "Enum", + 14 => "Keyword", + 15 => "Snip", + 16 => "Color", + 17 => "File", + 18 => "Ref", + 19 => "Folder", + 20 => "EnumMember", + 21 => "Const", + 22 => "Struct", + 23 => "Event", + 24 => "Op", + 25 => "TypeParam", + _ => "?", + } +} + +pub(crate) fn parse_lsp_diagnostic(d: &serde_json::Value) -> Option { + let range = d.get("range")?; + let start = range.get("start")?; + let end = range.get("end")?; + let sl = start.get("line")?.as_u64()? as usize; + let sc = start.get("character")?.as_u64()? as usize; + let el = end.get("line")?.as_u64()? as usize; + let ec = end.get("character")?.as_u64()? as usize; + let severity = match d.get("severity").and_then(|s| s.as_u64()) { + Some(1) => Severity::Error, + Some(2) => Severity::Warning, + Some(3) => Severity::Information, + Some(4) => Severity::Hint, + _ => Severity::Information, + }; + let message = d + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("") + .to_string(); + let source = d.get("source").and_then(|s| s.as_str()).map(String::from); + Some(Diagnostic { + range: DiagnosticRange { + start: Pos::new(sl, sc), + end: Pos::new(el, ec), + }, + severity, + message, + source, + }) +} diff --git a/widgets/text-editor-lsp/src/tests.rs b/widgets/text-editor-lsp/src/tests.rs new file mode 100644 index 0000000..40a27ee --- /dev/null +++ b/widgets/text-editor-lsp/src/tests.rs @@ -0,0 +1,380 @@ +use super::*; + +#[test] +fn noop_devuelve_vacio() { + let c = NoopLspClient; + assert!(c.diagnostics(&PathBuf::from("x")).is_empty()); +} + +#[test] +fn noop_no_panic_en_eventos() { + let mut c = NoopLspClient; + c.did_open(&PathBuf::from("x"), "rust", "fn main() {}"); + c.did_change(&PathBuf::from("x"), "fn main() { 1 }"); + c.did_close(&PathBuf::from("x")); +} + +#[test] +fn parse_diagnostic_minimo() { + let json = serde_json::json!({ + "range": { + "start": { "line": 3, "character": 5 }, + "end": { "line": 3, "character": 12 } + }, + "severity": 1, + "message": "no es así", + "source": "rustc" + }); + let d = parse_lsp_diagnostic(&json).unwrap(); + assert_eq!(d.range.start, Pos::new(3, 5)); + assert_eq!(d.range.end, Pos::new(3, 12)); + assert_eq!(d.severity, Severity::Error); + assert_eq!(d.message, "no es así"); + assert_eq!(d.source.as_deref(), Some("rustc")); +} + +#[test] +fn parse_diagnostic_sin_severidad_es_info() { + let json = serde_json::json!({ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 1 } + }, + "message": "x" + }); + let d = parse_lsp_diagnostic(&json).unwrap(); + assert_eq!(d.severity, Severity::Information); +} + +#[test] +fn parse_completion_minimo() { + let v = serde_json::json!({ + "label": "to_string", + "insertText": "to_string()", + "kind": 2, + "detail": "fn(&self) -> String" + }); + let c = parse_completion(&v).unwrap(); + assert_eq!(c.label, "to_string"); + assert_eq!(c.insert_text.as_deref(), Some("to_string()")); + assert_eq!(c.kind.as_deref(), Some("Method")); + assert_eq!(c.detail.as_deref(), Some("fn(&self) -> String")); +} + +#[test] +fn parse_hover_string_simple() { + let v = serde_json::json!({ "contents": "hola" }); + let h = parse_hover(&v).unwrap(); + assert_eq!(h.contents, "hola"); +} + +#[test] +fn parse_hover_marked_object() { + let v = serde_json::json!({ + "contents": { "kind": "markdown", "value": "**fn**(x: i32) -> i32" } + }); + let h = parse_hover(&v).unwrap(); + assert_eq!(h.contents, "**fn**(x: i32) -> i32"); +} + +#[test] +fn parse_hover_array_concatena() { + let v = serde_json::json!({ + "contents": ["primero", { "value": "segundo" }, ""] + }); + let h = parse_hover(&v).unwrap(); + assert_eq!(h.contents, "primero\nsegundo"); +} + +#[test] +fn parse_hover_vacio_devuelve_none() { + let v = serde_json::json!({ "contents": "" }); + assert!(parse_hover(&v).is_none()); +} + +#[test] +fn parse_completion_sin_insert_text_usa_label() { + let v = serde_json::json!({ "label": "main" }); + let c = parse_completion(&v).unwrap(); + assert_eq!(c.text_to_insert(), "main"); +} + +fn make_state() -> SharedState { + Arc::new(Mutex::new(SharedInner::default())) +} + +#[test] +fn handle_rename_changes_map() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": { + "changes": { + "file:///tmp/a.rs": [ + { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } }, "newText": "bar" } + ], + "file:///tmp/b.rs": [ + { "range": { "start": { "line": 5, "character": 4 }, "end": { "line": 5, "character": 7 } }, "newText": "bar" }, + { "range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 3 } }, "newText": "bar" } + ] + } + } + }); + handle_rename_response(&json, &s); + let we = s.lock().unwrap().workspace_edit.clone(); + assert_eq!(we.len(), 2); + assert_eq!(we.get(&PathBuf::from("/tmp/a.rs")).unwrap().len(), 1); + assert_eq!(we.get(&PathBuf::from("/tmp/b.rs")).unwrap().len(), 2); +} + +#[test] +fn handle_rename_document_changes() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": { + "documentChanges": [ + { + "textDocument": { "uri": "file:///tmp/x.rs", "version": 2 }, + "edits": [ + { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } }, "newText": "foo" } + ] + } + ] + } + }); + handle_rename_response(&json, &s); + let we = s.lock().unwrap().workspace_edit.clone(); + assert_eq!(we.len(), 1); + assert_eq!(we.get(&PathBuf::from("/tmp/x.rs")).unwrap().len(), 1); +} + +#[test] +fn handle_document_symbols_jerarquico() { + let s = make_state(); + // Estructura: struct Foo { fn bar(), fn baz() } + fn top() + let json = serde_json::json!({ + "id": 1, + "result": [ + { + "name": "Foo", + "kind": 23, // struct + "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 10, "character": 1 } }, + "selectionRange": { "start": { "line": 0, "character": 7 }, "end": { "line": 0, "character": 10 } }, + "children": [ + { + "name": "bar", + "kind": 6, // method + "range": { "start": { "line": 2, "character": 4 }, "end": { "line": 4, "character": 5 } }, + "selectionRange": { "start": { "line": 2, "character": 7 }, "end": { "line": 2, "character": 10 } } + }, + { + "name": "baz", + "kind": 6, + "range": { "start": { "line": 6, "character": 4 }, "end": { "line": 8, "character": 5 } }, + "selectionRange": { "start": { "line": 6, "character": 7 }, "end": { "line": 6, "character": 10 } } + } + ] + }, + { + "name": "top", + "kind": 12, // function + "range": { "start": { "line": 12, "character": 0 }, "end": { "line": 14, "character": 1 } }, + "selectionRange": { "start": { "line": 12, "character": 3 }, "end": { "line": 12, "character": 6 } } + } + ] + }); + handle_document_symbols_response(&json, &s); + let syms = s.lock().unwrap().document_symbols.clone(); + assert_eq!(syms.len(), 4, "esperaba 4 entradas flattening"); + + assert_eq!(syms[0].name, "Foo"); + assert_eq!(syms[0].kind, "struct"); + assert_eq!(syms[0].line, 0); + assert_eq!(syms[0].depth, 0); + assert_eq!(syms[0].container, None); + + assert_eq!(syms[1].name, "bar"); + assert_eq!(syms[1].kind, "method"); + assert_eq!(syms[1].line, 2); + assert_eq!(syms[1].depth, 1); + assert_eq!(syms[1].container.as_deref(), Some("Foo")); + + assert_eq!(syms[2].name, "baz"); + assert_eq!(syms[2].depth, 1); + assert_eq!(syms[2].container.as_deref(), Some("Foo")); + + assert_eq!(syms[3].name, "top"); + assert_eq!(syms[3].kind, "fn"); + assert_eq!(syms[3].depth, 0); +} + +#[test] +fn handle_document_symbols_legacy_symbolinformation() { + let s = make_state(); + // Formato viejo: SymbolInformation[] (plano + location). + let json = serde_json::json!({ + "id": 1, + "result": [ + { + "name": "main", + "kind": 12, + "location": { + "uri": "file:///tmp/x.rs", + "range": { "start": { "line": 0, "character": 3 }, "end": { "line": 0, "character": 7 } } + } + }, + { + "name": "helper", + "kind": 12, + "containerName": "main", + "location": { + "uri": "file:///tmp/x.rs", + "range": { "start": { "line": 5, "character": 3 }, "end": { "line": 5, "character": 9 } } + } + } + ] + }); + handle_document_symbols_response(&json, &s); + let syms = s.lock().unwrap().document_symbols.clone(); + assert_eq!(syms.len(), 2); + assert_eq!(syms[1].name, "helper"); + assert_eq!(syms[1].container.as_deref(), Some("main")); +} + +#[test] +fn handle_references_response_array() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": [ + { "uri": "file:///tmp/a.rs", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 5 } } }, + { "uri": "file:///tmp/b.rs", "range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 3 } } } + ] + }); + handle_references_response(&json, &s); + let refs = s.lock().unwrap().references.clone(); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0].path, PathBuf::from("/tmp/a.rs")); + assert_eq!(refs[0].line, 1); + assert_eq!(refs[1].path, PathBuf::from("/tmp/b.rs")); + assert_eq!(refs[1].line, 10); +} + +#[test] +fn parse_signature_help_basic() { + let result = serde_json::json!({ + "signatures": [{ + "label": "fn foo(x: i32, y: String) -> u64", + "parameters": [ + { "label": "x: i32" }, + { "label": "y: String" } + ] + }], + "activeSignature": 0, + "activeParameter": 1 + }); + let info = parse_signature_help(&result).unwrap(); + assert_eq!(info.label, "fn foo(x: i32, y: String) -> u64"); + assert_eq!(info.active_param, 1); + assert_eq!(info.param_labels, vec!["x: i32", "y: String"]); +} + +#[test] +fn parse_signature_help_offset_label() { + // Label como [start, end] dentro del label de la firma. + let result = serde_json::json!({ + "signatures": [{ + "label": "foo(x, y)", + "parameters": [ + { "label": [4, 5] }, + { "label": [7, 8] } + ] + }] + }); + let info = parse_signature_help(&result).unwrap(); + assert_eq!(info.param_labels, vec!["x", "y"]); +} + +#[test] +fn parse_text_edit_basic() { + let v = serde_json::json!({ + "range": { + "start": { "line": 1, "character": 0 }, + "end": { "line": 1, "character": 4 } + }, + "newText": "let " + }); + let e = parse_text_edit(&v).unwrap(); + assert_eq!(e.start_line, 1); + assert_eq!(e.start_col, 0); + assert_eq!(e.end_line, 1); + assert_eq!(e.end_col, 4); + assert_eq!(e.new_text, "let "); +} + +#[test] +fn handle_text_edits_response_array() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": [ + { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } }, "newText": "fn " }, + { "range": { "start": { "line": 1, "character": 4 }, "end": { "line": 1, "character": 5 } }, "newText": "" } + ] + }); + handle_text_edits_response(&json, &s); + let edits = s.lock().unwrap().text_edits.clone(); + assert_eq!(edits.len(), 2); +} + +#[test] +fn handle_definition_location_simple() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": { + "uri": "file:///tmp/x.rs", + "range": { + "start": { "line": 10, "character": 4 }, + "end": { "line": 10, "character": 9 } + } + } + }); + handle_definition_response(&json, &s); + let d = s.lock().unwrap().definition.clone().unwrap(); + assert_eq!(d.path, PathBuf::from("/tmp/x.rs")); + assert_eq!(d.line, 10); + assert_eq!(d.col, 4); +} + +#[test] +fn handle_definition_location_link_array() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": [ + { + "targetUri": "file:///tmp/y.rs", + "targetSelectionRange": { + "start": { "line": 0, "character": 7 }, + "end": { "line": 0, "character": 12 } + } + } + ] + }); + handle_definition_response(&json, &s); + let d = s.lock().unwrap().definition.clone().unwrap(); + assert_eq!(d.path, PathBuf::from("/tmp/y.rs")); + assert_eq!(d.line, 0); + assert_eq!(d.col, 7); +} + +#[test] +fn rust_analyzer_client_sin_binary_no_panic() { + // Si rust-analyzer no está instalado, el spawn falla en silencio + // y el client queda en modo no-op (state vacío). + let c = RustAnalyzerClient::with_command(PathBuf::from("/tmp"), "rust-analyzer-missing-99999"); + // diagnostics() siempre devuelve vacío hasta que el server responde. + assert!(c.diagnostics(&PathBuf::from("/tmp/x")).is_empty()); +} diff --git a/widgets/text-editor/Cargo.toml b/widgets/text-editor/Cargo.toml new file mode 100644 index 0000000..ba99f03 --- /dev/null +++ b/widgets/text-editor/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "llimphi-widget-text-editor" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-text-editor — capa visual Llimphi del editor de código (gutter, caret, selección, scroll, integración de teclado al update Elm). El núcleo agnóstico (buffer/cursor/ops/undo/highlight/…) vive en llimphi-widget-text-editor-core y se re-exporta. LSP queda para una capa superior." + +[dependencies] +llimphi-widget-text-editor-core = { workspace = true } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +# state.rs construye tree_sitter::InputEdit/Point para alimentar el +# parsing incremental del núcleo (el resto de tree-sitter vive en core). +tree-sitter = { workspace = true } + +[dev-dependencies] diff --git a/widgets/text-editor/LEEME.md b/widgets/text-editor/LEEME.md new file mode 100644 index 0000000..85de610 --- /dev/null +++ b/widgets/text-editor/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-editor + +> Editor de código (rope · cursor · undo · highlight · clipboard · find) para [llimphi](../../README.md). + +Rope con `crop` o `xi-rope` interno (eficiente para edits grandes). Cursores múltiples opcionales, syntax highlight (tree-sitter), clipboard real (`arboard`), find/replace con regex, undo grouped. Base de `nada`, `pluma-editor`, `pluma-notebook`, `nakui-sheet`. diff --git a/widgets/text-editor/README.md b/widgets/text-editor/README.md new file mode 100644 index 0000000..d45991d --- /dev/null +++ b/widgets/text-editor/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-editor + +> Code editor (rope · cursor · undo · highlight · clipboard · find) for [llimphi](../../README.md). + +Internal rope with `crop` or `xi-rope` (efficient for large edits). Optional multi-cursor, syntax highlight (tree-sitter), real clipboard (`arboard`), regex find/replace, grouped undo. Foundation of `nada`, `pluma-editor`, `pluma-notebook`, `nakui-sheet`. diff --git a/widgets/text-editor/src/lib.rs b/widgets/text-editor/src/lib.rs new file mode 100644 index 0000000..e43625d --- /dev/null +++ b/widgets/text-editor/src/lib.rs @@ -0,0 +1,68 @@ +//! `llimphi-widget-text-editor` — editor de código multilínea para Llimphi. +//! +//! Capa visual sobre el núcleo agnóstico [`llimphi_widget_text_editor_core`]: +//! +//! - El **núcleo** (`buffer`/`cursor`/`ops`/`undo`/`bracket`/`find`/ +//! `diagnostics`/`clipboard`/`highlight`) es puro — sin IO, sin Llimphi, +//! sin GPU — y se re-exporta aquí tal cual, de modo que los consumidores +//! históricos (`crate::cursor::Pos`, `crate::Buffer`, …) siguen resolviendo +//! sin cambios. +//! - [`state`] — el [`EditorState`] que une todo + `apply_key` para integrar +//! al `update` Elm (depende de los tipos de teclado de `llimphi-ui`). +//! - [`view`] — renderizado multilínea con gutter, caret, selección, scroll. +//! +//! El split núcleo/widget permite tests amplios del core y reutilizar la +//! lógica de edición desde un TUI, un `text-input` single-line, una +//! mini-REPL o un backend web, sin arrastrar `wgpu`/`vello`. + +#![forbid(unsafe_code)] + +// Núcleo agnóstico re-exportado como módulos del crate: mantiene viva la +// ruta `crate::::…` que usan `state`/`view` y los consumidores externos. +pub use llimphi_widget_text_editor_core::{ + bracket, buffer, clipboard, cursor, diagnostics, find, highlight, ops, undo, +}; + +// Capa Llimphi propia de este widget. +pub mod state; +pub mod view; + +pub use buffer::Buffer; +pub use clipboard::{Clipboard, MemClipboard, NullClipboard}; +pub use cursor::{Cursor, Pos, Selection}; +pub use diagnostics::{Diagnostic, DiagnosticRange, Severity}; +pub use find::{all_matches, find_next, find_prev, FindState}; +pub use highlight::{Highlighter, Language, Span, SyntaxPalette, TokenKind}; +pub use ops::{indent_str, EditDelta}; +pub use state::{ApplyResult, EditorOptions, EditorState}; +pub use undo::UndoStack; +pub use view::{ + text_editor_view, text_editor_view_full, text_editor_view_highlighted, EditorMetrics, + EditorPalette, GutterStyle, PointerEvent, +}; + +use llimphi_ui::llimphi_raster::peniko::Color; + +/// Paleta de syntax highlighting dark — deriva de un [`llimphi_theme::Theme`] +/// + colores hardcoded para las categorías que el theme no expone como +/// slots semánticos (string, number, keyword, …). +/// +/// Vive en el widget (no en el núcleo) porque es el único punto que toca +/// `llimphi-theme`; el núcleo se queda con el modelo de color puro. +pub fn syntax_palette_dark(theme: &llimphi_theme::Theme) -> SyntaxPalette { + fn rgb(r: u8, g: u8, b: u8) -> Color { + Color::from_rgb8(r, g, b) + } + SyntaxPalette { + keyword: rgb(198, 120, 221), // morado: keywords + typ: rgb(229, 192, 123), // amarillo cálido: tipos + function: rgb(97, 175, 239), // azul: funciones + string: rgb(152, 195, 121), // verde: strings + number: rgb(209, 154, 102), // naranja: números + comment: theme.fg_muted, // muted: comentarios + operator: theme.fg_text, + punctuation: theme.fg_muted, + identifier: theme.fg_text, + other: theme.fg_text, + } +} diff --git a/widgets/text-editor/src/state.rs b/widgets/text-editor/src/state.rs new file mode 100644 index 0000000..e13f66a --- /dev/null +++ b/widgets/text-editor/src/state.rs @@ -0,0 +1,1258 @@ +//! [`EditorState`] — la unión de buffer + cursor + undo + opciones, con +//! `apply_key` que mapea un `KeyEvent` de llimphi-ui a operaciones de +//! edición o movimiento. Este es el tipo que el caller pone en su +//! `Model` y mete en el `update` Elm. + +use std::cell::RefCell; + +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey}; + +use crate::buffer::Buffer; +use crate::clipboard::{Clipboard, NullClipboard}; +use crate::cursor::{Cursor, Pos}; +use crate::highlight::{Highlighter, Language, Span}; +use crate::ops::{ + dedent, delete_backward, delete_forward, indent_or_insert_tab, + insert_newline_auto_indent, replace_selection, +}; +use crate::undo::UndoStack; + +/// Opciones del editor — afectan indent + límite de undo + page size. +#[derive(Debug, Clone, Copy)] +pub struct EditorOptions { + /// `true` = Tab inserta `indent_size` spaces; `false` = inserta `\t`. + pub tab_to_spaces: bool, + pub indent_size: usize, + /// Cuántas líneas avanza PageUp/PageDown. + pub page_size: usize, + /// `true` = Enter no inserta `\n`; el caller maneja submit. (modo + /// single-line para el text-input refactorizado). + pub single_line: bool, +} + +impl Default for EditorOptions { + fn default() -> Self { + Self { + tab_to_spaces: true, + indent_size: 2, + page_size: 12, + single_line: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct EditorState { + pub buffer: Buffer, + /// Cursor primario — el que la API legacy expone como "el" cursor. + /// Edit ops aplican al primary + todos los `extra_cursors` en orden. + pub cursor: Cursor, + /// Cursores adicionales (multi-cursor). Vacío en el caso típico. + /// Cuando hay extras, las ediciones aplican a todos; Esc los colapsa + /// dejando sólo el primary. + pub extra_cursors: Vec, + /// Diagnostics del LSP (o equivalente). El client externo los popa + /// vía `set_diagnostics`; el render del editor los pinta como + /// subrayado bajo el rango con color según severity. + pub diagnostics: Vec, + pub options: EditorOptions, + /// Líneas-guarda: índices ordenados (ascendente, sin duplicados) de + /// líneas que el caret no puede ocupar y que el gutter no numera. + /// El widget no decide qué es guarda — lo decide el caller (el + /// `cuerpo_ide` lo computa a partir de la estructura de átomos + + /// flags de fusión). La lista debe mantenerse al día tras cada + /// edición que cambie la cantidad o posición de líneas: el caller + /// es responsable de actualizar este campo cuando reaccione a + /// `Changed`. Vacío = sin guardas, comportamiento clásico de IDE. + pub guard_lines: Vec, + /// Tinte de fondo por línea. `line_tints[i]` controla la línea + /// `i` del buffer: `Some(color)` pinta un rectángulo del ancho + /// completo del área de contenido al ALPHA del color, **debajo** + /// del texto y de cualquier highlight; `None` deja la línea sin + /// tinte. Vacío o ausente = sin tintes (modo IDE clásico). Pensado + /// para colorear zonas en editores narrativos sin afectar la + /// lectura — los callers deben elegir colores con alpha bajo (≤ + /// ~40/255 sobre el bg). + pub line_tints: Vec>, + pub undo: UndoStack, + /// Línea inicial visible — el viewport renderiza + /// `[scroll_offset, scroll_offset + visible)`. El caller llama a + /// [`Self::ensure_caret_visible`] tras movimientos para auto-scrollear. + pub scroll_offset: usize, + /// Contador monotónico que se incrementa con cada edición del buffer. + /// Lo usa el cache de highlight para invalidarse sin re-hashear el + /// texto entero por frame. + pub edit_seq: u64, + /// InputEdits que el editor produjo y todavía no fueron aplicados + /// al `Tree` cached del highlighter. El highlight, antes de + /// reparsear, los drena y los aplica al tree → parseo incremental + /// real (tree-sitter sólo reconstruye los subtrees afectados). + pub pending_input_edits: RefCell>, + /// Cache memoizado del syntax highlight. Interior mutability vía + /// `RefCell` para que el view (que recibe `&EditorState`) lo + /// actualice on-demand. Se invalida cuando cambian `edit_seq` o el + /// `Language` solicitado. + pub highlight_cache: RefCell>, +} + +/// Entrada del cache: spans por línea + clave que la generó. +#[derive(Debug, Clone)] +pub struct HighlightCache { + pub seq: u64, + pub language: Language, + pub spans: Vec>, +} + +impl Default for EditorState { + fn default() -> Self { + Self::new() + } +} + +impl EditorState { + pub fn new() -> Self { + Self { + buffer: Buffer::new(), + cursor: Cursor::new(), + extra_cursors: Vec::new(), + diagnostics: Vec::new(), + options: EditorOptions::default(), + guard_lines: Vec::new(), + line_tints: Vec::new(), + undo: UndoStack::new(), + scroll_offset: 0, + edit_seq: 0, + pending_input_edits: RefCell::new(Vec::new()), + highlight_cache: RefCell::new(None), + } + } + + /// Devuelve todos los cursores en orden: primary + extras. Útil para + /// el render que dibuja un caret + selección por cada uno. + pub fn all_cursors(&self) -> impl Iterator { + std::iter::once(&self.cursor).chain(self.extra_cursors.iter()) + } + + /// Agrega un cursor adicional con caret en `(line, col)`. Si ya hay + /// un cursor exactamente ahí, no duplica. + pub fn add_cursor_at(&mut self, line: usize, col: usize) { + let line = line.min(self.buffer.len_lines().saturating_sub(1)); + let col = col.min(self.buffer.line_len_chars(line)); + let pos = Pos::new(line, col); + if self.cursor.caret == pos { + return; + } + if self.extra_cursors.iter().any(|c| c.caret == pos) { + return; + } + self.extra_cursors.push(Cursor::at(line, col)); + } + + /// Colapsa multi-cursor: descarta los `extra_cursors`. No toca el + /// primary. + pub fn collapse_to_primary(&mut self) { + self.extra_cursors.clear(); + } + + pub fn has_multi_cursor(&self) -> bool { + !self.extra_cursors.is_empty() + } + + /// Reemplaza los diagnostics del editor. Usado por el client LSP + /// cuando recibe `textDocument/publishDiagnostics`. + pub fn set_diagnostics(&mut self, diags: Vec) { + self.diagnostics = diags; + } + + pub fn with_options(options: EditorOptions) -> Self { + Self { + options, + ..Self::new() + } + } + + /// Ajusta `scroll_offset` para que la línea del caret quede dentro + /// de `[scroll_offset, scroll_offset + visible_lines)`. Si el caret + /// está arriba, scrollea para arriba; si está abajo, scrollea para + /// abajo dejando el caret en la última línea visible. + pub fn ensure_caret_visible(&mut self, visible_lines: usize) { + if visible_lines == 0 { + return; + } + let line = self.cursor.caret.line; + if line < self.scroll_offset { + self.scroll_offset = line; + } else if line >= self.scroll_offset + visible_lines { + self.scroll_offset = line + 1 - visible_lines; + } + // Clampea al rango válido — no scrollear más allá del fin del + // buffer (deja la última línea siempre visible). + let max_scroll = self.line_count().saturating_sub(1); + if self.scroll_offset > max_scroll { + self.scroll_offset = max_scroll; + } + } + + /// Scrollea relativo (positivo = abajo). Clampea a 0..line_count-1. + pub fn scroll_by(&mut self, delta: i32) { + let new = (self.scroll_offset as i32 + delta).max(0) as usize; + let max = self.line_count().saturating_sub(1); + self.scroll_offset = new.min(max); + } + + pub fn text(&self) -> String { + self.buffer.text() + } + + pub fn set_text(&mut self, s: &str) { + self.buffer.set_text(s); + // Las guardas y tintes previos referían al texto viejo: + // limpiar. El caller los repuebla cuando reaccione al cambio. + self.guard_lines.clear(); + self.line_tints.clear(); + // Clampea el caret a la nueva longitud. + let last_line = self.buffer.len_lines().saturating_sub(1); + let col = self.buffer.line_len_chars(last_line); + self.cursor = Cursor::at(last_line, col); + self.undo.clear(); + self.bump_edit_seq(); + // Cambio masivo de buffer — el árbol cached del highlighter + // queda inválido. Lo borramos para forzar full parse próximo. + for lang in [Language::Rust, Language::Python] { + crate::highlight::invalidate_tree_cache(lang); + } + } + + /// Incrementa el contador de ediciones — invalidando el cache de + /// highlight automáticamente. + pub fn bump_edit_seq(&mut self) { + self.edit_seq = self.edit_seq.wrapping_add(1); + } + + /// Devuelve los spans del highlight cacheados. Si el cache no matchea + /// (distinto `edit_seq` o `language`), reparsea con tree-sitter + /// incremental — aplica los `pending_input_edits` al tree previo + /// antes de parsear, y guarda el nuevo tree. + pub fn highlighted_spans(&self, language: Language) -> Vec> { + if matches!(language, Language::Plain) { + return Vec::new(); + } + let mut cache = self.highlight_cache.borrow_mut(); + if let Some(c) = cache.as_ref() { + if c.seq == self.edit_seq && c.language == language { + return c.spans.clone(); + } + } + // Aplica los InputEdits pending al tree cached antes de parsear + // — eso convierte el parseo de "full" a "incremental real". + let edits: Vec = + self.pending_input_edits.borrow_mut().drain(..).collect(); + crate::highlight::apply_pending_edits(language, &edits); + + let mut h = Highlighter::new(language); + let spans = h.highlight(&self.buffer.text()); + *cache = Some(HighlightCache { + seq: self.edit_seq, + language, + spans: spans.clone(), + }); + spans + } + + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } + + pub fn line_count(&self) -> usize { + self.buffer.len_lines() + } + + /// Posiciona el caret en `(line, col)`, clampeando al rango válido + /// del buffer. Colapsa la selección. Usado por el caller cuando el + /// usuario clickea en el área de texto. + /// + /// Si la línea destino está en [`Self::guard_lines`], el caret + /// salta a la línea no-guarda más cercana (privilegia hacia + /// abajo). Así un click "en la franja entre zonas" aterriza en el + /// inicio de la zona siguiente. + pub fn set_caret_at(&mut self, line: usize, col: usize) { + self.cursor.set_caret(&self.buffer, Pos::new(line, col)); + if !self.guard_lines.is_empty() { + snap_cursor_off_guard(&mut self.cursor, &self.buffer, &self.guard_lines, 0); + } + } + + /// `true` si la línea `line` figura en `guard_lines`. + pub fn is_guard_line(&self, line: usize) -> bool { + self.guard_lines.binary_search(&line).is_ok() + } + + /// Reemplaza la lista de líneas-guarda. La entrada se ordena y + /// deduplica — el caller puede pasarlas en cualquier orden. Tras + /// el cambio NO se snappea el caret automáticamente: si tu nueva + /// lista deja al caret sobre una guarda, llamá a + /// [`Self::snap_off_guards`] explícitamente. + pub fn set_guard_lines(&mut self, mut lines: Vec) { + lines.sort_unstable(); + lines.dedup(); + self.guard_lines = lines; + } + + /// Salta el primary cursor + extras fuera de cualquier línea + /// guarda. `dir` orienta la búsqueda: `+1` busca primero abajo, + /// `-1` arriba, `0` igual a `+1` (con fallback al opuesto). + /// No-op si `guard_lines` está vacío. + pub fn snap_off_guards(&mut self, dir: i32) { + if self.guard_lines.is_empty() { + return; + } + snap_cursor_off_guard(&mut self.cursor, &self.buffer, &self.guard_lines, dir); + for c in &mut self.extra_cursors { + snap_cursor_off_guard(c, &self.buffer, &self.guard_lines, dir); + } + } + + /// Extiende la selección hasta `(line, col)`. Si no había anchor, + /// lo planta en el caret actual antes de mover. Usado por drag del + /// mouse: cada `Move` del drag llama esto con la nueva pos. + pub fn extend_selection_to(&mut self, line: usize, col: usize) { + let line = line.min(self.buffer.len_lines().saturating_sub(1)); + let col = col.min(self.buffer.line_len_chars(line)); + if self.cursor.anchor.is_none() { + self.cursor.anchor = Some(self.cursor.caret); + } + self.cursor.caret = Pos::new(line, col); + self.cursor.desired_col = col; + } + + /// Selecciona todo el buffer: anchor en `(0,0)`, caret al final de + /// la última línea. Colapsa los multi-cursor extras. Operación de + /// sólo-cursor (no edita) — la usan el menú de edición y Ctrl+A. + pub fn select_all(&mut self) { + self.collapse_to_primary(); + let last_line = self.buffer.len_lines().saturating_sub(1); + let last_col = self.buffer.line_len_chars(last_line); + self.cursor.anchor = Some(Pos::ORIGIN); + self.cursor.caret = Pos::new(last_line, last_col); + self.cursor.desired_col = last_col; + } + + /// `true` si hay algo que deshacer (para habilitar "Deshacer" en el + /// menú de edición). + pub fn can_undo(&self) -> bool { + self.undo.can_undo() + } + + /// `true` si hay algo que rehacer. + pub fn can_redo(&self) -> bool { + self.undo.can_redo() + } + + /// `true` si hay una selección no-vacía (para habilitar Cortar/ + /// Copiar/Eliminar en el menú de edición). + pub fn has_selection(&self) -> bool { + self.cursor.has_selection() + } + + /// Texto seleccionado, si hay selección no-vacía. `None` cuando el + /// cursor está colapsado. + pub fn selected_text(&self) -> Option { + if !self.cursor.has_selection() { + return None; + } + let (s, e) = self.cursor.selection_range(&self.buffer); + if s == e { + return None; + } + Some(self.buffer.slice(s, e)) + } + + /// Resultado: `Changed` si la tecla modificó el buffer o el cursor; + /// `Ignored` si la tecla no aplica al editor. Útil para que el + /// caller decida si rebuildear el view. + /// + /// Copy/cut/paste (Ctrl+C/X/V) son ignorados — para habilitarlos, + /// usá [`Self::apply_key_with_clipboard`] pasando un backend. + pub fn apply_key(&mut self, event: &KeyEvent) -> ApplyResult { + self.apply_key_with_clipboard(event, &mut NullClipboard) + } + + /// Como [`Self::apply_key`] pero con backend de clipboard activo: + /// Ctrl+C copia la selección, Ctrl+X la corta, Ctrl+V pega lo que + /// haya en el clipboard. + pub fn apply_key_with_clipboard( + &mut self, + event: &KeyEvent, + clipboard: &mut dyn Clipboard, + ) -> ApplyResult { + // Antes de aplicar la tecla guardamos la línea del primary + // cursor: si la edición/movimiento termina parando en una + // guarda, la dirección del salto es la diferencia + // post-pre. Up → snap arriba, Down → snap abajo, click/edit + // en el mismo sitio → snap abajo por default. + let pre_line = self.cursor.caret.line as i32; + let r = self.apply_key_inner(event, clipboard); + if r.changed() { + self.bump_edit_seq(); + } + if r.touched() && !self.guard_lines.is_empty() && !self.cursor.has_selection() { + // Si hay selección viva (shift+arrow / drag) no snappeamos: + // el usuario está seleccionando a través de la guarda y + // forzar el caret afuera rompería la selección. + let dir = (self.cursor.caret.line as i32 - pre_line).signum(); + self.snap_off_guards(dir); + } + r + } + + fn apply_key_inner( + &mut self, + event: &KeyEvent, + clipboard: &mut dyn Clipboard, + ) -> ApplyResult { + if event.state != KeyState::Pressed { + return ApplyResult::Ignored; + } + let extending = event.modifiers.shift; + let ctrl = event.modifiers.ctrl || event.modifiers.meta; + let alt = event.modifiers.alt; + + // Esc colapsa multi-cursor (sin extras = ignorado, el caller + // decide qué más hacer — cancelar edit, cerrar find, etc.). + if matches!(&event.key, Key::Named(NamedKey::Escape)) { + if self.has_multi_cursor() { + self.collapse_to_primary(); + return ApplyResult::CursorMoved; + } + return ApplyResult::Ignored; + } + + // Multi-cursor: Ctrl+Alt+ArrowDown/Up agrega un cursor en la + // línea siguiente/anterior usando la misma desired_col. Esc del + // caller debería colapsar — no lo manejamos acá porque el caller + // puede querer usar Esc para otras cosas (cerrar find, cancelar + // edit). El caller chequea has_multi_cursor() antes. + if ctrl && alt { + match &event.key { + Key::Named(NamedKey::ArrowDown) => { + let line = self.cursor.caret.line + 1; + if line < self.buffer.len_lines() { + self.add_cursor_at(line, self.cursor.desired_col); + return ApplyResult::CursorMoved; + } + return ApplyResult::Ignored; + } + Key::Named(NamedKey::ArrowUp) => { + if self.cursor.caret.line > 0 { + self.add_cursor_at(self.cursor.caret.line - 1, self.cursor.desired_col); + return ApplyResult::CursorMoved; + } + return ApplyResult::Ignored; + } + _ => {} + } + } + + let page = self.options.page_size; + match &event.key { + // Movimiento + Key::Named(NamedKey::ArrowLeft) => { + if ctrl { + self.apply_move_all(|b, c| c.move_word_left(b, extending)); + } else { + self.apply_move_all(|b, c| c.move_left(b, extending)); + } + ApplyResult::CursorMoved + } + Key::Named(NamedKey::ArrowRight) => { + if ctrl { + self.apply_move_all(|b, c| c.move_word_right(b, extending)); + } else { + self.apply_move_all(|b, c| c.move_right(b, extending)); + } + ApplyResult::CursorMoved + } + Key::Named(NamedKey::ArrowUp) => { + self.apply_move_all(|b, c| c.move_up(b, extending)); + ApplyResult::CursorMoved + } + Key::Named(NamedKey::ArrowDown) => { + self.apply_move_all(|b, c| c.move_down(b, extending)); + ApplyResult::CursorMoved + } + Key::Named(NamedKey::Home) => { + if ctrl { + self.apply_move_all(|b, c| c.move_doc_start(b, extending)); + } else { + self.apply_move_all(|b, c| c.move_home(b, extending)); + } + ApplyResult::CursorMoved + } + Key::Named(NamedKey::End) => { + if ctrl { + self.apply_move_all(|b, c| c.move_doc_end(b, extending)); + } else { + self.apply_move_all(|b, c| c.move_end(b, extending)); + } + ApplyResult::CursorMoved + } + Key::Named(NamedKey::PageUp) => { + self.apply_move_all(|b, c| c.move_page_up(b, extending, page)); + ApplyResult::CursorMoved + } + Key::Named(NamedKey::PageDown) => { + self.apply_move_all(|b, c| c.move_page_down(b, extending, page)); + ApplyResult::CursorMoved + } + + // Edición + Key::Named(NamedKey::Enter) => { + if self.options.single_line { + return ApplyResult::Ignored; + } + self.apply_edit_all(|b, c, _opts| Some(insert_newline_auto_indent(b, c))); + ApplyResult::Changed + } + Key::Named(NamedKey::Backspace) => { + if self.apply_edit_all(|b, c, _opts| delete_backward(b, c)) { + ApplyResult::Changed + } else { + ApplyResult::Ignored + } + } + Key::Named(NamedKey::Delete) => { + if self.apply_edit_all(|b, c, _opts| delete_forward(b, c)) { + ApplyResult::Changed + } else { + ApplyResult::Ignored + } + } + Key::Named(NamedKey::Tab) => { + let any = if extending { + self.apply_edit_all(|b, c, opts| { + dedent(b, c, opts.tab_to_spaces, opts.indent_size) + }) + } else { + self.apply_edit_all(|b, c, opts| { + Some(indent_or_insert_tab(b, c, opts.tab_to_spaces, opts.indent_size)) + }) + }; + if any { ApplyResult::Changed } else { ApplyResult::Ignored } + } + + // Clipboard + Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("c") => { + if let Some(text) = self.selected_text() { + clipboard.set(&text); + ApplyResult::CursorMoved + } else { + ApplyResult::Ignored + } + } + Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("x") => { + if let Some(text) = self.selected_text() { + clipboard.set(&text); + let d = replace_selection(&mut self.buffer, &mut self.cursor, ""); + self.undo.push(d); + ApplyResult::Changed + } else { + ApplyResult::Ignored + } + } + Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("v") => { + let Some(text) = clipboard.get() else { + return ApplyResult::Ignored; + }; + if text.is_empty() { + return ApplyResult::Ignored; + } + // En single-line, los `\n` del clipboard se aplanan. + let to_insert = if self.options.single_line { + text.replace(['\n', '\r'], " ") + } else { + text + }; + let d = replace_selection(&mut self.buffer, &mut self.cursor, &to_insert); + self.undo.push(d); + ApplyResult::Changed + } + + // Undo / Redo + Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("z") => { + let did = if extending { + self.undo.redo(&mut self.buffer, &mut self.cursor) + } else { + self.undo.undo(&mut self.buffer, &mut self.cursor) + }; + if did { ApplyResult::Changed } else { ApplyResult::Ignored } + } + Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("y") => { + let did = self.undo.redo(&mut self.buffer, &mut self.cursor); + if did { ApplyResult::Changed } else { ApplyResult::Ignored } + } + + // Inserción de chars imprimibles vía event.text (respeta IME + + // layouts no-US). Ignoramos cuando ctrl/meta están activos + // para no comernos Ctrl+S, Ctrl+C, etc. (eso lo hace el + // caller registrando shortcuts). + _ => { + if ctrl { + return ApplyResult::Ignored; + } + let Some(text) = event.text.as_ref() else { + return ApplyResult::Ignored; + }; + if text.is_empty() || text.chars().any(|c| c.is_control()) { + return ApplyResult::Ignored; + } + let text = text.clone(); + self.apply_edit_all(|b, c, _opts| Some(replace_selection(b, c, &text))); + ApplyResult::Changed + } + } + } + + // ----- Multi-cursor helpers ----- + + /// Aplica un movimiento (no edita el buffer) a todos los cursores: + /// primary + extras. Después dedupa para evitar cursores que terminan + /// en el mismo punto. + fn apply_move_all(&mut self, mut f: F) + where + F: FnMut(&Buffer, &mut Cursor), + { + f(&self.buffer, &mut self.cursor); + for c in &mut self.extra_cursors { + f(&self.buffer, c); + } + self.dedupe_cursors(); + } + + /// Aplica una edición (que puede modificar el buffer) a todos los + /// cursores. Procesa en orden de offset descendente para que las + /// ediciones tempranas no desplacen las posiciones de las + /// posteriores. Devuelve `true` si al menos uno produjo un delta. + /// Cada delta también genera un `tree_sitter::InputEdit` que va a + /// `pending_input_edits` para alimentar el incremental parsing. + fn apply_edit_all(&mut self, mut f: F) -> bool + where + F: FnMut(&mut Buffer, &mut Cursor, &EditorOptions) -> Option, + { + let mut all: Vec<(Option, usize)> = Vec::with_capacity(1 + self.extra_cursors.len()); + let p_off = self.buffer.pos_to_offset(self.cursor.caret.line, self.cursor.caret.col); + all.push((None, p_off)); + for (i, c) in self.extra_cursors.iter().enumerate() { + let off = self.buffer.pos_to_offset(c.caret.line, c.caret.col); + all.push((Some(i), off)); + } + all.sort_by_key(|(_, off)| std::cmp::Reverse(*off)); + + let opts = self.options; + let mut any = false; + for (which, _) in all { + let cursor: &mut Cursor = match which { + None => &mut self.cursor, + Some(i) => &mut self.extra_cursors[i], + }; + // Pre-edit positions del start del delta — necesitamos las + // coordenadas BYTE del buffer ANTES de la edición. + let start_char = self.buffer.pos_to_offset(cursor.caret.line, cursor.caret.col); + // Pero si hay selección, el start real es el min de la sel. + let (sel_start, _) = cursor.selection_range(&self.buffer); + let start_char = start_char.min(sel_start); + let start_byte = self.buffer.char_to_byte(start_char); + let start_line = self.buffer.char_to_line(start_char); + let start_col_byte = start_byte - self.buffer.line_to_byte(start_line); + let pre_pt = tree_sitter::Point { row: start_line, column: start_col_byte }; + + if let Some(d) = f(&mut self.buffer, cursor, &opts) { + let edit = compute_input_edit(start_byte, pre_pt, &d); + self.pending_input_edits.borrow_mut().push(edit); + self.undo.push(d); + any = true; + } + } + self.dedupe_cursors(); + any + } + + /// Elimina cursores extras que están en la misma posición que el + /// primary o que otros extras (después de una edición pueden + /// converger). + fn dedupe_cursors(&mut self) { + let primary = self.cursor.caret; + let mut seen: Vec = vec![primary]; + self.extra_cursors.retain(|c| { + if seen.contains(&c.caret) { + false + } else { + seen.push(c.caret); + true + } + }); + } +} + +/// Si `cursor.caret.line` cae sobre una línea presente en `guards`, +/// mueve el caret a la línea no-guarda más cercana siguiendo `dir`: +/// +/// - `dir > 0` → busca primero abajo, luego arriba. +/// - `dir < 0` → busca primero arriba, luego abajo. +/// - `dir == 0` → equivalente a `dir > 0`. +/// +/// Colapsa la selección y reposiciona el `col` clampeado al ancho de +/// la línea destino. `guards` debe estar ordenado ascendente; el +/// chequeo usa `binary_search`. Si TODAS las líneas son guardas, no +/// puede hacer nada y el caret queda donde está. +fn snap_cursor_off_guard( + cursor: &mut Cursor, + buffer: &Buffer, + guards: &[usize], + dir: i32, +) { + let n = buffer.len_lines(); + if n == 0 || guards.is_empty() { + return; + } + let line = cursor.caret.line.min(n - 1); + if guards.binary_search(&line).is_err() { + return; + } + // Orden de búsqueda: primero la dirección preferida, luego la opuesta. + let primary: i32 = if dir < 0 { -1 } else { 1 }; + let secondary: i32 = -primary; + for d in [primary, secondary] { + let mut probe = line as i32 + d; + while probe >= 0 && (probe as usize) < n { + let p = probe as usize; + if guards.binary_search(&p).is_err() { + let col = cursor.desired_col.min(buffer.line_len_chars(p)); + cursor.caret = Pos::new(p, col); + cursor.anchor = None; + return; + } + probe += d; + } + } + // Todas las líneas son guardas — no podemos hacer nada útil. +} + +/// Convierte un `EditDelta` + posiciones pre-edit a un `InputEdit` de +/// tree-sitter. tree-sitter trabaja en bytes y `Point { row, column_byte }`; +/// el editor trabaja en chars (y col_byte para esto). +/// +/// `start_byte` y `start_point` son las coords del inicio del delta +/// ANTES del cambio (el caller las captura). +fn compute_input_edit( + start_byte: usize, + start_point: tree_sitter::Point, + delta: &crate::ops::EditDelta, +) -> tree_sitter::InputEdit { + let removed_bytes = delta.removed.len(); + let inserted_bytes = delta.inserted.len(); + + let old_end_byte = start_byte + removed_bytes; + let new_end_byte = start_byte + inserted_bytes; + + let old_end_point = advance_point(start_point, &delta.removed); + let new_end_point = advance_point(start_point, &delta.inserted); + + tree_sitter::InputEdit { + start_byte, + old_end_byte, + new_end_byte, + start_position: start_point, + old_end_position: old_end_point, + new_end_position: new_end_point, + } +} + +/// Avanza un Point por el contenido de `text`: cuenta `\n` para filas, +/// bytes de la última línea para columna. +fn advance_point(start: tree_sitter::Point, text: &str) -> tree_sitter::Point { + let newlines = text.bytes().filter(|b| *b == b'\n').count(); + if newlines == 0 { + tree_sitter::Point { + row: start.row, + column: start.column + text.len(), + } + } else { + let after_last_nl = text.rsplit('\n').next().unwrap_or("").len(); + tree_sitter::Point { + row: start.row + newlines, + column: after_last_nl, + } + } +} + +/// Resultado de `apply_key`. El caller usa esto para decidir si +/// rebuildear el view o ignorar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyResult { + /// La tecla cambió el buffer (o sea, hay edición persistible). + Changed, + /// Sólo se movió el cursor — el view se redibuja, pero el `source` + /// del notebook no cambia. + CursorMoved, + /// La tecla no aplicaba al editor. + Ignored, +} + +impl ApplyResult { + pub fn changed(self) -> bool { + matches!(self, ApplyResult::Changed) + } + pub fn touched(self) -> bool { + !matches!(self, ApplyResult::Ignored) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_ui::Modifiers; + + fn ev(named: NamedKey, shift: bool, ctrl: bool) -> KeyEvent { + KeyEvent { + key: Key::Named(named), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers { shift, ctrl, alt: false, meta: false }, + repeat: false, + } + } + fn evtext(s: &str, shift: bool, ctrl: bool) -> KeyEvent { + KeyEvent { + key: Key::Character(s.into()), + state: KeyState::Pressed, + text: Some(s.to_owned()), + modifiers: Modifiers { shift, ctrl, alt: false, meta: false }, + repeat: false, + } + } + + #[test] + fn escribir_chars_inserta() { + let mut s = EditorState::new(); + s.apply_key(&evtext("h", false, false)); + s.apply_key(&evtext("i", false, false)); + assert_eq!(s.text(), "hi"); + } + + #[test] + fn enter_con_indent_auto() { + let mut s = EditorState::new(); + s.set_text(" hola"); + s.cursor = Cursor::at(0, 8); + s.apply_key(&ev(NamedKey::Enter, false, false)); + assert_eq!(s.text(), " hola\n "); + } + + #[test] + fn enter_en_single_line_ignorado() { + let mut s = EditorState::with_options(EditorOptions { + single_line: true, + ..Default::default() + }); + s.set_text("a"); + s.cursor = Cursor::at(0, 1); + let r = s.apply_key(&ev(NamedKey::Enter, false, false)); + assert_eq!(r, ApplyResult::Ignored); + assert_eq!(s.text(), "a"); + } + + #[test] + fn tab_inserta_indent() { + let mut s = EditorState::new(); + s.apply_key(&ev(NamedKey::Tab, false, false)); + assert_eq!(s.text(), " "); // indent_size por defecto = 2 + } + + #[test] + fn shift_tab_dedenta() { + let mut s = EditorState::new(); + s.set_text(" hola"); + s.cursor = Cursor::at(0, 4); + s.apply_key(&ev(NamedKey::Tab, true, false)); + // indent_size=2 → quita 2 espacios + assert_eq!(s.text(), " hola"); + } + + #[test] + fn ctrl_z_y_ctrl_y_son_undo_redo() { + let mut s = EditorState::new(); + s.apply_key(&evtext("a", false, false)); + s.apply_key(&evtext("b", false, false)); + assert_eq!(s.text(), "ab"); + s.apply_key(&evtext("z", false, true)); + assert_eq!(s.text(), "a"); + s.apply_key(&evtext("y", false, true)); + assert_eq!(s.text(), "ab"); + } + + #[test] + fn ctrl_shift_z_es_redo() { + let mut s = EditorState::new(); + s.apply_key(&evtext("a", false, false)); + s.apply_key(&evtext("z", false, true)); + assert!(s.is_empty()); + s.apply_key(&evtext("z", true, true)); + assert_eq!(s.text(), "a"); + } + + #[test] + fn ctrl_arrow_left_salta_palabra() { + let mut s = EditorState::new(); + s.set_text("hola mundo"); + s.cursor = Cursor::at(0, 10); + s.apply_key(&ev(NamedKey::ArrowLeft, false, true)); + assert_eq!(s.cursor.caret, Pos::new(0, 5)); // inicio de "mundo" + s.apply_key(&ev(NamedKey::ArrowLeft, false, true)); + assert_eq!(s.cursor.caret, Pos::new(0, 0)); // inicio de "hola" + } + + #[test] + fn shift_arrow_selecciona_y_chars_reemplazan() { + let mut s = EditorState::new(); + s.set_text("abc"); + s.cursor = Cursor::at(0, 0); + s.apply_key(&ev(NamedKey::ArrowRight, true, false)); + s.apply_key(&ev(NamedKey::ArrowRight, true, false)); + assert!(s.cursor.has_selection()); + s.apply_key(&evtext("X", false, false)); + assert_eq!(s.text(), "Xc"); + } + + #[test] + fn ctrl_chars_se_ignoran_en_input_normal() { + // Ctrl+S no debería insertar "s". + let mut s = EditorState::new(); + let r = s.apply_key(&evtext("s", false, true)); + assert_eq!(r, ApplyResult::Ignored); + assert!(s.is_empty()); + } + + #[test] + fn ctrl_c_copia_la_seleccion_al_clipboard() { + use crate::clipboard::MemClipboard; + let mut s = EditorState::new(); + s.set_text("hola mundo"); + s.cursor = Cursor { + anchor: Some(Pos::new(0, 0)), + caret: Pos::new(0, 4), + desired_col: 4, + }; + let mut clip = MemClipboard::new(); + let r = s.apply_key_with_clipboard(&evtext("c", false, true), &mut clip); + assert_eq!(r, ApplyResult::CursorMoved); + assert_eq!(clip.get().as_deref(), Some("hola")); + // El buffer no cambia. + assert_eq!(s.text(), "hola mundo"); + } + + #[test] + fn ctrl_x_corta_y_borra() { + use crate::clipboard::MemClipboard; + let mut s = EditorState::new(); + s.set_text("hola mundo"); + s.cursor = Cursor { + anchor: Some(Pos::new(0, 0)), + caret: Pos::new(0, 5), + desired_col: 5, + }; + let mut clip = MemClipboard::new(); + let r = s.apply_key_with_clipboard(&evtext("x", false, true), &mut clip); + assert_eq!(r, ApplyResult::Changed); + assert_eq!(clip.get().as_deref(), Some("hola ")); + assert_eq!(s.text(), "mundo"); + } + + #[test] + fn ctrl_v_pega_en_el_caret() { + use crate::clipboard::MemClipboard; + let mut s = EditorState::new(); + s.set_text("ab"); + s.cursor = Cursor::at(0, 1); + let mut clip = MemClipboard::with("XYZ"); + s.apply_key_with_clipboard(&evtext("v", false, true), &mut clip); + assert_eq!(s.text(), "aXYZb"); + } + + #[test] + fn ctrl_v_aplana_newlines_en_single_line() { + use crate::clipboard::MemClipboard; + let mut s = EditorState::with_options(EditorOptions { + single_line: true, + ..Default::default() + }); + let mut clip = MemClipboard::with("a\nb\nc"); + s.apply_key_with_clipboard(&evtext("v", false, true), &mut clip); + assert_eq!(s.text(), "a b c"); + } + + #[test] + fn ensure_caret_visible_scrollea_hacia_abajo() { + let mut s = EditorState::new(); + let lines: String = (0..100).map(|n| format!("line {n}\n")).collect(); + s.set_text(&lines); + s.cursor = Cursor::at(50, 0); + s.ensure_caret_visible(20); + // Caret en línea 50, visible_lines = 20 → scroll = 50 - 19 = 31. + assert_eq!(s.scroll_offset, 31); + // El caret debe estar dentro del viewport. + assert!(s.cursor.caret.line >= s.scroll_offset); + assert!(s.cursor.caret.line < s.scroll_offset + 20); + } + + #[test] + fn ensure_caret_visible_scrollea_hacia_arriba() { + let mut s = EditorState::new(); + let lines: String = (0..100).map(|n| format!("line {n}\n")).collect(); + s.set_text(&lines); + s.scroll_offset = 50; + s.cursor = Cursor::at(5, 0); + s.ensure_caret_visible(20); + assert_eq!(s.scroll_offset, 5); + } + + #[test] + fn ensure_caret_visible_no_mueve_si_ya_visible() { + let mut s = EditorState::new(); + let lines: String = (0..50).map(|n| format!("line {n}\n")).collect(); + s.set_text(&lines); + s.scroll_offset = 10; + s.cursor = Cursor::at(15, 0); + s.ensure_caret_visible(20); + assert_eq!(s.scroll_offset, 10); + } + + #[test] + fn input_edits_se_acumulan_y_drenan_en_highlight() { + use crate::highlight::Language; + let mut s = EditorState::new(); + s.set_text("fn main() {}"); + // Set_text invalida pero NO pushea InputEdit (es replace_all). + // Después de una edit normal, sí debería haber 1 pending. + s.cursor = Cursor::at(0, 12); + s.apply_key(&evtext("x", false, false)); + assert_eq!(s.pending_input_edits.borrow().len(), 1); + // El parse drena los pending. + let _ = s.highlighted_spans(Language::Rust); + assert!(s.pending_input_edits.borrow().is_empty()); + } + + #[test] + fn input_edit_multilinea_calcula_rows_correctamente() { + let mut s = EditorState::new(); + s.set_text("ab"); + s.cursor = Cursor::at(0, 2); + s.apply_key(&ev(NamedKey::Enter, false, false)); + let edits = s.pending_input_edits.borrow().clone(); + assert_eq!(edits.len(), 1); + let e = &edits[0]; + // Insertó "\n" (auto-indent vacío porque no había indent) → + // new_end_position debe estar en row=1, col=0. + assert_eq!(e.start_byte, 2); + assert_eq!(e.new_end_position.row, 1); + assert_eq!(e.new_end_position.column, 0); + } + + #[test] + fn edit_seq_se_incrementa_solo_con_cambios() { + let mut s = EditorState::new(); + let seq0 = s.edit_seq; + s.apply_key(&ev(NamedKey::ArrowRight, false, false)); // CursorMoved + assert_eq!(s.edit_seq, seq0, "movimiento no debería bumpear"); + s.apply_key(&evtext("a", false, false)); // Changed + assert!(s.edit_seq > seq0); + } + + #[test] + fn highlight_cache_reuse_cuando_seq_no_cambia() { + use crate::highlight::Language; + let mut s = EditorState::new(); + s.set_text("fn main() {}"); + let _ = s.highlighted_spans(Language::Rust); + let seq_before = s.edit_seq; + let _ = s.highlighted_spans(Language::Rust); + // Sin edición → seq igual → cache hit (no asserción directa + // posible sin mock, pero al menos el seq no cambia). + assert_eq!(s.edit_seq, seq_before); + } + + #[test] + fn multi_cursor_insert_aplica_a_todos() { + let mut s = EditorState::new(); + s.set_text("ab\ncd\nef"); + // Cursor primary al final de "ab", extras al final de "cd" y "ef". + s.cursor = Cursor::at(0, 2); + s.add_cursor_at(1, 2); + s.add_cursor_at(2, 2); + s.apply_key(&evtext("!", false, false)); + assert_eq!(s.text(), "ab!\ncd!\nef!"); + } + + #[test] + fn multi_cursor_backspace_aplica_a_todos() { + let mut s = EditorState::new(); + s.set_text("ab\ncd\nef"); + s.cursor = Cursor::at(0, 2); + s.add_cursor_at(1, 2); + s.add_cursor_at(2, 2); + s.apply_key(&ev(NamedKey::Backspace, false, false)); + assert_eq!(s.text(), "a\nc\ne"); + } + + #[test] + fn dedupe_cursors_remueve_solapados() { + let mut s = EditorState::new(); + s.set_text("abc"); + s.cursor = Cursor::at(0, 1); + s.add_cursor_at(0, 1); // exacto primary → no se agrega + s.add_cursor_at(0, 2); + // El primer add no agregó nada; el segundo sí. + assert_eq!(s.extra_cursors.len(), 1); + } + + #[test] + fn collapse_to_primary_descarta_extras() { + let mut s = EditorState::new(); + s.set_text("abc"); + s.cursor = Cursor::at(0, 0); + s.add_cursor_at(0, 1); + s.add_cursor_at(0, 2); + assert!(s.has_multi_cursor()); + s.collapse_to_primary(); + assert!(!s.has_multi_cursor()); + } + + #[test] + fn highlight_cache_invalida_con_cambio_de_lenguaje() { + use crate::highlight::Language; + let mut s = EditorState::new(); + s.set_text("def f(): pass"); + let py = s.highlighted_spans(Language::Python); + let rs = s.highlighted_spans(Language::Rust); + // Distinto lenguaje → spans distintos (al menos el conteo o + // las categorías difieren). + assert!(py != rs || s.is_empty()); + } + + #[test] + fn scroll_by_clampea_a_rango_valido() { + let mut s = EditorState::new(); + let lines: String = (0..10).map(|n| format!("line {n}\n")).collect(); + s.set_text(&lines); + s.scroll_by(-100); + assert_eq!(s.scroll_offset, 0); + s.scroll_by(1000); + assert!(s.scroll_offset < 11); + } + + fn estado_con_guardas(texto: &str, guards: Vec) -> EditorState { + let mut s = EditorState::new(); + s.set_text(texto); + s.set_guard_lines(guards); + s + } + + #[test] + fn guarda_set_caret_at_en_linea_vacia_salta_hacia_abajo() { + // "abc\n\ndef" → líneas: "abc", "", "def". La 1 es guarda. + let mut s = estado_con_guardas("abc\n\ndef", vec![1]); + s.set_caret_at(1, 0); + // El caret no puede quedar en la línea 1 (guarda) — salta a 2. + assert_eq!(s.cursor.caret, Pos::new(2, 0)); + } + + #[test] + fn guarda_sin_linea_abajo_salta_arriba() { + // Todas las líneas después de la 0 son guardas: el snap solo + // puede ir hacia arriba. + let mut s = estado_con_guardas("abc\n\n", vec![1, 2]); + s.set_caret_at(1, 0); + assert_eq!(s.cursor.caret.line, 0); + } + + #[test] + fn guarda_arrow_down_atraviesa_la_separacion() { + let mut s = estado_con_guardas("abc\n\ndef", vec![1]); + s.cursor = Cursor::at(0, 0); + // Down debería terminar en línea 2, no en la 1 (guarda). + s.apply_key(&ev(NamedKey::ArrowDown, false, false)); + assert_eq!(s.cursor.caret.line, 2); + } + + #[test] + fn guarda_arrow_up_atraviesa_la_separacion() { + let mut s = estado_con_guardas("abc\n\ndef", vec![1]); + s.cursor = Cursor::at(2, 1); + s.apply_key(&ev(NamedKey::ArrowUp, false, false)); + assert_eq!(s.cursor.caret.line, 0); + } + + #[test] + fn set_text_limpia_guardas() { + // Tras `set_text`, las guardas anteriores ya no son válidas: + // el caller las repuebla. La función las limpia. + let mut s = estado_con_guardas("abc\n\ndef", vec![1]); + assert_eq!(s.guard_lines, vec![1]); + s.set_text("nuevo"); + assert!(s.guard_lines.is_empty()); + } + + #[test] + fn sin_guardas_set_caret_at_en_blank_se_queda() { + // Con `guard_lines` vacío, comportamiento clásico: el caret + // puede caer en cualquier línea sin snap. + let mut s = EditorState::new(); + s.set_text("abc\n\ndef"); + s.set_caret_at(1, 0); + assert_eq!(s.cursor.caret, Pos::new(1, 0)); + } + + #[test] + fn guarda_shift_arrow_extiende_seleccion_a_traves() { + // Con selección viva atravesando la guarda, NO snapear: el + // usuario está seleccionando texto multi-zona. + let mut s = estado_con_guardas("abc\n\ndef", vec![1]); + s.cursor = Cursor::at(0, 3); + s.apply_key(&ev(NamedKey::ArrowDown, true, false)); + // El caret puede quedar en la línea 1 mientras hay selección + // viva — el snap se inhibe. + assert!(s.cursor.has_selection()); + assert_eq!(s.cursor.caret.line, 1); + } + + #[test] + fn set_guard_lines_ordena_y_deduplica() { + let mut s = EditorState::new(); + s.set_text("a\nb\nc\nd\ne"); + s.set_guard_lines(vec![3, 1, 1, 3]); + assert_eq!(s.guard_lines, vec![1, 3]); + } + + #[test] + fn guarda_no_es_solo_blank_puede_ser_cualquiera() { + // Una guarda no tiene que ser una línea vacía — el widget no + // mira el contenido, sólo el índice. Una línea con texto + // marcada como guarda igual repele al caret. + let mut s = EditorState::new(); + s.set_text("aaa\nbbb\nccc"); + s.set_guard_lines(vec![1]); // línea "bbb" es guarda + s.set_caret_at(1, 0); + assert!(s.cursor.caret.line != 1); + } + + #[test] + fn ctrl_c_sin_seleccion_es_ignorado() { + use crate::clipboard::MemClipboard; + let mut s = EditorState::new(); + s.set_text("hola"); + s.cursor = Cursor::at(0, 4); + let mut clip = MemClipboard::new(); + let r = s.apply_key_with_clipboard(&evtext("c", false, true), &mut clip); + assert_eq!(r, ApplyResult::Ignored); + assert!(clip.get().is_none()); + } +} diff --git a/widgets/text-editor/src/view.rs b/widgets/text-editor/src/view.rs new file mode 100644 index 0000000..556a39f --- /dev/null +++ b/widgets/text-editor/src/view.rs @@ -0,0 +1,855 @@ +//! Render del editor. Layout: gutter izquierdo (line numbers) + área +//! principal (texto + selección como rects + caret bloque). El scroll +//! vertical es implícito por viewport — el caller decide cuántas líneas +//! caben en el `height` que pasa. +//! +//! Limitaciones del PMV de render: +//! - **Char width fijo** — asume fuente monoespaciada y un ancho de +//! carácter en píxeles fijo. Para CJK / proportional el caret y la +//! selección se desalinean. Para texto ASCII monoespaciado es exacto. +//! - **Selección multilínea** se pinta como un rect por línea afectada +//! (sin "rio" continuo); estilo Sublime Text / antiguo, lectura clara. +//! - **Sin syntax highlight todavía** — eso vive en su propio bloque y +//! requiere `llimphi-text` rich (Vec); aquí cada línea va +//! monocolor `fg_text`. + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style}, + AlignItems, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +use crate::cursor::Pos; +use crate::diagnostics::{Diagnostic, Severity}; +use crate::highlight::{Language, Span, SyntaxPalette, TokenKind}; +use crate::state::EditorState; + +/// Paleta del editor. Defaults dark. +#[derive(Debug, Clone, Copy)] +pub struct EditorPalette { + pub bg: Color, + pub bg_gutter: Color, + pub bg_selection: Color, + pub bg_current_line: Color, + pub fg_text: Color, + pub fg_line_number: Color, + pub fg_line_number_active: Color, + pub caret: Color, + /// Fondo del bracket bajo el cursor + su par. Un acento sutil. + pub bg_bracket_pair: Color, + /// Fondo de cada match del find activo. + pub bg_match: Color, + /// Subrayado de diagnostic — Error. + pub diag_error: Color, + /// Subrayado de diagnostic — Warning. + pub diag_warning: Color, + /// Subrayado de diagnostic — Information. + pub diag_info: Color, + /// Subrayado de diagnostic — Hint. + pub diag_hint: Color, +} + +impl Default for EditorPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl EditorPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + // Reutilizamos slots del theme; los que no existen como semánticos + // se derivan con `mix`/transparencia conceptual. + Self { + bg: t.bg_input, + bg_gutter: t.bg_panel, + bg_selection: t.bg_selected, + bg_current_line: t.bg_panel_alt, + fg_text: t.fg_text, + fg_line_number: t.fg_muted, + fg_line_number_active: t.fg_text, + caret: t.accent, + bg_bracket_pair: t.bg_button_hover, + bg_match: t.bg_button_hover, + diag_error: t.fg_destructive, + diag_warning: Color::from_rgb8(229, 192, 123), + diag_info: Color::from_rgb8(97, 175, 239), + diag_hint: t.fg_muted, + } + } +} + +/// Cómo renderizar la columna izquierda del editor. +/// +/// - [`GutterStyle::Numbers`] es el comportamiento clásico de IDE: +/// "1", "2", "3"… alineados a la derecha del gutter. +/// - [`GutterStyle::Phantom`] suprime los números y dibuja en su lugar +/// un tick **muy sutil** por línea (un pequeño segmento horizontal +/// con baja opacidad). Sirve para prosa narrativa donde el número de +/// línea es ruido — la línea sigue estando, pero "fingiendo no +/// estar". El gutter en este modo se acorta a un sliver fino. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum GutterStyle { + #[default] + Numbers, + Phantom, +} + +/// Métricas del editor — todo derivado del `font_size`. Cambiar la +/// fuente requiere recalcular `char_width` empíricamente para la mono +/// que use llimphi-text; los valores acá son razonables para +/// `font_size = 12` con la mono default de parley. +#[derive(Debug, Clone, Copy)] +pub struct EditorMetrics { + pub font_size: f32, + /// Alto de cada línea en píxeles (font_size * line_height_ratio). + pub line_height: f32, + /// Ancho promedio de un char (mono). Si la fuente no es mono, esto + /// es sólo una aproximación. + pub char_width: f32, + /// Ancho del gutter (incluye padding interno). + pub gutter_width: f32, + /// Cómo se pinta el gutter. Default [`GutterStyle::Numbers`] — el + /// comportamiento clásico se conserva para callers existentes. + pub gutter_style: GutterStyle, + /// Si `true`, cada línea **guarda** (índices en + /// `EditorState::guard_lines`) recibe un segmento horizontal con + /// baja opacidad atravesando su centro — un divisor fantasma que + /// sugiere "acá termina un bloque" sin gritar. Sin guardas, esto + /// no hace nada visible. Default `false`: comportamiento IDE + /// clásico. + pub phantom_guard_lines: bool, +} + +impl Default for EditorMetrics { + fn default() -> Self { + Self::for_font_size(12.0) + } +} + +impl EditorMetrics { + pub const fn for_font_size(font_size: f32) -> Self { + Self { + font_size, + line_height: font_size * 1.4, + char_width: font_size * 0.6, + gutter_width: font_size * 3.5, + gutter_style: GutterStyle::Numbers, + phantom_guard_lines: false, + } + } + + /// Variante "prosa": gutter fantasma (ticks sutiles, sin números) + + /// divisores fantasma en cada guarda. Ancho del gutter reducido a + /// un sliver porque ya no necesita acomodar dígitos. + /// + /// Pensado para editores narrativos tipo `cuerpo_ide` donde el + /// número de línea es ruido y las junctions están marcadas como + /// guardas. + pub const fn prosa(font_size: f32) -> Self { + Self { + font_size, + line_height: font_size * 1.4, + char_width: font_size * 0.6, + gutter_width: font_size * 1.0, + gutter_style: GutterStyle::Phantom, + phantom_guard_lines: true, + } + } + + /// Convierte coords locales del **área de contenido** (no del gutter) + /// a `(line, col)` absolutas en el buffer. `local_x` se mide desde el + /// borde izquierdo del área de texto (sin el padding interno de 4 px); + /// `local_y` desde la primera línea visible. + /// + /// Devuelve coordenadas siempre dentro del buffer — el caller + /// generalmente las pasa a `EditorState::set_caret_at` que clampea + /// `col` al ancho real de la línea. + pub fn screen_to_pos(self, local_x: f32, local_y: f32, scroll_offset: usize) -> (usize, usize) { + let line_local = (local_y / self.line_height).max(0.0) as usize; + let col = ((local_x - 4.0).max(0.0) / self.char_width).round() as usize; + (scroll_offset + line_local, col) + } +} + +/// Render principal sin syntax highlight — todas las líneas visibles +/// en `palette.fg_text`. `visible_lines` es cuántas líneas mostrar como +/// máximo en el viewport. +/// +/// `on_pointer` se invoca con el evento del mouse dentro del área de +/// texto (no del gutter): el caller decide cómo mover el caret / +/// extender selección. Ver [`PointerEvent`]. +pub fn text_editor_view( + state: &EditorState, + palette: &EditorPalette, + metrics: EditorMetrics, + visible_lines: usize, + on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, +) -> View { + text_editor_view_highlighted( + state, + palette, + metrics, + visible_lines, + Language::Plain, + on_pointer, + ) +} + +/// Evento de mouse que el view envía al caller dentro del área de texto. +/// El caller convierte `(x, y)` con [`EditorMetrics::screen_to_pos`] y +/// aplica `set_caret_at` (Click) o `extend_selection_to` (Drag). +/// +/// `Drag` entrega `initial` (pos del press inicial, constante durante el +/// drag) + `delta` (delta desde el evento anterior). El caller debe +/// acumular el delta — el view no mantiene state. Patrón típico: +/// `accum += (dx, dy); actual = (initial_x + accum.0, initial_y + accum.1)`. +#[derive(Debug, Clone, Copy)] +pub enum PointerEvent { + Click { x: f32, y: f32 }, + Drag { initial_x: f32, initial_y: f32, dx: f32, dy: f32 }, +} + +/// Render con syntax highlight + **viewport scrolling**: sólo se renderizan +/// las líneas en `[state.scroll_offset, scroll_offset + visible_lines)`. +/// +/// `visible_lines` es cuántas líneas máximo dibujamos por frame; el caller +/// se asegura de tener un container con altura ≥ `visible_lines * line_height` +/// o aplica clip propio. Para archivos grandes (1000+ líneas), el cap es +/// crítico — sin él generaríamos miles de Views y wgpu rechazaría el bind +/// group por `max_*_buffer_binding_size`. +/// +/// Recomendación para el caller: tras cada edición, llamar a +/// [`EditorState::ensure_caret_visible`] con el mismo `visible_lines` para +/// que el viewport siga al caret. +pub fn text_editor_view_highlighted( + state: &EditorState, + palette: &EditorPalette, + metrics: EditorMetrics, + visible_lines: usize, + language: Language, + on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, +) -> View { + text_editor_view_full( + state, + palette, + metrics, + visible_lines, + language, + &[], + on_pointer, + ) +} + +/// Como [`text_editor_view_highlighted`] + `match_ranges` para pintar +/// las ocurrencias de un find activo. Cada par `(char_start, char_end)` +/// es un rango de chars globales del buffer. +pub fn text_editor_view_full( + state: &EditorState, + palette: &EditorPalette, + metrics: EditorMetrics, + visible_lines: usize, + language: Language, + match_ranges: &[(usize, usize)], + on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, +) -> View { + let caret = state.cursor.caret; + let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark()); + + let visible = visible_lines.max(1).min(200); + let line_count = state.line_count(); + let scroll = state.scroll_offset.min(line_count.saturating_sub(1)); + let end_line = (scroll + visible).min(line_count); + let height = (end_line - scroll) as f32 * metrics.line_height; + + // Memoizado por `edit_seq` — sólo reparseamos cuando el buffer + // realmente cambió o cambia el `Language`. + let spans = state.highlighted_spans(language); + + let gutter = build_gutter(state, scroll, end_line, caret.line, metrics, palette); + let content = build_content( + state, + palette, + metrics, + height, + scroll, + end_line, + spans, + &syntax, + match_ranges, + on_pointer, + ); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(height) }, + ..Default::default() + }) + .fill(palette.bg) + .clip(true) + .children(vec![gutter, content]) +} + +fn build_gutter( + state: &EditorState, + scroll: usize, + end_line: usize, + active_line: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + let count = end_line.saturating_sub(scroll); + let mut children: Vec> = Vec::with_capacity(count); + for n in scroll..end_line { + // Las líneas-guarda son separadores estructurales entre zonas + // de texto: ni se numeran ni se pueden escribir. El espacio + // se preserva (la línea sigue existiendo), pero el gutter las + // saltea — visualmente la numeración "rompe" en cada zona. + // Si `guard_lines` está vacío, este check es siempre `false` + // y la numeración cubre todas las líneas (modo IDE clásico). + if state.is_guard_line(n) { + continue; + } + let color = if n == active_line { + palette.fg_line_number_active + } else { + palette.fg_line_number + }; + let y = (n - scroll) as f32 * metrics.line_height; + match metrics.gutter_style { + GutterStyle::Numbers => { + let label = (n + 1).to_string(); + children.push( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(y), + right: length(4.0_f32), + bottom: auto(), + }, + size: Size { + width: length(metrics.gutter_width - 4.0), + height: length(metrics.line_height), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(label, metrics.font_size * 0.85, color, Alignment::End), + ); + } + GutterStyle::Phantom => { + // Tick fantasma — un segmento horizontal corto centrado + // verticalmente en la línea, con la opacidad bajada. + // La línea activa queda un pelín más visible. + let alpha = if n == active_line { 0.35 } else { 0.12 }; + let tick_w = (metrics.gutter_width * 0.5).max(3.0); + let tick_h = 1.0_f32; + let tick_y = y + (metrics.line_height - tick_h) * 0.5; + let tick_x = (metrics.gutter_width - tick_w) * 0.5; + children.push( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(tick_x), + top: length(tick_y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(tick_w), + height: length(tick_h), + }, + ..Default::default() + }) + .fill(with_alpha(color, alpha)), + ); + } + } + } + + // En modo Phantom el gutter es un sliver: no aplicamos `fill` — + // se mezcla con el fondo del editor. El gutter "está sin estar". + let bg = match metrics.gutter_style { + GutterStyle::Numbers => palette.bg_gutter, + GutterStyle::Phantom => palette.bg, + }; + View::new(Style { + size: Size { + width: length(metrics.gutter_width), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .clip(true) + .children(children) +} + +/// Devuelve `c` con la opacidad multiplicada por `alpha` (clamp 0..1). +fn with_alpha(c: Color, alpha: f32) -> Color { + let rgba = c.to_rgba8(); + let a = ((alpha.clamp(0.0, 1.0)) * (rgba.a as f32)) as u8; + Color::from_rgba8(rgba.r, rgba.g, rgba.b, a) +} + +fn build_content( + state: &EditorState, + palette: &EditorPalette, + metrics: EditorMetrics, + height: f32, + scroll: usize, + end_line: usize, + spans_per_line: Vec>, + syntax: &SyntaxPalette, + match_ranges: &[(usize, usize)], + on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, +) -> View { + let caret = state.cursor.caret; + let mut children: Vec> = Vec::new(); + + // 0) Tintes por línea — la capa más baja, debajo de todo el resto. + // Pinta un rect del ancho completo del área de contenido por + // cada línea con tinte asignado. El caller elige el alpha — el + // widget no lo modula. Si la línea cae fuera de viewport o no + // tiene tinte, no se pinta nada. + for n in scroll..end_line { + if let Some(Some(c)) = state.line_tints.get(n) { + children.push(line_tint(n - scroll, *c, metrics)); + } + } + + // 1) Fondo del renglón activo — sólo el del primary cursor. + if caret.line >= scroll && caret.line < end_line { + children.push(line_highlight(caret.line - scroll, metrics, palette)); + } + + // 1b) Highlight de matches del find. + for (s, e) in match_ranges { + children.extend(match_rects(state, *s, *e, scroll, end_line, metrics, palette)); + } + + // 2) Selección — por cada cursor que tenga selección. + for c in state.all_cursors() { + if c.has_selection() { + children.extend(selection_rects_for_cursor( + state, c, scroll, end_line, metrics, palette, + )); + } + } + + // 2b) Bracket pair bajo el primary cursor — si visible. + if let Some((a, b)) = crate::bracket::find_bracket_pair(&state.buffer, &state.cursor) { + if a.line >= scroll && a.line < end_line { + children.push(bracket_highlight(crate::cursor::Pos::new(a.line - scroll, a.col), metrics, palette)); + } + if b.line >= scroll && b.line < end_line { + children.push(bracket_highlight(crate::cursor::Pos::new(b.line - scroll, b.col), metrics, palette)); + } + } + + // 3) Texto — sólo las líneas en viewport. + // Si `phantom_guard_lines` está activo, cada guarda recibe un + // divisor fantasma (segmento horizontal con baja opacidad) + // atravesando su centro — sin texto, sólo un susurro visual. + for n in scroll..end_line { + let text = state.buffer.line(n); + let text = text.trim_end_matches('\n').to_owned(); + let local_line = n - scroll; + if metrics.phantom_guard_lines && state.is_guard_line(n) { + children.push(phantom_guard_divider(local_line, metrics, palette)); + continue; + } + if let Some(line_spans) = spans_per_line.get(n) { + children.push(line_text_tokens(local_line, &text, line_spans, metrics, palette, syntax)); + } else { + children.push(line_text_plain(local_line, text, metrics, palette)); + } + } + + // 3b) Diagnostics — subrayado bajo el rango, color por severity. + for d in &state.diagnostics { + children.extend(diagnostic_underline(d, scroll, end_line, metrics, palette)); + } + + // 4) Caret — uno por cursor, sólo si visible. + for c in state.all_cursors() { + let p = c.caret; + if p.line >= scroll && p.line < end_line { + let local = crate::cursor::Pos::new(p.line - scroll, p.col); + children.push(caret_rect(local, metrics, palette)); + } + } + + let click_cb = on_pointer.clone(); + let drag_cb = on_pointer; + View::new(Style { + flex_grow: 1.0, + size: Size { width: percent(1.0_f32), height: length(height) }, + ..Default::default() + }) + .fill(palette.bg) + .clip(true) + .on_click_at(move |x, y, _w, _h| click_cb(PointerEvent::Click { x, y })) + .draggable_at(move |phase, dx, dy, lx, ly| match phase { + llimphi_ui::DragPhase::Move => drag_cb(PointerEvent::Drag { + initial_x: lx, + initial_y: ly, + dx, + dy, + }), + llimphi_ui::DragPhase::End => None, + }) + .children(children) +} + +/// Rect de tinte para una línea. Cubre el ancho completo y el alto +/// exacto de la línea, pintado al color literal pasado (el caller +/// elige el alpha). Posición absoluta dentro del área de contenido. +fn line_tint( + line: usize, + color: Color, + metrics: EditorMetrics, +) -> View { + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(line as f32 * metrics.line_height), + right: length(0.0_f32), + bottom: auto(), + }, + size: Size { + width: percent(1.0_f32), + height: length(metrics.line_height), + }, + ..Default::default() + }) + .fill(color) +} + +fn line_highlight( + line: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(line as f32 * metrics.line_height), + right: length(0.0_f32), + bottom: auto(), + }, + size: Size { + width: percent(1.0_f32), + height: length(metrics.line_height), + }, + ..Default::default() + }) + .fill(palette.bg_current_line) +} + +/// Línea-fantasma para una guarda: un segmento horizontal con baja +/// opacidad atravesando el centro vertical de la línea. Ancho +/// limitado para que parezca un susurro y no una regla. Color derivado +/// de `fg_line_number` que ya está pensado como "muted". +fn phantom_guard_divider( + line: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + let h = 1.0_f32; + let y = line as f32 * metrics.line_height + (metrics.line_height - h) * 0.5; + // Largo visual del divisor — generoso pero no infinito. + let w = 320.0_f32; + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(8.0_f32), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(w), + height: length(h), + }, + ..Default::default() + }) + .fill(with_alpha(palette.fg_line_number, 0.18)) +} + +fn line_text_plain( + line: usize, + text: String, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(4.0_f32), + top: length(line as f32 * metrics.line_height), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(2000.0_f32), + height: length(metrics.line_height), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(text, metrics.font_size, palette.fg_text, Alignment::Start) +} + +/// Renderiza una línea como secuencia de Views absolutos posicionados, +/// cada uno con el color de su span. El posicionamiento horizontal usa +/// `char_width` (mono); para fuentes proporcionales habría que medir +/// cada token con parley (TODO). +fn line_text_tokens( + line: usize, + text: &str, + spans: &[Span], + metrics: EditorMetrics, + palette: &EditorPalette, + syntax: &SyntaxPalette, +) -> View { + // char-col → byte-offset: parley rangea por bytes, los spans por chars. + let mut byte_at: Vec = Vec::with_capacity(text.len() + 1); + let mut acc = 0usize; + byte_at.push(0); + for ch in text.chars() { + acc += ch.len_utf8(); + byte_at.push(acc); + } + let nchars = byte_at.len() - 1; + + // Un run de color por span no-Other (el default_color cubre el resto). + let mut runs: Vec<(usize, usize, Color)> = Vec::with_capacity(spans.len()); + for span in spans { + if span.start_col >= nchars || matches!(span.kind, TokenKind::Other) { + continue; + } + let end = span.end_col.min(nchars); + if end <= span.start_col { + continue; + } + runs.push((byte_at[span.start_col], byte_at[end], syntax.color(span.kind))); + } + + // Una sola línea shapeada de una vez, multicolor, en lugar de un nodo + // (+ layout parley) por token. El `+4` de gutter va en el inset del nodo. + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(4.0_f32), + top: length(line as f32 * metrics.line_height), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(2000.0_f32), height: length(metrics.line_height) }, + ..Default::default() + }) + .text_runs( + text.to_string(), + metrics.font_size, + palette.fg_text, + runs, + Alignment::Start, + ) +} + +fn caret_rect( + caret: Pos, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + let x = 4.0 + caret.col as f32 * metrics.char_width; + let y = caret.line as f32 * metrics.line_height; + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y + 2.0), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(2.0_f32), height: length(metrics.line_height - 4.0) }, + ..Default::default() + }) + .fill(palette.caret) +} + +fn bracket_highlight( + pos: Pos, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + let x = 4.0 + pos.col as f32 * metrics.char_width; + let y = pos.line as f32 * metrics.line_height; + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(metrics.char_width), height: length(metrics.line_height) }, + ..Default::default() + }) + .fill(palette.bg_bracket_pair) +} + +fn diagnostic_underline( + d: &Diagnostic, + scroll: usize, + end_viewport: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> Vec> { + let color = match d.severity { + Severity::Error => palette.diag_error, + Severity::Warning => palette.diag_warning, + Severity::Information => palette.diag_info, + Severity::Hint => palette.diag_hint, + }; + let mut out: Vec> = Vec::new(); + let first = d.range.start.line.max(scroll); + let last = d.range.end.line.min(end_viewport.saturating_sub(1)); + if first > last { + return out; + } + for line in first..=last { + let col_start = if line == d.range.start.line { d.range.start.col } else { 0 }; + let col_end = if line == d.range.end.line { + d.range.end.col + } else { + // Fin de línea — extendemos 1 char extra para visualizar el wrap. + col_start + 1 + }; + if col_end <= col_start { + continue; + } + let x = 4.0 + col_start as f32 * metrics.char_width; + let w = (col_end - col_start) as f32 * metrics.char_width; + // Subrayado de 1.5 px al final de la línea. + let y = (line - scroll) as f32 * metrics.line_height + metrics.line_height - 2.0; + out.push( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(w), height: length(1.5_f32) }, + ..Default::default() + }) + .fill(color), + ); + } + out +} + +fn match_rects( + state: &EditorState, + start_off: usize, + end_off: usize, + scroll: usize, + end_viewport: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> Vec> { + if start_off == end_off { + return vec![]; + } + let (start_line, start_col) = state.buffer.offset_to_pos(start_off); + let (end_line, end_col) = state.buffer.offset_to_pos(end_off); + let mut out: Vec> = Vec::new(); + let first = start_line.max(scroll); + let last = end_line.min(end_viewport.saturating_sub(1)); + if first > last { + return out; + } + for line in first..=last { + let line_len = state.buffer.line_len_chars(line); + let col_start = if line == start_line { start_col } else { 0 }; + let col_end = if line == end_line { end_col } else { line_len }; + if col_end <= col_start { + continue; + } + let x = 4.0 + col_start as f32 * metrics.char_width; + let w = (col_end - col_start) as f32 * metrics.char_width; + let local_y = (line - scroll) as f32 * metrics.line_height; + out.push( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(local_y), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(w), height: length(metrics.line_height) }, + ..Default::default() + }) + .fill(palette.bg_match), + ); + } + out +} + +fn selection_rects_for_cursor( + state: &EditorState, + cursor: &crate::cursor::Cursor, + scroll: usize, + end_viewport: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> Vec> { + let (start_off, end_off) = cursor.selection_range(&state.buffer); + if start_off == end_off { + return vec![]; + } + let (start_line, start_col) = state.buffer.offset_to_pos(start_off); + let (end_line, end_col) = state.buffer.offset_to_pos(end_off); + + let mut out: Vec> = Vec::new(); + let first = start_line.max(scroll); + let last = end_line.min(end_viewport.saturating_sub(1)); + if first > last { + return out; + } + for line in first..=last { + let line_len = state.buffer.line_len_chars(line); + let col_start = if line == start_line { start_col } else { 0 }; + let col_end = if line == end_line { end_col } else { line_len }; + let x = 4.0 + col_start as f32 * metrics.char_width; + let extra = if line < end_line { 1.0 } else { 0.0 }; + let w = ((col_end - col_start) as f32 + extra) * metrics.char_width; + if w <= 0.0 { + continue; + } + let local_y = (line - scroll) as f32 * metrics.line_height; + out.push( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(local_y), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(w), height: length(metrics.line_height) }, + ..Default::default() + }) + .fill(palette.bg_selection), + ); + } + out +} diff --git a/widgets/text-input/Cargo.toml b/widgets/text-input/Cargo.toml new file mode 100644 index 0000000..272da9a --- /dev/null +++ b/widgets/text-input/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-text-input" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-text-input — input de texto single-line para Llimphi. Wrappea el llimphi-widget-text-editor en modo single_line para heredar selección con shift+arrows, undo/redo, word-jump con Ctrl, sin perder la API compacta original." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-text-editor = { workspace = true } diff --git a/widgets/text-input/LEEME.md b/widgets/text-input/LEEME.md new file mode 100644 index 0000000..9f9f233 --- /dev/null +++ b/widgets/text-input/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-input + +> Input single-line para [llimphi](../../README.md). + +Reusa partes del [`text-editor`](../text-editor/README.md) pero con UI ajustada: sin multi-line, sin syntax, con placeholder, foco azul, enter dispara `Msg::Submit`. diff --git a/widgets/text-input/README.md b/widgets/text-input/README.md new file mode 100644 index 0000000..72a655d --- /dev/null +++ b/widgets/text-input/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-input + +> Single-line input for [llimphi](../../README.md). + +Reuses parts of [`text-editor`](../text-editor/README.md) but with tuned UI: no multi-line, no syntax, with placeholder, blue focus, enter fires `Msg::Submit`. diff --git a/widgets/text-input/src/lib.rs b/widgets/text-input/src/lib.rs new file mode 100644 index 0000000..5da19b5 --- /dev/null +++ b/widgets/text-input/src/lib.rs @@ -0,0 +1,270 @@ +//! `llimphi-widget-text-input` — input de texto single-line para Llimphi. +//! +//! Después del refactor 2026-05-25, [`TextInputState`] es un wrapper fino +//! sobre [`llimphi_widget_text_editor::EditorState`] con +//! `options.single_line = true` + un flag `masked` para passwords. La +//! API pública (`new`, `masked`, `text`, `set_text`, `clear`, `apply_key`, +//! `is_empty`, `push_str`, `pop`, `is_masked`) se mantiene salvo que +//! `text()` ahora devuelve `String` (antes `&str`) — los callers que +//! hacían `.text().trim().to_string()` siguen funcionando idénticos. +//! +//! Beneficios heredados del editor: selección con Shift+arrows, undo/ +//! redo con Ctrl+Z/Y, salto de palabra con Ctrl+arrows, Home/End, +//! Delete (además de Backspace). Tab/Enter siguen ignorados (single_line). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{KeyEvent, View}; +use llimphi_widget_text_editor::{EditorOptions, EditorState}; + +/// Paleta del input. Defaults son una variante dark con borde tenue que +/// se enciende al focar, equivalente conceptual al `nahual-theme` dark. +#[derive(Debug, Clone, Copy)] +pub struct TextInputPalette { + pub bg: Color, + pub bg_focus: Color, + pub border: Color, + pub border_focus: Color, + pub fg_text: Color, + pub fg_placeholder: Color, +} + +impl Default for TextInputPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TextInputPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_input, + bg_focus: t.bg_input_focus, + border: t.border, + border_focus: t.border_focus, + fg_text: t.fg_text, + fg_placeholder: t.fg_placeholder, + } + } +} + +/// Estado del input. Wrappea un `EditorState` single-line. +#[derive(Debug, Clone, Default)] +pub struct TextInputState { + inner: EditorState, + masked: bool, +} + +impl TextInputState { + /// Input vacío visible (texto plano). + pub fn new() -> Self { + Self { + inner: EditorState::with_options(EditorOptions { + single_line: true, + ..EditorOptions::default() + }), + masked: false, + } + } + + /// Input enmascarado — para campos de contraseña. + pub fn masked() -> Self { + Self { masked: true, ..Self::new() } + } + + /// Texto actual. Devuelve `String` (antes `&str` — el rope no expone + /// slice borrowed sin clone). Para evitar copias innecesarias, los + /// callers que sólo necesitan derivar `.trim()` o `.is_empty()` + /// pueden hacerlo directo sobre el `String` devuelto. + pub fn text(&self) -> String { + self.inner.text() + } + + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + pub fn is_masked(&self) -> bool { + self.masked + } + + pub fn clear(&mut self) { + self.inner.set_text(""); + } + + pub fn set_text(&mut self, s: impl Into) { + let s = s.into(); + self.inner.set_text(&s); + } + + pub fn push_str(&mut self, s: &str) { + let combined = format!("{}{}", self.inner.text(), s); + self.inner.set_text(&combined); + } + + pub fn pop(&mut self) -> Option { + let mut t = self.inner.text(); + let ch = t.pop()?; + self.inner.set_text(&t); + Some(ch) + } + + /// Aplica una tecla al estado. Devuelve `true` si cambió el contenido + /// **o** sólo se movió el cursor (cualquier cosa que requiera repintar). + pub fn apply_key(&mut self, event: &KeyEvent) -> bool { + self.inner.apply_key(event).touched() + } + + /// Acceso de bajo nivel al editor interno — útil si el caller + /// quiere consultar cursor/selección o aplicar ops avanzadas. + pub fn editor(&self) -> &EditorState { + &self.inner + } + pub fn editor_mut(&mut self) -> &mut EditorState { + &mut self.inner + } +} + +/// Compone el input box: borde de 1 px (rect padre coloreado), relleno +/// interno, texto o placeholder, caret simulado al final si está focado. +/// Click sobre el box emite `on_focus` (típicamente `Msg::Focus(Field)`). +pub fn text_input_view( + state: &TextInputState, + placeholder: &str, + focused: bool, + palette: &TextInputPalette, + on_focus: Msg, +) -> View { + let raw = state.text(); + let is_empty = raw.is_empty(); + let shown = if is_empty { + placeholder.to_string() + } else if state.masked { + "•".repeat(raw.chars().count()) + } else { + raw + }; + // El cambio de bg al focus ya transmite "este es el activo"; sin + // caret glyph (la fuente default rendea cuadrados de fallback). + let display = shown; + let text_color = if is_empty { + palette.fg_placeholder + } else { + palette.fg_text + }; + let (bg, border) = if focused { + (palette.bg_focus, palette.border_focus) + } else { + (palette.bg, palette.border) + }; + + let inner = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .radius(3.0) + .text_aligned(display, 13.0, text_color, Alignment::Start); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(34.0_f32), + }, + padding: Rect { + left: length(1.0_f32), + right: length(1.0_f32), + top: length(1.0_f32), + bottom: length(1.0_f32), + }, + ..Default::default() + }) + .fill(border) + .radius(4.0) + .on_click(on_focus) + .children(vec![inner]) +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_ui::{Key, KeyState, NamedKey}; + + fn key_press(key: Key, text: Option<&str>) -> KeyEvent { + KeyEvent { + key, + state: KeyState::Pressed, + text: text.map(|s| s.to_string()), + modifiers: Default::default(), + repeat: false, + } + } + + #[test] + fn apply_key_inserts_printable_chars() { + let mut s = TextInputState::new(); + let ev = key_press(Key::Character("a".into()), Some("a")); + assert!(s.apply_key(&ev)); + assert_eq!(s.text(), "a"); + } + + #[test] + fn apply_key_backspace_pops() { + let mut s = TextInputState::new(); + s.set_text("hola"); + let ev = key_press(Key::Named(NamedKey::Backspace), None); + assert!(s.apply_key(&ev)); + assert_eq!(s.text(), "hol"); + } + + #[test] + fn enter_ignorado_en_single_line() { + let mut s = TextInputState::new(); + s.set_text("hola"); + let enter = key_press(Key::Named(NamedKey::Enter), None); + assert!(!s.apply_key(&enter)); + assert_eq!(s.text(), "hola"); + } + + #[test] + fn masked_state_is_masked() { + let s = TextInputState::masked(); + assert!(s.is_masked()); + } + + #[test] + fn flecha_izquierda_mueve_cursor() { + // El refactor agrega esta capacidad — antes no había movimiento. + let mut s = TextInputState::new(); + s.set_text("hola"); + let arr = key_press(Key::Named(NamedKey::ArrowLeft), None); + assert!(s.apply_key(&arr)); + assert_eq!(s.editor().cursor.caret.col, 3); + } + + #[test] + fn push_str_y_pop_funcionan() { + let mut s = TextInputState::new(); + s.push_str("hola"); + assert_eq!(s.text(), "hola"); + assert_eq!(s.pop(), Some('a')); + assert_eq!(s.text(), "hol"); + } +} diff --git a/widgets/theme-switcher/Cargo.toml b/widgets/theme-switcher/Cargo.toml new file mode 100644 index 0000000..a310cb7 --- /dev/null +++ b/widgets/theme-switcher/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-widget-theme-switcher" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-theme-switcher — botón que cicla los presets de `llimphi_theme::Theme`. Análogo Llimphi del `nahual-widget-theme-switcher` GPUI: el caller lifta `Msg::ChangeTheme(Theme)` y reasigna el theme en su Model." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[[example]] +name = "theme_switcher_demo" +path = "examples/theme_switcher_demo.rs" diff --git a/widgets/theme-switcher/LEEME.md b/widgets/theme-switcher/LEEME.md new file mode 100644 index 0000000..74271fb --- /dev/null +++ b/widgets/theme-switcher/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-theme-switcher + +> Selector de tema para [llimphi](../../README.md). + +Botón que cicla Dark → Light → Aurora → Sunset, escribe la preferencia en `wawa-config`. Useful en el panel del escritorio. diff --git a/widgets/theme-switcher/README.md b/widgets/theme-switcher/README.md new file mode 100644 index 0000000..14e358c --- /dev/null +++ b/widgets/theme-switcher/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-theme-switcher + +> Theme selector for [llimphi](../../README.md). + +Button that cycles Dark → Light → Aurora → Sunset, writes the preference to `wawa-config`. Useful in the desktop panel. diff --git a/widgets/theme-switcher/examples/theme_switcher_demo.rs b/widgets/theme-switcher/examples/theme_switcher_demo.rs new file mode 100644 index 0000000..157391e --- /dev/null +++ b/widgets/theme-switcher/examples/theme_switcher_demo.rs @@ -0,0 +1,153 @@ +//! Showcase de `llimphi-widget-theme-switcher`. +//! +//! Una ventana con el switcher en la cabecera + un sample de paneles +//! que cambian de color al ciclar. Validación visual de que el theme +//! propaga a la UI: al hacer click en el switcher, los paneles se +//! repintan con el siguiente preset. +//! +//! Corré: `cargo run -p llimphi-widget-theme-switcher --example theme_switcher_demo --release`. + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, AlignItems, FlexDirection, JustifyContent, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_widget_theme_switcher::theme_switcher_view; + +#[derive(Clone, Debug)] +enum Msg { + ChangeTheme(Theme), +} + +struct Model { + theme: Theme, +} + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · theme-switcher" + } + + fn init(_: &Handle) -> Model { + Model { + theme: Theme::dark(), + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::ChangeTheme(t) => m.theme = t, + } + m + } + + fn view(model: &Model) -> View { + let switcher = theme_switcher_view(&model.theme, Msg::ChangeTheme); + + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(48.0_f32), + }, + flex_direction: FlexDirection::Row, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::SpaceBetween), + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(model.theme.bg_panel) + .children(vec![ + View::new(Style { + size: Size { + width: length(220.0_f32), + height: length(32.0_f32), + }, + ..Default::default() + }) + .text_aligned( + format!("Preset actual: {}", model.theme.name), + 13.0, + model.theme.fg_text, + Alignment::Start, + ), + switcher, + ]); + + let card_a = sample_card("Panel principal", &model.theme, model.theme.bg_panel); + let card_b = sample_card( + "Strip alternativo", + &model.theme, + model.theme.bg_panel_alt, + ); + let card_c = sample_card("Input focado", &model.theme, model.theme.bg_input_focus); + + let body = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(24.0_f32), + right: length(24.0_f32), + top: length(24.0_f32), + bottom: length(24.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .fill(model.theme.bg_app) + .children(vec![card_a, card_b, card_c]); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(model.theme.bg_app) + .children(vec![header, body]) + } +} + +fn sample_card(label: &str, theme: &Theme, bg: llimphi_ui::llimphi_raster::peniko::Color) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(60.0_f32), + }, + padding: Rect { + left: length(14.0_f32), + right: length(14.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .radius(6.0) + .text_aligned(label.to_string(), 13.0, theme.fg_text, Alignment::Start) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/theme-switcher/src/lib.rs b/widgets/theme-switcher/src/lib.rs new file mode 100644 index 0000000..560fa9a --- /dev/null +++ b/widgets/theme-switcher/src/lib.rs @@ -0,0 +1,179 @@ +//! `llimphi-widget-theme-switcher` — botón que rota los presets de +//! [`llimphi_theme::Theme`]. +//! +//! Análogo Llimphi del `nahual-widget-theme-switcher` GPUI. Diferencia +//! estructural: GPUI lleva el theme en un `Global` y el switcher lo +//! reemplaza con `cx.set_global`; Llimphi no tiene globals — el caller +//! guarda el theme en su `Model` y reasigna en su `update`. El widget +//! sólo emite `on_change(next_theme)` cuando el botón se clickea, donde +//! `next_theme` es el siguiente preset de [`Theme::next_after`]. +//! +//! El label del botón muestra el nombre del preset actual con un signo +//! de rotación (`Tema: Dark ▸`). Los colores salen del `Theme` actual +//! para que el switcher sea coherente con el resto de la UI. +//! +//! # Uso +//! +//! ```ignore +//! use llimphi_widget_theme_switcher::theme_switcher_view; +//! +//! // En App::view: +//! let switcher = theme_switcher_view(&model.theme, Msg::ChangeTheme); +//! ``` +//! +//! `Msg::ChangeTheme(Theme)` lo define la app; en `update`: +//! +//! ```ignore +//! Msg::ChangeTheme(t) => { model.theme = t; } +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, AlignItems, JustifyContent, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +/// Paleta del switcher. Por default replica el patrón del switcher de +/// nahual: `bg_panel_alt` + hover `bg_row_hover`, texto `fg_text`. +#[derive(Debug, Clone, Copy)] +pub struct ThemeSwitcherPalette { + pub bg: Color, + pub bg_hover: Color, + pub fg: Color, + pub radius: f64, +} + +impl Default for ThemeSwitcherPalette { + fn default() -> Self { + Self::from_theme(&Theme::dark()) + } +} + +impl ThemeSwitcherPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + bg: t.bg_panel_alt, + bg_hover: t.bg_row_hover, + fg: t.fg_text, + radius: 3.0, + } + } +} + +/// Compone el switcher: chip con texto `Tema: ▸`. Click rota +/// al siguiente preset y emite `on_change(next)`. +/// +/// Toma el `current` por referencia para no clonar el `Theme` entero +/// (es `Copy`, pero la API se mantiene consistente con `Palette::from_theme`). +/// La paleta se deriva del `current` para que el chip use el mismo set +/// de colores que el resto de la UI. +pub fn theme_switcher_view( + current: &Theme, + on_change: impl Fn(Theme) -> Msg, +) -> View { + let palette = ThemeSwitcherPalette::from_theme(current); + theme_switcher_styled(current, &palette, on_change) +} + +/// Variante con paleta explícita — útil cuando la app quiere un look +/// distinto al default (botón destacado, accent del switcher fijo, etc.). +pub fn theme_switcher_styled( + current: &Theme, + palette: &ThemeSwitcherPalette, + on_change: impl Fn(Theme) -> Msg, +) -> View { + let next = Theme::next_after(current.name); + let label = format!("Tema: {} ▸", current.name); + + View::new(Style { + size: Size { + width: length(140.0_f32), + height: length(26.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), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(palette.bg) + .hover_fill(palette.bg_hover) + .radius(palette.radius) + .text_aligned(label, 11.0, palette.fg, Alignment::Start) + .on_click(on_change(next)) +} + +/// Variante de tamaño flexible — toma el ancho dado por el padre y se +/// adapta al alto natural del slot. Útil dentro de toolbars con flexbox. +pub fn theme_switcher_flex( + current: &Theme, + palette: &ThemeSwitcherPalette, + on_change: impl Fn(Theme) -> Msg, +) -> View { + let next = Theme::next_after(current.name); + let label = format!("Tema: {} ▸", current.name); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(26.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), + ..Default::default() + }) + .fill(palette.bg) + .hover_fill(palette.bg_hover) + .radius(palette.radius) + .text_aligned(label, 11.0, palette.fg, Alignment::Start) + .on_click(on_change(next)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone, PartialEq)] + enum Msg { + Change(&'static str), + } + + #[test] + fn switcher_constructs_with_a_default_theme() { + let t = Theme::dark(); + let _v = theme_switcher_view::(&t, |th| Msg::Change(th.name)); + // Si el constructor no panicó, el widget queda armado. + } + + #[test] + fn palette_from_theme_matches_panel_alt_slots() { + let t = Theme::dark(); + let p = ThemeSwitcherPalette::from_theme(&t); + // No comparamos por igualdad de Color (no implementa PartialEq); + // sí garantizamos que la paleta derivó del theme — radius default. + assert_eq!(p.radius, 3.0); + } + + #[test] + fn on_change_receives_the_next_preset() { + // Verificación funcional independiente: la rotación que verá el + // handler coincide con `Theme::next_after`. + let current = Theme::dark(); + let expected_next = Theme::next_after(current.name).name; + assert_eq!(expected_next, "Light"); + } +} diff --git a/widgets/tiled/Cargo.toml b/widgets/tiled/Cargo.toml new file mode 100644 index 0000000..7001e75 --- /dev/null +++ b/widgets/tiled/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-widget-tiled" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-tiled — grid auto cols×rows con title bar por tile. Análogo Llimphi al `nahual-widget-tiled` GPUI (sin drag-to-swap todavía: requiere drop-targets globales que llimphi-ui aún no expone)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[[example]] +name = "tiled_demo" +path = "examples/tiled_demo.rs" diff --git a/widgets/tiled/LEEME.md b/widgets/tiled/LEEME.md new file mode 100644 index 0000000..6803870 --- /dev/null +++ b/widgets/tiled/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-tiled + +> Tiled window manager dentro de la app para [llimphi](../../README.md). + +Splits anidados horizontal/vertical sin overlap (estilo i3/sway intra-app). Atajos para split/swap/cerrar. Usado por `nada` cuando se abren múltiples buffers. diff --git a/widgets/tiled/README.md b/widgets/tiled/README.md new file mode 100644 index 0000000..6f5a5e1 --- /dev/null +++ b/widgets/tiled/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-tiled + +> Intra-app tiled window manager for [llimphi](../../README.md). + +Nested horizontal/vertical splits without overlap (i3/sway-style inside one app). Shortcuts for split/swap/close. Used by `nada` when multiple buffers are open. diff --git a/widgets/tiled/examples/tiled_demo.rs b/widgets/tiled/examples/tiled_demo.rs new file mode 100644 index 0000000..07e775f --- /dev/null +++ b/widgets/tiled/examples/tiled_demo.rs @@ -0,0 +1,218 @@ +//! Showcase de `llimphi-widget-tiled` con drag-to-swap. Cinco paneles +//! heterogéneos; arrastrá la title bar de uno sobre otro para +//! intercambiarlos. El destino se ilumina mientras está bajo el cursor. +//! +//! Corré con: `cargo run -p llimphi-widget-tiled --example tiled_demo --release`. + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_widget_tiled::{tiled_view_reorderable, TileSpec, TiledPalette}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TileId { + Logs, + Metrics, + Alerts, + Uptime, + Queue, +} + +#[derive(Clone)] +enum Msg { + Swap { from: usize, to: usize }, +} + +struct Model { + tiles: Vec, +} + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · tiled showcase (drag titles para intercambiar)" + } + + fn initial_size() -> (u32, u32) { + (1100, 720) + } + + fn init(_: &Handle) -> Model { + Model { + tiles: vec![ + TileId::Logs, + TileId::Metrics, + TileId::Alerts, + TileId::Uptime, + TileId::Queue, + ], + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::Swap { from, to } => { + if from != to && from < m.tiles.len() && to < m.tiles.len() { + m.tiles.swap(from, to); + } + } + } + m + } + + fn view(model: &Model) -> View { + let theme = Theme::dark(); + let palette = TiledPalette::from_theme(&theme); + + let tiles: Vec> = model + .tiles + .iter() + .map(|id| match id { + TileId::Logs => TileSpec { + label: "logs".into(), + content: log_body(&theme), + }, + TileId::Metrics => TileSpec { + label: "métricas".into(), + content: metrics_body(&theme), + }, + TileId::Alerts => TileSpec { + label: "alertas".into(), + content: alerts_body(&theme), + }, + TileId::Uptime => TileSpec { + label: "uptime".into(), + content: uptime_body(&theme), + }, + TileId::Queue => TileSpec { + label: "queue".into(), + content: queue_body(&theme), + }, + }) + .collect(); + + tiled_view_reorderable( + tiles, + |from, to| Some(Msg::Swap { from, to }), + &palette, + ) + } +} + +fn padded(text: &str, size: f32, color: Color, align: Alignment) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + padding: Rect { + left: length(14.0_f32), + right: length(14.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + ..Default::default() + }) + .text_aligned(text.to_string(), size, color, align) +} + +fn log_body(theme: &Theme) -> View { + padded( + "[12:01:33] boot\n[12:01:34] config ok\n[12:01:35] esperando eventos…\n[12:02:01] cliente 1 conectó\n[12:02:02] cliente 2 conectó", + 12.0, + theme.fg_text, + Alignment::Start, + ) +} + +fn metrics_body(theme: &Theme) -> View { + let stat = |label: &str, value: &str, color: Color| -> View { + let label_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .text_aligned(label.to_string(), 10.0, theme.fg_muted, Alignment::Start); + let value_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + ..Default::default() + }) + .text_aligned(value.to_string(), 22.0, color, Alignment::Start); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![label_view, value_view]) + }; + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + gap: Size { + width: length(12.0_f32), + height: length(0.0_f32), + }, + padding: Rect { + left: length(14.0_f32), + right: length(14.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + ..Default::default() + }) + .children(vec![ + stat("cpu", "37%", theme.accent), + stat("ram", "1.2 G", theme.fg_text), + stat("net", "12 kB/s", theme.fg_text), + ]) +} + +fn alerts_body(theme: &Theme) -> View { + padded( + "● info: dos clientes online\n● warn: latencia 250 ms\n● ok: backup nocturno verde", + 12.0, + theme.fg_text, + Alignment::Start, + ) +} + +fn uptime_body(theme: &Theme) -> View { + padded("4d 12h 33m", 26.0, theme.accent, Alignment::Center) +} + +fn queue_body(theme: &Theme) -> View { + padded( + "pending: 7\nin-flight: 2\ndone (24h): 1842", + 13.0, + theme.fg_text, + Alignment::Start, + ) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/tiled/src/lib.rs b/widgets/tiled/src/lib.rs new file mode 100644 index 0000000..4ab1422 --- /dev/null +++ b/widgets/tiled/src/lib.rs @@ -0,0 +1,322 @@ +//! `llimphi-widget-tiled` — grilla auto cols×rows de tiles con title +//! bar fija arriba. +//! +//! Cada tile es un panel rectangular con: +//! - una franja superior (20 px) con `bg_panel_alt` + label centrado a +//! la izquierda en `fg_muted`; +//! - un cuerpo flex que aloja el `View` provisto por el caller. +//! +//! La grilla se calcula como `cols = ⌈√n⌉`, `rows = ⌈n/cols⌉` — mismo +//! algoritmo que el `nahual-widget-tiled` GPUI. Las celdas son +//! equipesos: `flex_grow = 1` sobre ambos ejes. +//! +//! ## Variantes +//! +//! - [`tiled_view`] — grilla estática, sin reordenamiento. +//! - [`tiled_view_reorderable`] — drag-to-swap: arrastrar la title bar +//! de un tile y soltar sobre otro emite `on_reorder(from, to)`. El +//! tile destino se ilumina (`drop_hover_fill` = `accent`) mientras +//! el cursor está sobre él durante el drag. Usa los primitives +//! `drag_payload` + `on_drop` + `drop_hover_fill` de `llimphi-ui`. +//! - [`tiled_view_cols`] / [`tiled_view_reorderable_cols`] — fuerzan el +//! número de columnas (útil para sidebars verticales: `cols = 1`). + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{DragPhase, View}; + +const TITLE_BAR_HEIGHT: f32 = 20.0; +const TITLE_TEXT_SIZE: f32 = 10.0; +const TILE_GAP: f32 = 4.0; +const TILE_PADDING: f32 = 4.0; + +/// Paleta del tiled. +#[derive(Debug, Clone, Copy)] +pub struct TiledPalette { + /// Fondo del container outer (visible en los gaps entre tiles). + pub bg_outer: Color, + /// Fondo del cuerpo del tile. + pub bg_tile: Color, + /// Fondo de la title bar del tile. + pub bg_title: Color, + /// Color del label de la title bar. + pub fg_title: Color, + /// Color del tile destino durante un drag (drop hover). + pub bg_drop_hover: Color, +} + +impl Default for TiledPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TiledPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_outer: t.bg_app, + bg_tile: t.bg_panel, + bg_title: t.bg_panel_alt, + fg_title: t.fg_muted, + bg_drop_hover: t.bg_selected, + } + } +} + +/// Un tile de la grilla: label que va en la title bar + view del cuerpo. +pub struct TileSpec { + pub label: String, + pub content: View, +} + +type ReorderFn = Arc Option + Send + Sync>; + +/// Construye una grilla estática (sin drag-to-swap). Equivalente a +/// [`tiled_view_reorderable`] sin handler de reorder. +pub fn tiled_view(tiles: Vec>, palette: &TiledPalette) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + build(tiles, palette, None, None) +} + +/// Construye una grilla con drag-to-swap. Arrastrar la title bar de un +/// tile y soltar sobre otro invoca `on_reorder(from_index, to_index)`; +/// el `Msg` retornado se dispatchea al `update` antes de cerrar el +/// drag. El caller es responsable de filtrar `from == to`. +pub fn tiled_view_reorderable( + tiles: Vec>, + on_reorder: F, + palette: &TiledPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, + F: Fn(usize, usize) -> Option + Send + Sync + 'static, +{ + build(tiles, palette, Some(Arc::new(on_reorder)), None) +} + +/// Como [`tiled_view`] pero con número fijo de columnas. Útil para +/// sidebars verticales (`cols = 1`) o filas horizontales (`cols = n`) +/// donde el algoritmo auto-sqrt no sirve. `cols.max(1)` se aplica por +/// seguridad. +pub fn tiled_view_cols( + tiles: Vec>, + cols: usize, + palette: &TiledPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + build(tiles, palette, None, Some(cols)) +} + +/// Como [`tiled_view_reorderable`] pero con número fijo de columnas. +pub fn tiled_view_reorderable_cols( + tiles: Vec>, + cols: usize, + on_reorder: F, + palette: &TiledPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, + F: Fn(usize, usize) -> Option + Send + Sync + 'static, +{ + build(tiles, palette, Some(Arc::new(on_reorder)), Some(cols)) +} + +fn build( + tiles: Vec>, + palette: &TiledPalette, + on_reorder: Option>, + cols_override: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let n = tiles.len(); + if n == 0 { + return View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_outer) + .text( + "(tiled vacío)".to_string(), + 11.0, + palette.fg_title, + ); + } + + let cols = cols_override + .map(|c| c.max(1)) + .unwrap_or_else(|| ((n as f32).sqrt().ceil() as usize).max(1)); + let rows = (n + cols - 1) / cols; + + let mut tiles_iter = tiles.into_iter().enumerate(); + let mut rows_views: Vec> = Vec::with_capacity(rows); + + for _r in 0..rows { + let mut cells: Vec> = Vec::with_capacity(cols); + for _c in 0..cols { + let cell = match tiles_iter.next() { + Some((idx, tile)) => tile_view(idx, tile, palette, on_reorder.clone()), + None => empty_cell_view(palette), + }; + cells.push(cell); + } + rows_views.push(row_view(cells)); + } + + 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(TILE_GAP), + }, + padding: Rect { + left: length(TILE_PADDING), + right: length(TILE_PADDING), + top: length(TILE_PADDING), + bottom: length(TILE_PADDING), + }, + ..Default::default() + }) + .fill(palette.bg_outer) + .children(rows_views) +} + +fn row_view(cells: Vec>) -> View { + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + flex_basis: length(0.0_f32), + min_size: Size { + width: length(0.0_f32), + height: length(0.0_f32), + }, + gap: Size { + width: length(TILE_GAP), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(cells) +} + +fn tile_view( + idx: usize, + tile: TileSpec, + palette: &TiledPalette, + on_reorder: Option>, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let mut title = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(TITLE_BAR_HEIGHT), + }, + flex_shrink: 0.0, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_title) + .text_aligned(tile.label, TITLE_TEXT_SIZE, palette.fg_title, Alignment::Start); + + // Si hay reorder, la title bar arrastra con payload = idx. + if on_reorder.is_some() { + // Handler trivial: tiled no usa dx/dy. Devuelve None. + title = title + .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None) + .drag_payload(idx as u64); + } + + let body = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + min_size: Size { + width: length(0.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![tile.content]); + + let mut tile_view = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: Dimension::auto(), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + flex_basis: length(0.0_f32), + min_size: Size { + width: length(0.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_tile) + .radius(4.0) + .clip(true) + .children(vec![title, body]); + + // Drop target: si hay reorder, este tile entero recibe drops. + if let Some(reorder) = on_reorder { + let to_idx = idx; + tile_view = tile_view + .on_drop(move |from: u64| (reorder)(from as usize, to_idx)) + .drop_hover_fill(palette.bg_drop_hover); + } + + tile_view +} + +fn empty_cell_view(palette: &TiledPalette) -> View +where + Msg: Clone + 'static, +{ + View::new(Style { + size: Size { + width: Dimension::auto(), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + flex_basis: length(0.0_f32), + min_size: Size { + width: length(0.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_outer) +} diff --git a/widgets/timeline/Cargo.toml b/widgets/timeline/Cargo.toml new file mode 100644 index 0000000..5c930f3 --- /dev/null +++ b/widgets/timeline/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-timeline" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-timeline — barra de progreso/scrub clickeable (seek absoluto). El widget es stateless: el caller pasa la fracción de avance y un handler fracción→Msg." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/timeline/src/lib.rs b/widgets/timeline/src/lib.rs new file mode 100644 index 0000000..d6ebda8 --- /dev/null +++ b/widgets/timeline/src/lib.rs @@ -0,0 +1,154 @@ +//! `llimphi-widget-timeline` — barra de progreso/scrub clickeable. +//! +//! Pattern análogo a `llimphi-widget-slider`/`-progress`: el widget **no +//! mantiene estado**. El caller guarda la posición actual en su `Model`, +//! le pasa la **fracción de avance** (`0.0..=1.0` = posición/duración) y un +//! handler `Fn(f32) -> Option` que recibe la fracción **donde el +//! usuario clickeó** (scrub absoluto, estilo VLC). El widget no sabe de +//! tiempo ni de duración: sólo pinta el avance y reporta dónde se clickeó +//! como fracción del ancho de la barra (`on_click_at`). Quien mapea esa +//! fracción a un seek concreto es la app. +//! +//! ```text +//! [ ██████████▏░░░░░░░░░░░░ ] +//! recorrido playhead resto +//! ``` +//! +//! Uso típico (reproductor): +//! +//! ```ignore +//! let frac = pos.as_secs_f64() / dur.as_secs_f64(); +//! timeline_view(frac as f32, &TimelinePalette::default(), |f| { +//! Some(Msg::Command(MediaCommand::SeekTo { fraction: f })) +//! }) +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Size, Style}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect}; +use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; +use llimphi_ui::View; + +/// Paleta + dimensiones del timeline. Las medidas viajan acá (igual que +/// `SliderPalette`) porque definen cómo se ve la barra — el caller no +/// toca el `Style` directamente. +#[derive(Debug, Clone, Copy)] +pub struct TimelinePalette { + /// Color de la pista de fondo (el track entero). + pub track: Color, + /// Color del tramo recorrido (de 0 al playhead). + pub fill: Color, + /// Color del playhead (la barrita vertical en la posición actual). + pub knob: Color, + /// Alto total del widget en pixels. + pub height: f32, + /// Radio de las esquinas del track. + pub radius: f64, +} + +impl Default for TimelinePalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TimelinePalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + track: t.bg_button, + fill: t.accent, + knob: t.fg_text, + height: 14.0, + radius: 7.0, + } + } +} + +/// Compone una barra de progreso clickeable. +/// +/// `progress` es la fracción recorrida (`0.0..=1.0`); se clampea. El +/// handler `on_seek` recibe la fracción `0.0..=1.0` donde el usuario +/// clickeó (`local_x / ancho`) y devuelve el `Msg` a despachar (o `None` +/// para ignorar el click). El widget es stateless: redibujá pasando un +/// `progress` nuevo en cada frame y el playhead avanza solo. +pub fn timeline_view(progress: f32, palette: &TimelinePalette, on_seek: F) -> View +where + Msg: 'static, + F: Fn(f32) -> Option + Send + Sync + 'static, +{ + let p = progress.clamp(0.0, 1.0); + let fill_color = palette.fill; + let knob_color = palette.knob; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(palette.height), + }, + ..Default::default() + }) + .fill(palette.track) + .radius(palette.radius) + .paint_with(move |scene, _ts, rect| { + if rect.w <= 2.0 || rect.h <= 2.0 { + return; + } + let pad: f32 = 2.0; + let x0 = rect.x + pad; + let y0 = rect.y + pad; + let w = (rect.w - 2.0 * pad).max(1.0); + let h = (rect.h - 2.0 * pad).max(1.0); + // Tramo recorrido. + let fw = (w * p).max(0.0); + if fw > 0.5 { + let fill = Rect::new(x0 as f64, y0 as f64, (x0 + fw) as f64, (y0 + h) as f64); + scene.fill(Fill::NonZero, Affine::IDENTITY, fill_color, None, &fill); + } + // Playhead — fina barra vertical en la posición actual. + let kx = x0 + fw; + let kw: f32 = 3.0; + let knob = Rect::new( + (kx - kw * 0.5) as f64, + y0 as f64, + (kx + kw * 0.5) as f64, + (y0 + h) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, knob_color, None, &knob); + }) + .on_click_at(move |lx, _ly, w, _h| { + if w <= 0.0 { + return None; + } + on_seek((lx / w).clamp(0.0, 1.0)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Msg de prueba: el handler reporta la fracción clickeada. + #[derive(Debug, PartialEq)] + struct Seek(f32); + + #[test] + fn from_theme_usa_colores_semanticos() { + let t = llimphi_theme::Theme::dark(); + let p = TimelinePalette::from_theme(&t); + assert_eq!(p.track, t.bg_button); + assert_eq!(p.fill, t.accent); + assert_eq!(p.knob, t.fg_text); + } + + #[test] + fn construye_sin_panic_en_extremos() { + // El widget se arma para fracciones fuera de rango (se clampea + // internamente al pintar) sin reventar. + let pal = TimelinePalette::default(); + let _ = timeline_view(-0.5, &pal, |f| Some(Seek(f))); + let _ = timeline_view(0.0, &pal, |f| Some(Seek(f))); + let _ = timeline_view(1.0, &pal, |f| Some(Seek(f))); + let _ = timeline_view(2.0, &pal, |f| Some(Seek(f))); + } +} diff --git a/widgets/toast/Cargo.toml b/widgets/toast/Cargo.toml new file mode 100644 index 0000000..754ef7f --- /dev/null +++ b/widgets/toast/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-toast" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-toast — notificaciones efímeras apiladas bottom-right. Severidades info/success/warning/error. Auto-dismiss configurable. Render-only; el ciclo de vida lo maneja la app." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-icons = { workspace = true } diff --git a/widgets/toast/src/lib.rs b/widgets/toast/src/lib.rs new file mode 100644 index 0000000..52e6b76 --- /dev/null +++ b/widgets/toast/src/lib.rs @@ -0,0 +1,238 @@ +//! `llimphi-widget-toast` — notificaciones efímeras apiladas. +//! +//! Cuatro severidades (Info / Success / Warning / Error) con color +//! semántico hardcoded — un Error debe leerse rojo aunque la app esté +//! en tema "sunset". Cada toast lleva un icono de `llimphi-icons` y +//! un texto corto. +//! +//! El widget es **render-only**: recibe una lista de [`Toast`]s ya +//! filtrados por la app (los que aún no expiraron) y los apila en la +//! esquina bottom-right. El ciclo de vida (push, auto-dismiss tras +//! `duration`, dismiss manual al click) lo maneja la app desde su +//! `update`/`spawn`. +//! +//! Patrón típico: +//! 1. App tiene `Vec` en el modelo + `next_id: u64`. +//! 2. Para pushear: agregar Toast con `expires_at = Instant::now() + dur` +//! + `handle.spawn(move || { sleep(dur); Msg::ToastExpire(id) })`. +//! 3. `view_overlay` filtra los no expirados y los pasa a `toast_stack_view`. + +#![forbid(unsafe_code)] + +use std::time::Instant; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_icons::{icon_view, Icon}; +use llimphi_theme::radius; + +/// Severidad del toast — define color e icono. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + Info, + Success, + Warning, + Error, +} + +impl ToastKind { + /// Color de fondo (semántico, no dependiente del theme). + pub fn bg(self) -> Color { + match self { + ToastKind::Info => Color::from_rgba8(28, 56, 88, 245), + ToastKind::Success => Color::from_rgba8(28, 72, 44, 245), + ToastKind::Warning => Color::from_rgba8(88, 64, 20, 245), + ToastKind::Error => Color::from_rgba8(96, 32, 32, 245), + } + } + + /// Color del trazo y del texto principal. + pub fn fg(self) -> Color { + match self { + ToastKind::Info => Color::from_rgba8(180, 220, 250, 255), + ToastKind::Success => Color::from_rgba8(180, 240, 200, 255), + ToastKind::Warning => Color::from_rgba8(250, 220, 160, 255), + ToastKind::Error => Color::from_rgba8(250, 200, 200, 255), + } + } + + pub fn icon(self) -> Icon { + match self { + ToastKind::Info => Icon::Info, + ToastKind::Success => Icon::Check, + ToastKind::Warning => Icon::Warning, + ToastKind::Error => Icon::Error, + } + } +} + +/// Un toast en cola. La app mantiene `Vec` y descarta los +/// expirados antes de pasarlos al render. +#[derive(Debug, Clone)] +pub struct Toast { + /// Id estable para que la app pueda correlacionar con su Msg de + /// dismiss (`Msg::ToastDismiss(u64)`). + pub id: u64, + pub kind: ToastKind, + pub text: String, + /// Cuándo expira. El render no chequea esto — sólo apila lo que + /// recibe; la app filtra antes. + pub expires_at: Instant, +} + +const TOAST_W: f32 = 320.0; +const TOAST_H: f32 = 44.0; +const ICON_BOX: f32 = 24.0; +const GAP: f32 = 8.0; +const MARGIN: f32 = 16.0; +/// Ancho del "rail" de severidad en el edge izquierdo. 3px es el sweet +/// spot — visible al pasar sin chocar con el icono. Look Linear/Slack. +const RAIL_W: f32 = 3.0; + +/// Apila los toasts en la esquina bottom-right del viewport. `on_click` +/// se construye por toast vía `make_dismiss(id)`. Devuelve un `View` +/// para colgar de `view_overlay`. +pub fn toast_stack_view( + toasts: &[Toast], + viewport: (f32, f32), + make_dismiss: F, +) -> View +where + Msg: Clone + 'static, + F: Fn(u64) -> Msg, +{ + let n = toasts.len() as f32; + let stack_h = n * TOAST_H + (n - 1.0).max(0.0) * GAP; + let stack_y = (viewport.1 - stack_h - MARGIN).max(MARGIN); + let stack_x = (viewport.0 - TOAST_W - MARGIN).max(MARGIN); + + let children: Vec> = toasts + .iter() + .map(|t| single_toast_view(t, make_dismiss(t.id))) + .collect(); + + let stack = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(stack_x), + top: length(stack_y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(TOAST_W), + height: length(stack_h.max(0.0)), + }, + flex_direction: FlexDirection::Column, + gap: Size { + width: length(0.0_f32), + height: length(GAP), + }, + ..Default::default() + }) + .children(children); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![stack]) +} + +fn single_toast_view(toast: &Toast, on_dismiss: Msg) -> View { + let bg = toast.kind.bg(); + let fg = toast.kind.fg(); + let icon = toast.kind.icon(); + + // Rail de severidad: stripe del color fg semántico (más brillante + // que el bg) en el edge izquierdo. Visible al pasar el ojo sin + // chocar con el icono — refuerza la severidad para usuarios que ya + // están mirando a otra parte de la UI. + let rail = View::new(Style { + size: Size { + width: length(RAIL_W), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(fg); + + let icon_cell = View::new(Style { + size: Size { + width: length(ICON_BOX), + height: length(ICON_BOX), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![icon_view(icon, fg, 1.6)]); + + let text = 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(toast.text.clone(), 12.0, fg, Alignment::Start); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(TOAST_H), + }, + align_items: Some(AlignItems::Center), + // El rail vive en el edge — sin padding-left propio para que + // pegue al borde; el padding del contenido arranca después. + padding: Rect { + left: length(0.0_f32), + right: length(12.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(10.0_f32), + height: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .radius(radius::MD) + .clip(true) + .on_click(on_dismiss) + .children(vec![rail, icon_cell, text]) +} + +/// Helper de construcción para uso inmediato: +/// `Toast::info(1, "guardado", Duration::from_secs(3))`. +impl Toast { + pub fn info(id: u64, text: impl Into, dur: std::time::Duration) -> Self { + Self { id, kind: ToastKind::Info, text: text.into(), expires_at: Instant::now() + dur } + } + pub fn success(id: u64, text: impl Into, dur: std::time::Duration) -> Self { + Self { id, kind: ToastKind::Success, text: text.into(), expires_at: Instant::now() + dur } + } + pub fn warning(id: u64, text: impl Into, dur: std::time::Duration) -> Self { + Self { id, kind: ToastKind::Warning, text: text.into(), expires_at: Instant::now() + dur } + } + pub fn error(id: u64, text: impl Into, dur: std::time::Duration) -> Self { + Self { id, kind: ToastKind::Error, text: text.into(), expires_at: Instant::now() + dur } + } + + pub fn is_alive(&self, now: Instant) -> bool { + now < self.expires_at + } +} diff --git a/widgets/tooltip/Cargo.toml b/widgets/tooltip/Cargo.toml new file mode 100644 index 0000000..362e079 --- /dev/null +++ b/widgets/tooltip/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-tooltip" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-tooltip — tooltip flotante posicionado por anchor + viewport. Render-only: la app decide cuándo abrir (típico: hover-after-delay manejado en update)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/tooltip/src/lib.rs b/widgets/tooltip/src/lib.rs new file mode 100644 index 0000000..52d0cb6 --- /dev/null +++ b/widgets/tooltip/src/lib.rs @@ -0,0 +1,166 @@ +//! `llimphi-widget-tooltip` — tooltip flotante con anchor + clamping. +//! +//! Render puro: el widget recibe el anchor (típicamente bottom-center +//! del elemento que lo dispara), el viewport y el texto, y devuelve un +//! `View` posicionado en absolute para colgarlo de `view_overlay`. +//! La app es responsable de: +//! 1. Detectar el hover sobre el elemento via `View::on_pointer_enter` +//! + un `Tween`/delay para evitar tooltips que parpadean al pasar. +//! 2. Guardar el `Option` en su modelo. +//! 3. Devolverlo desde `view_overlay`. +//! 4. Cerrarlo con `View::on_pointer_leave` sobre el mismo elemento. +//! +//! No se incluye scrim — el tooltip es informativo, no modal: los +//! clicks atraviesan al árbol principal. (Para popovers con +//! interacción, usar `llimphi-widget-modal` o el `context-menu`). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, FlexDirection, Position, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_theme::{radius, Theme}; + +/// Paleta del tooltip — fondo "glass panel" oscuro, texto claro. +#[derive(Debug, Clone, Copy)] +pub struct TooltipPalette { + pub bg: Color, + pub fg: Color, + pub border: Color, +} + +impl TooltipPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + bg: t.bg_app, + fg: t.fg_text, + border: t.border, + } + } +} + +/// Lado preferido al que se coloca el tooltip respecto del anchor. +/// Si no entra en el viewport por ese lado, el clamping lo empuja al +/// lado contrario (no recortado). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Side { + Top, + #[default] + Bottom, + Left, + Right, +} + +/// Spec para [`tooltip_view`]. +#[derive(Debug, Clone)] +pub struct TooltipSpec { + /// Punto de origen — típicamente el centro del elemento que dispara. + pub anchor: (f32, f32), + /// Tamaño actual de la ventana, para clamping. + pub viewport: (f32, f32), + /// Lado preferido respecto del anchor. + pub side: Side, + pub text: String, + pub palette: TooltipPalette, +} + +const PAD_X: f32 = 8.0; +const PAD_Y: f32 = 5.0; +const GAP: f32 = 6.0; +const FONT_SIZE: f32 = 11.5; +/// Ancho aproximado de un carácter (estimación zonal — Llimphi +/// todavía no expone medición previa al layout). Sirve para clampear +/// tooltips largos a un ancho razonable. +const CHAR_W_APPROX: f32 = 6.5; +const MAX_W: f32 = 280.0; + +pub fn tooltip_view(spec: TooltipSpec) -> View { + let TooltipSpec { anchor, viewport, side, text, palette } = spec; + + // Tamaño estimado del tooltip — Llimphi resuelve layout pero el + // posicionamiento absolute necesita un x,y; estimamos con el ancho + // del texto y limitamos al MAX_W. Ancho real puede diferir un + // píxel — al ojo es invisible. + let est_w = (text.chars().count() as f32 * CHAR_W_APPROX + PAD_X * 2.0).min(MAX_W); + let est_h = FONT_SIZE * 1.3 + PAD_Y * 2.0; + + // Posicionamiento respecto del anchor. + let (raw_x, raw_y) = match side { + Side::Bottom => (anchor.0 - est_w * 0.5, anchor.1 + GAP), + Side::Top => (anchor.0 - est_w * 0.5, anchor.1 - GAP - est_h), + Side::Right => (anchor.0 + GAP, anchor.1 - est_h * 0.5), + Side::Left => (anchor.0 - GAP - est_w, anchor.1 - est_h * 0.5), + }; + + // Clamping al viewport (margen 4px para no pegarse al borde). + let margin = 4.0; + let x = raw_x + .min((viewport.0 - est_w - margin).max(margin)) + .max(margin); + let y = raw_y + .min((viewport.1 - est_h - margin).max(margin)) + .max(margin); + + let panel = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(est_w), + height: length(est_h), + }, + flex_direction: FlexDirection::Column, + align_items: Some(AlignItems::FlexStart), + padding: Rect { + left: length(PAD_X), + right: length(PAD_X), + top: length(PAD_Y), + bottom: length(PAD_Y), + }, + ..Default::default() + }) + .fill(palette.bg) + .radius(radius::SM) + .text_aligned(text, FONT_SIZE, palette.fg, Alignment::Start); + + // Wrapper invisible que ocupa toda la pantalla — el panel ya está + // posicionado en absolute, pero `view_overlay` espera un único root + // que cubre la ventana. Sin scrim ni intercept de clicks. + View::new(Style { + size: Size { + width: llimphi_ui::llimphi_layout::taffy::prelude::percent(1.0_f32), + height: llimphi_ui::llimphi_layout::taffy::prelude::percent(1.0_f32), + }, + ..Default::default() + }) + // Borde sutil pintado vía un nodo separado: pintamos el panel sobre + // un rect 1px más grande coloreado con `border` — barato y consistente + // con cómo el context-menu hace su borde. + .children(vec![ + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x - 1.0), + top: length(y - 1.0), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(est_w + 2.0), + height: length(est_h + 2.0), + }, + ..Default::default() + }) + .fill(palette.border) + .radius(radius::SM + 1.0), + panel, + ]) +} diff --git a/widgets/tree/Cargo.toml b/widgets/tree/Cargo.toml new file mode 100644 index 0000000..8c44804 --- /dev/null +++ b/widgets/tree/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-tree" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-tree — árbol con expand/collapse y selección. Análogo Llimphi al `nahual-widget-tree` GPUI. El caller mantiene el set de nodos expandidos y el seleccionado en su Model; el widget aplana el árbol en filas con indentación y emite Msg al togglear o seleccionar." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/tree/LEEME.md b/widgets/tree/LEEME.md new file mode 100644 index 0000000..16a6346 --- /dev/null +++ b/widgets/tree/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-tree + +> Árbol jerárquico para [llimphi](../../README.md). + +Tree-view con expand/collapse, virtualización (filas no visibles no se montan), drag-and-drop opcional. Lazy-load por nodo cuando los hijos son caros. Usado por file-explorer, sidebar de docs, etc. diff --git a/widgets/tree/README.md b/widgets/tree/README.md new file mode 100644 index 0000000..b510b5c --- /dev/null +++ b/widgets/tree/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-tree + +> Hierarchical tree for [llimphi](../../README.md). + +Tree-view with expand/collapse, virtualization (off-screen rows aren't mounted), optional drag-and-drop. Lazy-load per node when children are expensive. Used by file-explorer, doc sidebar, etc. diff --git a/widgets/tree/examples/tree_demo.rs b/widgets/tree/examples/tree_demo.rs new file mode 100644 index 0000000..4a7ad7d --- /dev/null +++ b/widgets/tree/examples/tree_demo.rs @@ -0,0 +1,207 @@ +//! Showcase de `llimphi-widget-tree`: jerarquía con expand/collapse + +//! selección. Click en ▸/▾ togglea; click en el resto de la fila +//! selecciona. +//! +//! Corré con: `cargo run -p llimphi-widget-tree --example tree_demo --release`. + +use std::collections::HashSet; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_theme::Theme; +use llimphi_widget_tree::{tree_view, TreePalette, TreeRow, TreeSpec}; + +#[derive(Clone)] +enum Msg { + Toggle(u32), + Select(u32), +} + +struct Model { + /// Set de ids expandidos. + expanded: HashSet, + selected: Option, +} + +struct Showcase; + +/// Estructura estática del árbol — `(id, parent_id, label)`. `parent_id = +/// 0` significa raíz. +const TREE: &[(u32, u32, &str)] = &[ + (1, 0, "00_unanchay (PERCIBIR)"), + (10, 1, "pluma"), + (101, 10, "core"), + (102, 10, "graph"), + (103, 10, "render-plan"), + (104, 10, "editor-llimphi"), + (11, 1, "khipu"), + (12, 1, "rimay"), + (13, 1, "puriy"), + (131, 13, "core"), + (132, 13, "engine"), + (2, 0, "01_yachay (CONOCER)"), + (20, 2, "cosmos"), + (21, 2, "dominium"), + (22, 2, "nakui"), + (3, 0, "02_ruway (HACER)"), + (30, 3, "llimphi"), + (301, 30, "hal"), + (302, 30, "raster"), + (303, 30, "layout"), + (304, 30, "text"), + (305, 30, "ui"), + (306, 30, "widgets/"), + (3061, 306, "button"), + (3062, 306, "list"), + (3063, 306, "splitter"), + (3064, 306, "tabs"), + (3065, 306, "text-input"), + (3066, 306, "tree"), + (31, 3, "mirada"), + (32, 3, "nahual"), + (4, 0, "03_ukupacha (RAÍZ)"), +]; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · tree showcase" + } + + fn initial_size() -> (u32, u32) { + (560, 720) + } + + fn init(_: &Handle) -> Model { + let mut expanded = HashSet::new(); + // Raíces abiertas por default. + expanded.insert(1); + expanded.insert(3); + expanded.insert(30); + Model { + expanded, + selected: None, + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::Toggle(id) => { + if !m.expanded.remove(&id) { + m.expanded.insert(id); + } + } + Msg::Select(id) => { + m.selected = Some(id); + } + } + m + } + + fn view(model: &Model) -> View { + let theme = Theme::dark(); + let palette = TreePalette::from_theme(&theme); + + let rows = flatten_visible(&model.expanded, model.selected); + let tree = tree_view(TreeSpec { + rows, + row_height: 22.0, + indent_px: 16.0, + palette, + guides: true, + }); + + // Header con info de la selección. + let header_text = match model.selected { + Some(id) => format!("seleccionado: id {id}"), + None => "(click en una fila para seleccionar)".to_string(), + }; + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .text_aligned(header_text, 12.0, theme.fg_muted, Alignment::Start); + + let tree_pane = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![tree]); + + 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![header, tree_pane]) + } +} + +/// Aplana el árbol estático respetando el set expandido. Profundidad +/// inferida de la cadena de parents. +fn flatten_visible(expanded: &HashSet, selected: Option) -> Vec> { + let mut out = Vec::new(); + visit(0, 0, expanded, selected, &mut out); + out +} + +fn visit( + parent_id: u32, + depth: usize, + expanded: &HashSet, + selected: Option, + out: &mut Vec>, +) { + for (id, p, label) in TREE { + if *p != parent_id { + continue; + } + let has_children = TREE.iter().any(|(_, pp, _)| *pp == *id); + let is_expanded = expanded.contains(id); + out.push(TreeRow { + label: label.to_string(), + depth, + has_children, + expanded: is_expanded, + selected: selected == Some(*id), + on_toggle: Msg::Toggle(*id), + on_select: Msg::Select(*id), + icon: None, + on_context: None, + editor: None, + }); + if has_children && is_expanded { + visit(*id, depth + 1, expanded, selected, out); + } + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/tree/src/lib.rs b/widgets/tree/src/lib.rs new file mode 100644 index 0000000..dfa6a14 --- /dev/null +++ b/widgets/tree/src/lib.rs @@ -0,0 +1,345 @@ +//! `llimphi-widget-tree` — árbol con expand/collapse y selección. +//! +//! Análogo Llimphi al `nahual-widget-tree` GPUI. No mantiene estado +//! propio: el `Model` del App lleva el set de nodos expandidos + el +//! seleccionado, le pasa al widget la lista aplanada de filas (sólo +//! las visibles según el estado de expansión) y maneja los Msg de +//! toggle/select. +//! +//! Aplanar el árbol vive del lado del caller para no imponer una +//! representación específica (recursiva, plana con paths, etc.). +//! +//! Cada fila lleva su `depth` (para indentar), `has_children` (para +//! decidir si dibujar la flecha ▸/▾) y `expanded` (cuál de las dos). +//! Click en la flecha → `on_toggle`; click en el resto de la fila → +//! `on_select`; click derecho → `on_context` (si lo trae). +//! +//! Extras opcionales: un **icono gráfico** por fila (`icon`, cualquier +//! `View` — típicamente un mini-canvas vectorial) entre el chevron y el +//! label, y **líneas guía** de indentación (`TreeSpec::guides`). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Line as KurboLine, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{PaintRect, View}; + +/// Paleta del árbol — un subset del `Theme` semántico, igual que los +/// otros widgets de Llimphi. +#[derive(Debug, Clone, Copy)] +pub struct TreePalette { + pub bg_panel: Color, + pub bg_selected: Color, + pub bg_hover: Color, + pub fg_text: Color, + pub fg_muted: Color, + pub fg_chevron: Color, + /// Color de las líneas guía de indentación. + pub guide: Color, +} + +impl Default for TreePalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TreePalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_selected: t.bg_selected, + bg_hover: t.bg_row_hover, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + fg_chevron: t.fg_muted, + guide: t.border, + } + } +} + +/// Una fila del árbol — ya posicionada en la lista plana visible. +pub struct TreeRow { + pub label: String, + /// Nivel de anidación (0 = raíz). Se traduce a indentación visual. + pub depth: usize, + /// Si el nodo tiene hijos. `false` = hoja; no se dibuja el chevron. + pub has_children: bool, + /// Estado actual del nodo. Ignorado si `has_children = false`. + pub expanded: bool, + /// Si esta fila es la seleccionada. + pub selected: bool, + /// Msg al hacer click en el chevron. Sólo se usa si `has_children`. + pub on_toggle: Msg, + /// Msg al hacer click en la fila (label o área alrededor). + pub on_select: Msg, + /// Icono gráfico opcional (cualquier `View`, p.ej. un mini-canvas + /// vectorial) que se pinta entre el chevron y el label. + pub icon: Option>, + /// Msg al hacer click derecho sobre la fila (menú contextual). `None` + /// = sin menú contextual. + pub on_context: Option, + /// Edición in-situ: si es `Some`, la fila se renderea con este + /// `View` (típicamente un `text_input_view`) en el lugar del label, + /// en vez del texto sólo-lectura. El chevron y la indentación se + /// mantienen; el editor ocupa el slot elástico del label y no se le + /// cablea `on_select` (las teclas las rutea el App). `None` = fila + /// normal de sólo-lectura. + pub editor: Option>, +} + +impl TreeRow { + /// Constructor mínimo (sin icono / contexto / editor) — azúcar para + /// callers que sólo quieren label + toggle + select. + pub fn new( + label: impl Into, + depth: usize, + has_children: bool, + expanded: bool, + selected: bool, + on_toggle: Msg, + on_select: Msg, + ) -> Self { + Self { + label: label.into(), + depth, + has_children, + expanded, + selected, + on_toggle, + on_select, + icon: None, + on_context: None, + editor: None, + } + } + + pub fn with_icon(mut self, icon: View) -> Self { + self.icon = Some(icon); + self + } + + pub fn with_context(mut self, msg: Msg) -> Self { + self.on_context = Some(msg); + self + } + + pub fn with_editor(mut self, editor: View) -> Self { + self.editor = Some(editor); + self + } +} + +/// Especificación completa del árbol a renderear. +pub struct TreeSpec { + pub rows: Vec>, + pub row_height: f32, + pub indent_px: f32, + pub palette: TreePalette, + /// Dibujar líneas guía verticales de indentación. + pub guides: bool, +} + +impl TreeSpec { + /// Spec con valores por defecto sensatos (row 22, indent 14, sin + /// guías) — sólo hay que pasar filas y paleta. + pub fn new(rows: Vec>, palette: TreePalette) -> Self { + Self { + rows, + row_height: 22.0, + indent_px: 14.0, + palette, + guides: false, + } + } +} + +/// Compone el árbol como `View`. El contenedor activa `clip` para +/// que filas que excedan el rect se recorten — usar dentro de un panel +/// del tamaño deseado. +pub fn tree_view(spec: TreeSpec) -> View { + let TreeSpec { + rows, + row_height, + indent_px, + palette, + guides, + } = spec; + + let children: Vec> = rows + .into_iter() + .map(|row| tree_row_view(row, row_height, indent_px, guides, &palette)) + .collect(); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(4.0_f32), + bottom: length(4.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_panel) + .clip(true) + .children(children) +} + +fn tree_row_view( + row: TreeRow, + height: f32, + indent_px: f32, + guides: bool, + palette: &TreePalette, +) -> View { + let bg = if row.selected { + palette.bg_selected + } else { + palette.bg_panel + }; + let indent = (row.depth as f32) * indent_px; + + // Chevron a la izquierda — 16px de ancho, ▸ si colapsado, ▾ si + // expandido. Si es hoja, espacio en blanco del mismo ancho para que + // los labels alineen. ASCII puro (`v`/`>`) por compat de fuentes. + let chevron_label = if row.has_children { + if row.expanded { + "v" + } else { + ">" + } + } else { + " " + }; + let chevron_msg = if row.has_children { + Some(row.on_toggle) + } else { + None + }; + let mut chevron = View::new(Style { + size: Size { + width: length(16.0_f32), + height: length(height), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text_aligned( + chevron_label.to_string(), + 12.0, + palette.fg_chevron, + Alignment::Center, + ); + if let Some(msg) = chevron_msg { + chevron = chevron.hover_fill(palette.bg_hover).on_click(msg); + } + + let mut row_children: Vec> = vec![chevron]; + + // Icono gráfico opcional, entre chevron y label. + if let Some(icon) = row.icon { + row_children.push( + View::new(Style { + size: Size { + width: length(20.0_f32), + height: length(height), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .children(vec![icon]), + ); + } + + // Slot elástico del label: editor in-situ si la fila lo trae, o el + // texto sólo-lectura clickeable en su defecto. Alto `auto` para que el + // `align_items: Center` de la fila lo centre verticalmente. + let label = if let Some(editor) = row.editor { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(height), + }, + flex_grow: 1.0, + padding: Rect { + left: length(4.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(vec![editor]) + } else { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + padding: Rect { + left: length(4.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start) + .on_click(row.on_select) + }; + row_children.push(label); + + let mut v = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(height), + }, + padding: Rect { + left: length(8.0_f32 + indent), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .hover_fill(palette.bg_hover) + .children(row_children); + + // Líneas guía de indentación, pintadas por debajo de los hijos. + if guides && row.depth > 0 { + let guide = palette.guide; + let depth = row.depth; + v = v.paint_with(move |scene, _ts, rect: PaintRect| { + let stroke = Stroke::new(1.0); + for k in 0..depth { + let x = (rect.x + 8.0 + k as f32 * indent_px + 7.0) as f64; + let line = KurboLine::new((x, rect.y as f64), (x, (rect.y + rect.h) as f64)); + scene.stroke(&stroke, Affine::IDENTITY, guide, None, &line); + } + }); + } + + if let Some(ctx) = row.on_context { + v = v.on_right_click(ctx); + } + + v +} diff --git a/widgets/wawa-mark/Cargo.toml b/widgets/wawa-mark/Cargo.toml new file mode 100644 index 0000000..974fe10 --- /dev/null +++ b/widgets/wawa-mark/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-wawa-mark" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-wawa-mark — sello vectorial de wawa: rombo con degradado azul índigo → púrpura profundo + 'W' implícita en trazo blanco continuo + Merkle Core luminoso en la sutura. Sin tipografía, todo geometría." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/wawa-mark/examples/wawa_mark_demo.rs b/widgets/wawa-mark/examples/wawa_mark_demo.rs new file mode 100644 index 0000000..c048e84 --- /dev/null +++ b/widgets/wawa-mark/examples/wawa_mark_demo.rs @@ -0,0 +1,85 @@ +//! Demo del sello wawa. Tres tamaños sobre fondo oscuro neutro. +//! +//! `cargo run -p llimphi-widget-wawa-mark --example wawa_mark_demo --release` + +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::{App, Handle, View}; +use llimphi_widget_wawa_mark::{wawa_mark_view, WawaMarkPalette}; + +struct Demo; + +impl App for Demo { + type Model = (); + type Msg = (); + + fn title() -> &'static str { + "wawa · sello" + } + + fn initial_size() -> (u32, u32) { + (820, 420) + } + + fn init(_: &Handle) {} + fn update(model: Self::Model, _: Self::Msg, _: &Handle) -> Self::Model { + model + } + + fn view(_: &Self::Model) -> View { + let palette = WawaMarkPalette::default(); + + let frame = |side: f32| -> View<()> { + View::new(Style { + size: Size { + width: length(side), + height: length(side), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .children(vec![wawa_mark_view(&palette)]) + }; + + let row = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::SpaceEvenly), + gap: Size { + width: length(24.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![frame(72.0), frame(160.0), frame(288.0)]); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(32.0_f32), + right: length(32.0_f32), + top: length(32.0_f32), + bottom: length(32.0_f32), + }, + ..Default::default() + }) + // Fondo grafito neutro para que el rombo destaque sin competir. + .fill(Color::from_rgba8(18, 18, 22, 255)) + .children(vec![row]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/wawa-mark/src/lib.rs b/widgets/wawa-mark/src/lib.rs new file mode 100644 index 0000000..1618ba2 --- /dev/null +++ b/widgets/wawa-mark/src/lib.rs @@ -0,0 +1,306 @@ +//! `llimphi-widget-wawa-mark` — sello vectorial del SO wawa. +//! +//! ## Spec (revisión 2026-05-29) +//! +//! Identidad nominal **implícita**: el rombo de fondo lleva la paleta +//! oficial (Azul Índigo / Púrpura Profundo) y los trazos blancos forman +//! las letras **"WA"** pero geométricamente — no son tipografía, son +//! aristas internas que rebotan en los mismos 45° del rombo, así dan +//! sensación de facetas talladas dentro del diamante. +//! +//! ### Composición +//! +//! 1. **Rombo de fondo** — degradado vertical inmaculado, sin sutura +//! visible: índigo arriba, púrpura abajo. El degradado lineal cubre +//! toda la altura del rombo (no sólo la mitad), de modo que el cambio +//! de tono es continuo. +//! 2. **Trazo "WA"** — un único `BezPath` con dos subtrazos: +//! - **W** (izquierda): zigzag de 4 segmentos, todos a 45° (matching +//! las aristas del rombo). Picos en la sutura azul/púrpura +//! (y = 0.50), valles en y = 0.60. Cinco vértices, cuatro segmentos. +//! - **A** (derecha): triángulo abierto formado por dos legs a 45° +//! + un crossbar horizontal a mitad de altura. Tres segmentos. +//! Las strokes diagonales (6 de las 7) son paralelas a las aristas +//! del rombo, por eso "leen" como filos cortados del diamante en vez +//! de letras pintadas encima. +//! 3. **Merkle Core** — punto luminoso con halo en el pico central de +//! la W (sobre la sutura, donde azul y púrpura se encuentran). Es el +//! nodo raíz que amarra el sistema. +//! +//! ### Geometría (en coords normalizadas `[0, 1] × [0, 1]` del rect) +//! +//! ```text +//! Top +//! ◇ +//! / \ +//! / \ ← azul índigo +//! / \ +//! P0 P2★ P4 A1 +//! ●─. ● .─● ●─. .─● ← y = 0.50 (sutura) +//! ╲ ╱ ╲ ╱ ╲ ╱ +//! ╳ ╳ ╲────╱ ← crossbar A (y=0.55) +//! ╱ ╲ ╱ ╲ ╱ ╲ +//! ●─' ● '─● ●─' '─● ← y = 0.60 (valles/pies) +//! P1 P3 A0 A2 +//! ↑ +//! gap entre W y A +//! Left ◇─────────────────────────────────◇ Right +//! (sutura, y = 0.50) +//! / +//! / +//! / ← púrpura profundo +//! / +//! ◇ +//! Bottom +//! ``` +//! +//! Las strokes diagonales todas a slope ±1, igual que las aristas del +//! rombo. El crossbar de la A es la única horizontal — concesión mínima +//! a la legibilidad de la letra, queda subordinado al patrón diamante. +//! +//! ## Uso +//! +//! ```ignore +//! use llimphi_widget_wawa_mark::{wawa_mark_view, WawaMarkPalette}; +//! +//! // En un view: +//! View::new(Style { size: Size { width: length(128.0), height: length(128.0) }, ..Default::default() }) +//! .children(vec![wawa_mark_view(&WawaMarkPalette::default())]) +//! ``` +//! +//! El widget rellena el rect del padre — pasarle un tamaño cuadrado para +//! que el rombo no se distorsione (lo respeta igual, pero queda mejor +//! cuadrado). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{percent, Size, Style}, + Position, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Point, Stroke}; +use llimphi_ui::llimphi_raster::peniko::{color::AlphaColor, Color, Fill, Gradient, Mix}; +use llimphi_ui::View; + +/// Paleta del sello. Los defaults corresponden a la especificación +/// oficial (Azul Índigo + Púrpura Profundo + trazo blanco + acento +/// cyan-eléctrico para el Merkle Core). +#[derive(Debug, Clone, Copy)] +pub struct WawaMarkPalette { + /// Color superior del degradado (tope del rombo). + pub indigo: Color, + /// Color inferior del degradado (base del rombo). + pub purple: Color, + /// Color del trazo de la 'W' implícita. + pub stroke: Color, + /// Color del Merkle Core (nodo central). Halo se deriva con alpha + /// reducido del mismo color. + pub core: Color, +} + +impl Default for WawaMarkPalette { + fn default() -> Self { + Self { + // Azul Índigo profundo — saturación alta, valor medio. + indigo: Color::from_rgba8(46, 56, 168, 255), + // Púrpura Profundo — más violeta, valor menor. + purple: Color::from_rgba8(76, 32, 122, 255), + // Blanco con leve calidez para no quemar contra el púrpura. + stroke: Color::from_rgba8(240, 240, 248, 255), + // Cyan eléctrico — el "color del cursor del osciloscopio". + core: Color::from_rgba8(120, 240, 255, 255), + } + } +} + +/// Construye el `View` que pinta el sello dentro del rect del padre. +/// El widget se posiciona absolute al 100% del padre — pasarle un +/// contenedor con tamaño cuadrado para evitar distorsión. +pub fn wawa_mark_view(palette: &WawaMarkPalette) -> View { + let p = *palette; + 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_mark(scene, rect, &p)) +} + +/// Pintor puro — recibe el `Scene`, el rect de pintura y la paleta. +/// Expuesto por separado para que apps avanzadas puedan reusar el +/// painter dentro de canvas custom (splash de boot, about box, etc.) +/// sin pasar por la fachada `View`. +pub fn paint_mark( + scene: &mut llimphi_ui::llimphi_raster::vello::Scene, + rect: llimphi_ui::PaintRect, + palette: &WawaMarkPalette, +) { + // Encajamos el rombo en el menor de los lados del rect, centrado. + // Así el sello mantiene su proporción incluso si el rect no es + // cuadrado (pero degrada gracilmente). + let side = rect.w.min(rect.h) as f64; + let cx = rect.x as f64 + rect.w as f64 * 0.5; + let cy = rect.y as f64 + rect.h as f64 * 0.5; + let half = side * 0.5; + + // === 1) Rombo de fondo con degradado vertical === + // + // Construimos el rombo como BezPath (4 segmentos rectos) en coords + // absolutas. El degradado lineal va de (cx, top) a (cx, bot) — toda + // la altura del rombo — para que el cambio de tono sea continuo y + // sin sutura visible. + let top = Point::new(cx, cy - half); + let right = Point::new(cx + half, cy); + let bot = Point::new(cx, cy + half); + let left = Point::new(cx - half, cy); + + let mut rhombus = BezPath::new(); + rhombus.move_to(top); + rhombus.line_to(right); + rhombus.line_to(bot); + rhombus.line_to(left); + rhombus.close_path(); + + let gradient = Gradient::new_linear(top, bot) + .with_stops([palette.indigo, palette.purple].as_slice()); + + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rhombus); + + // === 2) "WA" implícita === + // + // Coords en porcentaje del rombo (origen = esquina top-left del bbox + // del rombo = (cx-half, cy-half), unidad = side). Toda stroke diagonal + // tiene |dy/dx| = 1 (paralela a las aristas del rombo) — por eso lee + // como faceta del diamante en vez de letra dibujada encima. + let coord = |fx: f64, fy: f64| -> Point { + Point::new( + cx - half + fx * side, + cy - half + fy * side, + ) + }; + + // Unidad de escala: span vertical de las letras. dx==dy en cada leg + // hace que las strokes corran a 45° exactos (mismo ángulo que las + // aristas del rombo). Probado para que WA quede inscrita con holgura + // en el rombo a cualquier escala — al achicar (32px) sigue legible, + // al ampliar (300px) no se ve disperso. + let unit: f64 = 0.10; + // Línea de picos en la sutura azul/púrpura. + let top_y = 0.50; + // Línea de valles/pies en el cuadrante púrpura inferior. + let bot_y = top_y + unit; + + // ---- W (zigzag de 4 segmentos) ---- + // Centramos la composición WA: span total ≈ 0.61 (W 0.36 + gap 0.03 + // + A 0.18 + holgura). Empezamos en x = 0.19 para que el centro + // óptico de WA caiga cerca de x = 0.50. + let w_left = 0.20; + let p0 = coord(w_left + 0.0 * unit, top_y); + let p1 = coord(w_left + 1.0 * unit, bot_y); + let p2 = coord(w_left + 2.0 * unit, top_y); + let p3 = coord(w_left + 3.0 * unit, bot_y); + let p4 = coord(w_left + 4.0 * unit, top_y); + + // ---- A (legs + crossbar) ---- + // Gap entre W y A — apenas un respiro para que no se confundan en + // un solo zigzag. + let gap = 0.04; + let a_left = w_left + 4.0 * unit + gap; + let a0 = coord(a_left + 0.0 * unit, bot_y); + let a1 = coord(a_left + 1.0 * unit, top_y); + let a2 = coord(a_left + 2.0 * unit, bot_y); + // Crossbar a mitad de altura, en el tercio interno de cada leg para + // que no toque las puntas (queda más A que H). + let cross_y = (top_y + bot_y) * 0.5 + 0.005; // un toque debajo del medio óptico + let c_offset = 0.30 * unit; + let cb0 = coord(a_left + 0.0 * unit + c_offset, cross_y); + let cb1 = coord(a_left + 2.0 * unit - c_offset, cross_y); + + // Un único BezPath con cuatro subtrazos (move_to abre subtrazo nuevo). + let mut wa = BezPath::new(); + // W + wa.move_to(p0); + wa.line_to(p1); + wa.line_to(p2); + wa.line_to(p3); + wa.line_to(p4); + // A — legs. + wa.move_to(a0); + wa.line_to(a1); + wa.line_to(a2); + // A — crossbar (horizontal, único trazo no diagonal). + wa.move_to(cb0); + wa.line_to(cb1); + + // Espesor escalable: ~2.0% del lado del rombo. Levemente más fino + // que la W sola, porque ahora hay 7 strokes en vez de 4 y conviene + // bajar densidad. + let stroke_w = (side * 0.020).max(1.0); + let stroke = Stroke::new(stroke_w) + .with_join(llimphi_ui::llimphi_raster::kurbo::Join::Miter) + .with_caps(llimphi_ui::llimphi_raster::kurbo::Cap::Butt); + + scene.stroke( + &stroke, + Affine::IDENTITY, + palette.stroke, + None, + &wa, + ); + + // === 3) Merkle Core === + // + // Sobre P2 — pico central de la W, en la sutura exacta entre azul y + // púrpura. Halo amplio semi-transparente + núcleo opaco compacto + // dan sensación de glow sin blur real. + let core_r = (side * 0.018).max(1.2); + let halo_r = core_r * 2.6; + let halo_color = with_alpha(palette.core, 0.30); + scene.push_layer(Mix::Normal, 1.0, Affine::IDENTITY, &Circle::new(p2, halo_r)); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + halo_color, + None, + &Circle::new(p2, halo_r), + ); + scene.pop_layer(); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + palette.core, + None, + &Circle::new(p2, core_r), + ); +} + +/// Devuelve `color` con su alpha multiplicado por `mult` (no reemplazado). +/// Mantenemos la cromaticidad intacta. +fn with_alpha(color: Color, mult: f32) -> Color { + let [r, g, b, a] = color.components; + AlphaColor::new([r, g, b, a * mult]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_palette_has_distinct_indigo_and_purple() { + let p = WawaMarkPalette::default(); + assert_ne!(p.indigo.components, p.purple.components); + assert_ne!(p.stroke.components, p.core.components); + } + + #[test] + fn with_alpha_multiplies_not_replaces() { + let c = Color::from_rgba8(100, 100, 100, 255); + let halved = with_alpha(c, 0.5); + assert!((halved.components[3] - 0.5).abs() < 1e-3); + // RGB intactos. + assert!((halved.components[0] - c.components[0]).abs() < 1e-3); + } +}