From 95bc028eeab35a55ae8bef41f820de73348d5baa Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 4 Jun 2026 12:18:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20pata=20standalone=20=E2=80=94=20marco?= =?UTF-8?q?=20de=20escritorio=20declarativo=20(barras/dock/tray/widgets)?= =?UTF-8?q?=20portable=20Linux/Wawa=20(front-door,=20git-dep=20al=20monore?= =?UTF-8?q?po)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por git-dep del monorepo gioser.git. cargo check pasa (11 crates, 0 errores). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + 02_ruway/pata/LEEME.md | 19 + 02_ruway/pata/README.md | 37 + 02_ruway/pata/SDD.md | 536 ++ 02_ruway/pata/pata-config/Cargo.toml | 16 + .../pata/pata-config/assets/launcher.toml | 112 + 02_ruway/pata/pata-config/src/lib.rs | 92 + 02_ruway/pata/pata-config/src/main.rs | 196 + 02_ruway/pata/pata-core/Cargo.toml | 28 + 02_ruway/pata/pata-core/src/config.rs | 491 ++ 02_ruway/pata/pata-core/src/layout.rs | 289 + 02_ruway/pata/pata-core/src/lib.rs | 47 + 02_ruway/pata/pata-core/src/widget.rs | 663 ++ 02_ruway/pata/pata-core/src/wire.rs | 320 + .../pata/pata-core/tests/toml_contract.rs | 129 + 02_ruway/pata/pata-host/Cargo.toml | 12 + 02_ruway/pata/pata-host/src/client.rs | 65 + 02_ruway/pata/pata-host/src/lib.rs | 179 + 02_ruway/pata/pata-host/src/server.rs | 157 + 02_ruway/pata/pata-host/tests/roundtrip.rs | 68 + 02_ruway/pata/pata-llimphi/Cargo.toml | 73 + 02_ruway/pata/pata-llimphi/src/keys.rs | 74 + 02_ruway/pata/pata-llimphi/src/layer.rs | 1761 ++++ 02_ruway/pata/pata-llimphi/src/lib.rs | 652 ++ 02_ruway/pata/pata-llimphi/src/main.rs | 28 + 02_ruway/pata/pata-llimphi/src/nouser.rs | 454 + 02_ruway/pata/pata-llimphi/src/open.rs | 214 + 02_ruway/pata/pata-llimphi/src/render.rs | 996 +++ .../pata/pata-llimphi/src/render/sidebar.rs | 622 ++ 02_ruway/pata/pata-llimphi/src/sampler.rs | 446 + 02_ruway/pata/pata-llimphi/src/shuma.rs | 971 +++ 02_ruway/pata/pata-llimphi/src/toplevel.rs | 187 + 02_ruway/pata/pata-llimphi/src/tray.rs | 452 + Cargo.lock | 7406 +++++++++++++++++ Cargo.toml | 439 + LICENSE | 21 + README.md | 11 + 37 files changed, 18266 insertions(+) create mode 100644 .gitignore create mode 100644 02_ruway/pata/LEEME.md create mode 100644 02_ruway/pata/README.md create mode 100644 02_ruway/pata/SDD.md create mode 100644 02_ruway/pata/pata-config/Cargo.toml create mode 100644 02_ruway/pata/pata-config/assets/launcher.toml create mode 100644 02_ruway/pata/pata-config/src/lib.rs create mode 100644 02_ruway/pata/pata-config/src/main.rs create mode 100644 02_ruway/pata/pata-core/Cargo.toml create mode 100644 02_ruway/pata/pata-core/src/config.rs create mode 100644 02_ruway/pata/pata-core/src/layout.rs create mode 100644 02_ruway/pata/pata-core/src/lib.rs create mode 100644 02_ruway/pata/pata-core/src/widget.rs create mode 100644 02_ruway/pata/pata-core/src/wire.rs create mode 100644 02_ruway/pata/pata-core/tests/toml_contract.rs create mode 100644 02_ruway/pata/pata-host/Cargo.toml create mode 100644 02_ruway/pata/pata-host/src/client.rs create mode 100644 02_ruway/pata/pata-host/src/lib.rs create mode 100644 02_ruway/pata/pata-host/src/server.rs create mode 100644 02_ruway/pata/pata-host/tests/roundtrip.rs create mode 100644 02_ruway/pata/pata-llimphi/Cargo.toml create mode 100644 02_ruway/pata/pata-llimphi/src/keys.rs create mode 100644 02_ruway/pata/pata-llimphi/src/layer.rs create mode 100644 02_ruway/pata/pata-llimphi/src/lib.rs create mode 100644 02_ruway/pata/pata-llimphi/src/main.rs create mode 100644 02_ruway/pata/pata-llimphi/src/nouser.rs create mode 100644 02_ruway/pata/pata-llimphi/src/open.rs create mode 100644 02_ruway/pata/pata-llimphi/src/render.rs create mode 100644 02_ruway/pata/pata-llimphi/src/render/sidebar.rs create mode 100644 02_ruway/pata/pata-llimphi/src/sampler.rs create mode 100644 02_ruway/pata/pata-llimphi/src/shuma.rs create mode 100644 02_ruway/pata/pata-llimphi/src/toplevel.rs create mode 100644 02_ruway/pata/pata-llimphi/src/tray.rs create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md 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/02_ruway/pata/LEEME.md b/02_ruway/pata/LEEME.md new file mode 100644 index 0000000..cac846d --- /dev/null +++ b/02_ruway/pata/LEEME.md @@ -0,0 +1,19 @@ +# pata + +> El marco del escritorio: barras, paneles y dock declarativos — widgets que +> colocás donde quieras, desde un archivo de config. El mismo modelo en Linux y +> en Wawa. + +`pata` (quechua: *borde, repisa, andén*) es la capa de chrome del escritorio +gioser. No es el compositor (`mirada`) ni el shell (`shuma`): es el marco +configurable que rodea a las ventanas. Desde un archivo desplegás **barras**, +**paneles** y un **dock**, y dentro acomodás widgets — botón inicio, lista de +ventanas abiertas, clipboard / volumen / brillo, tray, reloj, un widget +**astro** (posición zodiacal del sol + ciclo lunar) y el input del shell que +despliega `shuma` estilo Quake. + +El modelo vive en `pata-core`, agnóstico y `no_std`, así que el mismo marco +corre como frontend Llimphi en Linux (sobre el compositor `mirada`) y desde el +kernel launcher de Wawa. + +Definición canónica y plan por fases: [`SDD.md`](SDD.md). diff --git a/02_ruway/pata/README.md b/02_ruway/pata/README.md new file mode 100644 index 0000000..9b9cdad --- /dev/null +++ b/02_ruway/pata/README.md @@ -0,0 +1,37 @@ +# pata + +> The desktop frame: declarative bars, panels and a dock — widgets you place +> anywhere, from one config file. The same model on Linux and on Wawa. + +`pata` (Quechua: *edge, ledge, terrace*) is the chrome layer of the gioser +desktop. It is **not** the compositor (`mirada`) nor the shell (`shuma`): it is +the configurable frame that surrounds the windows. From a config file you deploy +**bars**, **panels** and a **dock**, and inside them arrange widgets — start +button, open-window list, clipboard / volume / brightness, tray, clock, an +**astro** widget (the Sun's zodiac position + lunar cycle), and the shell input +that unfolds `shuma` Quake-style. + +The model lives in `pata-core`, agnostic and `no_std`, so the very same frame +runs as a Llimphi frontend on Linux (over the `mirada` compositor) and from the +Wawa kernel launcher. + +See [`SDD.md`](SDD.md) for the canonical definition and the phase plan. + +## Crates + +| Crate | Role | +|---|---| +| [`pata-core`](pata-core/) | Agnostic model + layout: `Config → [Surface] → slots → [WidgetSpec]` and `resolve(config, screen) → Frame`. `no_std + alloc`. | +| [`pata-config`](pata-config/) | Linux loader (std): reads the user's TOML from XDG paths into the model. Ships the `pata` inspector binary. | + +(`pata-llimphi` rendering and the Wawa launcher land in later phases.) + +## Try it + +```sh +cargo run -p pata-config --bin pata -- \ + --config 02_ruway/pata/pata-config/assets/launcher.toml --screen 1920x1080 +``` + +Prints how the frame resolves: each surface's rect, whether it reserves a strip, +its widgets per slot, and the work area left for windows. diff --git a/02_ruway/pata/SDD.md b/02_ruway/pata/SDD.md new file mode 100644 index 0000000..ff952cb --- /dev/null +++ b/02_ruway/pata/SDD.md @@ -0,0 +1,536 @@ +# SDD — `pata`, el marco del escritorio + +> Estado: **Fase 8b** (layer-shell sobre wlroots: barras, Quake, clicks, +> `window_list`). Este documento es la fuente autoritativa de qué es `pata` y +> dónde termina, por encima de README. + +## 0. El problema que resuelve + +El escritorio de gioser tenía el concepto de "launcher" **triplicado y mal +delimitado**: `mirada-launcher-llimphi` (la barra), `shuma-shell-llimphi` (un +chasis con tabs) y `shuma-module-launcher` (un módulo lista-de-apps) competían +por el mismo rol sin una frontera clara. Correr cualquiera bajo el compositor +daba ventanas sueltas en vez de un escritorio coherente. + +`pata` fija la frontera: es **una sola capa**, la del *marco* (chrome) del +escritorio, desacoplada del compositor y del shell, y portable entre Linux y +wawa. + +## 1. Las tres capas (no se solapan) + +| Capa | Quechua/es | Qué es | Qué **no** es | +|---|---|---|---| +| **mirada** | mirar | El **compositor**: el Cuerpo Wayland/DRM. Tesela, acopla franjas, decora, enruta input. | No dibuja barras ni widgets. | +| **shuma** | — | El **shell**: input + terminal + módulos. Se asoma como una barra inferior auto-escondible; al escribir, despliega el resto estilo Quake. | No es el chrome del escritorio; es un inquilino del marco. | +| **pata** | borde, repisa, andén | El **marco**: barras, paneles y dock declarados desde un archivo de config, con widgets colocables en cualquier slot. Hospeda el input de shuma. | No compone ventanas (eso es mirada) ni ejecuta comandos (eso es shuma). | + +Regla mnemónica: **mirada** pone las ventanas, **pata** pone el marco +alrededor, **shuma** es la boca por la que le hablás al sistema. + +## 2. Forma del dominio + +``` +pata-core Modelo agnóstico (no_std + alloc): el esquema declarativo + (Config → [Surface] → slots → [WidgetSpec]) + el layout + (resolve: config+pantalla → superficies colocadas + work_area). + No pinta, no toca el SO. Cruza a wawa por `path`, como mirada-layout. +pata-config Loader Linux (std): lee el TOML del usuario desde las rutas XDG y + lo materializa en el modelo. El límite std→no_std del marco. Trae + el binario `pata` para inspeccionar la geometría resuelta. En wawa + este rol lo cumple akasha, no este crate. +pata-llimphi Frontend Linux: monta pata-core sobre Llimphi. Cada Surface es + una ventana Wayland que el compositor mirada acopla; despacha + los widgets builtin; el shuma_input despliega shuma. (Fase 3, + hereda de mirada-launcher-llimphi.) +pata (wawa) El kernel launcher de wawa consume el MISMO pata-core y pinta + sobre el framebuffer. (Fase 7.) +``` + +UIs intercambiables sobre un `*-core` agnóstico — la regla dura del repo. El +modelo se escribe una vez; Linux y wawa son dos pinceles. + +## 3. El modelo (`pata-core`) + +- **`Config`** = `general` + `Vec`. Múltiples superficies, no un único + panel. El usuario despliega tantas barras/paneles/docks como quiera. +- **`Surface`** = `kind` (Bar | Panel | Dock) + `anchor` (Top/Bottom/Left/Right) + + `thickness` + `autohide` + tres slots `start`/`center`/`end` (+ `cards` + para paneles). Cada slot es una lista ordenada de widgets. +- **`WidgetSpec`** = `kind: String` (conjunto **abierto**) + `props` arbitrarias. + El frontend despacha por string y cae a un placeholder si no conoce el kind: + agregar un widget no toca el core. +- **`Prop`** = `Bool | Int | Num | Str`. Valor de propiedad **agnóstico del + formato en disco** — ni `toml::Value` ni nada atado a una plataforma. El + loader de cada SO (TOML en Linux, akasha en wawa) deserializa a esto. + +El formato en disco no es parte del modelo: en Linux un loader TOML deserializa +directo a estos tipos vía `serde` (contrato fijado en `tests/toml_contract.rs`); +en wawa el config llega por akasha. + +## 4. Widgets builtin previstos + +`start_button` · `window_list` (ventanas abiertas, vía mirada-ctl/-link) · +`clipboard` · `volume` · `brightness` · `tray` · `clock` · medidores +(`ram_meter`/`cpu_meter`) · **`astro`** (posición zodiacal del sol + ciclo +lunar, reusando `cosmos-ephemeris`) · `shuma_input` (el cabezal del shell). + +Cada uno se coloca libremente: superficie + slot se eligen desde el config. + +## 5. Integración con shuma (el Quake) + +El `shuma_input` es un widget que vive típicamente en una `Surface { kind: Bar, +anchor: Bottom, autohide: true }`. Muestra el cabezal del shell; al recibir +foco/escritura, el frontend **anima el despliegue** del resto de shuma sobre el +escritorio (drawer estilo Quake) y lo repliega al soltar. El marco provee el +borde; shuma provee el contenido. + +## 6. Estado y plan por fases + +- **Fase 1 ✅** — `pata-core` config: esquema declarativo, `Prop` agnóstico, + contrato TOML, `no_std` verificado (wasm32). En el workspace. +- **Fase 2 ✅** — `pata-core::layout`: resuelve config+pantalla en superficies + colocadas + `work_area` (lo que mirada tesela). Geometría pura testeada. +- **Fase 3 ✅** — `pata-config`: loader TOML/XDG → modelo + binario `pata` + inspector. Pipeline config→layout verificado sobre archivos reales. +- **Fase 4 ✅** — modelo de widget agnóstico (`pata-core::widget`): trait + [`Widget`] (`tick(&WidgetCtx)` / `view() → WidgetView`), un `WidgetCtx` que el + host muestrea (reloj, cpu, ram, volumen, brillo) y un view-model + `Text | Meter | Placeholder | Empty` sin pincel. Builtins con lógica portada: + `clock` (strftime reducido), `cpu_meter` / `ram_meter` / `volume` / + `brightness` (medidor genérico). `build(spec)` despacha por string y cae a + `Placeholder` para kinds no implementados. `no_std` verificado (wasm32); el + inspector `pata --widgets` lo muestra de punta a punta. +- **Fase 5 ✅** — `pata-llimphi`: el frontend Linux. `sampler` muestrea el + sistema (chrono + `/proc/stat` + `/proc/meminfo` + `/sys/class/backlight`) en + un `WidgetCtx`; `render` traduce cada `WidgetView` a `View` (texto, + medidor con barra, placeholder tenue) y coloca las superficies en los rects + que el layout resolvió (posición absoluta). `PataApp` (app-id `gioser.pata`) + carga config vía `pata-config`, `tick`ea a 1 Hz y pinta. Por ahora una sola + ventana; mirada acopla por superficie en la Fase 8. +- **Fase 6 (parcial)** — widgets nuevos: + - `astro` ✅ — posición zodiacal del Sol (signo + grado) + fase lunar. La + efeméride la computa el sampler (host) y la entrega en `WidgetCtx` + (`sun_longitude_deg` + `moon_phase`); el widget de core sólo mapea grados→ + signo y fracción→fase, con aritmética entera (no_std). El sampler usa la + fórmula de baja precisión del *Astronomical Almanac*; `cosmos-ephemeris` + es el upgrade drop-in cuando se quiera alta precisión. + - `start_button` ✅ — muestra su `label` (default `⊞`). Cablear su acción + (abrir el lanzador) espera al ruteo de clicks (Fase 7). + - `window_list` ✅ (en layer-shell) — lista de ventanas abiertas vía el + protocolo `wlr-foreign-toplevel-management` (el que usan waybar/eww), no por + IPC de mirada. Ver el detalle en la Fase 8b. Bajo el compositor `mirada` (el + path winit) sigue vacío hasta que mirada exponga sus toplevels. + - `tray` ⏳ — StatusNotifierItem; diferido. Placeholder por ahora. +- **Fase 7 ✅** — despliegue Quake de shuma desde `shuma_input`. El frontend + intercepta el kind `shuma_input` (es interacción, no pasa por el `build` + agnóstico de core, igual que mirada con su shuma_bar): un cabezal clicable en + la barra + hotkey (`keys`) despliegan un **drawer** animado (`llimphi-motion`, + scrim que cierra al click + panel inferior que crece con el tween) que captura + el teclado. El estado vive en `Model::shuma`, no en core. La ejecución del + comando es, estrictamente, de `shuma`: mientras no haya puente, `shuma:: + ejecutar_stand_in` corre por `sh -c` como **sustituto temporal** (patrón de + mirada) — se reemplaza sin tocar el mecanismo del drawer. + - **Puente real + cards (✅)** — el drawer corre por `shuma-exec` (no `sh -c` + pelado): historial de *cards* (`$ cmd` + etapas + salida + código), plegables, + con scroll. **Captura por etapa (tee, paridad con el shell de shuma):** un + pipe «simple» (sólo comandos/args/flags y `|`, sin comillas/variables/ + redirecciones/globs/`~`) corre por `Exec::Direct` con `capture_stages`; cada + etapa **intermedia** emite su stdout en vivo (`StageStdout`) y se guarda en + `DrawerBlock::stage_lines`. Clickear la chip de una etapa intermedia **revela + su salida capturada inline** (sin re-ejecutar); la última etapa no se captura + aparte (su stdout es el cuerpo de la card). Cualquier otra sintaxis cae a + `sh -c` (sin tee). Detección en `shuma::simple_pipe_stages` (espeja + `shuma-module-shell`), testeada. + - **Submit a IA (✅, paridad con el quake de mirada-launcher)** — el buffer sin + prefijo va al **LLM** (`pluma-llm::from_env`, cae a Mock sin credenciales); el + prefijo `!`/`$` lo fuerza a shell. `shuma::classify` decide (`Empty`/`Shell`/ + `Ia`, testeada); las consultas IA abren una card `✦ ` sin chips de + etapa que muestra `…pensando` y luego la respuesta. El resultado llega por el + mismo `ShumaResult`/`finish_last` que un comando. Es el último gap que tenía + `mirada-launcher-llimphi` sobre pata de cara a la Fase 10. +- **Fase 8 ✅** — `mirada-compositor` reconoce el marco `pata`: + - Identidad: el viejo `SHELL_APP_ID = "carmen.shell"` → `is_shell_app_id`, que + matchea `gioser.pata` (la identidad que anuncia `pata-llimphi`) o el alias + legacy `carmen.shell`, override por `MIRADA_SHELL_APP_ID`. + - Anclaje/grosor configurables (`MIRADA_SHELL_ANCHOR` / `MIRADA_SHELL_THICKNESS`, + defaults bottom/40), ya no una franja fija de 40px al pie. Geometría en + helpers puros testeados (`shell_strip` / `shell_insets`). + - **Zonas exclusivas en los cuatro bordes**: el acople ya no encoge la salida, + sino que reserva *insets* (top/bottom/left/right) vía el evento nuevo + `BodyEvent::OutputReserved` → `Body::reserve_output` → el Cerebro guarda + `Output::reserved` y tesela sobre `Output::work_rect()` (rect menos insets). + El motor de layout ya respetaba `screen.x/y`, así que top/left desplazan el + origen del teselado correctamente. Soporta barras en varios bordes a la vez. + Cerrar el shell libera la reserva (insets en cero). +- **Fase 8b (en curso)** — `pata` como **layer surface** (nivel eww/waybar) en + compositores wlroots (Hyprland, Sway, river), no como ventana cliente. Sin + esto, en Hyprland pata abría como ventana flotante; el acople de la Fase 8 era + sólo para el compositor `mirada`, no para terceros. + - `llimphi-hal::RawSurface` — `Surface` sobre una `wgpu::Surface` creada desde + handles raw, **sin `winit::Window`** (misma intermedia + blit que + `WinitSurface`). Es la costura: el render de Llimphi ya era winit-free salvo + la creación de la surface. + - `pata-llimphi::layer` — backend `wlr-layer-shell` con + `smithay-client-toolkit`: crea **una layer surface por cada superficie + `Bar`** de la config (cada una anclada a su borde + `set_exclusive_zone`), + saca su `wgpu::Surface` de los punteros `wl_display`/`wl_surface`, y la pinta + reusando `mount → compute → paint → render` vía [`render::bar_view`]. Un + `Hal` (instancia/device de wgpu) compartido; estado wgpu por panel + (`PanelGpu`). Muestreo 1Hz compartido + flag `dirty` por panel (no + re-rasteriza a 60fps). `main` elige layer-shell si hay `WAYLAND_DISPLAY` + (salvo `PATA_BACKEND=winit`), con fallback a la ventana winit. + Verificado en runtime (Hyprland): salen todas las barras ancladas, sin + error, y el muestreo/leyenda quietos. + - **Gotcha Vulkan WSI + smithay (mirada)** — `draw` redimensiona la surface + en cada cuadro (no hay evento de resize como en winit), así que + `RawSurface::resize` es **no-op cuando el tamaño no cambia**: reconfigurar + el swapchain por cuadro reconstruye el `wl_buffer` y destruye el recién + presentado antes de que el compositor lo componga — wlroots lo tolera, + smithay (mirada) no, y la barra quedaba negra (`buffer=None`). `acquire` + reconfigura+reintenta una vez ante `Outdated`/`Lost`. Fix en `b8747b90`. + - **Input + Quake** ✅ (verificado en Hyprland): seat/keyboard/pointer + vía sctk. Un cliente layer-shell **no recibe hotkeys globales**, así que el + Quake se abre con **click** en la barra de shuma (foco de teclado vía + `OnDemand` → al abrir pasa a `Exclusive`). En vez de una segunda surface, la + propia barra de shuma **crece hacia arriba** hasta `DRAWER_H` (su exclusive + zone queda en el grosor de la barra, así no recoloca el teselado); + `render::shuma_open_view` pinta el cuerpo del drawer (input + salida) arriba + y el cabezal abajo. Teclado con foco: Esc cierra, Backspace, Enter ejecuta + (`shuma::ejecutar_stand_in`, `sh -c` bloqueante), texto → buffer. + - **Clicks por hit-test** ✅ — cada panel guarda su árbol pintado + (`RenderCache`: `Mounted` + `ComputedLayout`); al click, `hit_test_click` + ubica el nodo bajo el puntero y dispara su `on_click` (vía `handle_msg`). El + cabezal `› shuma` togglea con precisión (abre y cierra); clickear el reloj o + un medidor no hace nada. Reemplaza al "cualquier click en la barra abre". + - **Acciones por widget** ✅ — cualquier widget con una prop `exec` se vuelve + clickeable (estilo waybar): `SlotWidget::Core { widget, exec }` lleva el + comando; el render le pone `on_click(Msg::Spawn(cmd))` + `hover_fill`; ambos + backends lo lanzan con `spawn_cmd` (`sh -c`, sin esperar). Ej. en el asset: + `start_button` con `exec = "wofi --show drun"`. + - **Exec asíncrono del Quake** ✅ — el `Enter` corre el comando en un hilo y el + resultado llega por un `mpsc::Receiver` que el latido sondea (`try_recv`) cada + frame; ya no bloquea el loop. Mientras corre, el drawer muestra `…`. + - **Volumen real** ✅ — el sampler lee el volumen del sink por defecto vía + PipeWire (`wpctl get-volume`) con fallback a PulseAudio (`pactl`), y rellena + `WidgetCtx::{volume, muted}` (el brillo ya lo lee de `/sys`). El medidor deja + de marcar 0%. Parseo en funciones puras testeadas (`parse_wpctl`/ + `parse_pactl_pct`). Bonus en el asset: `volume` con `exec = "pavucontrol"`. + - **`window_list`** ✅ — la lista de ventanas abiertas, vía + `wlr-foreign-toplevel-management` (`wayland-protocols-wlr`), el protocolo de + waybar/eww. El manager se bindea opcional (si el compositor no lo expone, el + widget queda vacío sin romper); cada toplevel acumula título/app_id/estado en + `pata-llimphi::toplevel::Toplevel` y se confirma en `done`. El render pinta un + chip clickeable por ventana (la activa resaltada); el click manda + `Msg::ActivateWindow(id)` → `activate(seat)`, que la trae al frente. Como el + `shuma_input`, es interacción + IPC: no pasa por el `build` agnóstico de core + sino que lo intercepta el frontend (`SlotWidget::WindowList`); los datos se + pasan al render aparte del view-model. El asset `launcher.toml` ya lo tiene en + el centro de la barra superior. + - **`clipboard`** ✅ — preview del texto copiado. El sampler lo lee con + `wl-paste --no-newline --type text/plain` (subproceso ~1Hz, como el volumen + con `wpctl`) y lo colapsa a una línea (`sampler::preview_clipboard`, testeada); + el render pinta `📋 ` recortado. Como `window_list`, es dato del host + interceptado por el frontend (`SlotWidget::Clipboard`), no view-model de core; + los datos del host (ventanas + portapapeles) viajan juntos en `render::BarData`. + Una prop `exec` lo vuelve clickeable → selector de historial (en el asset: + `cliphist list | wofi --dmenu | cliphist decode | wl-copy`). Sólo en + layer-shell (el path winit pasa `BarData::default()`). + - **`tray`** ✅ — la bandeja del sistema (StatusNotifierItem). pata corre como + **watcher + host**: posee `org.kde.StatusNotifierWatcher` y atiende a las apps + que registran su item. Como el bucle sctk es bloqueante y zbus es async, el + tray vive en su **propio hilo** con un runtime tokio current-thread (el + workspace fija zbus con la feature `tokio`, no la blocking — patrón de + `mirada-portal`); comparte el snapshot de items por `Arc` y recibe los + clicks por un canal tokio (como el exec del Quake). El render pinta un chip por + item (resaltando `NeedsAttention`); el click manda `Msg::TrayActivate(key)` → + `Activate(0,0)` por D-Bus. Interceptado por el frontend (`SlotWidget::Tray`), + los items viajan en `render::BarData`. **Íconos** ✅: resuelve el `IconPixmap` + (ARGB32 por D-Bus → RGBA, sin tema) y, si no, el `IconName` como PNG en los + dirs estándar (hicolor + pixmaps, sólo PNG, sin `index.theme` ni SVG); cae a + texto si nada resuelve. El hilo del tray decodifica a `TrayIcon{rgba}` y el + render lo envuelve en `peniko::Image` (`View::image`, 18px). **No** emite + señales del watcher ni hace fallback si ya hay un watcher (si el nombre está + tomado, queda vacío y loguea). `split_service` + normaliza el registro (ruta+remitente / nombre de bus / combinado), testeada. + El tray sólo arranca si la config declara un widget `tray`. Ver `02_ruway/pata/ + pata-llimphi/src/tray.rs`. + - **Fase 6 cerrada**: todos los widgets previstos (§4) existen, con íconos + reales en el tray. **`clipboard` y `tray` cableados también en el path winit** + (el `Model` muestrea el portapapeles cada tick y arranca el `TrayHandle` si la + config lo pide; `render::root` arma el `BarData` desde el `Model`). El único + pendiente es **`window_list` bajo el path winit/mirada**: necesita el cliente + foreign-toplevel (que vive en el backend layer-shell) o el IPC de toplevels de + mirada; hasta entonces queda vacío en ese path. Helper `config_tiene_widget` + compartido por ambos backends para arrancar el tray sólo si hace falta. +- **Fase 8c — pulido de escritorio** (en curso): + - **Gradiente en los medidores** ✅ — la barra de relleno de cpu/ram/volumen/ + brillo pinta un gradiente lineal (acento → acento aclarado) con `paint_with` + (Llimphi sólo tiene fill de color sólido). Ambos backends. + - **Task manager estilo KDE** ✅ — el `window_list` pasa de chips planos a + botones con ícono-badge (inicial del `app_id`) + título; la activa resaltada, + las minimizadas atenuadas. Clic izq. activa o **minimiza** si ya estaba activa; + clic der. **cierra** (`Msg::CloseWindow` → `handle.close()`). `Toplevel` + trackea `minimized`; el pointer del layer-shell rutea `BTN_RIGHT`. + - **Tarjetas flotantes (conky)** ✅ — `SurfaceKind::Panel` + `FloatingCard` ya + estaban en el modelo; ahora `render::card_view` las pinta y el layer-shell crea + **una layer surface por tarjeta** en `Layer::Bottom` (sobre el escritorio, + anclada a la esquina sup-izq con margen (x,y), sin reservar franja ni teclado). + En winit se pintan en absoluto. Asset con una tarjeta `sistema`. + - **Botón de inicio con menú nativo** ✅ — el `start_button` despliega un menú + de apps del registro (`app-bus AppRegistry::discover`). En layer-shell la barra + superior crece hacia abajo (truco del drawer Quake, al revés); en winit sale + por `view_overlay`. `exec` en el spec lo deja delegando a un lanzador externo. + - **Hover en todos los widgets** ✅ — el layer-shell pasaba `None` a `paint`, así + que `hover_fill` estaba muerto; ahora trackea `Motion`/`Leave` → + `hit_test_hover` → `hover_idx`. Todos los widgets dan realce al pasar el cursor. + - **Tooltip flotante (texto)** ✅ — cada widget lleva su tooltip vía el primitivo + nuevo `View::tooltip` de Llimphi (medidores: etiqueta + leyenda; ventanas/tray/ + clipboard: el texto completo, útil cuando la barra lo recorta). El layer-shell + crea una **layer surface dedicada en `Overlay`** con región de input vacía (no + roba puntero); al cambiar el nodo hovereado, `update_tooltip` lee texto + rect + del cache de hit-test y reubica la surface bajo el widget (`set_margin`/ + `set_size`); al salir se oculta fuera de vista. Cajita opaca (no depende de + transparencia de surface). Runtime a validar en compositor (norma de pata). +- **Fase 9 ✅** — kernel launcher de wawa sobre `pata-core`. El kernel + enlaza `pata-core` por `path` (`default-features = false`, como mirada-layout) y + consume el **mismo** modelo de widgets que el frontend Llimphi: `compositor:: + pata_marco` arma un `WidgetCtx` desde los datos del kernel (la RAM real del heap, + vía `memory::allocator::stats`), construye los widgets (`build_all`), los + `tick`ea y traduce cada `WidgetView` a las primitivas del framebuffer + (`grafico::Lienzo` + `texto::rasterizar`). Hoy pinta el cluster de indicadores + del taskbar (medidor de RAM) a la izquierda del reloj — un modelo, dos pinceles, + sobre bare-metal. Compila con `cargo +nightly check --target x86_64-unknown-none + -Z build-std=core,alloc`; runtime a validar en QEMU (norma de wawa). + - **`pata_core::resolve` integrado** — `pata_marco` ahora arma un `Config` + (barra de menú superior con `start_button` + `ram_meter`), lo resuelve con la + geometría canónica `resolve` (la misma que en Linux) y pinta **cada barra + resuelta** con sus tres slots (start izquierda / center centrado / end + derecha) en su rect. Se llama desde `consola::recomponer` sobre el área de + apps, tras componer el escritorio. El cluster suelto del taskbar se reemplazó + por esta barra completa. + - **Input al `start_button` cableado** — `pata_marco::start_button_rect(area)` + resuelve el mismo `Config` y devuelve el rect clickeable del ⊞ (espejando + dónde lo pinta `pintar_barra`); el ratón del compositor (`raton::atender_raton`) + detecta el clic ahí y **abre el launcher** (el mismo gesto que `Alt+P`), antes + de tocar foco/arrastre. El picker Spotlight ya existente se reusa tal cual. + - **Config por akasha** — el config del marco viaja por el grafo + direccionado por contenido, no armado en memoria. Como el modelo está afinado + para TOML (`WidgetSpec.props` con `flatten`, `Prop` `untagged`) y eso rompe + postcard (el codec de akasha, no auto-descriptivo), `pata-core` ganó un espejo + **postcard-safe**: `pata_core::wire::WireConfig` (props como lista ordenada, + `WireProp` etiquetado), con conversiones sin pérdida `Config ↔ WireConfig` + (round-trip por postcard fijado en un test del host). El kernel + (`pata_marco::marco`) serializa el default a `WireConfig`, lo **graba en el + grafo** (`almacen::almacenar`, BLAKE3 + postcard) y lo **lee de vuelta** —el + config hace el round-trip completo por akasha—, con fallback al default y + cacheado tras el primer uso. + - **Franja reservada** — el compositor ya **reserva** la franja de la barra de + menú: `area_apps` (la región que se tesela) descuenta `pata_marco::ALTO_BARRA` + además de las franjas de consola/taskbar, así las ventanas tilean **debajo** + de la barra (el equivalente al `Frame::work_area` de resolve). `region_barra_ + marco` deriva la franja una sola vez; el render la pinta ahí y el ratón + hit-testea el `start_button` ahí — sin drift entre reservar, pintar y clickear. + - **Propuesta de config desde userspace** — la capacidad WASM + `sys_marco_proponer(ptr, len)` (en `wasm/env/config.rs`, gateada por + `PERMISO_CONFIG` + foco, espejo de `sys_config_proponer`) recibe un + `WireConfig` postcard de la app, lo valida, lo graba en el grafo y reemplaza + el marco activo (`pata_marco::proponer`) — el config por akasha es + bidireccional. El cache es un `Mutex` que la propuesta reescribe. + - Cierre: el launcher de wawa corre sobre el MISMO `pata-core` que Linux — + declarativo, resuelto por `resolve`, con widgets, render al framebuffer, + input al `start_button`, y config por akasha (lectura + escritura). + - **Refino: reserva dinámica ✅** — `area_apps`/`region_barra_marco` ya no usan + una constante: leen `pata_marco::alto_reservado()`, la suma de los grosores de + las barras `Bar` superiores no-`autohide` del config **resuelto**. Si una app + propone (vía `sys_marco_proponer`) una barra de otro alto, la reserva, el + render y el hit-test la siguen sin drift. + - **Refino: persistir el marco entre reinicios ✅** — el marco activo ahora se + ancla en el manifiesto, como `configuracion`/`overlay_revocacion`. + `format::Manifiesto` ganó `marco: Option` (VERSION_MANIFIESTO 6→7); el + génesis (`wawa-boot`) nace con `marco: None`. Al arrancar, `pata_marco:: + cargar_inicial` lee el marco del manifiesto si está anclado, o siembra el + default en el grafo y lo reancla (`manifiesto::enlazar_marco`). `proponer` + reancla al nodo nuevo, así un marco propuesto sobrevive al reinicio. Seguro + porque el génesis local **no se verifica por firma al boot** (confirmado por + el autor) y el operador re-forja la imagen en cada `cargo run -p boot`, así + que el bump de versión nace limpio. +- **Fase 10 ✅** (2026-06-03) — `mirada-launcher-llimphi` **retirado**: pata cubre y + excede su rol (shell+tee+IA, task manager KDE, tarjetas conky, menú de inicio + nativo, tooltips, reloj UTC). Se borró el crate, se sacó del workspace y se + limpiaron las referencias (scripts/install-mirada-dm.sh, APPS.md, README de + mirada, REPORTE de shuma, LEEME de launcher-llimphi). El triplete de launchers + del §0 queda resuelto: el marco es **una sola capa**, `pata`. +- **Fase 11 — sidebars acoplables (navegador de Mónadas/archivos)** (en curso): + el marco gana un cuarto tipo de superficie, el **Sidebar**, para integrar el + plano de datos de `chasqui`/nouser en el escritorio. + - **11a ✅ (modelo, `pata-core`)** — `SurfaceKind::Sidebar`: un **rail de + dientes** (`llimphi-widget-dock-rail`) anclado a un borde vertical (left/ + right). Cada diente es un `SidebarTab { icon, label, content: WidgetSpec }`; + `content` es típicamente `kind = "navigator"`. Campos nuevos en `Surface`: + `tabs: Vec` + `panel_width` (el rail usa `thickness`, el panel + desplegado flota a su lado con `panel_width`). **Layout:** el rail reserva su + grosor como una barra vertical (salvo `autohide`, que flota); el panel que + despliega un diente **no** entra en `resolve` —flota sobre el área de trabajo + como un drawer de launcher, lo maneja el frontend—. Espejo postcard-safe + (`WireTab` + `tabs`/`panel_width` en `WireSurface`) para que viaje por akasha + en wawa; round-trip fijado en test. `no_std` (wasm32) verde, 36 tests. + - **11b-1 ✅ (protocolo nouser, `chasqui`)** — el navegador necesita el nivel + de archivos, y nouser es la **fuente autoritativa** de qué archivos componen + una Mónada (no el filesystem por su cuenta — decisión del autor). `chasqui- + card::query` gana `QueryRequest::ResolveMonad{id}` + `ResolveMonadResponse{ + monad, members}` + `FileView` slim; `client::resolve_monad` (round-trip + extraído a un `request()` genérico compartido con `list_monads`); el + `engine_socket` lo sirve mapeando `db.resolve_members(id) → FileView`. Tests + de roundtrip + servidor. + - **11b-2 ✅ (widget navegador Llimphi)** — `llimphi-widget-navigator` + **reutilizable y data-agnóstico**: bosque de `NavNode{id:u64,label,kind: + NavKind,children}` (kind = Monad/Group/Dir/File/Other) + dos modos + conmutables `NavMode::{Tree,Graph}`. **Árbol** reusa `llimphi-widget-tree` + (icono vectorial por kind, chevron toggle, click select, right-click + context). **Grafo** reusa `llimphi-widget-nodegraph` (layout en columnas por + profundidad, cables de contención padre→hijo, arrastrar selecciona, nodo + seleccionado resaltado por tint). Render-only: el estado (expanded/selected/ + mode) vive en el caller. `navigator_view(spec, is_expanded, on_toggle, + on_select, on_context)`. Demo `navigator_demo` con toggle segmentado; 4 + tests. El widget no sabe de nouser — lo alimenta pata. + - **11c ✅ (path winit, `pata-llimphi`)** — el frontend integra el plano de + datos de nouser y pinta el sidebar: + - **Plano de datos** (`nouser.rs`): descubre el socket del daemon (broker + brahman → fallback al default path, igual que `chasqui-explorer-llimphi`), + poll periódico de `list_monads` (2 s) y `resolve_monad` **bajo demanda** al + expandir una Mónada (carga perezosa: una Mónada con `cardinality > 0` aún + sin resolver lleva un hijo placeholder "…" para mostrar el chevron). El + `NavId` se deriva determinista del `MonadId`/path (FNV-1a con tag) para que + expansión y selección sobrevivan al re-poll. `NavState` (open/mode/selected/ + expanded/scroll/roots/targets) vive en el `Model`; queries en thread vía + `Handle::spawn` (no bloquean el UI). 7 tests. + - **Render** (`render/sidebar.rs`): el rail (`llimphi-widget-dock-rail`) se + pinta en el rect que el layout reservó para el Sidebar, un diente por + `SidebarTab` (el del panel desplegado va resaltado). El panel **flota** + junto al rail (no entra en `resolve`): cabezal con el toggle Árbol/Grafo + (`llimphi-widget-segmented`) + el navegador (`llimphi-widget-navigator`) + dentro de un área de scroll (`llimphi-widget-scroll`). Clic en diente → + despliega/repliega; Esc cierra el panel. Iconos de diente vectoriales por + nombre (`monads`/`files`/…). 1 test. + - **Config**: el asset `pata-config/assets/launcher.toml` gana un sidebar de + ejemplo (`kind = "sidebar"`, un diente `navigator` source=nouser); + deserialización fijada en `toml_contract.rs`. + - Sólo arranca el poll si la config declara un navegador + (`config_tiene_navigator`). + - **11c-layer ✅ (runtime sin verificar, `layer.rs`)** — el rail/panel bajo + `wlr-layer-shell`, el path de producción en Hyprland: + - Una layer surface por Sidebar, anclada al borde vertical con exclusive zone + = `thickness`. Al activar un diente la surface **crece en ancho** (a + `thickness + panel_width`) manteniendo la exclusive zone, así el panel flota + sobre el área de trabajo sin recolocar el teselado — el truco del drawer de + shuma, pero en el eje horizontal. `set_sidebar_open` redimensiona + + invalida el cache de hit-test; `sidebar_surface_view` (render-fill, en + `render/sidebar.rs`) ordena rail+panel según el anclaje. + - **Plano de datos**: un hilo poolea `list_monads` cada 2 s y entrega por + canal (patrón sampler/exec); `resolve_monad` en hilos one-shot por otro + canal; `poll_nav` los drena cada frame sin bloquear. Sólo arranca si la + config declara un navegador. + - **Clics**: el hit-test del pointer handler ahora cae a `on_click_at`/ + `on_right_click_at` (con coords locales al nodo) además de `on_click` — + necesario para los dientes del rail (que usan `on_click_at` para coexistir + con drag); paridad con el bucle winit. Navegación 100% por clic (sin + teclado): el panel se cierra re-clickeando el diente. + - **Limitación**: el modo **grafo** selecciona por arrastre (el nodegraph usa + `draggable`), que el backend layer-shell no rastrea aún → en layer-shell el + grafo es de sólo lectura; el modo **árbol** (default) funciona completo. + Compila; runtime se itera en el Hyprland del usuario (headless no verifica). + - **11d ✅** — abrir un archivo con la app que corresponda. El right-click sobre + un archivo (`Msg::NavOpen`) enruta por `open::open_file` (módulo `open.rs`) en + ambos backends: deriva el mime de la extensión (tabla acotada `mime_for_path`, sin + leer disco), busca una app nativa que lo declare (`app_bus::AppRegistry:: + handlers_for(mime)` → `AppEntry::open(path)` con sustitución freedesktop + `%f`/`%u`), y si ninguna lo maneja cae a `xdg-open` (que respeta las + asociaciones del escritorio). Las apps de la suite tienen prioridad sobre el + handler del sistema. 3 tests del resolutor de mime. (mirada no expone API de + apertura —es WM puro—; spawnear el proceso es la vía, como en + `chasqui-explorer`.) Manifiestos de ejemplo de apps reales de la suite en + `shared/app-bus/assets/apps/` (`media.toml` para video/audio, `nada.toml` para + texto/código); se copian a `~/.config/gioser/apps/`. La decisión de ruteo + (`open::handler_for`) es pura y testeada; el formato de manifiesto tiene + canario en `app-bus`. + - **11d-extra ✅** — menú "Abrir con…" para elegir el handler. El right-click + sobre un archivo (`Msg::NavContextMenu`) precomputa sus apps nativas + (`open::handlers_for_path`, guardadas en `NavState::menu_options`) y abre un + selector **dentro del panel** (no un overlay flotante: así no necesita coords + del cursor y funciona idéntico en winit y layer-shell). Cada fila → + `Msg::NavOpenWith(id, Some(app_id))`; "el sistema" → `NavOpenWith(id, None)` + (xdg-open); "Cancelar"/Esc → `NavMenuCancel`. El render lee sólo `NavState` + (decoplado del registro). 2 tests del listado de handlers. + - **drag en layer-shell ✅** — el pointer handler del backend layer-shell rastrea + un drag mínimo (press→move→release) e invoca el handler `draggable` del nodo + bajo el cursor: en `Press` sobre un nodo arrastrable arranca el drag (no lo + trata como click), en `Motion` le pasa el delta (`Move`), en `Release` emite + `End`. Así el modo **grafo** del navegador selecciona también bajo Wayland (el + nodegraph selecciona al soltar) — ya no es de sólo lectura ahí. `LayerDrag` + guarda el handler + última posición. + - **Pendiente opcional**: discernimiento por contenido (`shuma-discern`) como + upgrade del mime por extensión (a costa de leer una muestra del archivo). + +- **Fase 12 — rail hospedado (sidebars de apps en el rail global)**: + una app puede **delegar su sidebar** al marco: cuando tiene foco, sus "dientes" + aparecen en el rail de pata (debajo de los propios) y su ventana queda como puro + lienzo; al clickear un diente, el comando vuelve a la app, que muestra ese panel + sobre su canvas. pata sólo hospeda el **rail** —no los paneles ricos de la app—. + - **Protocolo `pata-host`** (`02_ruway/pata/pata-host`): socket Unix dedicado + (`$XDG_RUNTIME_DIR/pata-sidebar.sock`, override `PATA_SIDEBAR_SOCKET`), marco + postcard con prefijo de longitud. `AppMsg::{Register{app_id,title,teeth}, + Update,Bye}` (app→shell) + `ShellMsg::Activate{tooth}` (shell→app); + `HostedTooth{id,icon,label}`. `HostServer` (lado pata: acumula registros por + `app_id` en hilos lectores; `snapshot`/`activate`/`revision`) + `HostClient` + (lado app: registra, hilo lector entrega activaciones por callback, Drop manda + Bye). 5 tests. + - **Host en `pata-llimphi`** (sólo layer-shell, que conoce el foco): arranca el + `HostServer` si hay algún sidebar; `focused_app_id()` = toplevel activo; el + rail del sidebar muestra `host.snapshot(app_id)` de la app enfocada (segundo + `dock-rail` bajo los dientes de la config, con separador); clic → + `HostToothActivate(app_id,tooth)` → `host.activate`. `poll_host` re-pinta al + cambiar la revisión del host. El diente hospedado no abre panel ni redimensiona + pata: es control remoto del canvas de la app. El hit-test del pointer ya cae a + `on_click_at`, que estos dientes usan. + - **Integración cosmos** (opt-in `COSMOS_DELEGATE_SIDEBAR`): `app_id()= + "gioser.cosmos"`; publica sus `DockItem`s como dientes; `Msg::HostActivate` + togglea el panel correspondiente sobre su canvas; en modo delegado no pinta sus + rails (`dock_rail_overlay`→None) y un panel aparece sólo si su lado está + expandido → sin nada activo, puro canvas. + - **Requisitos runtime**: pata corriendo en layer-shell con un sidebar en la + config; cosmos lanzado con `COSMOS_DELEGATE_SIDEBAR=1`. Sin verificar headless. + - **media y pluma también delegan** (reusan el mismo `pata-host`): + - **media** (`MEDIA_DELEGATE_SIDEBAR`, `app_id="gioser.media"`): dientes + Config/Cola/Visualizadores/Ayuda; `Msg::HostActivate` despacha los Msgs de + toggle existentes (Config/Cola/Ayuda son ventanas/overlay) o togglea el flag + de visualizadores. media ya es canvas (no tiene rail propio que ocultar). + - **pluma** (`PLUMA_DELEGATE_SIDEBAR`, `app_id="gioser.pluma"`): dientes + Documentos/LLM/Buscar/Diff. Cambio **aditivo**: en modo delegado las columnas + laterales se vuelven colapsables (`side_izq_visible`/`side_der_visible`; cada + lado oculto sale del árbol con su splitter) → editor a pantalla completa; + Buscar/Diff reusan su lógica. Sin delegar, el layout de 3 columnas es idéntico. + - **shuma** (`SHUMA_DELEGATE_SIDEBAR`, `app_id="shuma.shell"`): un diente por + tab (id = índice → `Msg::HostActivate` selecciona esa tab) + un diente + "Monitores" (id sentinela `u32::MAX` → togglea el panel derecho). Cambio + **aditivo**: en modo delegado el panel de monitores arranca oculto (puro + lienzo) y el contenido toma todo el ancho sin splitter; el rail de pata lo + despliega. Sin delegar, el panel de monitores siempre se ve (`monitors_visible` + arranca según `host.is_none()`). La tira de tabs local sigue visible (el rail + es un switcher paralelo). + - **shuma embebida (in-process, NO socket)**: cuando el marco hospeda un + `shuma_input` (el drawer Quake; `ShumaState::present`), el rail del sidebar + suma un **tercer grupo** de dientes (bajo config-teeth y hosted-teeth, con + separador): un diente que despliega/repliega el drawer (`Msg::ShumaToggle`). + Al vivir en el propio proceso de pata —no detrás del socket `pata-host`—, el + diente **refleja el estado real** (`active = shuma.open`, a diferencia de los + hospedados, siempre inactivos) y **no depende del foco**: aparece igual en + winit y layer-shell mientras la config declare el `shuma_input`. Implementado + en `render::sidebar::{shuma_rail, rail_strip}` (firma de `sidebar_rail_view`/ + `sidebar_surface_view` enhebra `&ShumaState`); icono `shell`/`terminal` = + glifo Group. Esto es la contraparte in-process de la delegación por socket: + cruzar la frontera de proceso (app aparte) usa `pata-host`; embebido se cablea + directo leyendo el `Model`. + - **Pendiente opcional**: re-registro de dientes al reordenar el dock (hoy se + registran una vez al init; el lado de activación se computa en vivo, así que el + drop entre lados sigue funcionando); estado "activo" del diente hospedado (hoy + siempre inactivo en pata, lo lleva la app). diff --git a/02_ruway/pata/pata-config/Cargo.toml b/02_ruway/pata/pata-config/Cargo.toml new file mode 100644 index 0000000..134a3cf --- /dev/null +++ b/02_ruway/pata/pata-config/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pata-config" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "pata — la capa de carga (Linux): lee el TOML del usuario desde las rutas XDG y lo materializa en el modelo no_std de pata-core. Es el límite std→no_std del marco; en wawa este rol lo cumple akasha, no este crate. Trae el binario `pata` para inspeccionar cómo resuelve un config sobre una pantalla dada." + +[[bin]] +name = "pata" +path = "src/main.rs" + +[dependencies] +pata-core = { path = "../pata-core" } +toml = { workspace = true } diff --git a/02_ruway/pata/pata-config/assets/launcher.toml b/02_ruway/pata/pata-config/assets/launcher.toml new file mode 100644 index 0000000..a802b07 --- /dev/null +++ b/02_ruway/pata/pata-config/assets/launcher.toml @@ -0,0 +1,112 @@ +# Ejemplo de marco de pata. Copialo a ~/.config/pata/launcher.toml y editá. +# +# Un marco es una lista de superficies (bar | panel | dock). Cada una se ancla +# a un borde (top/bottom/left/right) y reparte widgets en sus slots +# start/center/end. Las barras sólidas reservan su franja; autohide/dock/panel +# flotan. Inspeccioná el resultado con: +# cargo run -p pata-config --bin pata -- --config este.toml --screen 1920x1080 + +[general] +timezone = "auto" # "auto" lo detecta del sistema; o un IANA como "America/Lima" + +# --- Barra superior: el menú clásico tipo waybar/eww --- +[[surfaces]] +kind = "bar" +anchor = "top" +thickness = 32 +gap = 16 +padding = 12 + +[[surfaces.start]] +kind = "start_button" +label = "⊞" +# Sin `exec`, el clic abre el menú nativo de apps (descubiertas de +# ~/.config/gioser/apps/*.toml). Para delegar a un lanzador externo en su lugar, +# fijá p. ej. `exec = "wofi --show drun"`. + +[[surfaces.start]] +kind = "clock" +format = "%H:%M" + +[[surfaces.center]] +kind = "window_list" + +# El racimo de indicadores a la derecha — orden libre. +[[surfaces.end]] +kind = "astro" # posición zodiacal del sol + ciclo lunar (aporte) +moon = true + +[[surfaces.end]] +kind = "clipboard" # preview del texto copiado (vía wl-paste) +# Click → selector de historial. Ejemplo con cliphist + wofi: +exec = "cliphist list | wofi --dmenu | cliphist decode | wl-copy" + +[[surfaces.end]] +kind = "volume" +exec = "pavucontrol" # click en el medidor → mezclador de audio + +[[surfaces.end]] +kind = "brightness" + +[[surfaces.end]] +kind = "tray" + +[[surfaces.end]] +kind = "ram_meter" + +[[surfaces.end]] +kind = "cpu_meter" + +# --- Panel: tarjetas flotantes estilo conky sobre el escritorio --- +# Un panel no reserva franja: sus tarjetas flotan en (x, y) con tamaño (w, h). +# En layer-shell cada tarjeta es su propia surface en la capa de fondo. +[[surfaces]] +kind = "panel" + +[[surfaces.cards]] +x = 40 +y = 80 +w = 220 +h = 110 +title = "sistema" + +[[surfaces.cards.widgets]] +kind = "cpu_meter" + +[[surfaces.cards.widgets]] +kind = "ram_meter" + +# --- Sidebar: rail de dientes acoplable con el navegador de Mónadas (Fase 11) --- +# Colapsado es sólo el rail (una franja de `thickness` px pegada al borde); al +# clickear un diente despliega su panel (de `panel_width` px) sobre el escritorio +# con el navegador de las Mónadas de nouser y sus archivos, conmutable +# árbol/grafo. Requiere un daemon nouser vivo (descubierto por el broker o por el +# socket default); sin él, el panel muestra "Conectando con nouser…". +# +# Abrir un archivo (right-click) lo enruta a la app nativa que declare su mime, o +# a `xdg-open` si ninguna lo hace. Para que las apps de la suite lo reciban, copiá +# sus manifiestos a ~/.config/gioser/apps/ (hay ejemplos en +# shared/app-bus/assets/apps/: media.toml, nada.toml). +[[surfaces]] +kind = "sidebar" +anchor = "left" +thickness = 44 +panel_width = 300 + +[[surfaces.tabs]] +icon = "monads" +label = "Mónadas" +content = { kind = "navigator", source = "nouser" } + +# --- Shell: barra inferior autoescondible con el input de shuma --- +# Al escribir, el frontend despliega el resto de shuma estilo Quake. +[[surfaces]] +kind = "bar" +anchor = "bottom" +thickness = 40 +autohide = true + +[[surfaces.center]] +kind = "shuma_input" +hotkey = "F12" +placeholder = "› preguntá, lanzá, navegá" diff --git a/02_ruway/pata/pata-config/src/lib.rs b/02_ruway/pata/pata-config/src/lib.rs new file mode 100644 index 0000000..f427e63 --- /dev/null +++ b/02_ruway/pata/pata-config/src/lib.rs @@ -0,0 +1,92 @@ +//! `pata-config` — el loader del marco en Linux. +//! +//! `pata-core` es `no_std` y no sabe leer archivos; este crate es el puente al +//! disco: busca el TOML del usuario en las rutas XDG, lo parsea al modelo y, si +//! no hay nada, cae al [`Config::preset`]. En wawa este rol lo cumple akasha +//! —el config llega direccionado por contenido—, no este crate. + +use std::path::PathBuf; + +pub use pata_core::{layout::resolve, Config, Frame, Rect}; + +/// Las rutas donde se busca `launcher.toml`, en orden de prioridad: +/// `$XDG_CONFIG_HOME/pata/` y luego `$HOME/.config/pata/`. +pub fn candidate_paths() -> Vec { + let mut out = Vec::new(); + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + out.push(PathBuf::from(xdg).join("pata/launcher.toml")); + } + if let Some(home) = std::env::var_os("HOME") { + out.push(PathBuf::from(home).join(".config/pata/launcher.toml")); + } + out +} + +/// Parsea un TOML al modelo. Error con el detalle de toml si no cuadra. +pub fn load_from_str(src: &str) -> Result { + toml::from_str(src) +} + +/// Carga el marco: el primer `launcher.toml` que parsee gana; si ninguno +/// existe o todos fallan, devuelve el [`Config::preset`]. Diagnostica por +/// stderr cuál cargó o por qué cayó al default. +pub fn load() -> Config { + for path in candidate_paths() { + match std::fs::read_to_string(&path) { + Ok(text) => match load_from_str(&text) { + Ok(cfg) => { + eprintln!("pata · cargué {}", path.display()); + return cfg; + } + Err(e) => { + eprintln!("pata · {} no parsea ({e}); intento siguiente", path.display()); + } + }, + Err(_) => { /* no existe en esta ruta; sigo */ } + } + } + eprintln!("pata · sin launcher.toml; uso el preset"); + Config::preset() +} + +#[cfg(test)] +mod tests { + use super::*; + use pata_core::{Anchor, SurfaceKind}; + + #[test] + fn load_from_str_parsea_dos_superficies() { + let cfg = load_from_str( + r#" + [[surfaces]] + anchor = "top" + thickness = 30 + + [[surfaces.start]] + kind = "clock" + + [[surfaces]] + kind = "dock" + anchor = "bottom" + "#, + ) + .unwrap(); + assert_eq!(cfg.surfaces.len(), 2); + assert_eq!(cfg.surfaces[0].anchor, Anchor::Top); + assert_eq!(cfg.surfaces[0].start[0].kind, "clock"); + assert_eq!(cfg.surfaces[1].kind, SurfaceKind::Dock); + } + + #[test] + fn candidate_paths_respeta_xdg() { + // No tocamos el entorno global: sólo verificamos que la función + // produce rutas terminadas en pata/launcher.toml cuando hay HOME. + let paths = candidate_paths(); + assert!(paths.iter().all(|p| p.ends_with("pata/launcher.toml"))); + } + + #[test] + fn toml_invalido_es_error_no_panic() { + assert!(load_from_str("esto no es toml [[[").is_err()); + } +} diff --git a/02_ruway/pata/pata-config/src/main.rs b/02_ruway/pata/pata-config/src/main.rs new file mode 100644 index 0000000..6da3e6c --- /dev/null +++ b/02_ruway/pata/pata-config/src/main.rs @@ -0,0 +1,196 @@ +//! `pata` — inspector del marco. +//! +//! Carga el `launcher.toml` del usuario (o el preset) y muestra cómo `pata` +//! resuelve las superficies sobre una pantalla dada: el rect de cada barra/ +//! dock/panel, si reserva franja, sus widgets por slot, y el área de trabajo +//! que le queda al compositor. Sirve para autorear configs y ver la geometría +//! sin levantar la UI. +//! +//! Con `--widgets` además materializa cada widget y lo `tick`ea con un contexto +//! de muestra, mostrando el view-model que el frontend recibiría —los `kind`s +//! que el core aún no implementa salen como `placeholder`—. +//! +//! ```sh +//! cargo run -p pata-config --bin pata -- --screen 1920x1080 +//! cargo run -p pata-config --bin pata -- --config ./mi.toml --screen 2560x1440 +//! cargo run -p pata-config --bin pata -- --widgets +//! ``` + +use std::process::ExitCode; + +use pata_core::widget::{self, ClockReading, WidgetCtx, WidgetView}; +use pata_core::{Config, Rect, Surface, SurfaceKind, WidgetSpec}; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let mut screen = (1920_i32, 1080_i32); + let mut config_path: Option = None; + let mut mostrar_widgets = false; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--widgets" => mostrar_widgets = true, + "--screen" => { + i += 1; + match args.get(i).and_then(|s| parse_wxh(s)) { + Some(s) => screen = s, + None => { + eprintln!("--screen espera WxH (ej. 1920x1080)"); + return ExitCode::FAILURE; + } + } + } + "--config" => { + i += 1; + config_path = args.get(i).cloned(); + if config_path.is_none() { + eprintln!("--config espera una ruta"); + return ExitCode::FAILURE; + } + } + "-h" | "--help" => { + println!("uso: pata [--config ] [--screen WxH] [--widgets]"); + return ExitCode::SUCCESS; + } + other => { + eprintln!("argumento desconocido: {other}"); + return ExitCode::FAILURE; + } + } + i += 1; + } + + let cfg: Config = match &config_path { + Some(p) => match std::fs::read_to_string(p) { + Ok(text) => match pata_config::load_from_str(&text) { + Ok(c) => c, + Err(e) => { + eprintln!("no parsea {p}: {e}"); + return ExitCode::FAILURE; + } + }, + Err(e) => { + eprintln!("no puedo leer {p}: {e}"); + return ExitCode::FAILURE; + } + }, + None => pata_config::load(), + }; + + let (sw, sh) = screen; + let frame = pata_config::resolve(&cfg, Rect::new(0, 0, sw, sh)); + + println!("pantalla: {sw}×{sh} · zona horaria: {}", cfg.general.timezone); + println!("superficies: {}", cfg.surfaces.len()); + for placed in &frame.surfaces { + let s = &cfg.surfaces[placed.index]; + let r = placed.rect; + println!( + " [{}] {:<6} {:<7} {:>4}×{:<4} @ ({:>4},{:>4}) {}", + placed.index, + kind_str(s.kind), + anchor_str(s), + r.w, + r.h, + r.x, + r.y, + if placed.reserva { "reserva" } else { "flota" }, + ); + print_slot("start ", &s.start, mostrar_widgets); + print_slot("center", &s.center, mostrar_widgets); + print_slot("end ", &s.end, mostrar_widgets); + } + let w = frame.work_area; + println!( + "área de trabajo (ventanas): {}×{} @ ({},{})", + w.w, w.h, w.x, w.y + ); + + ExitCode::SUCCESS +} + +fn print_slot(nombre: &str, widgets: &[WidgetSpec], con_view: bool) { + if widgets.is_empty() { + return; + } + let kinds: Vec<&str> = widgets.iter().map(|w| w.kind.as_str()).collect(); + println!(" {nombre}: {}", kinds.join(" · ")); + if !con_view { + return; + } + // Materializa cada widget y muéstralo ya `tick`eado con el contexto muestra. + let ctx = ctx_muestra(); + for spec in widgets { + let mut w = widget::build(spec); + w.tick(&ctx); + println!(" {:<14} → {}", spec.kind, render_view(&w.view())); + } +} + +/// Un [`WidgetCtx`] de muestra para que `--widgets` enseñe el view-model sin +/// muestrear el sistema real (eso es trabajo del frontend, no del inspector). +fn ctx_muestra() -> WidgetCtx { + WidgetCtx { + clock: ClockReading { + year: 2026, + month: 6, + day: 1, + weekday: 1, + hour: 14, + minute: 7, + second: 9, + }, + cpu: 0.42, + ram: 0.61, + ram_used_mb: 9687, + ram_total_mb: 15872, + volume: 0.75, + muted: false, + brightness: 0.55, + sun_longitude_deg: 132.0, // Leo 12° + moon_phase: 0.5, // llena + } +} + +/// Renderiza un [`WidgetView`] como una línea legible para el inspector. +fn render_view(v: &WidgetView) -> String { + match v { + WidgetView::Empty => "·".to_string(), + WidgetView::Text(t) => format!("text «{t}»"), + WidgetView::Meter { + label, + fraction, + caption, + } => { + let etiqueta = label.as_deref().unwrap_or("—"); + format!("meter [{etiqueta}] {:.0}% «{caption}»", fraction * 100.0) + } + WidgetView::Placeholder(k) => format!("placeholder ⟨{k}⟩"), + } +} + +fn kind_str(k: SurfaceKind) -> &'static str { + match k { + SurfaceKind::Bar => "bar", + SurfaceKind::Panel => "panel", + SurfaceKind::Dock => "dock", + SurfaceKind::Sidebar => "sidebar", + } +} + +fn anchor_str(s: &Surface) -> &'static str { + use pata_core::Anchor::*; + match s.anchor { + Top => "top", + Bottom => "bottom", + Left => "left", + Right => "right", + } +} + +/// Parsea `"1920x1080"` (también acepta `X` mayúscula) a `(w, h)`. +fn parse_wxh(s: &str) -> Option<(i32, i32)> { + let (a, b) = s.split_once(['x', 'X'])?; + Some((a.trim().parse().ok()?, b.trim().parse().ok()?)) +} diff --git a/02_ruway/pata/pata-core/Cargo.toml b/02_ruway/pata/pata-core/Cargo.toml new file mode 100644 index 0000000..159715c --- /dev/null +++ b/02_ruway/pata/pata-core/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "pata-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "pata — el marco declarativo del escritorio: barras, paneles y dock con widgets colocables desde un archivo de config. Núcleo agnóstico (no_std + alloc) que comparten el frontend Llimphi (Linux, sobre el compositor mirada) y el kernel launcher de wawa. No conoce a Llimphi, ni a Wayland, ni al framebuffer: sólo el modelo." + +[dependencies] +# pata-core cruza la frontera a wawa por `path` (como mirada-layout): mantiene +# sus dependencias autocontenidas y `no_std`. `serde` es opcional —el modelo se +# construye y consulta sin (de)serializar—; los loaders de cada plataforma +# (TOML en Linux, akasha en wawa) activan la feature. `default-features = false` +# evita arrastrar `std` aun con serde activo. +serde = { version = "1", optional = true, default-features = false, features = ["derive", "alloc"] } + +[features] +default = ["serde"] +serde = ["dep:serde"] + +[dev-dependencies] +# Sólo para los tests: fija el contrato del config en disco (Linux usa TOML). +# No entra en la build `no_std` —dev-deps no cruzan a wawa—. +toml = "0.8" +# Verifica el round-trip del espejo `wire` por postcard (el codec de akasha en +# wawa): postcard no soporta flatten/untagged, de ahí el espejo. Sólo en tests. +postcard = { version = "1", features = ["alloc"] } diff --git a/02_ruway/pata/pata-core/src/config.rs b/02_ruway/pata/pata-core/src/config.rs new file mode 100644 index 0000000..a52fcab --- /dev/null +++ b/02_ruway/pata/pata-core/src/config.rs @@ -0,0 +1,491 @@ +//! El esquema declarativo de `pata`. +//! +//! El archivo de config manda; el código no asume superficies ni widgets que +//! el config no nombre. Un [`Config`] es **una lista de [`Surface`]s** —no un +//! único panel—: el usuario despliega tantas barras, paneles y docks como +//! quiera, ancla cada uno a un borde, y reparte los widgets en sus slots +//! (`start` / `center` / `end`) con total libertad. +//! +//! El modelo es agnóstico del formato en disco: en Linux un loader TOML +//! deserializa directo a estos tipos (vía `serde`); en wawa el config llega por +//! akasha. Los valores de propiedad de cada widget se guardan como [`Prop`], +//! un enum cerrado y `no_std` —ni `toml::Value` ni nada atado a una +//! plataforma—. + +use alloc::collections::BTreeMap; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// El borde de la pantalla al que se ancla una superficie. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))] +pub enum Anchor { + /// Pegada al borde superior — la posición clásica de una barra de menú. + #[default] + Top, + /// Pegada al borde inferior — donde suele ir el dock o el shell. + Bottom, + /// Pegada al borde izquierdo (superficie vertical). + Left, + /// Pegada al borde derecho (superficie vertical). + Right, +} + +impl Anchor { + /// `true` si la superficie se extiende horizontalmente (top/bottom): su + /// grosor es alto y sus slots se reparten en X. `false` para left/right. + pub fn es_horizontal(&self) -> bool { + matches!(self, Anchor::Top | Anchor::Bottom) + } +} + +/// Qué clase de superficie del marco se está describiendo. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))] +pub enum SurfaceKind { + /// Barra fina pegada a un borde (estilo waybar/eww): tres slots + /// (`start` / `center` / `end`) con widgets en línea. Reserva su franja al + /// compositor para que las ventanas no la tapen. + #[default] + Bar, + /// Panel: un área donde flotan [`FloatingCard`]s posicionadas en píxeles + /// (estilo conky). No reserva franja; vive sobre el escritorio. + Panel, + /// Dock: lanzadores y/o ventanas abiertas, centrado en su borde y del + /// tamaño justo de su contenido. Puede autoesconderse. + Dock, + /// Sidebar acoplable: un **rail de dientes** vertical pegado a un borde + /// (left/right). Cada diente ([`SidebarTab`]) despliega un panel con su + /// widget de contenido (lógica de launcher: colapsado = sólo el rail, + /// activar un diente despliega su panel sobre el escritorio). El rail + /// reserva su grosor como una barra; si `autohide`, no reserva y reaparece + /// al rozar el borde. El panel desplegado flota, no reserva. + Sidebar, +} + +/// El valor de una propiedad de widget, agnóstico del formato en disco. El +/// loader de cada plataforma (TOML, RON, akasha) deserializa a esto; los +/// widgets lo leen con los helpers de [`WidgetSpec`]. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(untagged))] +pub enum Prop { + /// Booleano (`true` / `false`). + Bool(bool), + /// Entero — se prueba antes que [`Prop::Num`] para no perder la + /// distinción int/float de formatos como TOML. + Int(i64), + /// Número de punto flotante. + Num(f64), + /// Cadena de texto. + Str(String), +} + +impl Prop { + /// La prop como `&str`, si lo es. + pub fn as_str(&self) -> Option<&str> { + match self { + Prop::Str(s) => Some(s), + _ => None, + } + } + + /// La prop como número: un [`Prop::Int`] se promueve a `f64`. + pub fn as_num(&self) -> Option { + match self { + Prop::Num(n) => Some(*n), + Prop::Int(i) => Some(*i as f64), + _ => None, + } + } + + /// La prop como booleano, si lo es. + pub fn as_bool(&self) -> Option { + match self { + Prop::Bool(b) => Some(*b), + _ => None, + } + } +} + +/// Una entrada de widget: `kind` (qué widget) + props arbitrarias. El conjunto +/// de `kind`s es **abierto** —el frontend despacha por string y cae a un +/// placeholder si no lo conoce—, así que agregar un widget no toca el core. +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct WidgetSpec { + /// El identificador del widget builtin: `"clock"`, `"volume"`, + /// `"astro"`, `"start_button"`, `"window_list"`, `"tray"`, + /// `"shuma_input"`, … + pub kind: String, + /// Props que cada widget interpreta a su gusto (formato del reloj, hotkey + /// del quake, etc.). Las claves no reconocidas se conservan. + #[cfg_attr(feature = "serde", serde(default, flatten))] + pub props: BTreeMap, +} + +impl WidgetSpec { + /// Un widget sin props. + pub fn new(kind: impl Into) -> Self { + Self { + kind: kind.into(), + props: BTreeMap::new(), + } + } + + /// El mismo widget con una prop añadida (encadenable). + pub fn with(mut self, key: impl Into, value: Prop) -> Self { + self.props.insert(key.into(), value); + self + } + + /// Lee una prop string. Devuelve `default` si falta o no es string. + pub fn str_prop<'a>(&'a self, key: &str, default: &'a str) -> &'a str { + self.props.get(key).and_then(Prop::as_str).unwrap_or(default) + } + + /// Lee una prop numérica. Devuelve `default` si falta o no es número. + pub fn num_prop(&self, key: &str, default: f64) -> f64 { + self.props.get(key).and_then(Prop::as_num).unwrap_or(default) + } + + /// Lee una prop booleana. Devuelve `default` si falta o no es booleana. + pub fn bool_prop(&self, key: &str, default: bool) -> bool { + self.props.get(key).and_then(Prop::as_bool).unwrap_or(default) + } +} + +/// Una tarjeta posicionada en píxeles dentro de un panel (estilo conky). +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct FloatingCard { + /// Origen X en píxeles desde la esquina superior-izquierda del panel. + pub x: f32, + /// Origen Y en píxeles. + pub y: f32, + /// Ancho en píxeles. + pub w: f32, + /// Alto en píxeles. + pub h: f32, + /// Título opcional (chip arriba a la izquierda). + #[cfg_attr(feature = "serde", serde(default))] + pub title: Option, + /// Widgets apilados dentro de la tarjeta. + #[cfg_attr(feature = "serde", serde(default))] + pub widgets: Vec, +} + +/// Un diente de un [`SurfaceKind::Sidebar`]: una pestaña vertical del rail que, +/// al activarse, despliega un panel con su widget de contenido. +/// +/// El `icon` es un identificador que el frontend mapea a su glifo/dibujo (igual +/// que el `kind` de un widget: conjunto abierto, cae a un default si no lo +/// conoce). El `label` rotula el panel desplegado y el tooltip del diente. El +/// `content` es un [`WidgetSpec`] —típicamente `kind = "navigator"`— que el +/// frontend pinta en el panel; el modelo no asume qué es. +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct SidebarTab { + /// Identificador del icono del diente (`"files"`, `"monads"`, `"search"`…). + #[cfg_attr(feature = "serde", serde(default))] + pub icon: String, + /// Rótulo del panel desplegado y tooltip del diente. + #[cfg_attr(feature = "serde", serde(default))] + pub label: String, + /// El widget que se pinta en el panel cuando el diente está activo. + #[cfg_attr(feature = "serde", serde(default))] + pub content: WidgetSpec, +} + +impl SidebarTab { + /// Un diente con icono, rótulo y widget de contenido. + pub fn new(icon: impl Into, label: impl Into, content: WidgetSpec) -> Self { + Self { + icon: icon.into(), + label: label.into(), + content, + } + } +} + +/// Una superficie del marco: una barra, un panel, un dock o un sidebar anclado +/// a un borde. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct Surface { + /// Bar, Panel o Dock. + #[cfg_attr(feature = "serde", serde(default))] + pub kind: SurfaceKind, + /// A qué borde se ancla. + #[cfg_attr(feature = "serde", serde(default))] + pub anchor: Anchor, + /// Grosor en píxeles: alto para superficies horizontales (top/bottom), + /// ancho para verticales (left/right). Un dock puede ignorarlo y crecer + /// con su contenido. + #[cfg_attr(feature = "serde", serde(default = "default_thickness"))] + pub thickness: f32, + /// Si `true`, la superficie se esconde y reaparece al hover/hotkey. El + /// shell (`shuma_input`) vive típicamente en una barra inferior con esto. + #[cfg_attr(feature = "serde", serde(default))] + pub autohide: bool, + /// Padding interno (px) entre el borde y el primer widget. + #[cfg_attr(feature = "serde", serde(default = "default_padding"))] + pub padding: f32, + /// Separación (px) entre widgets adyacentes. + #[cfg_attr(feature = "serde", serde(default = "default_gap"))] + pub gap: f32, + /// Slot inicial: pegado al inicio del eje (izquierda / arriba). + #[cfg_attr(feature = "serde", serde(default))] + pub start: Vec, + /// Slot central: centrado en el eje. + #[cfg_attr(feature = "serde", serde(default))] + pub center: Vec, + /// Slot final: pegado al final del eje (derecha / abajo). + #[cfg_attr(feature = "serde", serde(default))] + pub end: Vec, + /// Para `kind = panel`: las tarjetas flotantes que contiene. + #[cfg_attr(feature = "serde", serde(default))] + pub cards: Vec, + /// Monitor al que anclar la superficie (nombre del conector, ej. + /// `"HDMI-A-1"` o `"DP-1"`). Vacío = el compositor elige el primario. + /// El backend `wlr-layer-shell` pasa este `wl_output` a + /// `create_layer_surface`; si el nombre no matchea ninguno conectado, + /// también cae al primario y se loguea un aviso. + #[cfg_attr(feature = "serde", serde(default))] + pub output: String, + /// Para `kind = sidebar`: los dientes del rail. Cada uno despliega su panel. + #[cfg_attr(feature = "serde", serde(default))] + pub tabs: Vec, + /// Para `kind = sidebar`: ancho (px) del panel que despliega un diente. El + /// rail mismo usa `thickness`; el panel flota a su lado con este ancho. + #[cfg_attr(feature = "serde", serde(default = "default_panel_width"))] + pub panel_width: f32, +} + +impl Default for Surface { + fn default() -> Self { + Self { + kind: SurfaceKind::default(), + anchor: Anchor::default(), + thickness: default_thickness(), + autohide: false, + padding: default_padding(), + gap: default_gap(), + start: Vec::new(), + center: Vec::new(), + end: Vec::new(), + cards: Vec::new(), + output: String::new(), + tabs: Vec::new(), + panel_width: default_panel_width(), + } + } +} + +impl Surface { + /// Una barra anclada a `anchor`, con los slots vacíos. + pub fn bar(anchor: Anchor) -> Self { + Self { + kind: SurfaceKind::Bar, + anchor, + ..Self::default() + } + } + + /// Un dock anclado a `anchor`. + pub fn dock(anchor: Anchor) -> Self { + Self { + kind: SurfaceKind::Dock, + anchor, + ..Self::default() + } + } + + /// Un sidebar acoplable anclado a `anchor` (left/right), con el rail vacío. + /// El grosor por defecto es el ancho del rail de dientes. + pub fn sidebar(anchor: Anchor) -> Self { + Self { + kind: SurfaceKind::Sidebar, + anchor, + thickness: default_rail_thickness(), + ..Self::default() + } + } +} + +/// Settings transversales a todas las superficies. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct General { + /// Zona horaria del reloj. `"auto"` la detecta del sistema; también acepta + /// nombres IANA. La sincronización NTP no es de pata: la da el SO. + #[cfg_attr(feature = "serde", serde(default = "default_timezone"))] + pub timezone: String, +} + +impl Default for General { + fn default() -> Self { + Self { + timezone: default_timezone(), + } + } +} + +/// El marco completo: settings generales + las superficies a desplegar. +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct Config { + #[cfg_attr(feature = "serde", serde(default))] + pub general: General, + /// Las superficies del escritorio, en orden de declaración. + #[cfg_attr(feature = "serde", serde(default))] + pub surfaces: Vec, +} + +impl Config { + /// El marco por defecto cuando no hay config: una barra superior con el + /// botón de inicio y el reloj a la izquierda, la lista de ventanas al + /// centro, y el racimo de indicadores (astro, clipboard, volumen, brillo, + /// tray, medidores) más el input del shell a la derecha; y un dock inferior + /// autoescondible. Referencia widgets que aún no existen como builtin —el + /// frontend cae a un placeholder— para encodear la visión completa. + pub fn preset() -> Self { + let mut top = Surface::bar(Anchor::Top); + top.start = vec![WidgetSpec::new("start_button"), WidgetSpec::new("clock")]; + top.center = vec![WidgetSpec::new("window_list")]; + top.end = vec![ + WidgetSpec::new("astro"), + WidgetSpec::new("clipboard"), + WidgetSpec::new("volume"), + WidgetSpec::new("brightness"), + WidgetSpec::new("tray"), + WidgetSpec::new("ram_meter"), + WidgetSpec::new("cpu_meter"), + ]; + + let mut shell = Surface::bar(Anchor::Bottom); + shell.autohide = true; + shell.thickness = 40.0; + shell.center = vec![ + WidgetSpec::new("shuma_input").with("hotkey", Prop::Str("F12".to_string())) + ]; + + Self { + general: General::default(), + surfaces: vec![top, shell], + } + } +} + +fn default_thickness() -> f32 { + 32.0 +} +fn default_padding() -> f32 { + 12.0 +} +fn default_gap() -> f32 { + 16.0 +} +fn default_rail_thickness() -> f32 { + 44.0 +} +fn default_panel_width() -> f32 { + 280.0 +} +fn default_timezone() -> String { + "auto".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anchor_horizontalidad() { + assert!(Anchor::Top.es_horizontal()); + assert!(Anchor::Bottom.es_horizontal()); + assert!(!Anchor::Left.es_horizontal()); + assert!(!Anchor::Right.es_horizontal()); + } + + #[test] + fn defaults_de_anchor_y_kind() { + assert_eq!(Anchor::default(), Anchor::Top); + assert_eq!(SurfaceKind::default(), SurfaceKind::Bar); + } + + #[test] + fn prop_helpers_y_promocion_int_a_num() { + let w = WidgetSpec::new("clock") + .with("format", Prop::Str("%H:%M".to_string())) + .with("size", Prop::Int(14)) + .with("ratio", Prop::Num(0.5)) + .with("flag", Prop::Bool(true)); + assert_eq!(w.str_prop("format", "?"), "%H:%M"); + // Int se promueve a f64 al leer como número. + assert_eq!(w.num_prop("size", 0.0), 14.0); + assert_eq!(w.num_prop("ratio", 0.0), 0.5); + assert!(w.bool_prop("flag", false)); + // Defaults cuando la clave falta o el tipo no calza. + assert_eq!(w.str_prop("nope", "def"), "def"); + assert_eq!(w.num_prop("format", -1.0), -1.0); + } + + #[test] + fn preset_tiene_barra_top_y_shell_bottom() { + let cfg = Config::preset(); + assert_eq!(cfg.surfaces.len(), 2); + let top = &cfg.surfaces[0]; + assert_eq!(top.anchor, Anchor::Top); + assert_eq!(top.kind, SurfaceKind::Bar); + assert_eq!(top.start[0].kind, "start_button"); + assert!(top.end.iter().any(|w| w.kind == "astro")); + + let shell = &cfg.surfaces[1]; + assert_eq!(shell.anchor, Anchor::Bottom); + assert!(shell.autohide); + assert_eq!(shell.center[0].kind, "shuma_input"); + assert_eq!(shell.center[0].str_prop("hotkey", "?"), "F12"); + } + + #[test] + fn config_default_esta_vacio() { + // Default (derive) = sin superficies; `preset()` es el marco poblado. + let cfg = Config::default(); + assert!(cfg.surfaces.is_empty()); + assert_eq!(cfg.general.timezone, "auto"); + } + + #[test] + fn surface_constructores() { + assert_eq!(Surface::bar(Anchor::Left).anchor, Anchor::Left); + assert_eq!(Surface::dock(Anchor::Bottom).kind, SurfaceKind::Dock); + } + + #[test] + fn sidebar_lleva_dientes_y_ancho_de_panel() { + let mut sb = Surface::sidebar(Anchor::Left); + sb.tabs.push(SidebarTab::new( + "monads", + "Mónadas", + WidgetSpec::new("navigator").with("source", Prop::Str("nouser".to_string())), + )); + sb.tabs + .push(SidebarTab::new("files", "Archivos", WidgetSpec::new("navigator"))); + assert_eq!(sb.kind, SurfaceKind::Sidebar); + assert_eq!(sb.anchor, Anchor::Left); + // El rail por defecto es fino; el panel desplegado es ancho. + assert_eq!(sb.thickness, 44.0); + assert_eq!(sb.panel_width, 280.0); + assert_eq!(sb.tabs.len(), 2); + assert_eq!(sb.tabs[0].icon, "monads"); + assert_eq!(sb.tabs[0].content.kind, "navigator"); + assert_eq!(sb.tabs[0].content.str_prop("source", "?"), "nouser"); + } +} diff --git a/02_ruway/pata/pata-core/src/layout.rs b/02_ruway/pata/pata-core/src/layout.rs new file mode 100644 index 0000000..698cf86 --- /dev/null +++ b/02_ruway/pata/pata-core/src/layout.rs @@ -0,0 +1,289 @@ +//! Resolución geométrica del marco: de [`Config`] + pantalla a superficies +//! colocadas en píxeles + el **área de trabajo** que queda libre. +//! +//! Es pura geometría —`no_std`, determinista, sin servidor gráfico—. Dos +//! consumidores la necesitan: +//! +//! - el **frontend** (Llimphi / framebuffer wawa), para saber dónde pintar cada +//! barra/dock/panel; +//! - el **compositor** (`mirada`), para saber qué franja reservar: el +//! [`Frame::work_area`] es exactamente el rectángulo donde teselar las +//! ventanas, ya descontadas las barras sólidas. +//! +//! Reglas de reserva: +//! - una **Bar** no-`autohide` reserva su grosor del borde y encoge el área; +//! - una **Bar** `autohide`, un **Dock** y un **Panel** *no* reservan: flotan +//! sobre el escritorio (su rect se calcula, pero el área de trabajo no cambia). + +use alloc::vec::Vec; + +use crate::config::{Anchor, Config, SurfaceKind}; + +/// Un rectángulo en píxeles de pantalla. Origen `(0,0)` arriba-izquierda; `x` +/// crece a la derecha, `y` hacia abajo. Propio de `pata` —no depende de +/// `mirada`— para que el marco sea independiente del compositor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rect { + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +impl Rect { + pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self { + Self { x, y, w, h } + } + + /// `true` si tiene ancho y alto positivos. + pub fn es_visible(&self) -> bool { + self.w > 0 && self.h > 0 + } +} + +/// Una superficie ya colocada: su índice en `config.surfaces` y su rect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Placed { + /// Índice dentro de [`Config::surfaces`], para recuperar sus widgets. + pub index: usize, + /// Rectángulo en píxeles donde va la superficie. + pub rect: Rect, + /// `true` si reservó franja (encogió el área de trabajo). + pub reserva: bool, +} + +/// El resultado de resolver el marco: las superficies colocadas y el área que +/// queda para las ventanas. +#[derive(Debug, Clone, PartialEq)] +pub struct Frame { + /// Superficies en el mismo orden que `config.surfaces`. + pub surfaces: Vec, + /// Lo que queda libre tras reservar las barras sólidas — donde el + /// compositor tesela las ventanas. + pub work_area: Rect, +} + +/// La franja de grosor `t` pegada al borde `anchor` de `area`. +fn strip(area: Rect, anchor: Anchor, t: i32) -> Rect { + let t = t.max(0); + match anchor { + Anchor::Top => Rect::new(area.x, area.y, area.w, t.min(area.h)), + Anchor::Bottom => { + let t = t.min(area.h); + Rect::new(area.x, area.y + area.h - t, area.w, t) + } + Anchor::Left => Rect::new(area.x, area.y, t.min(area.w), area.h), + Anchor::Right => { + let t = t.min(area.w); + Rect::new(area.x + area.w - t, area.y, t, area.h) + } + } +} + +/// `area` tras descontarle la franja de grosor `t` del borde `anchor`. +fn shrink(area: Rect, anchor: Anchor, t: i32) -> Rect { + let t = t.max(0); + match anchor { + Anchor::Top => { + let t = t.min(area.h); + Rect::new(area.x, area.y + t, area.w, area.h - t) + } + Anchor::Bottom => Rect::new(area.x, area.y, area.w, (area.h - t).max(0)), + Anchor::Left => { + let t = t.min(area.w); + Rect::new(area.x + t, area.y, area.w - t, area.h) + } + Anchor::Right => Rect::new(area.x, area.y, (area.w - t).max(0), area.h), + } +} + +/// Resuelve el marco sobre una pantalla. Recorre las superficies en orden: las +/// barras sólidas se apilan reservando franja (la segunda barra del mismo borde +/// va pegada a la primera); las `autohide`, docks y paneles flotan sin reservar. +pub fn resolve(config: &Config, screen: Rect) -> Frame { + let mut work = screen; + let mut surfaces = Vec::with_capacity(config.surfaces.len()); + + for (index, s) in config.surfaces.iter().enumerate() { + let t = s.thickness as i32; + let (rect, reserva) = match s.kind { + // Una barra y el rail de un sidebar reservan igual: su grosor pegado + // al borde, salvo que sean autohide (entonces flotan sin reservar). + // El panel que despliega un diente del sidebar no es parte de + // `resolve` —flota sobre el área de trabajo como un drawer de + // launcher, lo maneja el frontend—. + SurfaceKind::Bar | SurfaceKind::Sidebar => { + let r = strip(work, s.anchor, t); + if s.autohide { + (r, false) + } else { + work = shrink(work, s.anchor, t); + (r, true) + } + } + // Dock: franja pegada al borde del área actual, sin reservar. + SurfaceKind::Dock => (strip(work, s.anchor, t), false), + // Panel: ocupa el área libre como lienzo de sus tarjetas, sin reservar. + SurfaceKind::Panel => (work, false), + }; + surfaces.push(Placed { + index, + rect, + reserva, + }); + } + + Frame { + surfaces, + work_area: work, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Surface, WidgetSpec}; + + fn pantalla() -> Rect { + Rect::new(0, 0, 1920, 1080) + } + + #[test] + fn barra_top_reserva_su_franja() { + let mut cfg = Config::default(); + let mut top = Surface::bar(Anchor::Top); + top.thickness = 32.0; + cfg.surfaces.push(top); + + let f = resolve(&cfg, pantalla()); + assert_eq!(f.surfaces[0].rect, Rect::new(0, 0, 1920, 32)); + assert!(f.surfaces[0].reserva); + // El área de trabajo arranca 32px más abajo. + assert_eq!(f.work_area, Rect::new(0, 32, 1920, 1048)); + } + + #[test] + fn barra_autohide_no_reserva() { + let mut cfg = Config::default(); + let mut shell = Surface::bar(Anchor::Bottom); + shell.thickness = 40.0; + shell.autohide = true; + cfg.surfaces.push(shell); + + let f = resolve(&cfg, pantalla()); + // El rect de la barra existe, pegado al pie… + assert_eq!(f.surfaces[0].rect, Rect::new(0, 1080 - 40, 1920, 40)); + assert!(!f.surfaces[0].reserva); + // …pero el área de trabajo es la pantalla entera (flota encima). + assert_eq!(f.work_area, pantalla()); + } + + #[test] + fn top_solida_mas_shell_autohide_solo_reserva_la_top() { + // El caso del preset: barra top sólida + shell inferior autohide. + let cfg = Config::preset(); + let f = resolve(&cfg, pantalla()); + // top reserva 32; shell no reserva. + assert!(f.surfaces[0].reserva); + assert!(!f.surfaces[1].reserva); + assert_eq!(f.work_area, Rect::new(0, 32, 1920, 1048)); + } + + #[test] + fn dos_barras_top_se_apilan() { + let mut cfg = Config::default(); + let mut a = Surface::bar(Anchor::Top); + a.thickness = 24.0; + let mut b = Surface::bar(Anchor::Top); + b.thickness = 30.0; + cfg.surfaces.push(a); + cfg.surfaces.push(b); + + let f = resolve(&cfg, pantalla()); + assert_eq!(f.surfaces[0].rect, Rect::new(0, 0, 1920, 24)); + // La segunda va pegada bajo la primera. + assert_eq!(f.surfaces[1].rect, Rect::new(0, 24, 1920, 30)); + assert_eq!(f.work_area, Rect::new(0, 54, 1920, 1080 - 54)); + } + + #[test] + fn barras_verticales_reservan_ancho() { + let mut cfg = Config::default(); + let mut left = Surface::bar(Anchor::Left); + left.thickness = 48.0; + cfg.surfaces.push(left); + + let f = resolve(&cfg, pantalla()); + assert_eq!(f.surfaces[0].rect, Rect::new(0, 0, 48, 1080)); + assert_eq!(f.work_area, Rect::new(48, 0, 1920 - 48, 1080)); + } + + #[test] + fn dock_no_reserva_y_se_pega_al_borde() { + let mut cfg = Config::default(); + cfg.surfaces.push({ + let mut d = Surface::dock(Anchor::Bottom); + d.thickness = 64.0; + d + }); + let f = resolve(&cfg, pantalla()); + assert_eq!(f.surfaces[0].rect, Rect::new(0, 1080 - 64, 1920, 64)); + assert!(!f.surfaces[0].reserva); + assert_eq!(f.work_area, pantalla()); + } + + #[test] + fn panel_ocupa_el_area_libre_sin_reservar() { + let mut cfg = Config::default(); + // Una barra top sólida + un panel: el panel toma el área ya descontada. + let mut top = Surface::bar(Anchor::Top); + top.thickness = 32.0; + cfg.surfaces.push(top); + let mut panel = Surface::default(); + panel.kind = SurfaceKind::Panel; + panel.center.push(WidgetSpec::new("ram_meter")); + cfg.surfaces.push(panel); + + let f = resolve(&cfg, pantalla()); + assert_eq!(f.surfaces[1].rect, Rect::new(0, 32, 1920, 1048)); + assert!(!f.surfaces[1].reserva); + assert_eq!(f.work_area, Rect::new(0, 32, 1920, 1048)); + } + + #[test] + fn sidebar_reserva_su_rail_como_una_barra_vertical() { + let mut cfg = Config::default(); + let mut sb = Surface::sidebar(Anchor::Left); + sb.thickness = 44.0; + cfg.surfaces.push(sb); + + let f = resolve(&cfg, pantalla()); + // El rail toma una franja vertical fina pegada a la izquierda… + assert_eq!(f.surfaces[0].rect, Rect::new(0, 0, 44, 1080)); + assert!(f.surfaces[0].reserva); + // …y el área de trabajo arranca 44px a la derecha (el panel desplegado + // flota encima, no entra en `resolve`). + assert_eq!(f.work_area, Rect::new(44, 0, 1920 - 44, 1080)); + } + + #[test] + fn sidebar_autohide_no_reserva() { + let mut cfg = Config::default(); + let mut sb = Surface::sidebar(Anchor::Right); + sb.thickness = 44.0; + sb.autohide = true; + cfg.surfaces.push(sb); + + let f = resolve(&cfg, pantalla()); + assert_eq!(f.surfaces[0].rect, Rect::new(1920 - 44, 0, 44, 1080)); + assert!(!f.surfaces[0].reserva); + assert_eq!(f.work_area, pantalla()); + } + + #[test] + fn sin_superficies_el_area_es_la_pantalla() { + let f = resolve(&Config::default(), pantalla()); + assert!(f.surfaces.is_empty()); + assert_eq!(f.work_area, pantalla()); + } +} diff --git a/02_ruway/pata/pata-core/src/lib.rs b/02_ruway/pata/pata-core/src/lib.rs new file mode 100644 index 0000000..9f1c848 --- /dev/null +++ b/02_ruway/pata/pata-core/src/lib.rs @@ -0,0 +1,47 @@ +//! `pata-core` — el modelo del marco del escritorio. +//! +//! `pata` (quechua: borde, repisa, andén) es la capa que dibuja el *marco* del +//! escritorio: las **barras**, los **paneles** y el **dock** que rodean a las +//! ventanas, y los **widgets** que viven dentro. No es el compositor (eso es +//! `mirada`) ni el shell (eso es `shuma`): es el chrome configurable que ambos +//! mundos comparten. +//! +//! Este crate es sólo el **modelo**, deliberadamente tonto y portable: +//! +//! - [`config`] — el esquema declarativo. Un [`Config`] es una lista de +//! [`Surface`]s (bar/panel/dock), cada una anclada a un borde y con widgets +//! colocables en sus slots. Es lo que un archivo de config (TOML en Linux, +//! akasha en wawa) materializa. +//! - [`layout`] — la geometría: resuelve un `Config` + pantalla en superficies +//! colocadas en píxeles + el área de trabajo que queda para las ventanas. +//! - [`widget`] — la lógica de datos de cada widget: un [`config::WidgetSpec`] +//! se materializa en un [`widget::Widget`] vivo que, alimentado por un +//! snapshot del sistema, emite un view-model agnóstico del pincel. +//! +//! No pinta, no toca el SO, no sabe quién lo renderiza. Por eso es `no_std` + +//! `alloc`: el mismo modelo sirve al frontend Llimphi sobre Linux y al kernel +//! launcher de wawa (`x86_64-unknown-none`), que aporta su propio allocator. +//! La regla del repo: si un tipo se comparte entre kernel y userspace, vive +//! sin `std`. + +#![cfg_attr(not(test), no_std)] +#![forbid(unsafe_code)] + +extern crate alloc; + +pub mod config; +pub mod layout; +pub mod widget; +/// Espejo postcard-safe del modelo, para el cruce a wawa por akasha. Sólo con la +/// feature `serde` (el kernel la activa; el camino TOML de Linux no lo necesita). +#[cfg(feature = "serde")] +pub mod wire; + +pub use config::{ + Anchor, Config, FloatingCard, General, Prop, SidebarTab, Surface, SurfaceKind, WidgetSpec, +}; +pub use layout::{resolve, Frame, Placed, Rect}; +pub use widget::{ + build, build_all, Astro, Clock, ClockReading, Meter, MeterSource, Placeholder, StartButton, + Widget, WidgetCtx, WidgetView, +}; diff --git a/02_ruway/pata/pata-core/src/widget.rs b/02_ruway/pata/pata-core/src/widget.rs new file mode 100644 index 0000000..9911dd8 --- /dev/null +++ b/02_ruway/pata/pata-core/src/widget.rs @@ -0,0 +1,663 @@ +//! El modelo de widget de `pata`: **lógica de datos sin pincel**. +//! +//! Un [`WidgetSpec`] (lo que el config declara) se materializa en un objeto +//! [`Widget`] vivo. El widget no sabe dibujar: cada `tick` refresca su estado a +//! partir de un [`WidgetCtx`] —un snapshot agnóstico del sistema que el host +//! muestrea (reloj, CPU, RAM, volumen, brillo…)— y `view` emite un +//! [`WidgetView`], un view-model que describe *qué* mostrar (texto, medidor, +//! placeholder) sin decir *cómo*. El frontend (Llimphi en Linux, framebuffer en +//! wawa) traduce ese view-model a su pincel. +//! +//! La frontera está donde tiene que estar: el core es `no_std` y determinista, +//! así que **no lee el reloj ni los contadores del kernel** —eso son syscalls—. +//! El host los muestrea y los entrega en el [`WidgetCtx`]; el core sólo formatea +//! y compone. Misma lógica de datos para los dos mundos; cada uno aporta su +//! sampler y su pincel. + +use alloc::boxed::Box; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::config::WidgetSpec; + +/// Lectura del reloj descompuesta. El host la rellena desde su fuente de tiempo +/// (en Linux, la zona horaria de `general.timezone`); el core sólo la formatea. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ClockReading { + /// Año con siglo (p. ej. `2026`). + pub year: u16, + /// Mes `1..=12`. + pub month: u8, + /// Día del mes `1..=31`. + pub day: u8, + /// Día de la semana, `0` = domingo … `6` = sábado. + pub weekday: u8, + /// Hora `0..=23`. + pub hour: u8, + /// Minuto `0..=59`. + pub minute: u8, + /// Segundo `0..=59`. + pub second: u8, +} + +/// El snapshot del sistema que alimenta a los widgets en cada `tick`. El host +/// lo muestrea (vía sysfs/PulseAudio en Linux, vía el kernel en wawa) y lo pasa +/// por valor: el core no toca el SO. Todos los campos arrancan en cero, así que +/// un frontend puede llenar sólo lo que le importe. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct WidgetCtx { + /// Hora actual ya descompuesta. + pub clock: ClockReading, + /// Uso de CPU, fracción `0.0..=1.0`. + pub cpu: f32, + /// Uso de RAM, fracción `0.0..=1.0`. + pub ram: f32, + /// RAM usada en MiB (para la leyenda del medidor). + pub ram_used_mb: u32, + /// RAM total en MiB. + pub ram_total_mb: u32, + /// Volumen, fracción `0.0..=1.0`. + pub volume: f32, + /// `true` si el audio está silenciado. + pub muted: bool, + /// Brillo de pantalla, fracción `0.0..=1.0`. + pub brightness: f32, + /// Longitud eclíptica del Sol en grados `0..360` — la posición zodiacal. El + /// host la computa (en Linux, vía una efeméride; ver `pata-llimphi::sampler`); + /// el widget [`Astro`] la mapea a signo + grado. + pub sun_longitude_deg: f32, + /// Fase lunar como fracción del ciclo sinódico `0.0..=1.0`: `0` = luna nueva, + /// `0.5` = llena, de vuelta a `1` = nueva. + pub moon_phase: f32, +} + +/// El view-model que un widget emite: describe qué pintar sin atarse a ningún +/// pincel. El frontend hace el match y lo traduce a su backend gráfico. +#[derive(Debug, Clone, PartialEq)] +pub enum WidgetView { + /// Nada que pintar (un widget que aún no tiene datos). + Empty, + /// Una línea de texto: el reloj, una etiqueta. + Text(String), + /// Un medidor: `fraction` en `0.0..=1.0`, una `caption` ya formateada y una + /// `label` opcional (el nombre corto, p. ej. `"CPU"`). + Meter { + /// Etiqueta corta, o `None` si el widget la oculta. + label: Option, + /// Fracción `0.0..=1.0` que el frontend pinta como barra/arco. + fraction: f32, + /// Leyenda ya formateada (`"42%"`, `"3.2G"`, `"muted"`). + caption: String, + }, + /// Un widget cuyo `kind` el core no implementa todavía: el frontend pinta un + /// chip tenue con este nombre. Permite encodear la visión completa del marco + /// (start_button, tray, astro…) antes de que cada widget exista. + Placeholder(String), +} + +/// Un widget vivo: refresca su estado en cada `tick` y emite su view-model en +/// `view`. La lógica de datos vive acá; el dibujo, en el frontend. +pub trait Widget { + /// Refresca el estado interno con el snapshot del sistema. + fn tick(&mut self, ctx: &WidgetCtx); + /// El view-model actual. + fn view(&self) -> WidgetView; +} + +/// Reloj: formatea [`ClockReading`] según una cadena estilo `strftime` reducida. +/// +/// Tokens soportados (suficientes para una barra): `%H %M %S` (hora/min/seg a +/// dos dígitos), `%I %p` (12h + AM/PM), `%d %m %Y %y` (día/mes/año), `%%` +/// (porcentaje literal). Cualquier otro carácter pasa tal cual. No es un +/// `strftime` completo a propósito: nombres de mes/día localizados los resuelve +/// `rimay-localize` aguas arriba, no este core. +#[derive(Debug, Clone)] +pub struct Clock { + format: String, + text: String, +} + +impl Clock { + /// Construye desde el spec leyendo la prop `format` (default `"%H:%M"`). + pub fn from_spec(spec: &WidgetSpec) -> Self { + Self { + format: spec.str_prop("format", "%H:%M").to_string(), + text: String::new(), + } + } +} + +impl Widget for Clock { + fn tick(&mut self, ctx: &WidgetCtx) { + self.text = format_time(&self.format, &ctx.clock); + } + + fn view(&self) -> WidgetView { + if self.text.is_empty() { + WidgetView::Empty + } else { + WidgetView::Text(self.text.clone()) + } + } +} + +/// De dónde saca su valor un [`Meter`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeterSource { + /// Uso de CPU. + Cpu, + /// Uso de RAM (leyenda en GiB usados/total). + Ram, + /// Volumen de audio (leyenda `"muted"` si está silenciado). + Volume, + /// Brillo de pantalla. + Brightness, +} + +impl MeterSource { + /// La etiqueta corta por defecto de la fuente. + fn label_por_defecto(&self) -> &'static str { + match self { + MeterSource::Cpu => "CPU", + MeterSource::Ram => "RAM", + MeterSource::Volume => "VOL", + MeterSource::Brightness => "BRI", + } + } +} + +/// Medidor genérico: lee una fracción `0..1` del [`WidgetCtx`] según su +/// [`MeterSource`] y arma una leyenda. Cubre cpu/ram/volumen/brillo con la +/// misma lógica; el frontend decide si lo pinta como barra, arco o ícono. +#[derive(Debug, Clone)] +pub struct Meter { + source: MeterSource, + label: Option, + fraction: f32, + caption: String, +} + +impl Meter { + /// Construye un medidor de `source` leyendo del spec: + /// - `label` (string): override de la etiqueta corta; + /// - `show_label` (bool, default `true`): si es `false`, oculta la etiqueta. + pub fn from_spec(source: MeterSource, spec: &WidgetSpec) -> Self { + let label = if spec.bool_prop("show_label", true) { + Some( + spec.str_prop("label", source.label_por_defecto()) + .to_string(), + ) + } else { + None + }; + Self { + source, + label, + fraction: 0.0, + caption: String::new(), + } + } +} + +impl Widget for Meter { + fn tick(&mut self, ctx: &WidgetCtx) { + match self.source { + MeterSource::Cpu => { + self.fraction = ctx.cpu; + self.caption = porcentaje(ctx.cpu); + } + MeterSource::Ram => { + self.fraction = ctx.ram; + self.caption = leyenda_memoria(ctx.ram_used_mb, ctx.ram_total_mb); + } + MeterSource::Volume => { + self.fraction = ctx.volume; + self.caption = if ctx.muted { + "muted".to_string() + } else { + porcentaje(ctx.volume) + }; + } + MeterSource::Brightness => { + self.fraction = ctx.brightness; + self.caption = porcentaje(ctx.brightness); + } + } + } + + fn view(&self) -> WidgetView { + WidgetView::Meter { + label: self.label.clone(), + fraction: self.fraction.clamp(0.0, 1.0), + caption: self.caption.clone(), + } + } +} + +/// Los doce signos del zodíaco con su glifo, en orden desde Aries (0°). Los +/// nombres van en español (regla del repo); el glifo es el símbolo astrológico. +const SIGNOS: [(&str, &str); 12] = [ + ("Aries", "♈"), + ("Tauro", "♉"), + ("Géminis", "♊"), + ("Cáncer", "♋"), + ("Leo", "♌"), + ("Virgo", "♍"), + ("Libra", "♎"), + ("Escorpio", "♏"), + ("Sagitario", "♐"), + ("Capricornio", "♑"), + ("Acuario", "♒"), + ("Piscis", "♓"), +]; + +/// Las ocho fases lunares con su glifo, en orden desde la nueva. El índice se +/// saca de la fracción del ciclo (`moon_phase * 8`, redondeado). +const FASES_LUNA: [(&str, &str); 8] = [ + ("Nueva", "🌑"), + ("Creciente", "🌒"), + ("Cuarto creciente", "🌓"), + ("Gibosa creciente", "🌔"), + ("Llena", "🌕"), + ("Gibosa menguante", "🌖"), + ("Cuarto menguante", "🌗"), + ("Menguante", "🌘"), +]; + +/// Widget astral: la posición zodiacal del Sol (signo + grado) y, opcionalmente, +/// la fase lunar — el aporte que distingue al marco de gioser. La efeméride la +/// resuelve el host y la entrega en el [`WidgetCtx`]; este widget sólo mapea +/// grados a signo y fracción a fase, con aritmética entera (`core` no tiene +/// `floor`/`round` de punto flotante). +/// +/// Props: +/// - `degree` (bool, default `true`): muestra el grado dentro del signo. +/// - `moon` (bool, default `true`): añade el glifo de la fase lunar. +/// - `name` (bool, default `true`): muestra el nombre del signo (si no, sólo el +/// glifo). +#[derive(Debug, Clone)] +pub struct Astro { + show_degree: bool, + show_moon: bool, + show_name: bool, + text: String, +} + +impl Astro { + /// Construye desde el spec leyendo `degree` / `moon` / `name`. + pub fn from_spec(spec: &WidgetSpec) -> Self { + Self { + show_degree: spec.bool_prop("degree", true), + show_moon: spec.bool_prop("moon", true), + show_name: spec.bool_prop("name", true), + text: String::new(), + } + } +} + +impl Widget for Astro { + fn tick(&mut self, ctx: &WidgetCtx) { + // Grados enteros normalizados a 0..360 sin usar floor (no_std). + let lon = ((ctx.sun_longitude_deg as i32) % 360 + 360) % 360; + let (nombre, glifo) = SIGNOS[(lon / 30) as usize % 12]; + let grado = lon % 30; + + let mut s = String::new(); + s.push_str(glifo); + if self.show_name { + s.push(' '); + s.push_str(nombre); + } + if self.show_degree { + s.push_str(&format!(" {grado}°")); + } + if self.show_moon { + // Índice 0..7: redondeo entero de moon_phase*8 (la fracción es ≥0). + let frac = ctx.moon_phase.clamp(0.0, 1.0); + let idx = ((frac * 8.0 + 0.5) as usize) % 8; + let (_, luna) = FASES_LUNA[idx]; + s.push_str(" · "); + s.push_str(luna); + } + self.text = s; + } + + fn view(&self) -> WidgetView { + if self.text.is_empty() { + WidgetView::Empty + } else { + WidgetView::Text(self.text.clone()) + } + } +} + +/// Botón de inicio: el ancla del menú de aplicaciones. Por ahora sólo muestra su +/// etiqueta (`label`, default `"⊞"`); cablear su acción —abrir el lanzador— +/// llega cuando el marco rutee clicks (Fase 7, junto al Quake de shuma). +#[derive(Debug, Clone)] +pub struct StartButton { + label: String, +} + +impl StartButton { + /// Construye desde el spec leyendo la prop `label`. + pub fn from_spec(spec: &WidgetSpec) -> Self { + Self { + label: spec.str_prop("label", "⊞").to_string(), + } + } +} + +impl Widget for StartButton { + fn tick(&mut self, _ctx: &WidgetCtx) {} + + fn view(&self) -> WidgetView { + WidgetView::Text(self.label.clone()) + } +} + +/// Widget de relleno para un `kind` que el core no implementa todavía. Su `view` +/// es siempre un [`WidgetView::Placeholder`] con el nombre del kind. +#[derive(Debug, Clone)] +pub struct Placeholder { + kind: String, +} + +impl Placeholder { + /// Un placeholder que muestra `kind`. + pub fn new(kind: impl Into) -> Self { + Self { kind: kind.into() } + } +} + +impl Widget for Placeholder { + fn tick(&mut self, _ctx: &WidgetCtx) {} + + fn view(&self) -> WidgetView { + WidgetView::Placeholder(self.kind.clone()) + } +} + +/// Materializa un [`WidgetSpec`] en un [`Widget`] vivo. Los `kind`s que el core +/// ya implementa (reloj y medidores) se construyen con su lógica; el resto cae a +/// un [`Placeholder`] —el conjunto de kinds es abierto, así que esto nunca +/// falla—. Los widgets que dependen de IPC o crates externos (`window_list`, +/// `astro`, `tray`, `shuma_input`) llegan en fases posteriores. +pub fn build(spec: &WidgetSpec) -> Box { + match spec.kind.as_str() { + "clock" => Box::new(Clock::from_spec(spec)), + "cpu_meter" => Box::new(Meter::from_spec(MeterSource::Cpu, spec)), + "ram_meter" => Box::new(Meter::from_spec(MeterSource::Ram, spec)), + "volume" => Box::new(Meter::from_spec(MeterSource::Volume, spec)), + "brightness" => Box::new(Meter::from_spec(MeterSource::Brightness, spec)), + "astro" => Box::new(Astro::from_spec(spec)), + "start_button" => Box::new(StartButton::from_spec(spec)), + _ => Box::new(Placeholder::new(&spec.kind)), + } +} + +/// Materializa una lista de specs (un slot completo) de una pasada. +pub fn build_all(specs: &[WidgetSpec]) -> Vec> { + specs.iter().map(build).collect() +} + +/// Formatea un [`ClockReading`] con la cadena `fmt` (subconjunto de `strftime`, +/// ver [`Clock`]). +fn format_time(fmt: &str, t: &ClockReading) -> String { + let mut out = String::with_capacity(fmt.len() + 4); + let mut chars = fmt.chars(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.next() { + Some('H') => empuja_dos(&mut out, t.hour), + Some('M') => empuja_dos(&mut out, t.minute), + Some('S') => empuja_dos(&mut out, t.second), + Some('I') => { + let h12 = match t.hour % 12 { + 0 => 12, + h => h, + }; + empuja_dos(&mut out, h12); + } + Some('p') => out.push_str(if t.hour < 12 { "AM" } else { "PM" }), + Some('d') => empuja_dos(&mut out, t.day), + Some('m') => empuja_dos(&mut out, t.month), + Some('Y') => out.push_str(&format!("{:04}", t.year)), + Some('y') => empuja_dos(&mut out, (t.year % 100) as u8), + Some('%') => out.push('%'), + // Token desconocido: lo dejamos literal (`%` + el carácter). + Some(other) => { + out.push('%'); + out.push(other); + } + None => out.push('%'), + } + } + out +} + +/// Empuja `n` como dos dígitos con cero a la izquierda. +fn empuja_dos(out: &mut String, n: u8) { + out.push_str(&format!("{:02}", n)); +} + +/// Una fracción `0..1` como porcentaje entero: `0.42 → "42%"`. +fn porcentaje(frac: f32) -> String { + // `f32::round` vive en `std`; acá (no_std) redondeamos a mano. El valor es + // siempre ≥ 0 (fracción clampeada), así que `+ 0.5` y truncar basta. + let pct = (frac.clamp(0.0, 1.0) * 100.0 + 0.5) as i32; + format!("{}%", pct) +} + +/// Leyenda de memoria `"usado/total"` en GiB con un decimal: `"3.2/15.5G"`. +fn leyenda_memoria(used_mb: u32, total_mb: u32) -> String { + let used = used_mb as f32 / 1024.0; + let total = total_mb as f32 / 1024.0; + format!("{:.1}/{:.1}G", used, total) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Prop; + + fn ctx() -> WidgetCtx { + WidgetCtx { + clock: ClockReading { + year: 2026, + month: 6, + day: 1, + weekday: 1, + hour: 14, + minute: 7, + second: 9, + }, + cpu: 0.42, + ram: 0.5, + ram_used_mb: 3277, // ~3.2 GiB + ram_total_mb: 15872, + volume: 0.75, + muted: false, + brightness: 0.3, + ..WidgetCtx::default() + } + } + + #[test] + fn reloj_formatea_default_y_segundos() { + let mut c = Clock::from_spec(&WidgetSpec::new("clock")); + c.tick(&ctx()); + assert_eq!(c.view(), WidgetView::Text("14:07".to_string())); + + let mut con_seg = Clock::from_spec( + &WidgetSpec::new("clock").with("format", Prop::Str("%H:%M:%S".to_string())), + ); + con_seg.tick(&ctx()); + assert_eq!(con_seg.view(), WidgetView::Text("14:07:09".to_string())); + } + + #[test] + fn reloj_12h_fecha_y_literales() { + let spec = WidgetSpec::new("clock") + .with("format", Prop::Str("%d/%m/%Y %I:%M %p".to_string())); + let mut c = Clock::from_spec(&spec); + c.tick(&ctx()); + assert_eq!(c.view(), WidgetView::Text("01/06/2026 02:07 PM".to_string())); + + // %% literal y token desconocido pasa tal cual. + let mut raro = Clock::from_spec( + &WidgetSpec::new("clock").with("format", Prop::Str("%H%% %q".to_string())), + ); + raro.tick(&ctx()); + assert_eq!(raro.view(), WidgetView::Text("14% %q".to_string())); + } + + #[test] + fn medianoche_en_12h_es_las_12() { + let mut t = ctx(); + t.clock.hour = 0; + let mut c = Clock::from_spec( + &WidgetSpec::new("clock").with("format", Prop::Str("%I %p".to_string())), + ); + c.tick(&t); + assert_eq!(c.view(), WidgetView::Text("12 AM".to_string())); + } + + #[test] + fn cpu_meter_emite_fraccion_y_porcentaje() { + let mut m = Meter::from_spec(MeterSource::Cpu, &WidgetSpec::new("cpu_meter")); + m.tick(&ctx()); + assert_eq!( + m.view(), + WidgetView::Meter { + label: Some("CPU".to_string()), + fraction: 0.42, + caption: "42%".to_string(), + } + ); + } + + #[test] + fn ram_meter_leyenda_en_gib() { + let mut m = Meter::from_spec(MeterSource::Ram, &WidgetSpec::new("ram_meter")); + m.tick(&ctx()); + match m.view() { + WidgetView::Meter { caption, .. } => assert_eq!(caption, "3.2/15.5G"), + v => panic!("esperaba Meter, vino {v:?}"), + } + } + + #[test] + fn volumen_muteado_dice_muted() { + let mut t = ctx(); + t.muted = true; + let mut m = Meter::from_spec(MeterSource::Volume, &WidgetSpec::new("volume")); + m.tick(&t); + match m.view() { + WidgetView::Meter { caption, .. } => assert_eq!(caption, "muted"), + v => panic!("esperaba Meter, vino {v:?}"), + } + } + + #[test] + fn meter_oculta_label_con_show_label_false() { + let spec = WidgetSpec::new("cpu_meter").with("show_label", Prop::Bool(false)); + let mut m = Meter::from_spec(MeterSource::Cpu, &spec); + m.tick(&ctx()); + match m.view() { + WidgetView::Meter { label, .. } => assert_eq!(label, None), + v => panic!("esperaba Meter, vino {v:?}"), + } + } + + #[test] + fn meter_label_override() { + let spec = WidgetSpec::new("cpu_meter").with("label", Prop::Str("Proc".to_string())); + let m = Meter::from_spec(MeterSource::Cpu, &spec); + match m.view() { + WidgetView::Meter { label, .. } => assert_eq!(label, Some("Proc".to_string())), + v => panic!("esperaba Meter, vino {v:?}"), + } + } + + #[test] + fn kind_desconocido_cae_a_placeholder() { + // window_list/tray/shuma_input todavía no son builtin (IPC/shell pendiente). + let w = build(&WidgetSpec::new("window_list")); + assert_eq!(w.view(), WidgetView::Placeholder("window_list".to_string())); + assert_eq!( + build(&WidgetSpec::new("tray")).view(), + WidgetView::Placeholder("tray".to_string()) + ); + } + + #[test] + fn astro_mapea_longitud_a_signo_y_grado() { + let mut ctx = ctx(); + // 132° → 132/30 = 4 → Leo; 132 % 30 = 12°. + ctx.sun_longitude_deg = 132.0; + ctx.moon_phase = 0.0; // nueva + let mut a = Astro::from_spec(&WidgetSpec::new("astro")); + a.tick(&ctx); + assert_eq!(a.view(), WidgetView::Text("♌ Leo 12° · 🌑".to_string())); + } + + #[test] + fn astro_normaliza_longitud_negativa_y_redondea_luna() { + let mut ctx = ctx(); + // -30° normaliza a 330° → Piscis (330/30 = 11), grado 0. + ctx.sun_longitude_deg = -30.0; + ctx.moon_phase = 0.5; // llena → idx 4 + let mut a = Astro::from_spec(&WidgetSpec::new("astro")); + a.tick(&ctx); + assert_eq!(a.view(), WidgetView::Text("♓ Piscis 0° · 🌕".to_string())); + } + + #[test] + fn astro_respeta_props_degree_moon_name() { + let mut ctx = ctx(); + ctx.sun_longitude_deg = 5.0; // Aries 5° + let spec = WidgetSpec::new("astro") + .with("degree", Prop::Bool(false)) + .with("moon", Prop::Bool(false)) + .with("name", Prop::Bool(false)); + let mut a = Astro::from_spec(&spec); + a.tick(&ctx); + // Sólo el glifo del signo. + assert_eq!(a.view(), WidgetView::Text("♈".to_string())); + } + + #[test] + fn start_button_muestra_label_default_y_override() { + let def = build(&WidgetSpec::new("start_button")); + assert_eq!(def.view(), WidgetView::Text("⊞".to_string())); + let custom = StartButton::from_spec( + &WidgetSpec::new("start_button").with("label", Prop::Str("Inicio".to_string())), + ); + assert_eq!(custom.view(), WidgetView::Text("Inicio".to_string())); + } + + #[test] + fn build_despacha_los_builtins() { + let specs = [ + WidgetSpec::new("clock"), + WidgetSpec::new("cpu_meter"), + WidgetSpec::new("ram_meter"), + WidgetSpec::new("volume"), + WidgetSpec::new("brightness"), + ]; + let mut widgets = build_all(&specs); + for w in widgets.iter_mut() { + w.tick(&ctx()); + } + // El reloj da texto; los cuatro medidores dan Meter. + assert!(matches!(widgets[0].view(), WidgetView::Text(_))); + for w in &widgets[1..] { + assert!(matches!(w.view(), WidgetView::Meter { .. })); + } + } +} diff --git a/02_ruway/pata/pata-core/src/wire.rs b/02_ruway/pata/pata-core/src/wire.rs new file mode 100644 index 0000000..cdfbce6 --- /dev/null +++ b/02_ruway/pata/pata-core/src/wire.rs @@ -0,0 +1,320 @@ +//! Representación **postcard-safe** del marco para akasha (wawa). +//! +//! El modelo de [`Config`](crate::Config) está afinado para el loader TOML de +//! Linux: `WidgetSpec.props` usa `#[serde(flatten)]` (para que las props vivan al +//! nivel del `kind`) y [`Prop`] es `#[serde(untagged)]` (para que TOML infiera el +//! tipo). Ambos atributos **rompen postcard** —el codec de akasha—, que no es +//! auto-descriptivo y no soporta `deserialize_any`. +//! +//! Este módulo define un espejo *plano y etiquetado* del modelo: [`WireConfig`] +//! es exactamente la misma información, pero serializable con cualquier formato +//! binario no auto-descriptivo. El kernel de wawa serializa el `WireConfig` con +//! postcard, lo guarda en el grafo direccionado por contenido y lo lee de vuelta +//! —el config del marco viaja por akasha como cualquier otro objeto—. En Linux +//! el camino TOML sigue usando `Config` directo; este espejo es sólo para el +//! cruce a wawa. +//! +//! Las conversiones son **sin pérdida**: `Config → WireConfig → Config` devuelve +//! el mismo modelo (lo fija un test). + +use alloc::string::String; +use alloc::vec::Vec; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use crate::config::{ + Anchor, Config, FloatingCard, General, Prop, SidebarTab, Surface, SurfaceKind, WidgetSpec, +}; + +/// Valor de propiedad **etiquetado** (a diferencia de [`Prop`], que es +/// `untagged`): así postcard puede deserializarlo sin auto-descripción. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum WireProp { + Bool(bool), + Int(i64), + Num(f64), + Str(String), +} + +impl From<&Prop> for WireProp { + fn from(p: &Prop) -> Self { + match p { + Prop::Bool(b) => WireProp::Bool(*b), + Prop::Int(i) => WireProp::Int(*i), + Prop::Num(n) => WireProp::Num(*n), + Prop::Str(s) => WireProp::Str(s.clone()), + } + } +} + +impl From for Prop { + fn from(p: WireProp) -> Self { + match p { + WireProp::Bool(b) => Prop::Bool(b), + WireProp::Int(i) => Prop::Int(i), + WireProp::Num(n) => Prop::Num(n), + WireProp::Str(s) => Prop::Str(s), + } + } +} + +/// Espejo de [`WidgetSpec`] con las props como **lista ordenada** (no `flatten`). +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct WireWidget { + pub kind: String, + pub props: Vec<(String, WireProp)>, +} + +impl From<&WidgetSpec> for WireWidget { + fn from(w: &WidgetSpec) -> Self { + Self { + kind: w.kind.clone(), + props: w.props.iter().map(|(k, v)| (k.clone(), WireProp::from(v))).collect(), + } + } +} + +impl From for WidgetSpec { + fn from(w: WireWidget) -> Self { + let mut spec = WidgetSpec::new(w.kind); + for (k, v) in w.props { + spec = spec.with(k, Prop::from(v)); + } + spec + } +} + +fn a_wire(specs: &[WidgetSpec]) -> Vec { + specs.iter().map(WireWidget::from).collect() +} + +fn de_wire(wires: Vec) -> Vec { + wires.into_iter().map(WidgetSpec::from).collect() +} + +/// Espejo de [`FloatingCard`]. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct WireCard { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + pub title: Option, + pub widgets: Vec, +} + +impl From<&FloatingCard> for WireCard { + fn from(c: &FloatingCard) -> Self { + Self { + x: c.x, + y: c.y, + w: c.w, + h: c.h, + title: c.title.clone(), + widgets: a_wire(&c.widgets), + } + } +} + +impl From for FloatingCard { + fn from(c: WireCard) -> Self { + FloatingCard { + x: c.x, + y: c.y, + w: c.w, + h: c.h, + title: c.title, + widgets: de_wire(c.widgets), + } + } +} + +/// Espejo de [`SidebarTab`]. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct WireTab { + pub icon: String, + pub label: String, + pub content: WireWidget, +} + +impl From<&SidebarTab> for WireTab { + fn from(t: &SidebarTab) -> Self { + Self { + icon: t.icon.clone(), + label: t.label.clone(), + content: WireWidget::from(&t.content), + } + } +} + +impl From for SidebarTab { + fn from(t: WireTab) -> Self { + SidebarTab { + icon: t.icon, + label: t.label, + content: WidgetSpec::from(t.content), + } + } +} + +/// Espejo de [`Surface`]. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct WireSurface { + pub kind: SurfaceKind, + pub anchor: Anchor, + pub thickness: f32, + pub autohide: bool, + pub padding: f32, + pub gap: f32, + pub start: Vec, + pub center: Vec, + pub end: Vec, + pub cards: Vec, + pub output: String, + pub tabs: Vec, + pub panel_width: f32, +} + +impl From<&Surface> for WireSurface { + fn from(s: &Surface) -> Self { + Self { + kind: s.kind, + anchor: s.anchor, + thickness: s.thickness, + autohide: s.autohide, + padding: s.padding, + gap: s.gap, + start: a_wire(&s.start), + center: a_wire(&s.center), + end: a_wire(&s.end), + cards: s.cards.iter().map(WireCard::from).collect(), + output: s.output.clone(), + tabs: s.tabs.iter().map(WireTab::from).collect(), + panel_width: s.panel_width, + } + } +} + +impl From for Surface { + fn from(s: WireSurface) -> Self { + Surface { + kind: s.kind, + anchor: s.anchor, + thickness: s.thickness, + autohide: s.autohide, + padding: s.padding, + gap: s.gap, + start: de_wire(s.start), + center: de_wire(s.center), + end: de_wire(s.end), + cards: s.cards.into_iter().map(FloatingCard::from).collect(), + output: s.output, + tabs: s.tabs.into_iter().map(SidebarTab::from).collect(), + panel_width: s.panel_width, + } + } +} + +/// Espejo postcard-safe de [`Config`]: la misma información, sin `flatten` ni +/// `untagged`. Es lo que viaja por akasha en wawa. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct WireConfig { + pub general: General, + pub surfaces: Vec, +} + +impl From<&Config> for WireConfig { + fn from(c: &Config) -> Self { + Self { + general: c.general.clone(), + surfaces: c.surfaces.iter().map(WireSurface::from).collect(), + } + } +} + +impl From for Config { + fn from(c: WireConfig) -> Self { + Config { + general: c.general, + surfaces: c.surfaces.into_iter().map(Surface::from).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Prop, SidebarTab}; + use crate::{Config, Surface, WidgetSpec}; + use alloc::string::ToString; + + /// Un config con todos los rincones poblados (props de cada tipo, una + /// tarjeta flotante) para que el round-trip cubra el caso difícil. + fn config_rico() -> Config { + let mut top = Surface::bar(Anchor::Top); + top.thickness = 32.0; + top.start = alloc::vec![WidgetSpec::new("start_button").with("label", Prop::Str("⊞".to_string()))]; + top.center = alloc::vec![WidgetSpec::new("clock") + .with("format", Prop::Str("%H:%M".to_string())) + .with("size", Prop::Int(14)) + .with("ratio", Prop::Num(0.5)) + .with("flag", Prop::Bool(true))]; + top.end = alloc::vec![WidgetSpec::new("ram_meter")]; + + let mut panel = Surface::default(); + panel.kind = SurfaceKind::Panel; + panel.cards = alloc::vec![FloatingCard { + x: 40.0, + y: 80.0, + w: 220.0, + h: 110.0, + title: Some("sistema".to_string()), + widgets: alloc::vec![WidgetSpec::new("cpu_meter"), WidgetSpec::new("ram_meter")], + }]; + + let mut sidebar = Surface::sidebar(Anchor::Left); + sidebar.panel_width = 300.0; + sidebar.tabs = alloc::vec![ + SidebarTab::new( + "monads", + "Mónadas", + WidgetSpec::new("navigator").with("source", Prop::Str("nouser".to_string())), + ), + SidebarTab::new("files", "Archivos", WidgetSpec::new("navigator")), + ]; + + let mut cfg = Config::default(); + cfg.surfaces.push(top); + cfg.surfaces.push(panel); + cfg.surfaces.push(sidebar); + cfg + } + + #[test] + fn round_trip_sin_perdida() { + let cfg = config_rico(); + let wire = WireConfig::from(&cfg); + let vuelta: Config = wire.into(); + assert_eq!(cfg, vuelta); + } + + #[test] + fn round_trip_postcard() { + // El caso real de akasha: serializar con postcard (no auto-descriptivo, + // sin soporte de flatten/untagged) y volver. Falla con `Config` directo; + // funciona con `WireConfig`. + let cfg = config_rico(); + let wire = WireConfig::from(&cfg); + let bytes = postcard::to_allocvec(&wire).expect("postcard serializa el wire"); + let wire2: WireConfig = postcard::from_bytes(&bytes).expect("postcard deserializa el wire"); + let vuelta: Config = wire2.into(); + assert_eq!(cfg, vuelta); + } +} diff --git a/02_ruway/pata/pata-core/tests/toml_contract.rs b/02_ruway/pata/pata-core/tests/toml_contract.rs new file mode 100644 index 0000000..108b729 --- /dev/null +++ b/02_ruway/pata/pata-core/tests/toml_contract.rs @@ -0,0 +1,129 @@ +//! El contrato del config en disco: un TOML real debe deserializar al modelo +//! de `pata-core` sin pérdida. Linux carga TOML; este test fija que el esquema +//! (superficies múltiples, slots, props `flatten` + `Prop` untagged) sobrevive +//! el viaje. Vive como test de integración porque `toml` es `std` y dev-only. + +use pata_core::{Anchor, Config, Prop, SurfaceKind}; + +#[test] +fn deserializa_un_marco_completo_desde_toml() { + let src = r#" + [general] + timezone = "America/Lima" + + [[surfaces]] + kind = "bar" + anchor = "top" + thickness = 28 + + [[surfaces.start]] + kind = "start_button" + + [[surfaces.start]] + kind = "clock" + format = "%H:%M" + size = 14 + + [[surfaces.end]] + kind = "astro" + moon = true + lat = -12.04 + + [[surfaces]] + kind = "bar" + anchor = "bottom" + autohide = true + + [[surfaces.center]] + kind = "shuma_input" + hotkey = "F12" + "#; + + let cfg: Config = toml::from_str(src).expect("el TOML debe parsear al modelo"); + + assert_eq!(cfg.general.timezone, "America/Lima"); + assert_eq!(cfg.surfaces.len(), 2); + + let top = &cfg.surfaces[0]; + assert_eq!(top.kind, SurfaceKind::Bar); + assert_eq!(top.anchor, Anchor::Top); + assert_eq!(top.thickness, 28.0); + assert_eq!(top.start.len(), 2); + assert_eq!(top.start[0].kind, "start_button"); + // Props heterogéneas (string + int) llegan por flatten/untagged. + assert_eq!(top.start[1].str_prop("format", "?"), "%H:%M"); + assert_eq!(top.start[1].num_prop("size", 0.0), 14.0); + + let astro = &top.end[0]; + assert_eq!(astro.kind, "astro"); + assert!(astro.bool_prop("moon", false)); + assert_eq!(astro.num_prop("lat", 0.0), -12.04); + + let shell = &cfg.surfaces[1]; + assert_eq!(shell.anchor, Anchor::Bottom); + assert!(shell.autohide); + assert_eq!(shell.center[0].kind, "shuma_input"); + assert_eq!(shell.center[0].str_prop("hotkey", "?"), "F12"); +} + +#[test] +fn props_desconocidas_se_conservan() { + let src = r#" + [[surfaces]] + anchor = "right" + + [[surfaces.start]] + kind = "custom" + color = "rebeccapurple" + ratio = 0.42 + veces = 3 + "#; + let cfg: Config = toml::from_str(src).unwrap(); + let w = &cfg.surfaces[0].start[0]; + assert_eq!(w.str_prop("color", "?"), "rebeccapurple"); + assert_eq!(w.num_prop("ratio", 0.0), 0.42); + assert_eq!(w.num_prop("veces", 0.0), 3.0); + // anchor sin kind de superficie cae al default Bar. + assert_eq!(cfg.surfaces[0].kind, SurfaceKind::Bar); +} + +#[test] +fn deserializa_un_sidebar_con_dientes_navegador() { + // El rail (Fase 11): un SurfaceKind::Sidebar con `tabs` cuyo `content` es un + // WidgetSpec con props flatten (la fuente de datos del navegador). + let src = r#" + [[surfaces]] + kind = "sidebar" + anchor = "left" + thickness = 44 + panel_width = 300 + + [[surfaces.tabs]] + icon = "monads" + label = "Mónadas" + content = { kind = "navigator", source = "nouser" } + "#; + let cfg: Config = toml::from_str(src).expect("el sidebar debe parsear"); + let sb = &cfg.surfaces[0]; + assert_eq!(sb.kind, SurfaceKind::Sidebar); + assert_eq!(sb.anchor, Anchor::Left); + assert_eq!(sb.thickness, 44.0); + assert_eq!(sb.panel_width, 300.0); + assert_eq!(sb.tabs.len(), 1); + assert_eq!(sb.tabs[0].icon, "monads"); + assert_eq!(sb.tabs[0].label, "Mónadas"); + assert_eq!(sb.tabs[0].content.kind, "navigator"); + // La prop del contenido llega por flatten/untagged. + assert_eq!(sb.tabs[0].content.str_prop("source", "?"), "nouser"); +} + +#[test] +fn marco_minimo_y_vacio() { + // Sin superficies declaradas: config válido y vacío. + let cfg: Config = toml::from_str("").unwrap(); + assert!(cfg.surfaces.is_empty()); + assert_eq!(cfg.general.timezone, "auto"); + + // Un Prop entero no se confunde con float al volver a leerse. + let _ = Prop::Int(1); +} diff --git a/02_ruway/pata/pata-host/Cargo.toml b/02_ruway/pata/pata-host/Cargo.toml new file mode 100644 index 0000000..a7e6462 --- /dev/null +++ b/02_ruway/pata/pata-host/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pata-host" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "pata-host — protocolo del rail hospedado: una app le presta sus dientes (sidebar) al marco pata cuando tiene foco, y recibe de vuelta qué diente se activó. Socket Unix dedicado + marco postcard con prefijo de longitud. Lado app (HostClient) + lado shell (HostServer)." + +[dependencies] +serde = { workspace = true } +postcard = { workspace = true } diff --git a/02_ruway/pata/pata-host/src/client.rs b/02_ruway/pata/pata-host/src/client.rs new file mode 100644 index 0000000..376c676 --- /dev/null +++ b/02_ruway/pata/pata-host/src/client.rs @@ -0,0 +1,65 @@ +//! Lado **app** del rail hospedado: la app se conecta, registra sus dientes y +//! recibe las activaciones por un callback. + +use std::os::unix::net::UnixStream; + +use crate::{read_frame, socket_path, write_frame, AppMsg, HostedTooth, ShellMsg}; + +/// Cliente del rail hospedado, vive en la app. Mantiene la conexión a pata; un +/// hilo lector entrega las activaciones por el callback dado en [`HostClient::connect`]. +/// Al soltarse (`Drop`) manda `Bye`. +pub struct HostClient { + write: UnixStream, +} + +impl HostClient { + /// Se conecta al socket de pata, registra `app_id`/`title`/`teeth` y arranca el + /// hilo lector. `on_activate(tooth)` se invoca (en ese hilo) cada vez que el + /// usuario clickea un diente en el rail de pata. `None` si pata no está + /// escuchando — la app sigue andando con su propio rail. + /// + /// `app_id` **debe** ser el mismo que el compositor reporta para la ventana de + /// la app (el `app_id()` del trait `App` de llimphi), para que pata correlacione + /// foco ↔ dientes. + pub fn connect( + app_id: impl Into, + title: impl Into, + teeth: Vec, + on_activate: F, + ) -> Option + where + F: Fn(u32) + Send + 'static, + { + let mut stream = UnixStream::connect(socket_path()).ok()?; + write_frame( + &mut stream, + &AppMsg::Register { + app_id: app_id.into(), + title: title.into(), + teeth, + }, + ) + .ok()?; + + let mut read = stream.try_clone().ok()?; + std::thread::spawn(move || loop { + match read_frame::(&mut read) { + Ok(ShellMsg::Activate { tooth }) => on_activate(tooth), + Err(_) => break, // pata cerró o error: se acabó el hilo lector + } + }); + + Some(HostClient { write: stream }) + } + + /// Actualiza los dientes publicados (p. ej. si cambian dinámicamente). + pub fn update(&mut self, teeth: Vec) { + let _ = write_frame(&mut self.write, &AppMsg::Update { teeth }); + } +} + +impl Drop for HostClient { + fn drop(&mut self) { + let _ = write_frame(&mut self.write, &AppMsg::Bye); + } +} diff --git a/02_ruway/pata/pata-host/src/lib.rs b/02_ruway/pata/pata-host/src/lib.rs new file mode 100644 index 0000000..5a46de2 --- /dev/null +++ b/02_ruway/pata/pata-host/src/lib.rs @@ -0,0 +1,179 @@ +//! `pata-host` — el **rail hospedado**: el protocolo por el que una app le presta +//! sus "dientes" (su sidebar) al marco `pata` mientras tiene foco, y recibe de +//! vuelta qué diente activó el usuario. +//! +//! La idea (visión del autor): una app como `cosmos` puede dejar de pintar su +//! propio rail y quedar como **puro lienzo**; sus herramientas aparecen en el rail +//! global de pata cuando la app está enfocada. Al clickear un diente en pata, el +//! comando vuelve a la app, que muestra ese panel sobre su propio canvas. pata +//! sólo hospeda el **rail** (los dientes) — no los paneles ricos de la app. +//! +//! ## Transporte +//! +//! Un socket Unix dedicado ([`socket_path`], default +//! `$XDG_RUNTIME_DIR/pata-sidebar.sock`). pata escucha ([`HostServer`]); las apps +//! se conectan ([`HostClient`]). Cada conexión es un stream con marco +//! **prefijo-de-longitud + postcard** (igual que `mirada-link`): +//! +//! ```text +//! app → shell: [u32 LE len][postcard AppMsg]… (Register, Update, Bye) +//! shell → app: [u32 LE len][postcard ShellMsg]… (Activate) +//! ``` +//! +//! pata correlaciona el `app_id` que la app declara en `Register` con el `app_id` +//! del toplevel enfocado (que ya conoce vía wlr-foreign-toplevel). Cuando coinciden, +//! pinta los dientes de esa app; al clickear uno, le manda `Activate{tooth}`. + +#![forbid(unsafe_code)] + +use std::io::{self, Read, Write}; +use std::path::PathBuf; + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +// ===================================================================== +// Wire types +// ===================================================================== + +/// Un diente que la app presta al rail de pata: id opaco (asignado por la app), +/// nombre de icono (mismo vocabulario abierto que `pata_core::SidebarTab::icon`) +/// y etiqueta (tooltip). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostedTooth { + /// Identificador del diente, opaco para pata; vuelve tal cual en [`ShellMsg::Activate`]. + pub id: u32, + /// Nombre del icono (p. ej. `"folder"`, `"tools"`, `"astro"`). + pub icon: String, + /// Etiqueta corta (tooltip del diente). + pub label: String, +} + +impl HostedTooth { + pub fn new(id: u32, icon: impl Into, label: impl Into) -> Self { + Self { + id, + icon: icon.into(), + label: label.into(), + } + } +} + +/// Mensaje de la **app hacia el shell**. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppMsg { + /// Alta: la app se presenta con su `app_id` (el mismo que reporta el + /// compositor para su ventana), un título y sus dientes. + Register { + app_id: String, + title: String, + teeth: Vec, + }, + /// Sus dientes cambiaron (mismo `app_id` implícito por la conexión). + Update { teeth: Vec }, + /// Baja explícita (también se infiere al cerrarse la conexión). + Bye, +} + +/// Mensaje del **shell hacia la app**. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShellMsg { + /// El usuario clickeó el diente `tooth` (el `id` que la app declaró). La app + /// decide la semántica (típicamente: togglear ese panel sobre su canvas). + Activate { tooth: u32 }, +} + +// ===================================================================== +// Transporte +// ===================================================================== + +/// Variable de entorno para sobreescribir la ruta del socket. +pub const SOCKET_ENV: &str = "PATA_SIDEBAR_SOCKET"; +/// Nombre por defecto del socket. +pub const SOCKET_NAME: &str = "pata-sidebar.sock"; + +/// Ruta canónica del socket del rail hospedado. Honra [`SOCKET_ENV`]; si no, +/// arma sobre `$XDG_RUNTIME_DIR` (con fallback al directorio temporal). +pub fn socket_path() -> PathBuf { + if let Ok(p) = std::env::var(SOCKET_ENV) { + return PathBuf::from(p); + } + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join(SOCKET_NAME) +} + +fn postcard_err(e: postcard::Error) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, e) +} + +/// Escribe un mensaje con marco `[u32 LE len][postcard]`. +pub fn write_frame(w: &mut impl Write, msg: &T) -> io::Result<()> { + let bytes = postcard::to_stdvec(msg).map_err(postcard_err)?; + w.write_all(&(bytes.len() as u32).to_le_bytes())?; + w.write_all(&bytes)?; + w.flush() +} + +/// Lee un mensaje con marco `[u32 LE len][postcard]`. `Err(UnexpectedEof)` al +/// cerrarse la conexión limpiamente. +pub fn read_frame(r: &mut impl Read) -> io::Result { + let mut len = [0u8; 4]; + r.read_exact(&mut len)?; + let n = u32::from_le_bytes(len) as usize; + let mut buf = vec![0u8; n]; + r.read_exact(&mut buf)?; + postcard::from_bytes(&buf).map_err(postcard_err) +} + +#[cfg(unix)] +mod server; +#[cfg(unix)] +pub use server::HostServer; + +#[cfg(unix)] +mod client; +#[cfg(unix)] +pub use client::HostClient; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_roundtrip_appmsg() { + let m = AppMsg::Register { + app_id: "gioser.cosmos".into(), + title: "Cosmos".into(), + teeth: vec![HostedTooth::new(1, "folder", "Árbol"), HostedTooth::new(2, "tools", "Herramientas")], + }; + let bytes = postcard::to_stdvec(&m).unwrap(); + let back: AppMsg = postcard::from_bytes(&bytes).unwrap(); + assert_eq!(back, m); + } + + #[test] + fn wire_roundtrip_shellmsg() { + let m = ShellMsg::Activate { tooth: 7 }; + let bytes = postcard::to_stdvec(&m).unwrap(); + assert_eq!(postcard::from_bytes::(&bytes).unwrap(), m); + } + + #[test] + fn frame_roundtrip_sobre_buffer() { + let mut buf: Vec = Vec::new(); + let m = ShellMsg::Activate { tooth: 3 }; + write_frame(&mut buf, &m).unwrap(); + let mut cur = std::io::Cursor::new(buf); + let back: ShellMsg = read_frame(&mut cur).unwrap(); + assert_eq!(back, m); + } + + #[test] + fn socket_path_honra_env() { + // No tocamos el entorno global del proceso de forma persistente; sólo + // verificamos la forma del fallback. + let p = socket_path(); + assert!(p.ends_with(SOCKET_NAME) || std::env::var(SOCKET_ENV).is_ok()); + } +} diff --git a/02_ruway/pata/pata-host/src/server.rs b/02_ruway/pata/pata-host/src/server.rs new file mode 100644 index 0000000..cf215a3 --- /dev/null +++ b/02_ruway/pata/pata-host/src/server.rs @@ -0,0 +1,157 @@ +//! Lado **shell** del rail hospedado: pata escucha el socket, acumula los dientes +//! que registran las apps y les reenvía las activaciones. + +use std::collections::HashMap; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::{read_frame, socket_path, write_frame, AppMsg, HostedTooth, ShellMsg}; + +/// Una app registrada: su título, sus dientes y la mitad de escritura de su +/// conexión (para mandarle `Activate`). +struct AppReg { + title: String, + teeth: Vec, + write: UnixStream, +} + +/// El estado compartido entre el hilo aceptador/lectores y el bucle de UI. +struct Shared { + apps: Mutex>, + /// Se incrementa en cada cambio (alta/baja/update) para que el host detecte + /// "hay algo nuevo que pintar" sin difear el mapa. + revision: AtomicU64, +} + +/// El servidor del rail hospedado. Vive en pata; arranca su hilo aceptador en +/// [`HostServer::spawn`] y se consulta desde el bucle de UI. +pub struct HostServer { + shared: Arc, +} + +impl HostServer { + /// Bindea el socket y arranca el hilo aceptador. `None` si no se puede bindear + /// (otro pata ya escucha, o sin permisos) — el rail hospedado queda inactivo + /// sin romper el resto del marco. + pub fn spawn() -> Option { + let path = socket_path(); + // Limpia un socket viejo de un pata que murió (best-effort). Si hay otro + // pata vivo, el bind de abajo fallará y devolvemos None. + let _ = std::fs::remove_file(&path); + let listener = match UnixListener::bind(&path) { + Ok(l) => l, + Err(e) => { + eprintln!("pata host · no pude bindear {}: {e}", path.display()); + return None; + } + }; + + let shared = Arc::new(Shared { + apps: Mutex::new(HashMap::new()), + revision: AtomicU64::new(0), + }); + + let shared_accept = shared.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let shared = shared_accept.clone(); + // Un lector por conexión. + std::thread::spawn(move || handle_conn(stream, shared)); + } + }); + + Some(HostServer { shared }) + } + + /// Snapshot (clonado) del título + dientes de `app_id`, si está registrada. + /// Para que el host lo pinte sin retener el lock. + pub fn snapshot(&self, app_id: &str) -> Option<(String, Vec)> { + let apps = self.shared.apps.lock().ok()?; + apps.get(app_id).map(|r| (r.title.clone(), r.teeth.clone())) + } + + /// `true` si alguna app tiene dientes registrados (para saber si vale la pena + /// mirar el foco). + pub fn any_registered(&self) -> bool { + self.shared + .apps + .lock() + .map(|a| !a.is_empty()) + .unwrap_or(false) + } + + /// Le manda `Activate{tooth}` a `app_id`. `true` si se escribió. + pub fn activate(&self, app_id: &str, tooth: u32) -> bool { + let Ok(mut apps) = self.shared.apps.lock() else { + return false; + }; + let Some(reg) = apps.get_mut(app_id) else { + return false; + }; + write_frame(&mut reg.write, &ShellMsg::Activate { tooth }).is_ok() + } + + /// Contador de revisión: cambia cuando un alta/baja/update tocó el mapa. + pub fn revision(&self) -> u64 { + self.shared.revision.load(Ordering::Relaxed) + } +} + +/// Atiende una conexión: lee `AppMsg`s y mantiene el mapa. Al cerrarse el stream +/// (EOF) o recibir `Bye`, da de baja la app. +fn handle_conn(stream: UnixStream, shared: Arc) { + // La mitad de escritura (clonada) viaja al mapa para mandar `Activate`; la de + // lectura se queda en este hilo. + let write = match stream.try_clone() { + Ok(w) => w, + Err(_) => return, + }; + let mut read = stream; + let mut my_app_id: Option = None; + + loop { + match read_frame::(&mut read) { + Ok(AppMsg::Register { + app_id, + title, + teeth, + }) => { + let write = match write.try_clone() { + Ok(w) => w, + Err(_) => return, + }; + if let Ok(mut apps) = shared.apps.lock() { + apps.insert(app_id.clone(), AppReg { title, teeth, write }); + } + my_app_id = Some(app_id); + bump(&shared); + } + Ok(AppMsg::Update { teeth }) => { + if let Some(id) = &my_app_id { + if let Ok(mut apps) = shared.apps.lock() { + if let Some(reg) = apps.get_mut(id) { + reg.teeth = teeth; + } + } + bump(&shared); + } + } + Ok(AppMsg::Bye) | Err(_) => { + // Bye explícito o EOF/error: damos de baja y salimos. + if let Some(id) = &my_app_id { + if let Ok(mut apps) = shared.apps.lock() { + apps.remove(id); + } + bump(&shared); + } + return; + } + } + } +} + +fn bump(shared: &Arc) { + shared.revision.fetch_add(1, Ordering::Relaxed); +} diff --git a/02_ruway/pata/pata-host/tests/roundtrip.rs b/02_ruway/pata/pata-host/tests/roundtrip.rs new file mode 100644 index 0000000..25326bd --- /dev/null +++ b/02_ruway/pata/pata-host/tests/roundtrip.rs @@ -0,0 +1,68 @@ +//! Integración server↔client sobre un socket Unix real: el registro de dientes +//! llega al server, una activación vuelve a la app, y la baja se infiere al +//! soltar el cliente. +//! +//! Un solo `#[test]` a propósito: el path del socket se fija por env (proceso- +//! global), así que dos tests en paralelo competirían por él. + +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use pata_host::{HostClient, HostServer, HostedTooth}; + +/// Reintenta `f` hasta que devuelva `Some` o venza `dur`. +fn esperar(dur: Duration, mut f: impl FnMut() -> Option) -> Option { + let fin = Instant::now() + dur; + loop { + if let Some(v) = f() { + return Some(v); + } + if Instant::now() >= fin { + return None; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn registro_activacion_y_baja() { + let sock = std::env::temp_dir().join(format!("pata-host-test-{}.sock", std::process::id())); + std::env::set_var(pata_host::SOCKET_ENV, &sock); + let _ = std::fs::remove_file(&sock); + + let server = HostServer::spawn().expect("server bindea"); + + let teeth = vec![ + HostedTooth::new(1, "folder", "Árbol"), + HostedTooth::new(2, "tools", "Herramientas"), + ]; + + // --- Registro + activación ida y vuelta --- + let (tx, rx) = mpsc::channel::(); + let client = HostClient::connect("gioser.test", "Test", teeth.clone(), move |t| { + let _ = tx.send(t); + }) + .expect("client conecta"); + + let snap = esperar(Duration::from_secs(2), || server.snapshot("gioser.test")) + .expect("el server registró la app"); + assert_eq!(snap.0, "Test"); + assert_eq!(snap.1, teeth); + assert!(server.any_registered()); + + assert!(server.activate("gioser.test", 2)); + let got = rx.recv_timeout(Duration::from_secs(2)).expect("llega la activación"); + assert_eq!(got, 2); + + // Una app desconocida no recibe nada. + assert!(!server.activate("otra.app", 1)); + + // --- Baja: soltar el cliente da de baja la app --- + drop(client); + let ido = esperar(Duration::from_secs(2), || { + server.snapshot("gioser.test").is_none().then_some(()) + }); + assert!(ido.is_some(), "la app debe darse de baja al soltar el cliente"); + + let _ = std::fs::remove_file(&sock); +} diff --git a/02_ruway/pata/pata-llimphi/Cargo.toml b/02_ruway/pata/pata-llimphi/Cargo.toml new file mode 100644 index 0000000..b9d3618 --- /dev/null +++ b/02_ruway/pata/pata-llimphi/Cargo.toml @@ -0,0 +1,73 @@ +[package] +name = "pata-llimphi" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "pata — el frontend Linux: monta el modelo de pata-core sobre Llimphi. Resuelve la geometría de las superficies (barras/paneles/dock) y pinta cada widget traduciendo su view-model agnóstico al backend gráfico. Muestrea el sistema (reloj, CPU, RAM, volumen, brillo) en un WidgetCtx que alimenta a los widgets. Corre sobre el compositor mirada." + +[[bin]] +name = "pata-llimphi" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[dependencies] +pata-core = { path = "../pata-core" } +pata-config = { path = "../pata-config" } +# Rail hospedado (sidebar delegado): pata hospeda los dientes de la app enfocada +# y le reenvía las activaciones. Lado servidor del protocolo. +pata-host = { path = "../pata-host" } +# El puente pata→shuma (SDD §5): el marco provee el borde, shuma la ejecución. +# El drawer Quake corre los comandos por el ejecutor real de shuma (captura +# acotada, streaming) en vez de un `sh -c` pelado. +shuma-exec = { workspace = true } +# El cerebro de la línea de shuma (agnóstico de GUI): descompone el comando en +# etapas de pipe para las chips clickeables del drawer. +shuma-line = { workspace = true } +# IA en el drawer Quake: el submit sin prefijo `!`/`$` va al LLM (fachada +# transparente de pluma; cae a Mock sin credenciales). Paridad con el quake de +# mirada-launcher, que pata reemplaza (SDD Fase 10). +pluma-llm = { workspace = true } +# Registro de apps para el menú del botón de inicio (descubre ~/.config/gioser/ +# apps/*.toml). El menú nativo reemplaza a depender de wofi/rofi externos. +app-bus = { workspace = true } +llimphi-ui = { workspace = true } +# Área de scroll del historial del drawer (clip + offset + barra arrastrable). +llimphi-widget-scroll = { workspace = true } +# Sidebars acoplables (Fase 11c): rail de dientes + navegador de Mónadas/archivos +# + toggle Árbol/Grafo. El plano de datos lo provee nouser vía chasqui-card. +llimphi-widget-dock-rail = { workspace = true } +llimphi-widget-navigator = { workspace = true } +llimphi-widget-segmented = { workspace = true } +# Protocolo de query de nouser (list_monads / resolve_monad) — fuente +# autoritativa de qué archivos componen una Mónada. Importamos sólo los wire +# types + cliente blocking, sin arrastrar el engine (notify/sled/blake3). +chasqui-card = { workspace = true } +# Descubrimiento del socket del daemon vía broker brahman (Card consumer + +# await_provider_blocking), con fallback al default path. Mismo patrón que +# chasqui-explorer-llimphi. +card-sidecar = { workspace = true } +# MonadId = Ulid: derivamos NavId deterministas de sus bytes; los tests crean ids. +ulid = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-motion = { workspace = true } +chrono = { workspace = true } +# Backend wlr-layer-shell (Linux/Wayland): hace que pata se siente al nivel de +# eww/waybar — layer surface anclada con exclusive zone, no una ventana cliente. +smithay-client-toolkit = "0.19" +wayland-client = "0.31" +# Lista de ventanas (widget `window_list`): wlr-foreign-toplevel-management, el +# protocolo que usan waybar/eww para enumerar y activar toplevels en wlroots. +wayland-protocols-wlr = { version = "0.3", features = ["client"] } +# Bandeja del sistema (widget `tray`): StatusNotifierItem sobre D-Bus. pata corre +# como watcher+host en un hilo aparte con zbus async sobre un runtime tokio +# current-thread (el workspace fija zbus con la feature `tokio`, no la blocking). +zbus = { workspace = true } +tokio = { workspace = true } +# Decodificar PNG de íconos del tray resueltos por nombre (IconName). +image = { workspace = true } +raw-window-handle = { workspace = true } +pollster = { workspace = true } diff --git a/02_ruway/pata/pata-llimphi/src/keys.rs b/02_ruway/pata/pata-llimphi/src/keys.rs new file mode 100644 index 0000000..712afae --- /dev/null +++ b/02_ruway/pata/pata-llimphi/src/keys.rs @@ -0,0 +1,74 @@ +//! Parser mínimo `string → Key` para los hotkeys declarados en el config. +//! +//! Cubre teclas únicas: `F1..F12`, `Escape/Esc`, `Enter/Return`, `Tab`, +//! `Space`, `Backspace`, o un carácter suelto (`a`, `/`). Sin modifiers +//! (`Ctrl+Space`) por ahora — falla cerrado si no parsea. + +use llimphi_ui::{Key, NamedKey}; + +/// Convierte una etiqueta tipo `"F12"` al `Key` correspondiente. +pub fn parse(label: &str) -> Option { + let s = label.trim(); + if s.is_empty() { + return None; + } + let named = match s { + "F1" => NamedKey::F1, + "F2" => NamedKey::F2, + "F3" => NamedKey::F3, + "F4" => NamedKey::F4, + "F5" => NamedKey::F5, + "F6" => NamedKey::F6, + "F7" => NamedKey::F7, + "F8" => NamedKey::F8, + "F9" => NamedKey::F9, + "F10" => NamedKey::F10, + "F11" => NamedKey::F11, + "F12" => NamedKey::F12, + "Escape" | "Esc" => NamedKey::Escape, + "Enter" | "Return" => NamedKey::Enter, + "Tab" => NamedKey::Tab, + "Space" => NamedKey::Space, + "Backspace" => NamedKey::Backspace, + _ => { + if s.chars().count() == 1 { + return Some(Key::Character(s.into())); + } + return None; + } + }; + Some(Key::Named(named)) +} + +/// `true` si la tecla del evento corresponde a la etiqueta. Falla cerrado. +pub fn matches(label: &str, event_key: &Key) -> bool { + match parse(label) { + Some(parsed) => &parsed == event_key, + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parsea_teclas_de_funcion_y_caracter() { + assert_eq!(parse("F12"), Some(Key::Named(NamedKey::F12))); + assert_eq!(parse("Esc"), Some(Key::Named(NamedKey::Escape))); + assert_eq!(parse("/"), Some(Key::Character("/".into()))); + } + + #[test] + fn etiqueta_vacia_o_desconocida_es_none() { + assert_eq!(parse(" "), None); + assert_eq!(parse("Ctrl+Space"), None); + } + + #[test] + fn matches_falla_cerrado() { + assert!(matches("F12", &Key::Named(NamedKey::F12))); + assert!(!matches("", &Key::Named(NamedKey::F12))); + assert!(!matches("F1", &Key::Named(NamedKey::F12))); + } +} diff --git a/02_ruway/pata/pata-llimphi/src/layer.rs b/02_ruway/pata/pata-llimphi/src/layer.rs new file mode 100644 index 0000000..3902436 --- /dev/null +++ b/02_ruway/pata/pata-llimphi/src/layer.rs @@ -0,0 +1,1761 @@ +//! Backend `wlr-layer-shell`: hace que `pata` se siente **al nivel de eww/ +//! waybar** en cualquier compositor wlroots (Hyprland, Sway, river…), no como +//! una ventana cliente. +//! +//! Una *layer surface* se ancla a un borde y declara una *exclusive zone* —el +//! compositor le reserva esa franja y tesela el resto alrededor—, igual que eww. +//! Aquí: nos conectamos a Wayland con `smithay-client-toolkit`, creamos **una +//! layer surface por cada superficie `Bar`** de la config (cada una anclada a su +//! borde con su exclusive zone), sacamos su `wgpu::Surface` de los punteros raw +//! del `wl_surface`/`wl_display` (envuelta en [`RawSurface`]) y la pintamos +//! reusando el pipeline de Llimphi (`mount → compute → paint → render`). +//! +//! **Estado**: pinta todas las barras de la config (varios bordes a la vez), +//! con input (teclado/clicks), drawer Quake, window_list, clipboard y tray. +//! Verificado en Hyprland. No se verifica headless: se itera en un compositor +//! real. +//! +//! **Gotcha Vulkan WSI + smithay (mirada):** NO reconfigurar el swapchain por +//! cuadro. [`Self::draw`] llama a `surface.resize(w, h)` cada frame; por eso +//! `RawSurface::resize` es no-op cuando el tamaño no cambia. Reconfigurar el +//! swapchain reconstruye el `wl_buffer` y destruye el recién presentado antes de +//! que el compositor lo componga — wlroots lo tolera, smithay no (la barra queda +//! negra, el compositor ve `buffer=None`). Ver commit `b8747b90`. + +use std::error::Error; +use std::ffi::c_void; +use std::ptr::NonNull; + +use raw_window_handle::{ + RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, +}; +use smithay_client_toolkit::{ + compositor::{CompositorHandler, CompositorState, Region}, + delegate_compositor, delegate_keyboard, delegate_layer, delegate_output, delegate_pointer, + delegate_registry, delegate_seat, + output::{OutputHandler, OutputState}, + registry::{ProvidesRegistryState, RegistryState}, + registry_handlers, + seat::{ + keyboard::{KeyEvent as KbEvent, KeyboardHandler, Keysym, Modifiers}, + pointer::{PointerEvent, PointerEventKind, PointerHandler, BTN_LEFT, BTN_RIGHT}, + Capability, SeatHandler, SeatState, + }, + shell::{ + wlr_layer::{ + Anchor as LayerAnchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler, + LayerSurface, LayerSurfaceConfigure, + }, + WaylandSurface, + }, +}; +use wayland_client::{ + event_created_child, + globals::registry_queue_init, + protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, + Connection, Dispatch, Proxy, QueueHandle, +}; +use wayland_protocols_wlr::foreign_toplevel::v1::client::{ + zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1}, + zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1, EVT_TOPLEVEL_OPCODE}, +}; + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_compositor::{ + hit_test_click, hit_test_hover, hit_test_scroll, measure_text_node, mount, paint, DragFn, + DragPhase, Mounted, +}; +use llimphi_ui::llimphi_hal::{wgpu, Hal, RawSurface, Surface as _}; +use llimphi_ui::llimphi_layout::{taffy, ComputedLayout, LayoutTree}; +use llimphi_ui::llimphi_raster::{peniko::color::palette, vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use pata_core::config::FloatingCard; +use pata_core::widget::{Widget, WidgetCtx}; +use pata_core::{Anchor, Config, SurfaceKind}; + +use crate::nouser::{self, MembersOutcome, NavState, PollOutcome}; +use crate::sampler::SamplerHandle; +use pata_host::HostServer; +use crate::toplevel::{Toplevel, WindowEntry}; +use crate::tray::TrayHandle; +use crate::{render, Model, Msg}; + +use std::sync::mpsc::{Receiver, Sender}; + +/// Traza de diagnóstico gateada por `PATA_DIAG` (cualquier valor la enciende). +/// Para depurar el camino layer-shell en hardware sin recompilar A/B: +/// `PATA_DIAG=1 pata-llimphi 2>&1 | tee /tmp/pata.log`. +macro_rules! diag { + ($($a:tt)*) => { + if std::env::var_os("PATA_DIAG").is_some() { + eprintln!($($a)*); + } + }; +} + +/// El estado wgpu de **una** layer surface (una barra). El `Hal` (instancia + +/// device de wgpu) se comparte entre todas las barras, en [`LayerApp::hal`]. +struct PanelGpu { + surface: RawSurface, + renderer: Renderer, + typesetter: Typesetter, + scene: vello::Scene, + layout: LayoutTree, +} + +/// El árbol pintado en el último frame de un panel, para hacer hit-test de los +/// clicks (qué nodo está bajo el puntero y qué `on_click` dispara). +struct RenderCache { + mounted: Mounted, + computed: ComputedLayout, +} + +/// Un arrastre en curso sobre un nodo arrastrable (p. ej. un nodo del grafo del +/// navegador, que selecciona al soltar). El backend layer-shell rastrea el press→ +/// move→release mínimo para invocar el handler `draggable` del nodo —el bucle +/// winit lo hace nativo; acá lo replicamos a mano para que el modo grafo +/// seleccione también bajo Wayland. +struct LayerDrag { + /// El handler del nodo: `Fn(DragPhase, dx, dy) -> Option`. + handler: DragFn, + /// Última posición del puntero, para el delta de cada `Move`. + last: (f32, f32), +} + +/// El estado de una tarjeta flotante (estilo conky) montada como su propia layer +/// surface: la spec (título/tamaño) + sus widgets vivos. La diferencia con una +/// barra es que vive en `Layer::Bottom` sobre el escritorio, no reserva franja y +/// no toma teclado. +struct CardState { + spec: FloatingCard, + widgets: Vec>, +} + +/// Una layer surface de pata: o una **barra** anclada a un borde (con sus tres +/// slots), o una **tarjeta flotante** (`card`). En ambos casos lleva su propio +/// estado wgpu y su cache de hit-test. +struct Panel { + /// Índice de su superficie en `cfg.surfaces` (la barra, o el `Panel` dueño de + /// la tarjeta). + idx: usize, + /// `Some` si esta surface es una tarjeta flotante; `None` si es una barra. + card: Option, + layer: LayerSurface, + /// El árbol del último frame (para hit-test de clicks). + cache: Option, + width: u32, + height: u32, + /// `true` cuando hay algo nuevo que pintar (cambió el muestreo o el tamaño). + dirty: bool, + /// Nodo bajo el puntero en este panel (para `hover_fill` y, a futuro, + /// tooltips). `None` si el puntero no está sobre ningún nodo hovereable. + hover_idx: Option, + gpu: Option, +} + +/// Alto del drawer Quake cuando se despliega (px). El compositor lo clampa a la +/// salida; la barra crece hacia arriba hasta este alto. +const DRAWER_H: u32 = 420; + +/// Alto de la barra superior cuando despliega el menú de inicio (px): crece hacia +/// abajo hasta este alto, manteniendo su exclusive zone en el grosor de la barra. +const MENU_H: u32 = 480; + +/// El cliente Wayland del backend layer-shell. +struct LayerApp { + registry_state: RegistryState, + output_state: OutputState, + seat_state: SeatState, + conn: Connection, + /// `Hal` compartido (una instancia/device de wgpu para todas las barras). + hal: Option, + keyboard: Option, + pointer: Option, + /// El seat (para activar ventanas: `activate(seat)` lo exige). + seat: Option, + /// El manager de wlr-foreign-toplevel, si el compositor lo expone. `None` en + /// compositores sin el protocolo: el `window_list` queda vacío, sin romper. + /// Se guarda para mantener vivo el binding (de él cuelgan los eventos de cada + /// toplevel), aunque no se vuelva a leer. + #[allow(dead_code)] + toplevel_mgr: Option, + /// Las ventanas abiertas que reporta el compositor. + toplevels: Vec, + /// Contador para asignar [`Toplevel::id`] estables. + next_toplevel_id: u32, + /// Texto del portapapeles (una línea), para el widget `clipboard`. Se + /// re-muestrea con el resto del sistema (~1Hz) vía `wl-paste`. + clipboard: Option, + /// La bandeja del sistema (StatusNotifierItem), corriendo en su propio hilo. + /// `None` si la config no tiene ningún widget `tray`. + tray: Option, + theme: Theme, + cfg: Config, + surfaces: Vec, + shuma: crate::shuma::ShumaState, + /// Índice (en `panels`) de la barra que hospeda el `shuma_input`, si hay. + shuma_panel: Option, + /// Grosor original (px) de esa barra — al que vuelve al replegar el drawer. + shuma_bar_px: u32, + /// Registro de apps para el menú de inicio (descubierto del dir canónico). + registry: app_bus::AppRegistry, + /// `true` cuando el menú de inicio está desplegado. + menu_open: bool, + /// Índice (en `panels`) de la barra que hospeda el `start_button`, si hay. + menu_panel: Option, + /// Grosor original (px) de esa barra — al que vuelve al replegar el menú. + menu_bar_px: u32, + /// Muestreador del sistema en su propio hilo (subprocesos wpctl/wl-paste sin + /// tocar el bucle de UI). Publica un snapshot ~1Hz; `maybe_sample` lo recoge. + sampler: SamplerHandle, + /// Último snapshot del sistema recogido del hilo de muestreo. + ctx: WidgetCtx, + /// Comando del Quake corriendo en un hilo: su resultado llega por aquí. El + /// latido del frame-callback lo sondea (`try_recv`) sin bloquear el loop. + exec_rx: Option>, + /// Estado del sidebar navegador (Mónadas de nouser). Vacío si la config no + /// declara un navegador. + nav: NavState, + /// Canal por donde el hilo de poll de `list_monads` entrega resultados (~2s). + /// `None` si la config no tiene navegador (no se arranca el hilo). + nav_rx: Option>, + /// Canal para que los hilos one-shot de `resolve_monad` entreguen miembros. + members_tx: Sender, + members_rx: Receiver, + /// Arrastre en curso (selección de nodo del grafo). `None` si no se arrastra. + drag: Option, + /// Servidor del rail hospedado: apps que prestan sus dientes a pata mientras + /// tienen foco. `None` si la config no tiene ningún sidebar donde alojarlos. + host: Option, + /// Última revisión vista del `host` (para detectar altas/bajas/updates y + /// re-pintar los sidebars). + last_host_rev: u64, + /// Una layer surface por cada barra de la config. + panels: Vec, + /// Índice (en `panels`) de la surface del **tooltip flotante**: una layer + /// surface en `Overlay`, reubicada al hover. `None` si no se creó. + tooltip_pi: Option, + /// Texto del tooltip actualmente visible (`None` = oculto). + tooltip_text: Option, + exit: bool, +} + +/// El anclaje sctk + el tamaño `(w, h)` pedido para un borde y grosor. El eje +/// libre va en 0 → el compositor lo estira al ancho/alto de la salida. +fn anchor_y_size(anchor: Anchor, thickness: u32) -> (LayerAnchor, (u32, u32)) { + match anchor { + Anchor::Top => ( + LayerAnchor::TOP | LayerAnchor::LEFT | LayerAnchor::RIGHT, + (0, thickness), + ), + Anchor::Bottom => ( + LayerAnchor::BOTTOM | LayerAnchor::LEFT | LayerAnchor::RIGHT, + (0, thickness), + ), + Anchor::Left => ( + LayerAnchor::LEFT | LayerAnchor::TOP | LayerAnchor::BOTTOM, + (thickness, 0), + ), + Anchor::Right => ( + LayerAnchor::RIGHT | LayerAnchor::TOP | LayerAnchor::BOTTOM, + (thickness, 0), + ), + } +} + +/// Levanta el backend layer-shell. Devuelve error si no hay sesión Wayland o el +/// compositor no expone `wlr-layer-shell` (en ese caso el caller cae a winit). +pub fn run() -> Result<(), Box> { + let cfg = pata_config::load(); + let bars: Vec = cfg + .surfaces + .iter() + .enumerate() + .filter(|(_, s)| s.kind == SurfaceKind::Bar) + .map(|(i, _)| i) + .collect(); + // Los sidebars (Fase 11) también se anclan como layer surfaces propias. + let sidebars: Vec = cfg + .surfaces + .iter() + .enumerate() + .filter(|(_, s)| s.kind == SurfaceKind::Sidebar) + .map(|(i, _)| i) + .collect(); + if bars.is_empty() && sidebars.is_empty() { + return Err("pata · la config no tiene ninguna superficie anclable (bar/sidebar)".into()); + } + diag!( + "pata diag · backend LAYER-SHELL arranca · {} barra(s) + {} sidebar(s)", + bars.len(), + sidebars.len() + ); + + let conn = Connection::connect_to_env()?; + let (globals, mut event_queue) = registry_queue_init(&conn)?; + let qh: QueueHandle = event_queue.handle(); + + let compositor = CompositorState::bind(&globals, &qh)?; + let layer_shell = LayerShell::bind(&globals, &qh)?; + + // El manager de ventanas (window_list): opcional. Si el compositor no lo + // expone, el widget queda vacío en vez de fallar el arranque. + let toplevel_mgr = globals + .bind::(&qh, 1..=3, ()) + .ok(); + if toplevel_mgr.is_none() { + eprintln!("pata layer · el compositor no expone wlr-foreign-toplevel; window_list vacío"); + } + + // El tray sólo arranca (y toma el nombre del watcher) si la config lo pide. + let tray = crate::config_tiene_widget(&cfg, "tray") + .then(TrayHandle::spawn) + .flatten(); + + // Plano de datos del sidebar: un hilo que poolea `list_monads` cada ~2s y + // entrega por canal (mismo patrón que el sampler/exec — el bucle Wayland lo + // sondea sin bloquear). Sólo arranca si la config declara un navegador. + let nav_rx = crate::config_tiene_navigator(&cfg).then(|| { + let (tx, rx) = std::sync::mpsc::channel::(); + std::thread::spawn(move || { + let mut socket = None; + loop { + let outcome = nouser::poll(socket.clone()); + socket = match &outcome { + PollOutcome::Ok { socket: s, .. } => Some(s.clone()), + PollOutcome::Failed(_) => None, + }; + if tx.send(outcome).is_err() { + break; // el bucle de UI terminó + } + std::thread::sleep(nouser::REFRESH_INTERVAL); + } + }); + rx + }); + let (members_tx, members_rx) = std::sync::mpsc::channel::(); + + let (surfaces, shuma) = Model::construir(&cfg); + // El sampler en UTC si la config lo pide (se lee antes de mover `cfg` al app). + let utc = crate::usa_utc(&cfg); + let mut app = LayerApp { + registry_state: RegistryState::new(&globals), + output_state: OutputState::new(&globals, &qh), + seat_state: SeatState::new(&globals, &qh), + conn, + hal: None, + keyboard: None, + pointer: None, + seat: None, + toplevel_mgr, + toplevels: Vec::new(), + next_toplevel_id: 0, + clipboard: None, + tray, + theme: Theme::dark(), + cfg, + surfaces, + shuma, + // Se calculan después, una vez creados los panels. + shuma_panel: None, + shuma_bar_px: 40, + registry: app_bus::AppRegistry::discover(), + menu_open: false, + menu_panel: None, + menu_bar_px: 32, + sampler: SamplerHandle::spawn(utc), + ctx: WidgetCtx::default(), + exec_rx: None, + nav: NavState::default(), + nav_rx, + members_tx, + members_rx, + drag: None, + // El rail hospedado sólo tiene sentido si hay un sidebar donde alojar los + // dientes de la app enfocada. + host: (!sidebars.is_empty()).then(HostServer::spawn).flatten(), + last_host_rev: 0, + panels: Vec::new(), + tooltip_pi: None, + tooltip_text: None, + exit: false, + }; + + // Roundtrip para que `OutputState` reciba `wl_output.geometry` + el + // `xdg_output.name` de cada monitor: lo necesitamos para resolver + // `Surface::output` (nombre del conector) a un `wl_output` real antes + // de pedir cada layer surface. SCTK publica el nombre en el segundo + // roundtrip (xdg-output llega después del wl_output base). + event_queue.roundtrip(&mut app)?; + event_queue.roundtrip(&mut app)?; + + // Mapa `nombre del conector → wl_output` (ej. `"HDMI-A-1" → WlOutput`). + // Sin nombre (compositor sin xdg-output) la entrada se omite — esa + // salida sólo es alcanzable con `output: ""` (= primario, sin hint). + let mut outputs_by_name: std::collections::HashMap = + std::collections::HashMap::new(); + for out in app.output_state.outputs() { + if let Some(info) = app.output_state.info(&out) { + if let Some(name) = info.name { + outputs_by_name.insert(name, out); + } + } + } + diag!("pata diag · outputs descubiertos: {:?}", outputs_by_name.keys().collect::>()); + + // Resuelve `output: String` de la config a `Option<&wl_output>`. Vacío = + // None (el compositor decide). Nombre desconocido = None + aviso (la + // surface cae al primario en vez de fallar el arranque). + let resolve_output = + |name: &str| -> Option { + if name.is_empty() { + return None; + } + if let Some(o) = outputs_by_name.get(name) { + return Some(o.clone()); + } + eprintln!("pata layer · output «{name}» no conectado; cae al primario"); + None + }; + + // Una layer surface por barra: anclada a su borde, con su exclusive zone. + for &idx in &bars { + let s = &app.cfg.surfaces[idx]; + let thickness = s.thickness.max(1.0) as u32; + let (sctk_anchor, size) = anchor_y_size(s.anchor, thickness); + let wl_surface = compositor.create_surface(&qh); + let layer = layer_shell.create_layer_surface( + &qh, + wl_surface, + Layer::Top, + Some("pata".to_string()), + resolve_output(&s.output).as_ref(), + ); + layer.set_anchor(sctk_anchor); + layer.set_size(size.0, size.1); + layer.set_exclusive_zone(thickness as i32); + layer.commit(); + app.panels.push(Panel { + idx, + card: None, + layer, + cache: None, + width: size.0.max(1), + height: thickness, + dirty: true, + hover_idx: None, + gpu: None, + }); + } + + // Una layer surface por sidebar (Fase 11): rail anclado al borde vertical con + // exclusive zone = su grosor. Colapsado mide `thickness`; al abrir un diente + // la surface CRECE en ancho (a `thickness + panel_width`) manteniendo la + // exclusive zone, así el panel flota sobre el área de trabajo sin recolocar el + // teselado — el mismo truco del drawer de shuma, pero en el eje horizontal. + for &idx in &sidebars { + let s = &app.cfg.surfaces[idx]; + let thickness = s.thickness.max(1.0) as u32; + let (sctk_anchor, size) = anchor_y_size(s.anchor, thickness); + let wl_surface = compositor.create_surface(&qh); + let layer = layer_shell.create_layer_surface( + &qh, + wl_surface, + Layer::Top, + Some("pata-sidebar".to_string()), + resolve_output(&s.output).as_ref(), + ); + layer.set_anchor(sctk_anchor); + layer.set_size(size.0, size.1); + layer.set_exclusive_zone(thickness as i32); + // Sin teclado: la navegación es por clic (como las barras). Se cierra el + // panel volviendo a clickear el diente. + layer.set_keyboard_interactivity(KeyboardInteractivity::None); + layer.commit(); + app.panels.push(Panel { + idx, + card: None, + layer, + cache: None, + width: thickness, + height: size.1.max(1), + dirty: true, + hover_idx: None, + gpu: None, + }); + } + + // Tarjetas flotantes (estilo conky): cada `card` de una superficie `Panel` + // es su propia layer surface en `Layer::Bottom` (sobre el escritorio, + // debajo de las ventanas), anclada a la esquina superior-izquierda con + // margen (x, y) y del tamaño (w, h) de la tarjeta. No reserva franja ni + // toma teclado. Heredan el `output` del Panel padre. + for (idx, s) in app.cfg.surfaces.iter().enumerate() { + if s.kind != SurfaceKind::Panel { + continue; + } + let panel_output = resolve_output(&s.output); + for card in &s.cards { + let (cw, ch) = (card.w.max(1.0) as u32, card.h.max(1.0) as u32); + let wl_surface = compositor.create_surface(&qh); + let layer = layer_shell.create_layer_surface( + &qh, + wl_surface, + Layer::Bottom, + Some("pata-card".to_string()), + panel_output.as_ref(), + ); + layer.set_anchor(LayerAnchor::TOP | LayerAnchor::LEFT); + layer.set_size(cw, ch); + // Margen: (top, right, bottom, left). (x, y) desde la esquina sup-izq. + layer.set_margin(card.y as i32, 0, 0, card.x as i32); + layer.set_exclusive_zone(0); + layer.set_keyboard_interactivity(KeyboardInteractivity::None); + layer.commit(); + let widgets = card.widgets.iter().map(pata_core::widget::build).collect(); + app.panels.push(Panel { + idx, + card: Some(CardState { spec: card.clone(), widgets }), + layer, + cache: None, + width: cw, + height: ch, + dirty: true, + hover_idx: None, + gpu: None, + }); + } + } + + // ¿Qué barra hospeda el shuma_input? Esa recibe foco de teclado al clickearla + // (OnDemand) para poder desplegar el Quake y escribir. + app.shuma_panel = app.panels.iter().position(|p| { + let s = &app.cfg.surfaces[p.idx]; + s.start + .iter() + .chain(&s.center) + .chain(&s.end) + .any(|w| w.kind == "shuma_input") + }); + app.shuma_bar_px = app + .shuma_panel + .map(|pi| app.cfg.surfaces[app.panels[pi].idx].thickness.max(1.0) as u32) + .unwrap_or(40); + if let Some(pi) = app.shuma_panel { + // Barra cerrada: NO pide teclado. Con `OnDemand` el compositor + // consumía el primer click para darle foco (de ahí «dos clicks para + // desplegar»); con `None` el click va directo a togglear. + app.panels[pi] + .layer + .set_keyboard_interactivity(KeyboardInteractivity::None); + app.panels[pi].layer.commit(); + } + + // La surface del tooltip flotante: una layer surface en Overlay (sobre todo), + // anclada arriba-izquierda, sin teclado ni zona exclusiva y con **región de + // input vacía** (no roba clicks ni hover). Arranca 1×1 fuera de vista; al + // hover se redimensiona y reubica con `set_margin`. Sin buffer hasta el primer + // tooltip → no se mapea (invisible). Sin output específico: la pone el + // compositor donde caiga el puntero. + app.tooltip_pi = { + let wl_surface = compositor.create_surface(&qh); + if let Ok(region) = Region::new(&compositor) { + // Región vacía (sin add): el tooltip nunca intercepta el puntero. + wl_surface.set_input_region(Some(region.wl_region())); + } + let layer = layer_shell.create_layer_surface( + &qh, + wl_surface, + Layer::Overlay, + Some("pata-tooltip".to_string()), + None, + ); + layer.set_anchor(LayerAnchor::TOP | LayerAnchor::LEFT); + layer.set_size(1, 1); + layer.set_margin(100_000, 0, 0, 0); // fuera de vista + layer.set_exclusive_zone(0); + layer.set_keyboard_interactivity(KeyboardInteractivity::None); + layer.commit(); + app.panels.push(Panel { + idx: 0, + card: None, + layer, + cache: None, + width: 1, + height: 1, + dirty: false, + hover_idx: None, + gpu: None, + }); + Some(app.panels.len() - 1) + }; + + // ¿Qué barra hospeda el start_button? Esa crece hacia abajo al desplegar el + // menú de inicio (mismo truco que shuma, hacia el otro lado). + app.menu_panel = app.panels.iter().position(|p| { + let s = &app.cfg.surfaces[p.idx]; + s.start + .iter() + .chain(&s.center) + .chain(&s.end) + .any(|w| w.kind == "start_button") + }); + app.menu_bar_px = app + .menu_panel + .map(|pi| app.cfg.surfaces[app.panels[pi].idx].thickness.max(1.0) as u32) + .unwrap_or(32); + + while !app.exit { + if let Err(e) = event_queue.blocking_dispatch(&mut app) { + // El compositor cerró la conexión (se apagó / Ctrl+Alt+Backspace): + // es una salida normal, no una falla del backend. Devolvemos Ok para + // que el caller NO caiga a la ventana winit (que paniquearía al no + // encontrar compositor). La caída a winit es sólo para cuando el + // layer-shell no arranca de entrada (X11, sin wlr-layer-shell). + eprintln!("pata layer · el compositor cerró la conexión: {e}"); + break; + } + } + Ok(()) +} + +impl LayerApp { + /// Índice del panel cuya layer surface es `surface`. + fn panel_de(&self, surface: &wl_surface::WlSurface) -> Option { + self.panels + .iter() + .position(|p| p.layer.wl_surface() == surface) + } + + /// Marca la barra de shuma para re-pintar (tras teclear, etc.). + fn marcar_shuma_dirty(&mut self) { + if let Some(pi) = self.shuma_panel { + self.panels[pi].dirty = true; + } + } + + /// Marca todas las barras para re-pintar (p. ej. cambió la lista de ventanas). + fn marcar_todo_dirty(&mut self) { + for p in &mut self.panels { + p.dirty = true; + } + } + + /// La lista de ventanas para el render del `window_list`, desde los toplevels + /// que reporta el compositor. + fn window_entries(&self) -> Vec { + self.toplevels + .iter() + .map(|t| WindowEntry { + id: t.id, + label: t.etiqueta(), + app_id: t.app_id.clone(), + active: t.activated, + minimized: t.minimized, + }) + .collect() + } + + /// El toplevel con ese `id`, si sigue abierto. + fn toplevel_por_id(&self, id: u32) -> Option<&Toplevel> { + self.toplevels.iter().find(|t| t.id == id) + } + + /// Despliega o repliega el drawer Quake: agranda/encoge la layer surface de + /// la barra de shuma hacia arriba (su exclusive zone queda en el grosor de la + /// barra, así no recoloca el teselado) y toma/suelta el foco de teclado. + fn set_shuma_open(&mut self, open: bool) { + let Some(pi) = self.shuma_panel else { return }; + if self.shuma.open == open { + return; + } + self.shuma.open = open; + let h = if open { DRAWER_H } else { self.shuma_bar_px }; + let layer = &self.panels[pi].layer; + layer.set_size(0, h); + // Abierto: foco Exclusive para escribir. Cerrado: `None` — no + // retiene el teclado, así una app lanzada (kitty) lo recibe. + layer.set_keyboard_interactivity(if open { + KeyboardInteractivity::Exclusive + } else { + KeyboardInteractivity::None + }); + layer.commit(); + // El cache de hit-test es del layout viejo; invalidarlo evita que el + // click siguiente pegue contra el árbol previo («no se guarda»). Se + // re-arma en el próximo frame con la geometría nueva. + self.panels[pi].cache = None; + self.panels[pi].dirty = true; + } + + /// Despliega/repliega el menú de inicio: agranda/encoge hacia abajo la layer + /// surface de la barra del `start_button` (su exclusive zone queda en el + /// grosor de la barra, así no recoloca el teselado). No toma teclado (clics). + fn set_menu_open(&mut self, open: bool) { + let Some(pi) = self.menu_panel else { return }; + if self.menu_open == open { + return; + } + self.menu_open = open; + let h = if open { MENU_H } else { self.menu_bar_px }; + let layer = &self.panels[pi].layer; + layer.set_size(0, h); + layer.commit(); + // Invalida el cache de hit-test (geometría vieja) — igual que shuma. + self.panels[pi].cache = None; + self.panels[pi].dirty = true; + } + + /// Actualiza el tooltip flotante para el nodo `node_idx` bajo el cursor en el + /// panel `pi`: si ese nodo tiene texto de tooltip, redimensiona y reubica la + /// surface del tooltip bajo el widget y la marca para pintar; si no, la + /// oculta. Reposiciona sólo al cambiar de nodo (no sigue al cursor). Pinta de + /// inmediato (`draw`) porque los eventos llegan por OTRA surface. + fn update_tooltip(&mut self, pi: usize, node_idx: Option, qh: &QueueHandle) { + let Some(tpi) = self.tooltip_pi else { return }; + if pi == tpi { + return; + } + // Texto + rect del nodo hovereado, desde el cache de hit-test del panel. + let info = node_idx.and_then(|i| { + let c = self.panels[pi].cache.as_ref()?; + let node = c.mounted.nodes.get(i)?; + let text = node.tooltip.clone()?; + let rect = c.computed.get(node.id)?; + Some((text, rect)) + }); + match info { + Some((text, rect)) => { + // Posición: bajo el widget. La barra superior (la única con + // widgets hovereables) está en y=0, así que su alto da el offset. + let x = rect.x.max(0.0) as i32; + let y = self.panels[pi].height as i32 + 4; + // Tamaño estimado (no medimos texto acá): ~8px/glifo + padding. + let w = (text.chars().count() as u32 * 8 + 16).clamp(24, 600); + let h = 24u32; + self.tooltip_text = Some(text); + { + let layer = &self.panels[tpi].layer; + layer.set_size(w, h); + layer.set_margin(y, 0, 0, x); + layer.commit(); + } + self.panels[tpi].width = w; + self.panels[tpi].height = h; + self.panels[tpi].dirty = true; + self.draw(tpi, qh); + } + None => self.hide_tooltip(qh), + } + } + + /// Oculta el tooltip: lo empuja fuera de vista (1×1 con margen enorme) y lo + /// re-pinta una vez ahí. No hace nada si ya está oculto. + fn hide_tooltip(&mut self, qh: &QueueHandle) { + let Some(tpi) = self.tooltip_pi else { return }; + if self.tooltip_text.is_none() { + return; + } + self.tooltip_text = None; + { + let layer = &self.panels[tpi].layer; + layer.set_size(1, 1); + layer.set_margin(100_000, 0, 0, 0); + layer.commit(); + } + self.panels[tpi].width = 1; + self.panels[tpi].height = 1; + self.panels[tpi].dirty = true; + self.draw(tpi, qh); + } + + /// Lanza una app del menú por su `id` y cierra el menú. Sólo `Exec` spawnea; + /// `Action`/`Wasm` los despacharía un host con chasis (acá no aplica). + fn lanzar_app(&mut self, id: String) { + if let Some(app) = self.registry.get(&id) { + let _ = app.spawn(); + } + self.set_menu_open(false); + } + + /// Enter en el drawer: corre el comando por el **ejecutor real de shuma** + /// (`shuma::ejecutar`, sobre `shuma-exec`) en un hilo de fondo y muestra su + /// salida en el drawer — el puente pata→shuma del SDD §5. El hilo manda el + /// `Result` por un canal que `poll_exec` sondea cada frame; la UI no se + /// bloquea. El drawer **queda abierto** (sigue Exclusive, podés encadenar + /// comandos y leer la salida); se cierra con Esc. Para lanzar una app GUI y + /// olvidarte, está el launcher (clic en un ítem) o `Super+p`. + fn shuma_submit(&mut self) { + let cmd = std::mem::take(&mut self.shuma.buffer); + if cmd.trim().is_empty() { + self.marcar_shuma_dirty(); + return; + } + self.shuma.push_pending(cmd.clone()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(crate::shuma::ejecutar(&cmd)); + }); + self.exec_rx = Some(rx); + self.marcar_shuma_dirty(); + } + + /// Sondea (sin bloquear) si el comando del Quake terminó; si sí, guarda su + /// salida y re-pinta. Se llama en cada frame (el latido del shuma corre a + /// ~60fps, así que el resultado aparece a los ~16ms de terminar). + fn poll_exec(&mut self) { + let got = self.exec_rx.as_ref().and_then(|rx| rx.try_recv().ok()); + if let Some(res) = got { + self.shuma.finish_last(res); + self.exec_rx = None; + self.marcar_shuma_dirty(); + } + } + + /// Sondea (sin bloquear) el plano de datos del sidebar: aplica el último poll + /// de `list_monads` y cualquier `resolve_monad` que haya terminado. Si cambió + /// algo, marca las superficies sidebar para re-pintar. Se llama en cada frame. + fn poll_nav(&mut self) { + let mut cambios = false; + // Drena el poll de Mónadas (nos quedamos con el último si hay varios). + if let Some(rx) = self.nav_rx.as_ref() { + let mut ultimo = None; + while let Ok(o) = rx.try_recv() { + ultimo = Some(o); + } + if let Some(outcome) = ultimo { + match outcome { + PollOutcome::Ok { socket, resp } => { + self.nav.socket = Some(socket); + self.nav.apply_monads(*resp); + } + PollOutcome::Failed(e) => { + self.nav.socket = None; + self.nav.error = Some(e); + } + } + cambios = true; + } + } + // Drena los miembros resueltos. + while let Ok(outcome) = self.members_rx.try_recv() { + match outcome { + MembersOutcome::Ok { monad, members } => self.nav.apply_members(monad, members), + MembersOutcome::Failed(e) => self.nav.error = Some(e), + } + cambios = true; + } + if cambios { + self.marcar_sidebars_dirty(); + } + } + + /// El `app_id` del toplevel que tiene foco ahora, si hay alguno. + fn focused_app_id(&self) -> Option<&str> { + self.toplevels + .iter() + .find(|t| t.activated) + .map(|t| t.app_id.as_str()) + } + + /// Sondea el rail hospedado: si cambió su revisión (un alta/baja/update de + /// dientes de alguna app), re-pinta los sidebars. El cambio de foco ya re-pinta + /// vía `marcar_todo_dirty` (eventos de toplevel). + fn poll_host(&mut self) { + let Some(h) = &self.host else { return }; + let rev = h.revision(); + if rev != self.last_host_rev { + self.last_host_rev = rev; + self.marcar_sidebars_dirty(); + } + } + + /// Marca todas las superficies sidebar para re-pintar. + fn marcar_sidebars_dirty(&mut self) { + for p in &mut self.panels { + if p.card.is_none() && self.cfg.surfaces[p.idx].kind == SurfaceKind::Sidebar { + p.dirty = true; + } + } + } + + /// Índice (en `panels`) de la layer surface del sidebar `si`. + fn sidebar_panel_de(&self, si: usize) -> Option { + self.panels.iter().position(|p| p.idx == si && p.card.is_none()) + } + + /// Activa/repliega el diente `(si, ti)`: actualiza el estado y **redimensiona** + /// la layer surface del sidebar (crece a `thickness + panel_width` al abrir, + /// vuelve a `thickness` al cerrar). La exclusive zone no cambia, así el panel + /// flota sobre el área de trabajo (drawer horizontal). + fn set_sidebar_open(&mut self, si: usize, ti: usize) { + self.nav.toggle_tab(si, ti); + let Some(pi) = self.sidebar_panel_de(si) else { + return; + }; + let s = &self.cfg.surfaces[si]; + let thickness = s.thickness.max(1.0) as u32; + let abierto = matches!(self.nav.open, Some((s2, _)) if s2 == si); + let w = if abierto { + thickness + s.panel_width.max(1.0) as u32 + } else { + thickness + }; + { + let layer = &self.panels[pi].layer; + layer.set_size(w, 0); + layer.commit(); + } + // El cache de hit-test es del layout viejo; invalidarlo (igual que shuma). + self.panels[pi].cache = None; + self.panels[pi].dirty = true; + } + + /// Cierra el panel del sidebar (si alguno está abierto) y encoge su surface. + fn cerrar_sidebar(&mut self) { + if let Some((si, ti)) = self.nav.open { + self.set_sidebar_open(si, ti); // toggle del abierto = cerrar + } + } + + /// Expande/colapsa un nodo del navegador; al abrir una Mónada sin miembros + /// resueltos lanza su `resolve_monad` en un hilo one-shot (entrega por canal). + fn nav_toggle(&mut self, id: u64) { + if self.nav.expanded.contains(&id) { + self.nav.expanded.remove(&id); + } else { + self.nav.expanded.insert(id); + if let (Some(mid), Some(sock)) = + (self.nav.needs_resolve(id), self.nav.socket.clone()) + { + let tx = self.members_tx.clone(); + std::thread::spawn(move || { + let _ = tx.send(nouser::resolve(sock, mid)); + }); + } + } + self.marcar_sidebars_dirty(); + } + + /// Recoge el último snapshot del hilo de muestreo (no bloquea). Si llegó uno + /// nuevo, `tick`ea los widgets y marca todas las barras para re-pintar. El + /// muestreo en sí (subprocesos que pueden colgarse) vive en `SamplerHandle`, + /// nunca acá: el bucle de UI no se bloquea aunque wpctl/wl-paste se cuelguen. + fn maybe_sample(&mut self) { + let Some((ctx, clipboard)) = self.sampler.latest() else { + return; + }; + self.ctx = ctx; + self.clipboard = clipboard; + for sw in &mut self.surfaces { + for w in sw.core_mut() { + w.tick(&ctx); + } + } + // Las tarjetas flotantes tienen sus widgets en su propio Panel. + for p in &mut self.panels { + if let Some(c) = p.card.as_mut() { + for w in &mut c.widgets { + w.tick(&ctx); + } + } + p.dirty = true; + } + } + + /// Crea el estado wgpu de un panel sobre los punteros raw de Wayland + /// (`wl_display` + `wl_surface`). El `Hal` se comparte; lo crea el primer panel + /// **eligiendo el adaptador compatible con su surface** (el dispositivo que + /// mirada compone) — clave en multi-GPU/Optimus. Los paneles siguientes reusan + /// ese `Hal` (mismo compositor → mismo dispositivo, el adaptador ya sirve). + fn ensure_gpu(&mut self, pi: usize) { + if self.panels[pi].gpu.is_some() { + return; + } + let display_ptr = self.conn.backend().display_ptr() as *mut c_void; + let surface_ptr = self.panels[pi].layer.wl_surface().id().as_ptr() as *mut c_void; + let (w, h) = (self.panels[pi].width, self.panels[pi].height); + let display_handle = RawDisplayHandle::Wayland(WaylandDisplayHandle::new( + NonNull::new(display_ptr).expect("wl_display ptr"), + )); + let window_handle = RawWindowHandle::Wayland(WaylandWindowHandle::new( + NonNull::new(surface_ptr).expect("wl_surface ptr"), + )); + // SAFETY: los handles apuntan a objetos Wayland que `self` mantiene vivos + // (la conexión y la layer surface) durante toda la vida de la surface. + let make_target = || wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: display_handle, + raw_window_handle: window_handle, + }; + + let surface = if self.hal.is_none() { + // Primer panel: crea el Hal pidiendo el adaptador compatible con ESTA + // surface (no `HighPerformance` a ciegas, que en Optimus agarraría la + // GPU equivocada → 0 formatos). + match pollster::block_on(unsafe { Hal::new_for_raw_surface(make_target, w, h) }) { + Ok((hal, surface)) => { + self.hal = Some(hal); + surface + } + Err(e) => { + eprintln!("pata layer · panel {pi} sin gpu: {e}"); + return; + } + } + } else { + // Paneles siguientes: reusan el Hal ya creado. + let hal = self.hal.as_ref().expect("hal"); + let wgpu_surface = match unsafe { hal.instance.create_surface_unsafe(make_target()) } { + Ok(s) => s, + Err(e) => { + eprintln!("pata layer · panel {pi} sin gpu: {e}"); + return; + } + }; + // Sin formatos la WSI no soporta esta surface: en vez de paniquear, + // dejamos el panel sin gpu (no pinta) y seguimos — un panel roto no + // tira todo el marco. Reintenta en el próximo `draw`. + match RawSurface::from_surface(hal, wgpu_surface, w, h) { + Ok(s) => s, + Err(e) => { + eprintln!("pata layer · panel {pi} sin gpu: {e}"); + return; + } + } + }; + let hal = self.hal.as_ref().expect("hal"); + diag!( + "pata diag · panel {pi} surface creada {w}x{h} · backend={:?} format={:?}", + hal.adapter.get_info().backend, + surface.format(), + ); + let renderer = Renderer::new(hal).expect("renderer"); + self.panels[pi].gpu = Some(PanelGpu { + surface, + renderer, + typesetter: Typesetter::new(), + scene: vello::Scene::new(), + layout: LayoutTree::new(), + }); + } + + /// Mantiene vivo el latido de un panel: pide su siguiente frame-callback. + fn latido(&self, pi: usize, qh: &QueueHandle) { + let surface = self.panels[pi].layer.wl_surface(); + surface.frame(qh, surface.clone()); + surface.commit(); + } + + /// Avanza el frame de un panel: re-muestrea ~1Hz (compartido) y pinta sólo si + /// hay algo nuevo; entre cambios sólo mantiene el latido. + fn draw(&mut self, pi: usize, qh: &QueueHandle) { + self.maybe_sample(); + self.poll_exec(); + self.poll_nav(); + self.poll_host(); + self.ensure_gpu(pi); + + if !self.panels[pi].dirty { + self.latido(pi, qh); + return; + } + + let idx = self.panels[pi].idx; + let (w, h) = (self.panels[pi].width, self.panels[pi].height); + let windows = self.window_entries(); + let tray_items = self.tray.as_ref().map(|t| t.items()).unwrap_or_default(); + let data = render::BarData { + windows: &windows, + clipboard: self.clipboard.as_deref(), + tray: &tray_items, + }; + // Una tarjeta flotante pinta su contenido (relleno de su surface); la + // barra de shuma desplegada pinta el drawer (cuerpo + cabezal); el resto + // pinta su barra normal. + let view = if self.tooltip_pi == Some(pi) { + // La surface del tooltip: pinta la cajita con el texto actual (o vacía + // cuando está oculta fuera de vista). + render::tooltip_view(self.tooltip_text.as_deref().unwrap_or(""), &self.theme) + } else if let Some(c) = self.panels[pi].card.as_ref() { + render::card_view(&c.spec, &c.widgets, &self.theme) + } else if self.menu_panel == Some(pi) && self.menu_open { + render::start_menu_view( + &self.cfg.surfaces[idx], + &self.surfaces[idx], + &self.shuma, + &data, + &self.theme, + self.menu_bar_px as f32, + self.registry.all(), + ) + } else if self.shuma_panel == Some(pi) && self.shuma.open { + // Viewport del historial: la surface menos la barra, la línea de + // input y los paddings. Lo cacheamos para que el clamp del scroll + // en `update` (rueda/arrastre) sea exacto. + let vh = (h as f32 - self.shuma_bar_px as f32 - 60.0).max(40.0); + self.shuma.viewport_h = vh; + render::shuma_open_view( + &self.cfg.surfaces[idx], + &self.surfaces[idx], + &self.shuma, + &data, + &self.theme, + self.shuma_bar_px as f32, + vh, + ) + } else if self.cfg.surfaces[idx].kind == SurfaceKind::Sidebar { + // Dientes hospedados de la app enfocada (si registró alguno en el host). + let hosted = { + let app = self.focused_app_id().map(|s| s.to_string()); + match (app, self.host.as_ref()) { + (Some(id), Some(h)) => h.snapshot(&id).map(|(_, teeth)| (id, teeth)), + _ => None, + } + }; + let (hosted_app, hosted_teeth): (&str, &[pata_host::HostedTooth]) = match &hosted { + Some((id, teeth)) => (id.as_str(), teeth.as_slice()), + None => ("", &[]), + }; + render::sidebar_surface_view( + &self.cfg.surfaces[idx], + idx, + w as f32, + h as f32, + &self.nav, + hosted_teeth, + hosted_app, + &self.shuma, + &self.theme, + ) + } else { + render::bar_view( + &self.cfg.surfaces[idx], + &self.surfaces[idx], + &self.shuma, + &data, + &self.theme, + ) + }; + + let hover_idx = self.panels[pi].hover_idx; + let hal = self.hal.as_ref().expect("hal"); + let gpu = match self.panels[pi].gpu.as_mut() { + Some(g) => g, + None => { + self.latido(pi, qh); + return; + } + }; + gpu.surface.resize(w, h); + let frame = match gpu.surface.acquire() { + Ok(f) => f, + Err(_) => { + // Soltamos el préstamo mutable de `gpu` antes de tocar `self`. + let _ = gpu; + self.latido(pi, qh); + return; + } + }; + gpu.layout.clear(); + let mounted: Mounted = mount(&mut gpu.layout, view); + let computed = { + let ts = &mut gpu.typesetter; + let tmap = &mounted.text_measures; + gpu.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 => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + gpu.scene.reset(); + paint(&mut gpu.scene, &mounted, &computed, &mut gpu.typesetter, hover_idx, None); + if let Err(e) = gpu.renderer.render(hal, &gpu.scene, &frame, palette::css::BLACK) { + eprintln!("pata layer · render: {e}"); + } + gpu.surface.present(frame, hal); + diag!("pata diag · present panel {pi} {w}x{h}"); + + // Recién con el cuadro presentado damos el panel por limpio: si la + // adquisición hubiera fallado, `dirty` sigue puesto y el próximo + // frame-callback reintenta (no esperamos al re-muestreo de 1 Hz). + self.panels[pi].dirty = false; + // Guarda el árbol pintado para el hit-test de los clicks. + self.panels[pi].cache = Some(RenderCache { mounted, computed }); + self.latido(pi, qh); + } + + /// Aplica el `Msg` que produjo un click: togglear shuma (su cabezal) o lanzar + /// el comando de un widget con `exec`. El resto no sale de un click. + fn handle_msg(&mut self, msg: Msg) { + match msg { + Msg::ShumaToggle => self.set_shuma_open(!self.shuma.open), + // Clic en una etapa de pipe de una card: re-ejecuta la línea + // truncada en el drawer (lo abre si estaba cerrado). + Msg::ShumaRunLine(line) => { + if !line.trim().is_empty() { + if !self.shuma.open { + self.set_shuma_open(true); + } + self.shuma.buffer = line; + self.shuma_submit(); + } + } + // Clic en el `$` de una card: la pliega/despliega. + Msg::ShumaCollapse(idx) => { + if let Some(b) = self.shuma.blocks.get_mut(idx) { + b.collapsed = !b.collapsed; + self.marcar_shuma_dirty(); + } + } + Msg::ShumaScroll(delta) => { + self.shuma.scroll_by(delta); + self.marcar_shuma_dirty(); + } + Msg::Spawn(cmd) => crate::spawn_cmd(&cmd), + Msg::StartToggle => self.set_menu_open(!self.menu_open), + Msg::LaunchApp(id) => self.lanzar_app(id), + Msg::ActivateWindow(id) => self.activar_ventana(id), + Msg::CloseWindow(id) => self.cerrar_ventana(id), + Msg::TrayActivate(key) => { + if let Some(t) = &self.tray { + t.activate(key); + } + } + // --- Sidebar navegador (Fase 11c-layer) --- + Msg::NavTabActivate(si, ti) => self.set_sidebar_open(si, ti), + Msg::NavClosePanel => self.cerrar_sidebar(), + Msg::NavSetMode(m) => { + self.nav.mode = m; + self.marcar_sidebars_dirty(); + } + Msg::NavSelect(id) => { + self.nav.selected = Some(id); + self.marcar_sidebars_dirty(); + } + Msg::NavToggle(id) => self.nav_toggle(id), + Msg::NavContextMenu(id) => { + // Fase 11d-extra: right-click sobre archivo abre el menú "Abrir con…". + if let Some(path) = self.nav.file_path(id).map(str::to_owned) { + let opts = crate::open::handlers_for_path(&self.registry, &path); + self.nav.open_menu(id, opts); + self.marcar_sidebars_dirty(); + } + } + Msg::NavOpenWith(id, app_id) => { + if let Some(path) = self.nav.file_path(id).map(str::to_owned) { + match app_id { + Some(aid) => { + let _ = crate::open::open_with_id(&self.registry, &aid, &path); + } + None => { + let _ = crate::open::open_system(&path); + } + } + } + self.nav.close_menu(); + self.marcar_sidebars_dirty(); + } + Msg::NavMenuCancel => { + self.nav.close_menu(); + self.marcar_sidebars_dirty(); + } + Msg::HostToothActivate(app_id, tooth) => { + // Reenvía el clic del diente hospedado a la app enfocada; ella + // muestra ese panel sobre su propio canvas. + if let Some(h) = &self.host { + h.activate(&app_id, tooth); + } + } + Msg::NavScroll(delta) => { + self.nav.scroll = (self.nav.scroll + delta).max(0.0); + self.marcar_sidebars_dirty(); + } + Msg::Quit => self.exit = true, + _ => {} + } + } + + /// Click en una ventana del task manager (estilo KDE): si ya está activa, la + /// **minimiza**; si no, la trae al frente (y la desminimiza). Sin seat (raro) + /// no hace nada. El compositor responde con `state`/`done` que actualiza el + /// resaltado y el atenuado. + fn activar_ventana(&mut self, id: u32) { + let Some(seat) = self.seat.clone() else { return }; + if let Some(t) = self.toplevel_por_id(id) { + if t.activated { + t.handle.set_minimized(); + } else { + t.handle.unset_minimized(); + t.handle.activate(&seat); + } + } + } + + /// Cierra la ventana `id` (clic derecho en su chip del task manager). El + /// compositor manda `closed` que la retira de la lista. + fn cerrar_ventana(&mut self, id: u32) { + if let Some(t) = self.toplevel_por_id(id) { + t.handle.close(); + } + } +} + +impl CompositorHandler for LayerApp { + fn scale_factor_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: i32, + ) { + } + + fn transform_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: wl_output::Transform, + ) { + } + + fn frame( + &mut self, + _: &Connection, + qh: &QueueHandle, + surface: &wl_surface::WlSurface, + _: u32, + ) { + if let Some(pi) = self.panel_de(surface) { + self.draw(pi, qh); + } + } + + fn surface_enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } + + fn surface_leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } +} + +impl LayerShellHandler for LayerApp { + fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) { + // Cerrar cualquier barra cierra el marco entero. + self.exit = true; + } + + fn configure( + &mut self, + _: &Connection, + qh: &QueueHandle, + layer: &LayerSurface, + configure: LayerSurfaceConfigure, + _: u32, + ) { + let (cw, ch) = configure.new_size; + let Some(pi) = self.panel_de(layer.wl_surface()) else { + return; + }; + diag!("pata diag · configure panel {pi} new_size={cw}x{ch}"); + // El compositor nos da el tamaño definitivo (el eje libre ya resuelto). + if cw > 0 { + self.panels[pi].width = cw; + } + if ch > 0 { + self.panels[pi].height = ch; + } + self.panels[pi].dirty = true; // tamaño nuevo → re-pintar + self.draw(pi, qh); + } +} + +impl OutputHandler for LayerApp { + fn output_state(&mut self) -> &mut OutputState { + &mut self.output_state + } + fn new_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} +} + +impl SeatHandler for LayerApp { + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat_state + } + fn new_seat(&mut self, _: &Connection, _: &QueueHandle, seat: wl_seat::WlSeat) { + // Guardamos el seat para poder activar ventanas (`activate(seat)`). + if self.seat.is_none() { + self.seat = Some(seat); + } + } + + fn new_capability( + &mut self, + _: &Connection, + qh: &QueueHandle, + seat: wl_seat::WlSeat, + capability: Capability, + ) { + match capability { + Capability::Keyboard if self.keyboard.is_none() => { + if let Ok(kbd) = self.seat_state.get_keyboard(qh, &seat, None) { + self.keyboard = Some(kbd); + } + } + Capability::Pointer if self.pointer.is_none() => { + if let Ok(ptr) = self.seat_state.get_pointer(qh, &seat) { + self.pointer = Some(ptr); + } + } + _ => {} + } + } + + fn remove_capability( + &mut self, + _: &Connection, + _: &QueueHandle, + _: wl_seat::WlSeat, + capability: Capability, + ) { + match capability { + Capability::Keyboard => { + if let Some(k) = self.keyboard.take() { + k.release(); + } + } + Capability::Pointer => { + if let Some(p) = self.pointer.take() { + p.release(); + } + } + _ => {} + } + } + + fn remove_seat(&mut self, _: &Connection, _: &QueueHandle, _: wl_seat::WlSeat) {} +} + +impl KeyboardHandler for LayerApp { + fn enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: &wl_surface::WlSurface, + _: u32, + _: &[u32], + _: &[Keysym], + ) { + } + + fn leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: &wl_surface::WlSurface, + _: u32, + ) { + } + + fn press_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + event: KbEvent, + ) { + // El teclado sólo nos importa con el drawer abierto (foco Exclusive). + if !self.shuma.open { + return; + } + match event.keysym { + Keysym::Escape => self.set_shuma_open(false), + Keysym::BackSpace => { + self.shuma.buffer.pop(); + self.marcar_shuma_dirty(); + } + Keysym::Return | Keysym::KP_Enter => self.shuma_submit(), + _ => { + if let Some(txt) = event.utf8 { + if !txt.is_empty() && !txt.chars().any(|c| c.is_control()) { + self.shuma.buffer.push_str(&txt); + self.marcar_shuma_dirty(); + } + } + } + } + } + + fn release_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: KbEvent, + ) { + } + + fn update_modifiers( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: Modifiers, + _: u32, + ) { + } +} + +impl PointerHandler for LayerApp { + fn pointer_frame( + &mut self, + _: &Connection, + qh: &QueueHandle, + _: &wl_pointer::WlPointer, + events: &[PointerEvent], + ) { + for e in events { + // Hover: el nodo bajo el puntero da feedback (`hover_fill`) y, si + // tiene texto de tooltip, lo muestra en la surface flotante. El + // layer-shell no trackeaba hover (pasaba `None` a `paint`), así que el + // realce estaba muerto en todas las barras. + match e.kind { + PointerEventKind::Motion { .. } => { + // Drag en curso: el delta va al handler del nodo (Move). El + // nodegraph del navegador no reposiciona (devuelve None en + // Move); selecciona al soltar (End). + if self.drag.is_some() { + let (px, py) = (e.position.0 as f32, e.position.1 as f32); + let (handler, last) = { + let d = self.drag.as_ref().unwrap(); + (d.handler.clone(), d.last) + }; + if let Some(d) = self.drag.as_mut() { + d.last = (px, py); + } + if let Some(msg) = (handler)(DragPhase::Move, px - last.0, py - last.1) { + self.handle_msg(msg); + } + continue; + } + if let Some(pi) = self.panel_de(&e.surface) { + let (px, py) = (e.position.0 as f32, e.position.1 as f32); + let nuevo = self.panels[pi] + .cache + .as_ref() + .and_then(|c| hit_test_hover(&c.mounted, &c.computed, px, py)); + if self.panels[pi].hover_idx != nuevo { + self.panels[pi].hover_idx = nuevo; + self.panels[pi].dirty = true; + self.update_tooltip(pi, nuevo, qh); + } + } + continue; + } + PointerEventKind::Leave { .. } => { + if let Some(pi) = self.panel_de(&e.surface) { + if self.panels[pi].hover_idx.is_some() { + self.panels[pi].hover_idx = None; + self.panels[pi].dirty = true; + } + } + self.hide_tooltip(qh); + continue; + } + _ => {} + } + // Rueda sobre el historial del drawer: el nodo de scroll bajo el + // cursor consume el delta y emite `ShumaScroll`. Convención de + // signo igual que llimphi-ui (wayland y winit traen el eje y con + // signos opuestos, así que acá NO se niega). + if let PointerEventKind::Axis { vertical, .. } = e.kind { + let dy = if vertical.discrete != 0 { + vertical.discrete as f32 + } else { + vertical.absolute as f32 / 20.0 + }; + if dy != 0.0 { + let (px, py) = (e.position.0 as f32, e.position.1 as f32); + if let Some(pi) = self.panel_de(&e.surface) { + let msg = self.panels[pi].cache.as_ref().and_then(|c| { + hit_test_scroll(&c.mounted, &c.computed, px, py) + .and_then(|i| c.mounted.nodes.get(i)) + .and_then(|n| n.on_scroll.as_ref().and_then(|h| h(0.0, dy))) + }); + if let Some(msg) = msg { + self.handle_msg(msg); + } + } + } + continue; + } + // Soltar el botón izquierdo termina un drag en curso: el handler del + // nodo recibe `End` y emite su Msg (p. ej. seleccionar el nodo del + // grafo). Los deltas en End los ignoran los consumidores. + if let PointerEventKind::Release { button, .. } = e.kind { + if button == BTN_LEFT { + if let Some(d) = self.drag.take() { + if let Some(msg) = (d.handler)(DragPhase::End, 0.0, 0.0) { + self.handle_msg(msg); + } + } + } + continue; + } + if let PointerEventKind::Press { button, .. } = e.kind { + if button != BTN_LEFT && button != BTN_RIGHT { + continue; + } + // Hit-test: qué nodo está bajo el puntero y qué handler dispara. + // El izquierdo usa `on_click` (cabezal de shuma, activar ventana, + // lanzar exec); el derecho `on_right_click` (cerrar ventana del + // task manager). El click ya dio foco de teclado. + let Some(pi) = self.panel_de(&e.surface) else { + continue; + }; + let (px, py) = (e.position.0 as f32, e.position.1 as f32); + let derecho = button == BTN_RIGHT; + // Nodo arrastrable bajo el press (izquierdo): arranca un drag y NO + // lo tratamos como click (el nodegraph selecciona al soltar). + if !derecho { + let handler = self.panels[pi].cache.as_ref().and_then(|c| { + let i = hit_test_click(&c.mounted, &c.computed, px, py)?; + c.mounted.nodes.get(i)?.drag.clone() + }); + if let Some(handler) = handler { + self.drag = Some(LayerDrag { handler, last: (px, py) }); + continue; + } + } + let msg = self.panels[pi].cache.as_ref().and_then(|c| { + let i = hit_test_click(&c.mounted, &c.computed, px, py)?; + let n = c.mounted.nodes.get(i)?; + // Primero el handler simple (`on_click`/`on_right_click`); si no + // hay, el `*_at` (coords locales al nodo) — lo usan widgets que + // coexisten con drag, como los dientes del rail. Paridad con el + // bucle winit, que también prioriza así. + if derecho { + if let Some(m) = n.on_right_click.clone() { + return Some(m); + } + let at = n.on_right_click_at.as_ref()?; + let r = c.computed.get(n.id)?; + at(px - r.x, py - r.y, r.w, r.h) + } else { + if let Some(m) = n.on_click.clone() { + return Some(m); + } + let at = n.on_click_at.as_ref()?; + let r = c.computed.get(n.id)?; + at(px - r.x, py - r.y, r.w, r.h) + } + }); + if let Some(msg) = msg { + self.handle_msg(msg); + } + } + } + } +} + +impl ProvidesRegistryState for LayerApp { + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry_state + } + registry_handlers![OutputState, SeatState]; +} + +/// El manager de ventanas: anuncia un toplevel nuevo (creando su handle hijo) y +/// el fin del servicio. `event_created_child!` declara cómo enrutar el handle que +/// nace en el evento `toplevel` (sin esto, wayland-client paniquea al recibirlo). +impl Dispatch for LayerApp { + fn event( + state: &mut Self, + _mgr: &ZwlrForeignToplevelManagerV1, + event: zwlr_foreign_toplevel_manager_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use zwlr_foreign_toplevel_manager_v1::Event; + match event { + Event::Toplevel { toplevel } => { + let id = state.next_toplevel_id; + state.next_toplevel_id = state.next_toplevel_id.wrapping_add(1); + state.toplevels.push(Toplevel::new(id, toplevel)); + } + Event::Finished => { + state.toplevels.clear(); + state.marcar_todo_dirty(); + } + _ => {} + } + } + + event_created_child!(LayerApp, ZwlrForeignToplevelManagerV1, [ + EVT_TOPLEVEL_OPCODE => (ZwlrForeignToplevelHandleV1, ()), + ]); +} + +/// Un handle de toplevel: el compositor le manda título / app_id / estado en +/// eventos sueltos y los confirma con `done`; `closed` lo retira. Acumulamos en +/// el [`Toplevel`] y aplicamos en `done` para no pintar estados a medias. +impl Dispatch for LayerApp { + fn event( + state: &mut Self, + handle: &ZwlrForeignToplevelHandleV1, + event: zwlr_foreign_toplevel_handle_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use zwlr_foreign_toplevel_handle_v1::Event; + let pos = state.toplevels.iter().position(|t| &t.handle == handle); + let Some(i) = pos else { return }; + match event { + Event::Title { title } => state.toplevels[i].set_title(title), + Event::AppId { app_id } => state.toplevels[i].set_app_id(app_id), + Event::State { state: estados } => state.toplevels[i].set_state(&estados), + Event::Done => { + if state.toplevels[i].confirmar() { + state.marcar_todo_dirty(); + } + } + Event::Closed => { + let t = state.toplevels.remove(i); + t.handle.destroy(); + state.marcar_todo_dirty(); + } + _ => {} + } + } +} + +delegate_compositor!(LayerApp); +delegate_output!(LayerApp); +delegate_layer!(LayerApp); +delegate_seat!(LayerApp); +delegate_keyboard!(LayerApp); +delegate_pointer!(LayerApp); +delegate_registry!(LayerApp); diff --git a/02_ruway/pata/pata-llimphi/src/lib.rs b/02_ruway/pata/pata-llimphi/src/lib.rs new file mode 100644 index 0000000..d8bbdde --- /dev/null +++ b/02_ruway/pata/pata-llimphi/src/lib.rs @@ -0,0 +1,652 @@ +//! `pata-llimphi` — el frontend Linux del marco. +//! +//! Monta el modelo agnóstico de [`pata_core`] sobre Llimphi. El reparto de +//! responsabilidades es la regla dura del repo (UIs intercambiables sobre un +//! `*-core` agnóstico): +//! +//! - **`pata-core`** decide *qué* mostrar: resuelve la geometría +//! ([`pata_core::layout::resolve`]) y, por cada [`WidgetSpec`], materializa un +//! [`Widget`] que emite un view-model ([`WidgetView`]) en cada `tick`. +//! - **este crate** decide *cómo*: muestrea el sistema en un +//! [`WidgetCtx`](pata_core::widget::WidgetCtx) (ver [`sampler`]) y traduce el +//! view-model a `View` de Llimphi (ver [`render`]). +//! +//! El `shuma_input` es la excepción: es **interacción**, no modelo de dominio, +//! así que lo intercepta el frontend (ver [`shuma`]) en lugar de pasar por el +//! `build` agnóstico —igual que `mirada-launcher` trata su shuma_bar—. +//! +//! Hoy todas las superficies se pintan en una sola ventana, en los rects que el +//! layout resolvió. Cuando el compositor `mirada` reconozca superficies `pata` +//! (Fase 8), cada una será su propia ventana acoplada. + +pub mod keys; +pub mod layer; +pub mod nouser; +pub mod open; +pub mod render; +pub mod sampler; +pub mod shuma; +pub mod toplevel; +pub mod tray; + +use std::time::Duration; + +use llimphi_motion::{animate, motion, Tween}; +use llimphi_theme::Theme; +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View}; + +use llimphi_widget_navigator::{NavId, NavMode}; + +use pata_core::config::{FloatingCard, SurfaceKind}; +use pata_core::widget::{build, Widget, WidgetCtx}; +use pata_core::{Config, Frame, Rect}; + +use nouser::{MembersOutcome, NavState, PollOutcome}; +use sampler::Sampler; +use shuma::ShumaState; +use tray::TrayHandle; + +/// Los mensajes de la app. +#[derive(Clone, Debug)] +pub enum Msg { + /// Refresh periódico (1 Hz): re-muestrea el sistema y `tick`ea los widgets. + Tick, + /// Desplegar/replegar el drawer de shuma. + ShumaToggle, + /// Carácter al input de shuma. + ShumaChar(char), + /// Backspace en el input de shuma. + ShumaBackspace, + /// Enter en el input de shuma — ejecuta el comando. + ShumaSubmit, + /// Resultado estructurado del comando (líneas + código) para la card. + ShumaResult(shuma::RunResult), + /// Re-ejecutar una línea (clic en el comando de una card sin pipe). + ShumaRunLine(String), + /// Revelar/ocultar la salida capturada (tee) de una etapa intermedia de la + /// card `idx`: `(idx_card, idx_etapa)`. + ShumaStageToggle(usize, usize), + /// Plegar/desplegar la card `idx` del historial. + ShumaCollapse(usize), + /// Desplazar el historial del drawer `delta` px (rueda / arrastre de barra). + ShumaScroll(f32), + /// Tick de la animación de despliegue (sólo re-render). + ShumaAnim, + /// Lanzar un programa (click sobre un widget con prop `exec`). + Spawn(String), + /// Desplegar/replegar el menú del botón de inicio. + StartToggle, + /// Lanzar una app del menú de inicio por su `id` en el [`app_bus::AppRegistry`]. + LaunchApp(String), + /// Activar una ventana del `window_list` (traerla al frente, o minimizarla si + /// ya está activa — estilo KDE). El `u32` es el [`toplevel::Toplevel::id`]; + /// sólo el backend layer-shell sabe resolverlo. + ActivateWindow(u32), + /// Cerrar una ventana del task manager (clic derecho). El `u32` es el + /// [`toplevel::Toplevel::id`]; sólo el backend layer-shell sabe resolverlo. + CloseWindow(u32), + /// Activar un item del `tray` (click). El `String` es la `key` del + /// [`tray::TrayItem`]; sólo el backend layer-shell sabe resolverlo. + TrayActivate(String), + // --- Sidebar navegador (Fase 11c) --- + /// Clic en un diente del rail `(surface_idx, tab_idx)`: despliega/repliega su + /// panel navegador. + NavTabActivate(usize, usize), + /// Cerrar el panel navegador desplegado (Esc / clic fuera). + NavClosePanel, + /// Cambiar el modo del navegador (árbol/grafo). + NavSetMode(NavMode), + /// Seleccionar un nodo del navegador. + NavSelect(NavId), + /// Expandir/colapsar un nodo rama; al expandir una Mónada sin miembros + /// resueltos dispara su `resolve_monad`. + NavToggle(NavId), + /// Right-click sobre un nodo: si es un archivo, abre el menú "Abrir con…" + /// (precomputa sus apps); si no, no-op. + NavContextMenu(NavId), + /// Elegir cómo abrir el archivo del menú: `Some(app_id)` con esa app nativa, + /// `None` con el handler del sistema (`xdg-open`). + NavOpenWith(NavId, Option), + /// Cerrar el menú "Abrir con…" sin abrir nada. + NavMenuCancel, + /// Clic en un diente **hospedado** (de la app enfocada) en el rail de pata: + /// `(app_id, tooth_id)`. Se reenvía a la app por el rail hospedado. Sólo el + /// backend layer-shell (que conoce el foco y corre el `HostServer`) lo resuelve. + HostToothActivate(String, u32), + /// Desplazar el panel navegador `delta` px. + NavScroll(f32), + /// Disparo periódico del poll de Mónadas (`list_monads`). + NavTick, + /// Resultado del poll de Mónadas. + NavPoll(PollOutcome), + /// Resultado de resolver los miembros de una Mónada. + NavMembers(MembersOutcome), + /// Cerrar la app. + Quit, +} + +/// Un widget dentro de un slot: o un widget de `pata-core` (que emite un +/// view-model), o el `shuma_input` —interacción que pinta el frontend—. +pub enum SlotWidget { + /// Un widget builtin de `pata-core`. `exec` es el comando que lanza al + /// clickearlo (de la prop `exec` del spec), o `None` si no es clickeable. + Core { + widget: Box, + exec: Option, + }, + /// El botón de inicio: muestra su `label` y, al clickearlo, despliega el + /// menú nativo de apps (o lanza `exec` si la config lo fija, override estilo + /// waybar). Es interacción, no view-model de core. + Start { + /// Texto/ícono del botón (prop `label`, default `⊞`). + label: String, + /// Comando a lanzar en vez de abrir el menú, si la config lo fija. + exec: Option, + }, + /// El cabezal del shell; su estado vive en [`Model::shuma`]. + Shuma, + /// La lista de ventanas abiertas. Es interacción + IPC (igual que `Shuma`): + /// los datos los provee el backend (vía wlr-foreign-toplevel en layer-shell) + /// y se pasan al render aparte, no por el view-model de core. + WindowList, + /// El portapapeles: muestra el texto copiado actual. Dato del host (vía + /// `wl-paste`), no del view-model de core. `exec` (opcional) es el comando a + /// lanzar al clickearlo — típicamente un selector de historial (cliphist). + Clipboard { + /// Comando del selector de historial, o `None` si no es clickeable. + exec: Option, + }, + /// La bandeja del sistema (StatusNotifierItem). Dato del host (vía D-Bus, ver + /// [`tray`]), no del view-model de core. Cada item se activa al clickearlo. + Tray, +} + +/// `true` si la config pide el reloj en **UTC** (`general.timezone = "UTC"`). +/// Cualquier otro valor (incluido `"auto"`) usa la hora local. Paridad con el +/// `TzMode` de mirada-launcher (que sólo distinguía auto/UTC). Compartido por +/// ambos backends para construir el sampler. +pub fn usa_utc(cfg: &Config) -> bool { + cfg.general.timezone.trim().eq_ignore_ascii_case("utc") +} + +/// Lanza `cmd` por `sh -c` como proceso hijo, sin esperarlo (no bloquea). Lo +/// usan ambos backends al recibir [`Msg::Spawn`]. +pub fn spawn_cmd(cmd: &str) { + let _ = std::process::Command::new("sh").arg("-c").arg(cmd).spawn(); +} + +/// Envuelve `s` en comillas simples para `sh -c`, escapando comillas internas. +/// Para pasar rutas con espacios al stand-in de apertura (Fase 11d). +pub fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// `true` si la config declara al menos un widget de ese `kind` en cualquier slot +/// de cualquier superficie. Lo usan ambos backends para arrancar servicios caros +/// (el tray, que toma el nombre del watcher) sólo si hacen falta. +pub fn config_tiene_widget(cfg: &Config, kind: &str) -> bool { + cfg.surfaces.iter().any(|s| { + s.start + .iter() + .chain(&s.center) + .chain(&s.end) + .any(|w| w.kind == kind) + }) +} + +/// `true` si la config declara al menos un `SurfaceKind::Sidebar` con un diente +/// cuyo contenido es un navegador (`kind = "navigator"`). Sólo entonces arranca +/// el plano de datos de nouser (el poll periódico de Mónadas). +pub fn config_tiene_navigator(cfg: &Config) -> bool { + cfg.surfaces + .iter() + .filter(|s| s.kind == SurfaceKind::Sidebar) + .flat_map(|s| s.tabs.iter()) + .any(|t| t.content.kind == "navigator") +} + +/// Los widgets vivos de una superficie, repartidos por slot. +pub struct SurfaceWidgets { + /// Slot inicial (izquierda / arriba). + pub start: Vec, + /// Slot central. + pub center: Vec, + /// Slot final (derecha / abajo). + pub end: Vec, +} + +impl SurfaceWidgets { + /// Itera los widgets de core de la superficie (los que se `tick`ean). + fn core_mut(&mut self) -> impl Iterator> { + self.start + .iter_mut() + .chain(self.center.iter_mut()) + .chain(self.end.iter_mut()) + .filter_map(|sw| match sw { + SlotWidget::Core { widget, .. } => Some(widget), + SlotWidget::Start { .. } + | SlotWidget::Shuma + | SlotWidget::WindowList + | SlotWidget::Clipboard { .. } + | SlotWidget::Tray => None, + }) + } +} + +/// El estado de la app: config + geometría resuelta + widgets vivos + sampler. +pub struct Model { + /// Paleta de Llimphi. + pub theme: Theme, + /// El marco declarado. + pub cfg: Config, + /// La geometría resuelta sobre la pantalla. + pub frame: Frame, + /// Widgets vivos, en el mismo orden que `cfg.surfaces`. + pub surfaces: Vec, + /// Tarjetas flotantes (estilo conky) de las superficies `Panel`, cada una con + /// sus widgets vivos. En layer-shell cada tarjeta es su propia surface; en el + /// path winit se pintan en absoluto sobre la ventana única. + pub cards: Vec<(FloatingCard, Vec>)>, + /// Estado del cabezal del shell y su drawer Quake. + pub shuma: ShumaState, + /// Registro de apps para el menú del botón de inicio. + pub registry: app_bus::AppRegistry, + /// `true` cuando el menú de inicio está desplegado. + pub menu_open: bool, + /// Muestreador del sistema (con estado para el delta de CPU). + pub sampler: Sampler, + /// Texto del portapapeles (una línea), para el widget `clipboard`. Se + /// re-muestrea cada tick vía `wl-paste`. + pub clipboard: Option, + /// La bandeja del sistema, corriendo en su propio hilo. `None` si la config no + /// declara ningún widget `tray`. + pub tray: Option, + /// Estado del sidebar navegador (Mónadas de nouser). Vacío si la config no + /// declara ningún `SurfaceKind::Sidebar` con un navegador. + pub nav: NavState, + /// Tamaño de la pantalla en píxeles. + pub screen: (i32, i32), +} + +impl Model { + /// Construye los widgets de cada superficie y el estado de shuma desde la + /// config. El primer `shuma_input` que aparece define el cabezal. + fn construir(cfg: &Config) -> (Vec, ShumaState) { + let mut shuma = ShumaState::default(); + let mut build_slot = |specs: &[pata_core::WidgetSpec]| -> Vec { + specs + .iter() + .map(|spec| { + if spec.kind == "start_button" { + let exec = spec.str_prop("exec", ""); + SlotWidget::Start { + label: spec.str_prop("label", "⊞").to_string(), + exec: (!exec.is_empty()).then(|| exec.to_string()), + } + } else if spec.kind == "shuma_input" { + if !shuma.present { + shuma = ShumaState::from_spec(spec); + } + SlotWidget::Shuma + } else if spec.kind == "window_list" { + SlotWidget::WindowList + } else if spec.kind == "clipboard" { + let exec = spec.str_prop("exec", ""); + SlotWidget::Clipboard { + exec: (!exec.is_empty()).then(|| exec.to_string()), + } + } else if spec.kind == "tray" { + SlotWidget::Tray + } else { + let exec = spec.str_prop("exec", ""); + SlotWidget::Core { + widget: build(spec), + exec: (!exec.is_empty()).then(|| exec.to_string()), + } + } + }) + .collect() + }; + let surfaces = cfg + .surfaces + .iter() + .map(|s| SurfaceWidgets { + start: build_slot(&s.start), + center: build_slot(&s.center), + end: build_slot(&s.end), + }) + .collect(); + (surfaces, shuma) + } + + /// Construye las tarjetas flotantes de todas las superficies `Panel` con sus + /// widgets vivos. Compartido por el path winit ([`PataApp::init`]) y el + /// layer-shell ([`crate::layer`]): el modelo se escribe una vez. + pub fn construir_cards(cfg: &Config) -> Vec<(FloatingCard, Vec>)> { + cfg.surfaces + .iter() + .filter(|s| s.kind == SurfaceKind::Panel) + .flat_map(|s| s.cards.iter()) + .map(|card| { + let ws = card.widgets.iter().map(build).collect(); + (card.clone(), ws) + }) + .collect() + } + + /// `tick`ea todos los widgets de core (barras y tarjetas) con el contexto dado. + fn tick_widgets(&mut self, ctx: &WidgetCtx) { + for sw in &mut self.surfaces { + for w in sw.core_mut() { + w.tick(ctx); + } + } + for (_, ws) in &mut self.cards { + for w in ws { + w.tick(ctx); + } + } + } + + /// Arranca la animación del drawer hacia `destino` (0 = replegado, 1 = + /// desplegado) y dispara el bucle de `ShumaAnim`. + fn animar_shuma(&mut self, destino: f32, handle: &Handle) { + let desde = self.shuma.anim.value(); + self.shuma.anim = Tween::new(desde, destino, motion::FAST, motion::ease_out_cubic); + animate(handle, motion::FAST, || Msg::ShumaAnim); + } +} + +/// Tamaño inicial de la ventana. Cuando mirada acople las superficies (Fase 8) +/// esto lo fijará el compositor; por ahora cubrimos un 1080p. +const PANTALLA: (i32, i32) = (1920, 1080); + +/// La app Llimphi del marco. +pub struct PataApp; + +impl App for PataApp { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "pata" + } + + fn app_id() -> Option<&'static str> { + Some("gioser.pata") + } + + fn initial_size() -> (u32, u32) { + (PANTALLA.0 as u32, PANTALLA.1 as u32) + } + + fn init(handle: &Handle) -> Model { + let cfg = pata_config::load(); + let screen = PANTALLA; + let frame = pata_core::resolve(&cfg, Rect::new(0, 0, screen.0, screen.1)); + let (surfaces, shuma) = Model::construir(&cfg); + let cards = Model::construir_cards(&cfg); + let mut sampler = Sampler::with_utc(usa_utc(&cfg)); + let ctx = sampler.sample(); + let clipboard = crate::sampler::leer_clipboard(); + let tray = config_tiene_widget(&cfg, "tray") + .then(TrayHandle::spawn) + .flatten(); + + let mut model = Model { + theme: Theme::dark(), + cfg, + frame, + surfaces, + cards, + shuma, + registry: app_bus::AppRegistry::discover(), + menu_open: false, + sampler, + clipboard, + tray, + nav: NavState::default(), + screen, + }; + // Primer tick para que los widgets arranquen con datos. + model.tick_widgets(&ctx); + + handle.spawn_periodic(Duration::from_secs(1), || Msg::Tick); + // Plano de datos del sidebar: poll de Mónadas a nouser, sólo si la config + // declara un navegador (no molestar al broker si no hace falta). + if config_tiene_navigator(&model.cfg) { + handle.dispatch(Msg::NavTick); + handle.spawn_periodic(nouser::REFRESH_INTERVAL, || Msg::NavTick); + } + model + } + + fn update(mut model: Model, msg: Msg, handle: &Handle) -> Model { + match msg { + Msg::Tick => { + let ctx = model.sampler.sample(); + model.tick_widgets(&ctx); + model.clipboard = crate::sampler::leer_clipboard(); + } + Msg::Quit => handle.quit(), + Msg::ShumaToggle => { + if model.shuma.present { + model.shuma.open = !model.shuma.open; + let destino = if model.shuma.open { 1.0 } else { 0.0 }; + model.animar_shuma(destino, handle); + } + } + Msg::ShumaChar(c) => { + if model.shuma.open { + model.shuma.buffer.push(c); + } + } + Msg::ShumaBackspace => { + if model.shuma.open { + model.shuma.buffer.pop(); + } + } + Msg::ShumaSubmit => { + if model.shuma.open { + // El buffer sin prefijo `!`/`$` va a la IA; con prefijo, al + // shell (paridad con el quake de mirada-launcher). + let buffer = std::mem::take(&mut model.shuma.buffer); + match shuma::classify(&buffer) { + shuma::SubmitKind::Empty => {} + shuma::SubmitKind::Shell(cmd) => { + let cmd = cmd.to_string(); + model.shuma.push_pending(cmd.clone()); + handle.spawn(move || Msg::ShumaResult(shuma::ejecutar(&cmd))); + } + shuma::SubmitKind::Ia(prompt) => { + let prompt = prompt.to_string(); + model.shuma.push_pending_ia(prompt.clone()); + handle.spawn(move || Msg::ShumaResult(shuma::preguntar_ia(&prompt))); + } + } + } + } + Msg::ShumaResult(res) => model.shuma.finish_last(res), + Msg::ShumaRunLine(line) => { + if model.shuma.open && !line.trim().is_empty() { + model.shuma.push_pending(line.clone()); + handle.spawn(move || Msg::ShumaResult(shuma::ejecutar(&line))); + } + } + Msg::ShumaStageToggle(idx, stage) => { + if let Some(b) = model.shuma.blocks.get_mut(idx) { + b.expanded_stage = if b.expanded_stage == Some(stage) { + None + } else { + Some(stage) + }; + } + } + Msg::ShumaCollapse(idx) => { + if let Some(b) = model.shuma.blocks.get_mut(idx) { + b.collapsed = !b.collapsed; + } + } + Msg::ShumaScroll(delta) => model.shuma.scroll_by(delta), + Msg::ShumaAnim => {} + Msg::Spawn(cmd) => spawn_cmd(&cmd), + Msg::StartToggle => model.menu_open = !model.menu_open, + Msg::LaunchApp(id) => { + if let Some(app) = model.registry.get(&id) { + let _ = app.spawn(); + } + model.menu_open = false; + } + Msg::TrayActivate(key) => { + if let Some(t) = &model.tray { + t.activate(key); + } + } + // El window_list necesita el cliente foreign-toplevel del backend + // layer-shell; bajo el compositor mirada llegará por su IPC. No-op acá. + Msg::ActivateWindow(_) => {} + Msg::CloseWindow(_) => {} + // --- Sidebar navegador (Fase 11c) --- + Msg::NavTabActivate(si, ti) => model.nav.toggle_tab(si, ti), + Msg::NavClosePanel => model.nav.open = None, + Msg::NavSetMode(m) => model.nav.mode = m, + Msg::NavSelect(id) => model.nav.selected = Some(id), + Msg::NavToggle(id) => { + if model.nav.expanded.contains(&id) { + model.nav.expanded.remove(&id); + } else { + model.nav.expanded.insert(id); + // Carga perezosa: al abrir una Mónada sin miembros, pídelos. + if let (Some(mid), Some(sock)) = + (model.nav.needs_resolve(id), model.nav.socket.clone()) + { + handle.spawn(move || Msg::NavMembers(nouser::resolve(sock, mid))); + } + } + } + Msg::NavContextMenu(id) => { + // Fase 11d-extra: right-click sobre un archivo abre el menú "Abrir + // con…". Precomputamos sus apps acá (con el registro) para que el + // render no lo toque. + if let Some(path) = model.nav.file_path(id).map(str::to_owned) { + let opts = open::handlers_for_path(&model.registry, &path); + model.nav.open_menu(id, opts); + } + } + Msg::NavOpenWith(id, app_id) => { + if let Some(path) = model.nav.file_path(id).map(str::to_owned) { + match app_id { + Some(aid) => { + let _ = open::open_with_id(&model.registry, &aid, &path); + } + None => { + let _ = open::open_system(&path); + } + } + } + model.nav.close_menu(); + } + Msg::NavMenuCancel => model.nav.close_menu(), + // El rail hospedado vive en el backend layer-shell (conoce el foco y + // corre el HostServer). En winit no hay toplevels: no-op. + Msg::HostToothActivate(_, _) => {} + Msg::NavScroll(delta) => { + model.nav.scroll = (model.nav.scroll + delta).max(0.0); + } + Msg::NavTick => { + let sock = model.nav.socket.clone(); + handle.spawn(move || Msg::NavPoll(nouser::poll(sock))); + } + Msg::NavPoll(outcome) => match outcome { + PollOutcome::Ok { socket, resp } => { + model.nav.socket = Some(socket); + model.nav.apply_monads(*resp); + } + PollOutcome::Failed(e) => { + // Invalida el socket cacheado para re-descubrir en el próximo poll. + model.nav.socket = None; + model.nav.error = Some(e); + } + }, + Msg::NavMembers(outcome) => match outcome { + MembersOutcome::Ok { monad, members } => model.nav.apply_members(monad, members), + MembersOutcome::Failed(e) => model.nav.error = Some(e), + }, + } + model + } + + fn view(model: &Model) -> View { + render::root(model) + } + + fn view_overlay(model: &Model) -> Option> { + // El drawer Quake tiene prioridad; si no, el menú de inicio. + if let Some(d) = shuma::drawer_overlay(&model.shuma, model.screen, &model.theme) { + return Some(d); + } + if model.menu_open { + // Lo ofrecemos bajo la barra superior que hospeda el start_button. + let bar_h = model + .cfg + .surfaces + .iter() + .find(|s| { + s.start + .iter() + .chain(&s.center) + .chain(&s.end) + .any(|w| w.kind == "start_button") + }) + .map(|s| s.thickness) + .unwrap_or(32.0); + return Some(render::start_menu_overlay( + model.registry.all(), + bar_h, + &model.theme, + )); + } + None + } + + fn on_key(model: &Model, event: &KeyEvent) -> Option { + if event.state != KeyState::Pressed { + return None; + } + // 1) El hotkey del shuma_input abre/cierra el drawer (prioridad). + if model.shuma.present { + if let Some(hk) = &model.shuma.hotkey { + if keys::matches(hk, &event.key) { + return Some(Msg::ShumaToggle); + } + } + } + // 2) Con el drawer abierto, el teclado va al input. + if model.shuma.open { + return match &event.key { + Key::Named(NamedKey::Escape) => Some(Msg::ShumaToggle), + Key::Named(NamedKey::Backspace) => Some(Msg::ShumaBackspace), + Key::Named(NamedKey::Enter) => Some(Msg::ShumaSubmit), + Key::Character(s) => s.chars().next().map(Msg::ShumaChar), + _ => None, + }; + } + // 3) Con el menú "Abrir con…" abierto, Esc lo cierra primero. + if model.nav.menu.is_some() { + if let Key::Named(NamedKey::Escape) = &event.key { + return Some(Msg::NavMenuCancel); + } + } + // 4) Con el panel navegador desplegado, Esc lo cierra (no la app). + if model.nav.open.is_some() { + if let Key::Named(NamedKey::Escape) = &event.key { + return Some(Msg::NavClosePanel); + } + } + // 5) Sin nada abierto, Esc cierra la app. + match &event.key { + Key::Named(NamedKey::Escape) => Some(Msg::Quit), + _ => None, + } + } +} diff --git a/02_ruway/pata/pata-llimphi/src/main.rs b/02_ruway/pata/pata-llimphi/src/main.rs new file mode 100644 index 0000000..0808a1f --- /dev/null +++ b/02_ruway/pata/pata-llimphi/src/main.rs @@ -0,0 +1,28 @@ +//! Binario del frontend `pata`: levanta el marco. +//! +//! Elige el backend de windowing: +//! - **`wlr-layer-shell`** (default en Wayland): pata se ancla como una *layer +//! surface* al nivel de eww/waybar, con exclusive zone — el compositor le +//! reserva su franja. Es lo que querés en Hyprland/Sway/river. +//! - **winit** (fallback): una ventana normal. Sirve en X11, o si el compositor +//! no expone `wlr-layer-shell`, o forzándolo con `PATA_BACKEND=winit`. +//! +//! ```sh +//! cargo run -p pata-llimphi --release # layer-shell si hay Wayland +//! PATA_BACKEND=winit cargo run -p pata-llimphi # fuerza ventana winit +//! ``` + +fn main() { + let forzar_winit = std::env::var("PATA_BACKEND").as_deref() == Ok("winit"); + let hay_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some(); + + if !forzar_winit && hay_wayland { + match pata_llimphi::layer::run() { + Ok(()) => return, + Err(e) => { + eprintln!("pata · backend layer-shell falló ({e}); caigo a ventana winit."); + } + } + } + llimphi_ui::run::(); +} diff --git a/02_ruway/pata/pata-llimphi/src/nouser.rs b/02_ruway/pata/pata-llimphi/src/nouser.rs new file mode 100644 index 0000000..a43c75d --- /dev/null +++ b/02_ruway/pata/pata-llimphi/src/nouser.rs @@ -0,0 +1,454 @@ +//! `nouser` — el **plano de datos** del sidebar navegador (Fase 11c). +//! +//! El sidebar de `pata` muestra las **Mónadas** de nouser y sus archivos en un +//! navegador conmutable árbol/grafo ([`llimphi_widget_navigator`]). nouser es la +//! **fuente autoritativa** de qué archivos componen una Mónada (no el filesystem +//! por su cuenta — decisión del autor); por eso el nivel de archivos se resuelve +//! por el query de nouser (`chasqui_card::query`) y no leyendo directorios. +//! +//! Este módulo: +//! - descubre el socket del daemon (broker brahman → fallback al default path), +//! igual que `chasqui-explorer-llimphi`; +//! - consulta `list_monads` (poll liviano) y `resolve_monad` (miembros bajo +//! demanda al expandir una Mónada); +//! - construye el bosque de [`NavNode`]s que el widget pinta, manteniendo el +//! estado de UI (modo, selección, expansión, diente desplegado) en el caller. +//! +//! La asignación de [`NavId`] es **determinista** (hash del `MonadId`/path) para +//! que la expansión y la selección sobrevivan a un re-poll sin parpadear. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::time::Duration; + +use card_sidecar::{await_provider_blocking, build_consumer_card}; +use chasqui_card::query::client as qclient; +use chasqui_card::query::{ + transport, FileView, ListMonadsResponse, MonadView, FLOW_MONAD_LIST, FLOW_TYPE_NAME, +}; +use chasqui_card::MonadId; +use llimphi_widget_navigator::{NavId, NavKind, NavMode, NavNode}; + +/// Timeout para descubrir el provider por el broker. +const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3); +/// Timeout de un query single-shot al daemon. +const QUERY_TIMEOUT: Duration = Duration::from_secs(2); +/// Cada cuánto se repolea `list_monads`. +pub const REFRESH_INTERVAL: Duration = Duration::from_secs(2); + +// ===================================================================== +// Mapeo NavId → qué representa +// ===================================================================== + +/// Qué representa un nodo del navegador, para resolver miembros (al expandir una +/// Mónada) y para abrir con la app que corresponda (Fase 11d). +#[derive(Debug, Clone)] +pub enum NavTarget { + /// Una Mónada de nouser, por su id. + Monad(MonadId), + /// Un archivo miembro, por su ruta. + File(String), +} + +/// Hash FNV-1a de 64 bits — determinista y sin dependencias, suficiente para +/// derivar un [`NavId`] estable de un identificador opaco. +fn fnv1a(tag: u8, bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + h ^= tag as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// [`NavId`] de una Mónada (tag 1). +fn monad_nav_id(id: &MonadId) -> NavId { + fnv1a(1, &id.to_bytes()) +} + +/// [`NavId`] del placeholder "cargando…" de una Mónada aún no resuelta (tag 2). +fn placeholder_nav_id(id: &MonadId) -> NavId { + fnv1a(2, &id.to_bytes()) +} + +/// [`NavId`] de un archivo, por su ruta (tag 3). +fn file_nav_id(path: &str) -> NavId { + fnv1a(3, path.as_bytes()) +} + +/// El último componente de una ruta (su "nombre"), o la ruta entera si no tiene +/// separadores. Para la etiqueta de la fila — el path completo va al tooltip / +/// al abrir. +fn file_label(path: &str) -> String { + path.rsplit('/').next().filter(|s| !s.is_empty()).unwrap_or(path).to_string() +} + +// ===================================================================== +// Estado del navegador +// ===================================================================== + +/// Estado del sidebar navegador. Vive en el `Model` del frontend; el widget es +/// render-only y lo consulta cada `view`. +pub struct NavState { + /// Diente desplegado: `(surface_idx, tab_idx)`. `None` = rail colapsado, sin + /// panel. + pub open: Option<(usize, usize)>, + /// Modo de visualización activo (compartido entre dientes). + pub mode: NavMode, + /// Nodo seleccionado (resaltado). + pub selected: Option, + /// Nodos rama expandidos. + pub expanded: HashSet, + /// Offset de scroll del panel (px). + pub scroll: f32, + /// El bosque a pintar (Mónadas como raíces, archivos como hijos). + pub roots: Vec, + /// Qué representa cada [`NavId`] (para resolver/abrir). + pub targets: HashMap, + /// Mónadas vivas del último poll (vista slim). + monads: Vec, + /// Miembros ya resueltos por Mónada (cache, llenado bajo demanda). + members: HashMap>, + /// Socket del daemon, cacheado entre polls (`None` fuerza re-descubrimiento). + pub socket: Option, + /// Último error de descubrimiento/query (para mostrar en el panel). + pub error: Option, + /// Menú "Abrir con…" abierto sobre un archivo (su [`NavId`]). `None` = sin + /// menú. Las opciones se precomputan al abrirlo ([`NavState::open_menu`]) para + /// que el render no toque el registro de apps. + pub menu: Option, + /// Apps nativas que ofrece el menú abierto: `(app_id, label)`. El render las + /// pinta como filas "Abrir con