feat: pata standalone — marco de escritorio declarativo (barras/dock/tray/widgets) portable Linux/Wawa (front-door, git-dep al monorepo)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:18:00 +00:00
commit 95bc028eea
37 changed files with 18266 additions and 0 deletions
+19
View File
@@ -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).
+37
View File
@@ -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.
+536
View File
@@ -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<Surface>`. 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<Msg>` (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 `✦ <prompt>` 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 `📋 <preview>` 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<Mutex>` 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<Config>` 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<Hash>` (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<SidebarTab>` + `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<R>()` 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).
+16
View File
@@ -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 }
@@ -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á"
+92
View File
@@ -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<PathBuf> {
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<Config, toml::de::Error> {
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());
}
}
+196
View File
@@ -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<String> = std::env::args().skip(1).collect();
let mut screen = (1920_i32, 1080_i32);
let mut config_path: Option<String> = 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 <ruta>] [--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()?))
}
+28
View File
@@ -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"] }
+491
View File
@@ -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<f64> {
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<bool> {
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<String, Prop>,
}
impl WidgetSpec {
/// Un widget sin props.
pub fn new(kind: impl Into<String>) -> 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<String>, 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<String>,
/// Widgets apilados dentro de la tarjeta.
#[cfg_attr(feature = "serde", serde(default))]
pub widgets: Vec<WidgetSpec>,
}
/// 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<String>, label: impl Into<String>, 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<WidgetSpec>,
/// Slot central: centrado en el eje.
#[cfg_attr(feature = "serde", serde(default))]
pub center: Vec<WidgetSpec>,
/// Slot final: pegado al final del eje (derecha / abajo).
#[cfg_attr(feature = "serde", serde(default))]
pub end: Vec<WidgetSpec>,
/// Para `kind = panel`: las tarjetas flotantes que contiene.
#[cfg_attr(feature = "serde", serde(default))]
pub cards: Vec<FloatingCard>,
/// 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<SidebarTab>,
/// 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<Surface>,
}
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");
}
}
+289
View File
@@ -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<Placed>,
/// 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());
}
}
+47
View File
@@ -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,
};
+663
View File
@@ -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<String>,
/// 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<String>,
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<String>) -> 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<dyn Widget> {
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<Box<dyn Widget>> {
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 { .. }));
}
}
}
+320
View File
@@ -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<WireProp> 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<WireWidget> 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<WireWidget> {
specs.iter().map(WireWidget::from).collect()
}
fn de_wire(wires: Vec<WireWidget>) -> Vec<WidgetSpec> {
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<String>,
pub widgets: Vec<WireWidget>,
}
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<WireCard> 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<WireTab> 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<WireWidget>,
pub center: Vec<WireWidget>,
pub end: Vec<WireWidget>,
pub cards: Vec<WireCard>,
pub output: String,
pub tabs: Vec<WireTab>,
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<WireSurface> 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<WireSurface>,
}
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<WireConfig> 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);
}
}
@@ -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);
}
+12
View File
@@ -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 }
+65
View File
@@ -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<F>(
app_id: impl Into<String>,
title: impl Into<String>,
teeth: Vec<HostedTooth>,
on_activate: F,
) -> Option<HostClient>
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::<ShellMsg>(&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<HostedTooth>) {
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);
}
}
+179
View File
@@ -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<String>, label: impl Into<String>) -> 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<HostedTooth>,
},
/// Sus dientes cambiaron (mismo `app_id` implícito por la conexión).
Update { teeth: Vec<HostedTooth> },
/// 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<T: Serialize>(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<T: DeserializeOwned>(r: &mut impl Read) -> io::Result<T> {
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::<ShellMsg>(&bytes).unwrap(), m);
}
#[test]
fn frame_roundtrip_sobre_buffer() {
let mut buf: Vec<u8> = 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());
}
}
+157
View File
@@ -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<HostedTooth>,
write: UnixStream,
}
/// El estado compartido entre el hilo aceptador/lectores y el bucle de UI.
struct Shared {
apps: Mutex<HashMap<String, AppReg>>,
/// 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<Shared>,
}
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<HostServer> {
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<HostedTooth>)> {
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<Shared>) {
// 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<String> = None;
loop {
match read_frame::<AppMsg>(&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>) {
shared.revision.fetch_add(1, Ordering::Relaxed);
}
@@ -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<T>(dur: Duration, mut f: impl FnMut() -> Option<T>) -> Option<T> {
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::<u32>();
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);
}
+73
View File
@@ -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 }
+74
View File
@@ -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<Key> {
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)));
}
}
File diff suppressed because it is too large Load Diff
+652
View File
@@ -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<Msg>` 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<String>),
/// 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<dyn Widget>,
exec: Option<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<SlotWidget>,
/// Slot central.
pub center: Vec<SlotWidget>,
/// Slot final (derecha / abajo).
pub end: Vec<SlotWidget>,
}
impl SurfaceWidgets {
/// Itera los widgets de core de la superficie (los que se `tick`ean).
fn core_mut(&mut self) -> impl Iterator<Item = &mut Box<dyn Widget>> {
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<SurfaceWidgets>,
/// 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<Box<dyn Widget>>)>,
/// 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<String>,
/// La bandeja del sistema, corriendo en su propio hilo. `None` si la config no
/// declara ningún widget `tray`.
pub tray: Option<TrayHandle>,
/// 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<SurfaceWidgets>, ShumaState) {
let mut shuma = ShumaState::default();
let mut build_slot = |specs: &[pata_core::WidgetSpec]| -> Vec<SlotWidget> {
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<Box<dyn Widget>>)> {
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<Msg>) {
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<Msg>) -> 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<Msg>) -> 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<Msg> {
render::root(model)
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
// 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<Msg> {
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,
}
}
}
+28
View File
@@ -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::<pata_llimphi::PataApp>();
}
+454
View File
@@ -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<NavId>,
/// Nodos rama expandidos.
pub expanded: HashSet<NavId>,
/// Offset de scroll del panel (px).
pub scroll: f32,
/// El bosque a pintar (Mónadas como raíces, archivos como hijos).
pub roots: Vec<NavNode>,
/// Qué representa cada [`NavId`] (para resolver/abrir).
pub targets: HashMap<NavId, NavTarget>,
/// Mónadas vivas del último poll (vista slim).
monads: Vec<MonadView>,
/// Miembros ya resueltos por Mónada (cache, llenado bajo demanda).
members: HashMap<MonadId, Vec<FileView>>,
/// Socket del daemon, cacheado entre polls (`None` fuerza re-descubrimiento).
pub socket: Option<PathBuf>,
/// Último error de descubrimiento/query (para mostrar en el panel).
pub error: Option<String>,
/// 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<NavId>,
/// Apps nativas que ofrece el menú abierto: `(app_id, label)`. El render las
/// pinta como filas "Abrir con <label>"; siempre se les suma "el sistema".
pub menu_options: Vec<(String, String)>,
}
impl Default for NavState {
fn default() -> Self {
Self {
open: None,
mode: NavMode::Tree,
selected: None,
expanded: HashSet::new(),
scroll: 0.0,
roots: Vec::new(),
targets: HashMap::new(),
monads: Vec::new(),
members: HashMap::new(),
socket: None,
error: None,
menu: None,
menu_options: Vec::new(),
}
}
}
impl NavState {
/// `true` si el diente `(si, ti)` está desplegado ahora.
pub fn is_open(&self, si: usize, ti: usize) -> bool {
self.open == Some((si, ti))
}
/// Activa/repliega el diente `(si, ti)`: si ya estaba abierto lo cierra, si
/// no, lo abre (cerrando cualquier otro).
pub fn toggle_tab(&mut self, si: usize, ti: usize) {
self.close_menu(); // un cambio de diente descarta el menú "Abrir con…"
if self.open == Some((si, ti)) {
self.open = None;
} else {
self.open = Some((si, ti));
self.scroll = 0.0;
}
}
/// La ruta del archivo que representa `id`, si es un archivo. `None` para
/// Mónadas (no tienen una ruta única).
pub fn file_path(&self, id: NavId) -> Option<&str> {
match self.targets.get(&id) {
Some(NavTarget::File(p)) => Some(p.as_str()),
_ => None,
}
}
/// Abre el menú "Abrir con…" sobre `id` con las `options` (app_id, label) ya
/// resueltas por el caller (que tiene el registro de apps).
pub fn open_menu(&mut self, id: NavId, options: Vec<(String, String)>) {
self.menu = Some(id);
self.menu_options = options;
}
/// Cierra el menú "Abrir con…".
pub fn close_menu(&mut self) {
self.menu = None;
self.menu_options.clear();
}
/// Si `id` es una Mónada todavía sin miembros resueltos, devuelve su id para
/// que el caller dispare el `resolve_monad`. `None` en caso contrario.
pub fn needs_resolve(&self, id: NavId) -> Option<MonadId> {
match self.targets.get(&id) {
Some(NavTarget::Monad(mid)) if !self.members.contains_key(mid) => Some(*mid),
_ => None,
}
}
/// Aplica una respuesta de `list_monads`: reemplaza la lista de Mónadas y
/// reconstruye el bosque (preservando miembros ya resueltos).
pub fn apply_monads(&mut self, resp: ListMonadsResponse) {
self.monads = resp.monads;
// Descarta del cache las Mónadas que ya no existen, para no acumular.
let vivos: HashSet<MonadId> = self.monads.iter().map(|m| m.id).collect();
self.members.retain(|id, _| vivos.contains(id));
self.error = None;
self.rebuild();
}
/// Aplica los miembros resueltos de una Mónada y reconstruye el bosque.
pub fn apply_members(&mut self, monad: MonadId, members: Vec<FileView>) {
self.members.insert(monad, members);
self.rebuild();
}
/// Reconstruye `roots` + `targets` desde `monads` + `members`. Una Mónada con
/// `cardinality > 0` aún no resuelta lleva un hijo placeholder "…" para que
/// muestre el chevron y se pueda expandir (carga perezosa).
fn rebuild(&mut self) {
let mut roots = Vec::with_capacity(self.monads.len());
let mut targets = HashMap::new();
for mv in &self.monads {
let mid = monad_nav_id(&mv.id);
targets.insert(mid, NavTarget::Monad(mv.id));
let children = if let Some(files) = self.members.get(&mv.id) {
files
.iter()
.map(|f| {
let fid = file_nav_id(&f.path);
targets.insert(fid, NavTarget::File(f.path.clone()));
NavNode::leaf(fid, file_label(&f.path), NavKind::File)
})
.collect()
} else if mv.cardinality > 0 {
vec![NavNode::leaf(placeholder_nav_id(&mv.id), "", NavKind::Other)]
} else {
Vec::new()
};
let label = if mv.label.is_empty() {
"(sin nombre)".to_string()
} else {
mv.label.clone()
};
roots.push(NavNode::branch(mid, label, NavKind::Monad, children));
}
self.roots = roots;
self.targets = targets;
}
}
// =====================================================================
// Queries (corren en un thread vía Handle::spawn, no bloquean el UI)
// =====================================================================
/// Resultado de un poll de `list_monads`.
#[derive(Clone, Debug)]
pub enum PollOutcome {
/// El daemon respondió: socket usado + Mónadas.
Ok {
socket: PathBuf,
resp: Box<ListMonadsResponse>,
},
/// No se pudo descubrir/consultar; mensaje para el panel.
Failed(String),
}
/// Descubre el socket (broker → fallback default path) y pide `list_monads`.
/// Reusa `prior_socket` si está cacheado (evita re-descubrir cada poll).
pub fn poll(prior_socket: Option<PathBuf>) -> PollOutcome {
let socket = match prior_socket {
Some(p) => p,
None => match resolve_socket() {
Ok(p) => p,
Err(e) => return PollOutcome::Failed(e),
},
};
match qclient::list_monads(&socket, QUERY_TIMEOUT) {
Ok(resp) => PollOutcome::Ok {
socket,
resp: Box::new(resp),
},
Err(e) => PollOutcome::Failed(format!("query a {}: {e}", socket.display())),
}
}
/// Resultado de resolver los miembros de una Mónada.
#[derive(Clone, Debug)]
pub enum MembersOutcome {
Ok {
monad: MonadId,
members: Vec<FileView>,
},
Failed(String),
}
/// Pide los archivos miembros de `monad` al daemon en `socket`.
pub fn resolve(socket: PathBuf, monad: MonadId) -> MembersOutcome {
match qclient::resolve_monad(&socket, monad, QUERY_TIMEOUT) {
Ok(resp) => MembersOutcome::Ok {
monad,
members: resp.members,
},
Err(e) => MembersOutcome::Failed(format!("resolve_monad: {e}")),
}
}
/// Resuelve el socket del daemon: primero el broker brahman (Card consumer +
/// `await_provider_blocking`), luego el default path si el broker no responde.
/// Idéntico a `chasqui-explorer-llimphi`.
fn resolve_socket() -> Result<PathBuf, String> {
let card = build_consumer_card("pata-llimphi", FLOW_MONAD_LIST, FLOW_TYPE_NAME);
match await_provider_blocking(card, DISCOVERY_TIMEOUT) {
Ok(p) => Ok(p),
Err(broker_err) => {
let fallback = transport::default_socket_path();
if fallback.exists() {
Ok(fallback)
} else {
Err(format!(
"broker: {broker_err}; fallback {} no existe",
fallback.display()
))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chasqui_card::query::EngineInfo;
use ulid::Ulid;
fn monad_view(label: &str, cardinality: u32) -> MonadView {
MonadView {
id: Ulid::new(),
label: label.into(),
summary: String::new(),
keywords: Vec::new(),
cardinality,
entropy: 0.0,
dominant_lens: Default::default(),
path_hint: None,
centroid_model: None,
}
}
fn list_resp(monads: Vec<MonadView>) -> ListMonadsResponse {
ListMonadsResponse {
engine: EngineInfo {
id: Ulid::new(),
label: "test".into(),
watching: None,
},
monads,
}
}
#[test]
fn nav_id_determinista_y_separado_por_tag() {
let id = Ulid::new();
assert_eq!(monad_nav_id(&id), monad_nav_id(&id));
// El placeholder y la Mónada no colisionan (tags distintos).
assert_ne!(monad_nav_id(&id), placeholder_nav_id(&id));
// Dos rutas distintas → ids distintos.
assert_ne!(file_nav_id("/a/x.rs"), file_nav_id("/a/y.rs"));
}
#[test]
fn file_label_toma_el_ultimo_componente() {
assert_eq!(file_label("/proj/src/lib.rs"), "lib.rs");
assert_eq!(file_label("solo.txt"), "solo.txt");
assert_eq!(file_label("/dir/"), "/dir/");
}
#[test]
fn apply_monads_construye_raices_con_placeholder_si_hay_cardinalidad() {
let mut st = NavState::default();
let m = monad_view("src", 3);
let mid = m.id;
st.apply_monads(list_resp(vec![m]));
assert_eq!(st.roots.len(), 1);
// Aún sin resolver: tiene un hijo placeholder → chevron visible.
assert!(st.roots[0].has_children());
assert_eq!(st.roots[0].children.len(), 1);
// La Mónada necesita resolverse al expandir.
let nav = monad_nav_id(&mid);
assert_eq!(st.needs_resolve(nav), Some(mid));
}
#[test]
fn monada_vacia_no_tiene_chevron() {
let mut st = NavState::default();
st.apply_monads(list_resp(vec![monad_view("vacia", 0)]));
assert!(!st.roots[0].has_children());
}
#[test]
fn apply_members_reemplaza_placeholder_por_archivos() {
let mut st = NavState::default();
let m = monad_view("src", 2);
let mid = m.id;
st.apply_monads(list_resp(vec![m]));
let files = vec![
FileView {
id: Ulid::new(),
path: "/p/lib.rs".into(),
size: 1,
extension: Some("rs".into()),
mtime_ms: 0,
},
FileView {
id: Ulid::new(),
path: "/p/main.rs".into(),
size: 1,
extension: Some("rs".into()),
mtime_ms: 0,
},
];
st.apply_members(mid, files);
assert_eq!(st.roots[0].children.len(), 2);
assert_eq!(st.roots[0].children[0].label, "lib.rs");
// Ya resuelta: no vuelve a pedir.
assert_eq!(st.needs_resolve(monad_nav_id(&mid)), None);
// El target del archivo apunta a su ruta completa.
let fid = file_nav_id("/p/lib.rs");
matches!(st.targets.get(&fid), Some(NavTarget::File(p)) if p == "/p/lib.rs");
}
#[test]
fn apply_monads_descarta_miembros_de_monadas_muertas() {
let mut st = NavState::default();
let m = monad_view("a", 1);
let mid = m.id;
st.apply_monads(list_resp(vec![m]));
st.apply_members(mid, vec![]);
assert!(st.members.contains_key(&mid));
// Re-poll sin esa Mónada: su cache se purga.
st.apply_monads(list_resp(vec![monad_view("b", 1)]));
assert!(!st.members.contains_key(&mid));
}
#[test]
fn toggle_tab_abre_y_cierra() {
let mut st = NavState::default();
assert!(!st.is_open(0, 0));
st.toggle_tab(0, 0);
assert!(st.is_open(0, 0));
// Abrir otro diente cierra el anterior.
st.toggle_tab(0, 1);
assert!(st.is_open(0, 1));
assert!(!st.is_open(0, 0));
// Re-clic en el abierto lo cierra.
st.toggle_tab(0, 1);
assert_eq!(st.open, None);
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Apertura de archivos del navegador con la app que corresponda (Fase 11d).
//!
//! El navegador del sidebar lista archivos miembros de una Mónada (su ruta real
//! en disco, resuelta por nouser). Al abrir uno (right-click), enrutamos a la app
//! adecuada en dos pasos:
//!
//! 1. **Apps nativas de gioser** (`app-bus`): si alguna app declara manejar el
//! mime del archivo (`handles = ["…"]` en su manifiesto), la lanzamos con la
//! ruta como argumento (sustitución freedesktop `%f`/`%u` vía
//! [`app_bus::AppEntry::open`]). Las apps de la suite tienen prioridad.
//! 2. **Fallback del sistema** (`xdg-open`): si ninguna app nativa lo maneja,
//! delegamos en las asociaciones del escritorio.
//!
//! El mime se deriva de la **extensión** con una tabla acotada (sin leer el
//! archivo — la UI no hace I/O de disco). Lo desconocido cae directo a `xdg-open`,
//! que de todas formas respeta las asociaciones del usuario. (El discernimiento
//! por contenido de `shuma-discern` sería el upgrade, a costa de leer una muestra.)
use app_bus::AppRegistry;
/// Mapea la extensión de `path` (lo que va tras el último punto, en minúsculas) a
/// un mime canónico. `None` si no hay extensión o no está en la tabla — el caller
/// cae a `xdg-open`.
pub fn mime_for_path(path: &str) -> Option<&'static str> {
// La extensión es el segmento tras el último punto del último componente.
let name = path.rsplit('/').next().unwrap_or(path);
let ext = name.rsplit_once('.').map(|(_, e)| e)?.to_ascii_lowercase();
Some(match ext.as_str() {
// Código y texto estructurado.
"rs" => "text/x-rust",
"py" => "text/x-python",
"js" | "mjs" | "cjs" => "text/javascript",
"ts" | "tsx" => "text/typescript",
"c" | "h" => "text/x-c",
"cpp" | "cc" | "cxx" | "hpp" => "text/x-c++",
"go" => "text/x-go",
"java" => "text/x-java",
"rb" => "text/x-ruby",
"sh" | "bash" | "zsh" => "application/x-shellscript",
"toml" => "application/toml",
"json" => "application/json",
"yaml" | "yml" => "application/yaml",
"xml" => "application/xml",
"html" | "htm" => "text/html",
"css" => "text/css",
// Texto plano y documentos.
"md" | "markdown" => "text/markdown",
"rst" => "text/x-rst",
"txt" | "log" | "ron" => "text/plain",
"csv" => "text/csv",
"pdf" => "application/pdf",
// Imágenes.
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
// Audio.
"mp3" => "audio/mpeg",
"flac" => "audio/flac",
"wav" => "audio/wav",
"ogg" | "oga" => "audio/ogg",
"opus" => "audio/opus",
// Video.
"mp4" | "m4v" => "video/mp4",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
"avi" => "video/x-msvideo",
"mov" => "video/quicktime",
// Archivos comprimidos.
"zip" => "application/zip",
"tar" => "application/x-tar",
"gz" | "tgz" => "application/gzip",
"7z" => "application/x-7z-compressed",
_ => return None,
})
}
/// Qué hizo [`open_file`], para log/diagnóstico.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Opened {
/// Una app nativa de gioser abrió el archivo (su `label`).
NativeApp(String),
/// Se delegó en `xdg-open`.
SystemDefault,
}
/// La app nativa que abriría `path`: la primera del registro (orden por label)
/// que declare manejar el mime de su extensión. `None` si no se conoce el mime o
/// ninguna app lo maneja (→ el caller cae a `xdg-open`). Función **pura**: decide
/// el handler sin spawnear nada — testeable sin tocar el sistema.
pub fn handler_for<'a>(registry: &'a AppRegistry, path: &str) -> Option<&'a app_bus::AppEntry> {
let mime = mime_for_path(path)?;
registry.handlers_for(mime).into_iter().next()
}
/// Todas las apps nativas que declaran manejar el mime de `path`, como pares
/// `(app_id, label)` — para poblar el menú "Abrir con…". Vacío si no hay mime
/// conocido o ningún handler. Función **pura**.
pub fn handlers_for_path(registry: &AppRegistry, path: &str) -> Vec<(String, String)> {
let Some(mime) = mime_for_path(path) else {
return Vec::new();
};
registry
.handlers_for(mime)
.into_iter()
.map(|a| (a.id.clone(), a.label.clone()))
.collect()
}
/// Abre `path` con la app de id `app_id`; si no existe o falla el spawn, cae a
/// `xdg-open`. Para la elección explícita del menú "Abrir con…".
pub fn open_with_id(registry: &AppRegistry, app_id: &str, path: &str) -> Opened {
if let Some(app) = registry.get(app_id) {
match app.open(path) {
Ok(_) => return Opened::NativeApp(app.label.clone()),
Err(e) => eprintln!("pata · {} no pudo abrir {path}: {e}; uso xdg-open", app.label),
}
}
open_system(path)
}
/// Abre `path` con el handler del escritorio (`xdg-open`).
pub fn open_system(path: &str) -> Opened {
crate::spawn_cmd(&format!("xdg-open {}", crate::shell_quote(path)));
Opened::SystemDefault
}
/// Abre `path` con la app del registro que declare su mime; si ninguna, cae a
/// `xdg-open`. No bloquea (spawnea y olvida). Devuelve qué ruta tomó.
pub fn open_file(registry: &AppRegistry, path: &str) -> Opened {
if let Some(app) = handler_for(registry, path) {
match app.open(path) {
Ok(_) => return Opened::NativeApp(app.label.clone()),
Err(e) => {
eprintln!("pata · {} no pudo abrir {path}: {e}; uso xdg-open", app.label);
}
}
}
open_system(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mime_por_extension_comun() {
assert_eq!(mime_for_path("/proj/src/lib.rs"), Some("text/x-rust"));
assert_eq!(mime_for_path("foto.PNG"), Some("image/png")); // case-insensitive
assert_eq!(mime_for_path("a/b/notas.md"), Some("text/markdown"));
assert_eq!(mime_for_path("clip.mp4"), Some("video/mp4"));
assert_eq!(mime_for_path("data.json"), Some("application/json"));
}
#[test]
fn sin_extension_o_desconocida_es_none() {
assert_eq!(mime_for_path("README"), None);
assert_eq!(mime_for_path("/etc/hosts"), None);
assert_eq!(mime_for_path("archivo.xyzqux"), None);
}
#[test]
fn extension_de_un_punto_en_directorio_no_confunde() {
// El punto está en un componente de directorio, no en el archivo.
assert_eq!(mime_for_path("/home/.config/Makefile"), None);
// Pero un dotfile con extensión real sí.
assert_eq!(mime_for_path("/home/.bashrc.md"), Some("text/markdown"));
}
/// Un registro con las apps de ejemplo (media + nada), construido en memoria
/// desde el mismo formato TOML de `~/.config/gioser/apps/`.
fn registro_ejemplo() -> AppRegistry {
let media = app_bus::parse_entry(
"id='media'\nlabel='Media'\nhandles=['video/mp4','audio/mpeg']\n[launch]\nexec='media-app'\nargs=['%f']",
)
.unwrap();
let nada = app_bus::parse_entry(
"id='nada'\nlabel='Nada'\nhandles=['text/x-rust','text/markdown','text/plain']\n[launch]\nexec='nada'\nargs=['%f']",
)
.unwrap();
AppRegistry::new(vec![media, nada])
}
#[test]
fn rutea_a_la_app_nativa_por_mime() {
let reg = registro_ejemplo();
// Un .mp4 va a media; un .rs y un .md a nada.
assert_eq!(handler_for(&reg, "/v/clip.mp4").map(|a| a.id.as_str()), Some("media"));
assert_eq!(handler_for(&reg, "/s/lib.rs").map(|a| a.id.as_str()), Some("nada"));
assert_eq!(handler_for(&reg, "/d/README.md").map(|a| a.id.as_str()), Some("nada"));
}
#[test]
fn sin_handler_nativo_cae_al_sistema() {
let reg = registro_ejemplo();
// .pdf tiene mime conocido pero ninguna app de ejemplo lo declara.
assert!(handler_for(&reg, "/d/manual.pdf").is_none());
// Sin extensión, ni siquiera hay mime.
assert!(handler_for(&reg, "/etc/hosts").is_none());
}
#[test]
fn handlers_for_path_lista_opciones_del_menu() {
let reg = registro_ejemplo();
// Un .rs ofrece "nada" como handler nativo.
let opts = handlers_for_path(&reg, "/s/lib.rs");
assert_eq!(opts, vec![("nada".to_string(), "Nada".to_string())]);
// Un .pdf no tiene handlers nativos → lista vacía (sólo "sistema" en UI).
assert!(handlers_for_path(&reg, "/d/x.pdf").is_empty());
}
}
+996
View File
@@ -0,0 +1,996 @@
//! El pincel: traduce el modelo resuelto de `pata-core` a `View<Msg>` de
//! Llimphi.
//!
//! Dos niveles:
//! - [`widget_view`] traduce un [`WidgetView`] —el view-model agnóstico que un
//! widget emite— a un `View<Msg>` concreto (texto, medidor con barra,
//! placeholder tenue).
//! - [`root`] coloca cada superficie en el rect que [`pata_core::layout`]
//! resolvió (posición absoluta, en píxeles de pantalla) y reparte sus widgets
//! en los slots start/center/end según el eje del anclaje.
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{
auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style,
},
Rect as TaffyRect,
};
use llimphi_ui::llimphi_raster::peniko::{Blob, Image, ImageFormat};
use llimphi_ui::View;
use app_bus::AppEntry;
use pata_core::config::{FloatingCard, Surface, SurfaceKind};
use pata_core::layout::Rect;
use pata_core::widget::{Widget, WidgetView};
use crate::shuma::{self, ShumaState};
use crate::toplevel::WindowEntry;
use crate::tray::{TrayIcon, TrayItem};
use crate::{Model, Msg, SlotWidget, SurfaceWidgets};
mod sidebar;
pub use sidebar::{nav_panel_view, sidebar_rail_view, sidebar_surface_view};
/// Largo máximo de la etiqueta de una ventana en el `window_list` antes de
/// recortar con `…`. Evita que un título largo empuje el resto de la barra.
const WINDOW_LABEL_MAX: usize = 22;
/// Largo máximo del preview del portapapeles antes de recortar con `…`.
const CLIPBOARD_PREVIEW_MAX: usize = 28;
/// Largo máximo de la etiqueta de un item del tray antes de recortar con `…`.
const TRAY_LABEL_MAX: usize = 14;
/// Los datos del host que el render necesita además del view-model de los
/// widgets de core: lo dinámico que vive en el backend (ventanas abiertas,
/// portapapeles) y se pasa aparte. Agrupado para no inflar cada firma a medida
/// que se suman widgets de este tipo (mañana, el tray).
#[derive(Default)]
pub struct BarData<'a> {
/// Las ventanas abiertas, para el `window_list`.
pub windows: &'a [WindowEntry],
/// El texto del portapapeles (ya en una línea), para el `clipboard`.
pub clipboard: Option<&'a str>,
/// Los items de la bandeja del sistema, para el `tray`.
pub tray: &'a [TrayItem],
}
/// Ancho de la barrita de un medidor, en píxeles.
const BARRA_W: f32 = 48.0;
/// Ancho fijo de la leyenda de un medidor (px). Cabe `"10.5/15.5G"` (RAM), la
/// más ancha; evita que el cambio de dígitos reacomode la barra.
const CAPTION_W: f32 = 72.0;
/// El texto de tooltip de un widget de core, derivado de su view-model: la
/// lectura completa (medidor con su etiqueta + leyenda, texto tal cual). `None`
/// para los vacíos. Lo muestra el tooltip flotante al posar el cursor.
pub fn widget_tooltip(v: &WidgetView) -> Option<String> {
match v {
WidgetView::Empty => None,
WidgetView::Text(t) if t.trim().is_empty() => None,
WidgetView::Text(t) => Some(t.clone()),
WidgetView::Meter { label, caption, .. } => {
let l = label.as_deref().unwrap_or("").trim();
let c = caption.trim();
let s = format!("{l} {c}");
let s = s.trim().to_string();
(!s.is_empty()).then_some(s)
}
WidgetView::Placeholder(kind) => Some(kind.clone()),
}
}
/// El cuerpo del **tooltip flotante**: una cajita opaca con el texto, rellenando
/// su contenedor (en layer-shell, la propia surface popup). Opaca a propósito —
/// así no depende de transparencia de la surface (que en algún compositor podría
/// fallar y ennegrecer todo).
pub fn tooltip_view(text: &str, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: TaffyRect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(theme.bg_panel)
.radius(6.0)
.text(text.to_string(), 12.0, theme.fg_text)
}
/// Traduce el view-model de un widget al `View<Msg>` que lo pinta.
pub fn widget_view(v: &WidgetView, theme: &Theme) -> View<Msg> {
match v {
WidgetView::Empty => View::new(Style {
size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
}),
WidgetView::Text(t) => chip(theme).text(t.clone(), 13.0, theme.fg_text),
WidgetView::Meter {
label,
fraction,
caption,
} => meter_view(label.as_deref(), *fraction, caption, theme),
WidgetView::Placeholder(kind) => chip(theme)
.fill(theme.bg_panel)
.radius(6.0)
.text(kind.clone(), 12.0, theme.fg_muted),
}
}
/// Un contenedor compacto, centrado, con padding horizontal — la base de
/// cualquier widget de barra.
fn chip(_theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: auto(),
height: length(22.0_f32),
},
padding: TaffyRect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
}
/// Aclara un color hacia el blanco en `amount` (`0.0` = igual, `1.0` = blanco).
/// Para el extremo claro del gradiente de los medidores.
fn aclarar(c: llimphi_theme::Color, amount: f32) -> llimphi_theme::Color {
use llimphi_ui::llimphi_raster::peniko::color::AlphaColor;
let [r, g, b, a] = c.components;
let m = amount.clamp(0.0, 1.0);
AlphaColor::new([r + (1.0 - r) * m, g + (1.0 - g) * m, b + (1.0 - b) * m, a])
}
/// Un medidor: etiqueta opcional + barrita proporcional + leyenda. La barra de
/// relleno lleva un **gradiente** horizontal del acento (izquierda) a un acento
/// aclarado (derecha), pintado a mano con `paint_with` (Llimphi no tiene fill de
/// brush, sólo color sólido).
fn meter_view(label: Option<&str>, fraction: f32, caption: &str, theme: &Theme) -> View<Msg> {
let frac = fraction.clamp(0.0, 1.0);
let c0 = theme.accent;
let c1 = aclarar(theme.accent, 0.5);
let relleno = View::new(Style {
size: Size {
width: length(BARRA_W * frac),
height: length(6.0_f32),
},
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect};
use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient};
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let (x0, y0) = (rect.x as f64, rect.y as f64);
let (x1, y1) = ((rect.x + rect.w) as f64, (rect.y + rect.h) as f64);
let rr = RoundedRect::new(x0, y0, x1, y1, 2.0);
let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x1, y0))
.with_stops([c0, c1].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
});
let barra = View::new(Style {
size: Size {
width: length(BARRA_W),
height: length(6.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.radius(2.0)
.children(vec![relleno]);
let mut hijos: Vec<View<Msg>> = Vec::new();
if let Some(l) = label {
hijos.push(etiqueta(l, theme));
}
hijos.push(barra);
if !caption.is_empty() {
// Ancho FIJO: la leyenda cambia de dígitos cada tick ("7%"→"42%"→
// "100%") y, con ancho automático, eso reflota toda la barra. Una caja
// fija mantiene el layout quieto.
hijos.push(caption_fija(caption, theme));
}
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: auto(),
height: length(22.0_f32),
},
padding: TaffyRect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(hijos)
}
/// La leyenda de un medidor en una caja de **ancho fijo**: como el texto cambia
/// de dígitos a cada tick, una caja fija evita que el medidor (y con él toda la
/// barra) se reacomode. Cabe la más ancha (`"10.5/15.5G"` de la RAM).
fn caption_fija(t: &str, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: length(CAPTION_W),
height: length(22.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
..Default::default()
})
.text(t.to_string(), 12.0, theme.fg_muted)
}
/// Un texto corto en color tenue (etiqueta o leyenda de un medidor).
fn etiqueta(t: &str, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: auto(),
height: length(22.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(t.to_string(), 12.0, theme.fg_muted)
}
/// El **interior** de una tarjeta flotante (estilo conky): título opcional +
/// widgets apilados, rellenando su contenedor (100%×100%). Lo usa el backend
/// layer-shell, donde la propia layer surface ya tiene el tamaño de la tarjeta.
/// Para el path winit, [`card_view_absolute`] lo posiciona en (x, y).
pub fn card_view(card: &FloatingCard, widgets: &[Box<dyn Widget>], theme: &Theme) -> View<Msg> {
let mut hijos: Vec<View<Msg>> = Vec::new();
if let Some(t) = &card.title {
hijos.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
..Default::default()
})
.text(t.clone(), 12.0, theme.fg_muted),
);
}
for w in widgets {
hijos.push(widget_view(&w.view(), theme));
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: TaffyRect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(6.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.radius(10.0)
.children(hijos)
}
/// Una tarjeta flotante posicionada en **absoluto** en (x, y) con tamaño (w, h)
/// — para el path winit, donde todas las superficies viven en una sola ventana.
fn card_view_absolute(card: &FloatingCard, widgets: &[Box<dyn Widget>], theme: &Theme) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: TaffyRect {
left: length(card.x),
top: length(card.y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(card.w),
height: length(card.h),
},
..Default::default()
})
.children(vec![card_view(card, widgets, theme)])
}
/// El `View` raíz: cubre la pantalla y coloca cada superficie en su rect.
pub fn root(model: &Model) -> View<Msg> {
let (sw, sh) = model.screen;
let mut superficies: Vec<View<Msg>> = Vec::new();
// Datos del host muestreados por el Model: portapapeles y tray ya funcionan en
// este path winit. El `window_list` queda vacío hasta que el compositor mirada
// exponga sus toplevels por IPC (en layer-shell sí se llena).
let tray_items = model.tray.as_ref().map(|t| t.items()).unwrap_or_default();
let data = BarData {
windows: &[],
clipboard: model.clipboard.as_deref(),
tray: &tray_items,
};
for placed in &model.frame.surfaces {
let surface = &model.cfg.surfaces[placed.index];
let widgets = &model.surfaces[placed.index];
if !placed.rect.es_visible() {
continue;
}
// Un Sidebar no tiene slots: pinta el rail de dientes a partir de
// `surface.tabs` (su panel flota aparte, después, para quedar encima).
if surface.kind == SurfaceKind::Sidebar {
superficies.push(sidebar_rail_view(
surface,
placed.index,
placed.rect,
&model.nav,
&model.shuma,
&model.theme,
));
continue;
}
superficies.push(surface_view(
surface,
placed.rect,
widgets,
&model.shuma,
&data,
&model.theme,
));
}
// El panel del diente desplegado flota sobre el área de trabajo, junto al
// rail (no entra en el layout — lo maneja el frontend, como un drawer).
if let Some((si, ti)) = model.nav.open {
if let Some(placed) = model.frame.surfaces.iter().find(|p| p.index == si) {
if let Some(surface) = model.cfg.surfaces.get(si) {
if surface.kind == SurfaceKind::Sidebar {
superficies.push(nav_panel_view(
surface,
ti,
placed.rect,
(sw, sh),
&model.nav,
&model.theme,
));
}
}
}
}
// Tarjetas flotantes (estilo conky), posicionadas en absoluto sobre la
// pantalla. En layer-shell cada una es su propia surface; acá (winit) viven
// en la ventana única.
for (card, ws) in &model.cards {
superficies.push(card_view_absolute(card, ws, &model.theme));
}
View::new(Style {
size: Size {
width: length(sw as f32),
height: length(sh as f32),
},
..Default::default()
})
.children(superficies)
}
/// Una superficie colocada: rectángulo absoluto con los tres slots repartidos
/// a lo largo de su eje (fila si el anclaje es horizontal, columna si vertical).
fn surface_view(
surface: &Surface,
rect: Rect,
widgets: &SurfaceWidgets,
shuma_state: &ShumaState,
data: &BarData,
theme: &Theme,
) -> View<Msg> {
let dir = if surface.anchor.es_horizontal() {
FlexDirection::Row
} else {
FlexDirection::Column
};
View::new(Style {
position: Position::Absolute,
inset: TaffyRect {
left: length(rect.x as f32),
top: length(rect.y as f32),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(rect.w as f32),
height: length(rect.h as f32),
},
flex_direction: dir,
padding: TaffyRect {
left: length(surface.padding),
right: length(surface.padding),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
..Default::default()
})
.fill(theme.bg_panel_alt)
.children(slots_de(surface, widgets, shuma_state, data, theme, dir))
}
/// La barra de shuma **desplegada**: la propia layer surface creció hacia
/// arriba, así que pintamos el cuerpo del drawer (input + salida) llenando lo
/// alto y la barra (su cabezal) abajo, con su grosor original `bar_px`. Asume
/// anclaje inferior (el caso del preset).
pub fn shuma_open_view(
surface: &Surface,
widgets: &SurfaceWidgets,
shuma_state: &ShumaState,
data: &BarData,
theme: &Theme,
bar_px: f32,
viewport_h: f32,
) -> View<Msg> {
// El cuerpo del drawer ocupa todo lo que sobra por encima de la barra.
let mut body_style = Style {
size: Size {
width: percent(1.0_f32),
height: length(0.0_f32),
},
..Default::default()
};
body_style.flex_grow = 1.0;
let body =
View::new(body_style).children(vec![shuma::drawer_body_view(shuma_state, theme, viewport_h)]);
let bar = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(bar_px),
},
..Default::default()
})
.children(vec![bar_view(surface, widgets, shuma_state, data, theme)]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![body, bar])
}
/// Construye los tres slots (start/center/end) de una superficie a lo largo de
/// su eje. Compartido por [`surface_view`] (una superficie colocada en su rect
/// dentro de una ventana grande) y [`bar_view`] (la barra llenando su propia
/// layer surface de Wayland).
fn slots_de(
surface: &Surface,
widgets: &SurfaceWidgets,
shuma_state: &ShumaState,
data: &BarData,
theme: &Theme,
dir: FlexDirection,
) -> Vec<View<Msg>> {
let slot = |ws: &[SlotWidget], justify: JustifyContent| -> View<Msg> {
let items: Vec<View<Msg>> = ws
.iter()
.map(|sw| match sw {
SlotWidget::Core { widget, exec } => {
// Realce al hover en todos los widgets (feedback de "estoy
// encima") + tooltip con su lectura completa; los que tienen
// `exec` además lanzan su comando.
let wv = widget.view();
let mut v = widget_view(&wv, theme)
.radius(6.0)
.hover_fill(theme.bg_button_hover);
if let Some(tip) = widget_tooltip(&wv) {
v = v.tooltip(tip);
}
match exec {
Some(cmd) => v.on_click(Msg::Spawn(cmd.clone())),
None => v,
}
}
SlotWidget::Start { label, exec } => start_button_view(label, exec.as_deref(), theme),
SlotWidget::Shuma => shuma::headline_view(shuma_state, theme),
SlotWidget::WindowList => window_list_view(data.windows, surface.gap, dir, theme),
SlotWidget::Clipboard { exec } => {
clipboard_view(data.clipboard, exec.as_deref(), theme)
}
SlotWidget::Tray => tray_view(data.tray, surface.gap, dir, theme),
})
.collect();
let mut style = Style {
flex_direction: dir,
align_items: Some(AlignItems::Center),
justify_content: Some(justify),
gap: Size {
width: length(surface.gap),
height: length(surface.gap),
},
..Default::default()
};
style.flex_grow = 1.0;
View::new(style).children(items)
};
vec![
slot(&widgets.start, JustifyContent::FlexStart),
slot(&widgets.center, JustifyContent::Center),
slot(&widgets.end, JustifyContent::FlexEnd),
]
}
/// Lado del ícono-badge (cuadrado) de una ventana en el task manager, en px.
const WIN_BADGE_PX: f32 = 18.0;
/// El **task manager** (estilo KDE): un botón por ventana abierta con un
/// ícono-badge (la inicial del `app_id`) + el título. La activa va resaltada con
/// fondo de panel y badge en acento; las minimizadas, atenuadas. Clic izquierdo
/// → [`Msg::ActivateWindow`] (activa, o minimiza si ya estaba activa); clic
/// derecho → [`Msg::CloseWindow`]. Los botones siguen el eje de la barra.
fn window_list_view(
windows: &[WindowEntry],
gap: f32,
dir: FlexDirection,
theme: &Theme,
) -> View<Msg> {
let chips: Vec<View<Msg>> = windows.iter().map(|w| window_button(w, theme)).collect();
View::new(Style {
flex_direction: dir,
align_items: Some(AlignItems::Center),
gap: Size {
width: length(gap),
height: length(gap),
},
..Default::default()
})
.children(chips)
}
/// Un botón de ventana del task manager: badge (inicial) + título recortado.
fn window_button(w: &WindowEntry, theme: &Theme) -> View<Msg> {
// Activa: fondo de panel, texto pleno, badge en acento. Inactiva: tenue.
// Minimizada: aún más atenuada (texto y badge en muted).
let (fg, fill, badge_bg, badge_fg) = if w.active {
(theme.fg_text, theme.bg_panel, theme.accent, theme.bg_panel)
} else if w.minimized {
(theme.fg_muted, theme.bg_panel_alt, theme.bg_panel, theme.fg_muted)
} else {
(theme.fg_text, theme.bg_panel_alt, theme.bg_panel, theme.fg_muted)
};
let badge = View::new(Style {
size: Size {
width: length(WIN_BADGE_PX),
height: length(WIN_BADGE_PX),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(badge_bg)
.radius(4.0)
.text(w.inicial(), 11.0, badge_fg);
let titulo = View::new(Style {
size: Size {
width: auto(),
height: length(22.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(recortar(&w.label, WINDOW_LABEL_MAX), 12.0, fg);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: auto(),
height: length(24.0_f32),
},
padding: TaffyRect {
left: length(6.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(fill)
.radius(6.0)
.hover_fill(theme.bg_button_hover)
.tooltip(w.label.clone())
.on_click(Msg::ActivateWindow(w.id))
.on_right_click(Msg::CloseWindow(w.id))
.children(vec![badge, titulo])
}
/// El **botón de inicio**: un chip con su label/ícono. Clic → despliega el menú
/// nativo de apps ([`Msg::StartToggle`]), salvo que la config fije `exec` (en
/// cuyo caso lanza ese comando, override estilo waybar).
fn start_button_view(label: &str, exec: Option<&str>, theme: &Theme) -> View<Msg> {
let click = match exec {
Some(cmd) => Msg::Spawn(cmd.to_string()),
None => Msg::StartToggle,
};
chip(theme)
.fill(theme.bg_panel)
.radius(6.0)
.hover_fill(theme.bg_button_hover)
.tooltip(if exec.is_some() { "Lanzar" } else { "Menú de inicio" })
.on_click(click)
.text(label.to_string(), 14.0, theme.accent)
}
/// Ancho del menú de inicio desplegado, en px.
const START_MENU_W: f32 = 280.0;
/// El **menú de inicio** desplegado bajo la barra superior: un scrim que cierra
/// al click + un panel a la izquierda con una fila por app del registro. Pensado
/// para llenar el área que la barra superior libera al crecer hacia abajo (mismo
/// truco que el drawer Quake, pero hacia abajo). Cada fila lanza su app
/// ([`Msg::LaunchApp`]); si el registro está vacío, una pista.
pub fn start_menu_body(apps: &[AppEntry], theme: &Theme) -> View<Msg> {
let filas: Vec<View<Msg>> = if apps.is_empty() {
vec![View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
align_items: Some(AlignItems::Center),
..Default::default()
})
.text(
"sin apps en ~/.config/gioser/apps/".to_string(),
12.0,
theme.fg_muted,
)]
} else {
apps.iter().map(|a| app_row(a, theme)).collect()
};
let panel = View::new(Style {
position: Position::Absolute,
inset: TaffyRect {
left: length(0.0_f32),
top: length(0.0_f32),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(START_MENU_W),
height: auto(),
},
flex_direction: FlexDirection::Column,
padding: TaffyRect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(2.0_f32) },
..Default::default()
})
.fill(theme.bg_panel)
.radius(10.0)
.children(filas);
// Scrim a ancho completo del área: cierra al click fuera del panel.
View::new(Style {
position: Position::Absolute,
inset: TaffyRect {
left: length(0.0_f32),
top: length(0.0_f32),
right: length(0.0_f32),
bottom: length(0.0_f32),
},
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.alpha(0.45)
.on_click(Msg::StartToggle)
.children(vec![panel])
}
/// Una fila del menú de inicio: ícono (glyph) + label, clickeable.
fn app_row(a: &AppEntry, theme: &Theme) -> View<Msg> {
let icono = a.icon.clone().unwrap_or_else(|| "".to_string());
let badge = View::new(Style {
size: Size { width: length(22.0_f32), height: length(22.0_f32) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(icono, 14.0, theme.accent);
let nombre = View::new(Style {
size: Size { width: auto(), height: length(28.0_f32) },
align_items: Some(AlignItems::Center),
..Default::default()
})
.text(a.label.clone(), 13.0, theme.fg_text);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
padding: TaffyRect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
..Default::default()
})
.radius(6.0)
.hover_fill(theme.bg_button_hover)
.on_click(Msg::LaunchApp(a.id.clone()))
.children(vec![badge, nombre])
}
/// El menú de inicio como **overlay** para el path winit: un contenedor a
/// pantalla completa desplazado `bar_h` px hacia abajo (para que el panel caiga
/// bajo la barra superior) que aloja [`start_menu_body`]. El scrim del body
/// cierra al click.
pub fn start_menu_overlay(apps: &[AppEntry], bar_h: f32, theme: &Theme) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: TaffyRect {
left: length(0.0_f32),
top: length(bar_h),
right: length(0.0_f32),
bottom: length(0.0_f32),
},
size: Size {
width: percent(1.0_f32),
height: auto(),
},
..Default::default()
})
.children(vec![start_menu_body(apps, theme)])
}
/// La barra superior con el menú de inicio **desplegado** hacia abajo: la barra
/// arriba (su grosor original) y el menú llenando lo que queda. Espeja
/// [`shuma_open_view`] pero hacia abajo (anclaje superior). El compositor ya
/// creció la layer surface a [`crate::layer`]'s alto de menú.
pub fn start_menu_view(
surface: &Surface,
widgets: &SurfaceWidgets,
shuma_state: &ShumaState,
data: &BarData,
theme: &Theme,
bar_px: f32,
apps: &[AppEntry],
) -> View<Msg> {
let bar = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(bar_px),
},
..Default::default()
})
.children(vec![bar_view(surface, widgets, shuma_state, data, theme)]);
let mut body_style = Style {
size: Size {
width: percent(1.0_f32),
height: length(0.0_f32),
},
..Default::default()
};
body_style.flex_grow = 1.0;
let body = View::new(body_style).children(vec![start_menu_body(apps, theme)]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![bar, body])
}
/// El `clipboard`: un chip con el ícono 📋 y un preview del texto copiado
/// (recortado). Si `exec` está, clickearlo lanza ese comando —típicamente un
/// selector de historial (cliphist)— con realce al hover. Sin texto copiado
/// muestra sólo el ícono tenue.
fn clipboard_view(text: Option<&str>, exec: Option<&str>, theme: &Theme) -> View<Msg> {
let (etiqueta, fg) = match text {
Some(t) if !t.is_empty() => (format!("📋 {}", recortar(t, CLIPBOARD_PREVIEW_MAX)), theme.fg_text),
_ => ("📋".to_string(), theme.fg_muted),
};
// Tooltip: el texto copiado completo (sin recortar), útil cuando el preview
// de la barra lo trunca.
let v = chip(theme)
.hover_fill(theme.bg_button_hover)
.radius(6.0)
.text(etiqueta, 12.0, fg);
let v = match text {
Some(t) if !t.is_empty() => v.tooltip(t.to_string()),
_ => v,
};
match exec {
Some(cmd) => v.on_click(Msg::Spawn(cmd.to_string())),
None => v,
}
}
/// Tamaño del ícono del tray en la barra (px).
const TRAY_ICON_PX: f32 = 18.0;
/// El `tray`: un chip clickeable por item de la bandeja, resaltando los que
/// piden atención (`NeedsAttention`). Click → [`Msg::TrayActivate`] con su `key`;
/// el backend activa el item por D-Bus. Pinta el ícono si la app lo proveyó (pixmap
/// o PNG por nombre); si no, cae a la etiqueta de texto. Los chips siguen el eje.
fn tray_view(items: &[TrayItem], gap: f32, dir: FlexDirection, theme: &Theme) -> View<Msg> {
let chips: Vec<View<Msg>> = items
.iter()
.map(|it| {
let tip = if it.label.trim().is_empty() {
it.key.clone()
} else {
it.label.clone()
};
let base = chip(theme)
.fill(theme.bg_panel_alt)
.radius(6.0)
.hover_fill(theme.bg_button_hover)
.tooltip(tip)
.on_click(Msg::TrayActivate(it.key.clone()));
match &it.icon {
Some(icon) => base.children(vec![tray_icon_node(icon)]),
None => {
// NeedsAttention: acento; el resto, normal.
let fg = if it.status == "NeedsAttention" {
theme.accent
} else {
theme.fg_text
};
base.text(recortar(&it.label, TRAY_LABEL_MAX), 12.0, fg)
}
}
})
.collect();
View::new(Style {
flex_direction: dir,
align_items: Some(AlignItems::Center),
gap: Size {
width: length(gap),
height: length(gap),
},
..Default::default()
})
.children(chips)
}
/// Un nodo cuadrado de [`TRAY_ICON_PX`] con el ícono del item (aspect-fit). Arma la
/// `peniko::Image` desde los bytes RGBA que el hilo del tray ya decodificó.
fn tray_icon_node(icon: &TrayIcon) -> View<Msg> {
let blob = Blob::from(icon.rgba.clone());
let img = Image::new(blob, ImageFormat::Rgba8, icon.width, icon.height);
View::new(Style {
size: Size {
width: length(TRAY_ICON_PX),
height: length(TRAY_ICON_PX),
},
..Default::default()
})
.image(img)
}
/// Recorta una cadena a `max` caracteres, agregando `…` si sobró.
fn recortar(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
/// La barra de **una** superficie llenando su contenedor (100%×100%): la raíz
/// que pinta el backend `wlr-layer-shell`, donde el compositor ya dimensionó y
/// ancló la layer surface al borde — no hace falta posicionarla en absoluto.
pub fn bar_view(
surface: &Surface,
widgets: &SurfaceWidgets,
shuma_state: &ShumaState,
data: &BarData,
theme: &Theme,
) -> View<Msg> {
let dir = if surface.anchor.es_horizontal() {
FlexDirection::Row
} else {
FlexDirection::Column
};
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_direction: dir,
padding: TaffyRect {
left: length(surface.padding),
right: length(surface.padding),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
..Default::default()
})
.fill(theme.bg_panel_alt)
.children(slots_de(surface, widgets, shuma_state, data, theme, dir))
}
#[cfg(test)]
mod tests {
use super::widget_tooltip;
use pata_core::widget::WidgetView;
#[test]
fn tooltip_de_un_medidor_junta_etiqueta_y_leyenda() {
let v = WidgetView::Meter {
label: Some("CPU".into()),
fraction: 0.42,
caption: "42%".into(),
};
assert_eq!(widget_tooltip(&v).as_deref(), Some("CPU 42%"));
}
#[test]
fn tooltip_de_texto_y_vacio() {
assert_eq!(widget_tooltip(&WidgetView::Text("14:05".into())).as_deref(), Some("14:05"));
assert_eq!(widget_tooltip(&WidgetView::Text(" ".into())), None);
assert_eq!(widget_tooltip(&WidgetView::Empty), None);
}
}
@@ -0,0 +1,622 @@
//! Render del **sidebar navegador** (Fase 11c): el rail de dientes pegado al
//! borde y, cuando un diente está activo, el panel con el navegador de
//! Mónadas/archivos.
//!
//! - El **rail** reusa [`llimphi_widget_dock_rail`]: una franja vertical con un
//! diente por `SidebarTab`. El diente activo (su panel desplegado) va
//! resaltado. Clic → [`Msg::NavTabActivate`].
//! - El **panel** ([`panel_inner`]) lleva un cabezal con el toggle Árbol/Grafo +
//! el navegador ([`llimphi_widget_navigator`]) dentro de un área de scroll. El
//! plano de datos lo provee [`crate::nouser`].
//!
//! Dos backends montan estas piezas distinto:
//! - **winit** ([`sidebar_rail_view`] + [`nav_panel_view`]): cada superficie vive
//! en la ventana única, posicionada en absoluto sobre la pantalla; el panel
//! flota como un drawer.
//! - **layer-shell** ([`sidebar_surface_view`]): el rail es su propia layer
//! surface anclada al borde; al abrir un diente la surface **crece** en ancho y
//! el panel se pinta junto al rail (el eje libre lo estira el compositor).
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style},
Rect as TaffyRect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_widget_dock_rail::{dock_rail_view, DockRailItem, DockRailPalette};
use pata_host::HostedTooth;
use llimphi_widget_navigator::{
navigator_view, NavId, NavKind, NavMode, NavNode, NavPalette, NavSpec,
};
use llimphi_widget_scroll::{clamp_offset, scroll_y, ScrollPalette};
use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
use std::collections::HashSet;
use pata_core::config::{Anchor, Surface};
use pata_core::layout::Rect;
use crate::nouser::NavState;
use crate::shuma::ShumaState;
use crate::Msg;
/// Alto del cabezal del panel (título + toggle de modo), en px.
const HEADER_H: f32 = 40.0;
/// Padding interno del panel, en px.
const PAD: f32 = 8.0;
/// Alto estimado de una fila del navegador en modo árbol (igual al `ROW_H`
/// interno del widget). Para dimensionar el scroll.
const TREE_ROW_H: f32 = 24.0;
/// Alto estimado de un nodo del navegador en modo grafo (nodo + separación).
const GRAPH_ROW_H: f32 = 60.0;
// =====================================================================
// Piezas compartidas por ambos backends
// =====================================================================
/// El rail de dientes (sin fondo de franja): un diente por `SidebarTab`. `si`
/// identifica la superficie para el `Msg` del clic.
fn rail_widget(surface: &Surface, si: usize, width: f32, nav: &NavState, theme: &Theme) -> View<Msg> {
let items: Vec<DockRailItem> = surface
.tabs
.iter()
.enumerate()
.map(|(ti, _)| DockRailItem {
id: ti as u64,
active: nav.is_open(si, ti),
})
.collect();
let icons: Vec<String> = surface.tabs.iter().map(|t| t.icon.clone()).collect();
dock_rail_view(
&items,
width,
&DockRailPalette::from_theme(theme),
move |id, size, color| {
let name = icons.get(id as usize).map(|s| s.as_str()).unwrap_or("");
tooth_icon(name, size, color)
},
move |id| Msg::NavTabActivate(si, id as usize),
// Mover un diente de un rail a otro: Fase futura (drop entre sidebars).
|_| None,
)
}
/// El rail de **dientes hospedados** de la app enfocada (`app_id`): un diente por
/// [`HostedTooth`]. Al clickear, manda `HostToothActivate(app_id, id)` (la app lo
/// resuelve sobre su propio canvas). No tienen estado "activo" en pata (lo lleva
/// la app), así que van todos inactivos.
fn hosted_rail(app_id: &str, teeth: &[HostedTooth], width: f32, theme: &Theme) -> View<Msg> {
let items: Vec<DockRailItem> = teeth
.iter()
.map(|t| DockRailItem {
id: t.id as u64,
active: false,
})
.collect();
let icons: Vec<String> = teeth.iter().map(|t| t.icon.clone()).collect();
let app = app_id.to_string();
dock_rail_view(
&items,
width,
&DockRailPalette::from_theme(theme),
move |id, size, color| {
let name = icons.get(id as usize).map(|s| s.as_str()).unwrap_or("");
tooth_icon(name, size, color)
},
move |id| Msg::HostToothActivate(app.clone(), id as u32),
|_| None,
)
}
/// El diente **in-process** de shuma: cuando el marco hospeda un `shuma_input`
/// ([`ShumaState::present`]), el rail muestra un diente que despliega/repliega el
/// drawer Quake. A diferencia de los dientes hospedados (estado en la app remota,
/// vía socket), shuma vive en el **propio proceso** de pata, así que el diente
/// refleja su estado real (`active = open`) y el clic va directo a
/// [`Msg::ShumaToggle`]. Por eso no depende del foco: aparece igual en winit y en
/// layer-shell mientras la config declare un `shuma_input`.
fn shuma_rail(open: bool, width: f32, theme: &Theme) -> View<Msg> {
let items = vec![DockRailItem { id: 0, active: open }];
dock_rail_view(
&items,
width,
&DockRailPalette::from_theme(theme),
|_id, size, color| tooth_icon("shell", size, color),
|_id| Msg::ShumaToggle,
|_| None,
)
}
/// Un separador tenue horizontal entre grupos de dientes del rail.
fn rail_separator(thickness: f32, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: length(thickness * 0.5),
height: length(1.0_f32),
},
margin: TaffyRect {
left: length(thickness * 0.25),
right: length(thickness * 0.25),
top: length(6.0_f32),
bottom: length(6.0_f32),
},
..Default::default()
})
.fill(theme.fg_muted)
}
/// Una franja de rail que **llena su alto**: fondo de panel + el rail de dientes
/// de la config arriba, debajo los dientes **hospedados** de la app enfocada (si
/// los hay) y, al fondo, el diente **in-process** de shuma (si el marco hospeda un
/// `shuma_input`). La usan ambos backends (en winit dentro del rect absoluto —sin
/// dientes hospedados, que dependen del foco, pero sí con el de shuma—, en
/// layer-shell como columna de ancho `thickness` dentro de la surface).
fn rail_strip(
surface: &Surface,
si: usize,
thickness: f32,
nav: &NavState,
hosted: &[HostedTooth],
hosted_app: &str,
shuma: &ShumaState,
theme: &Theme,
) -> View<Msg> {
let mut hijos = vec![rail_widget(surface, si, thickness, nav, theme)];
if !hosted.is_empty() {
hijos.push(rail_separator(thickness, theme));
hijos.push(hosted_rail(hosted_app, hosted, thickness, theme));
}
if shuma.present {
hijos.push(rail_separator(thickness, theme));
hijos.push(shuma_rail(shuma.open, thickness, theme));
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(thickness),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(theme.bg_panel_alt)
.children(hijos)
}
/// El contenido del panel (cabezal con toggle de modo + navegador con scroll),
/// dimensionado para llenar su contenedor de alto `panel_h` px. Trae su propio
/// fondo y padding. `ti` es el diente desplegado.
fn panel_inner(surface: &Surface, ti: usize, panel_h: f32, nav: &NavState, theme: &Theme) -> View<Msg> {
// --- Cabezal: título del diente + toggle Árbol/Grafo ---
let titulo = surface
.tabs
.get(ti)
.map(|t| t.label.clone())
.unwrap_or_default();
let titulo_view = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(0.0_f32),
height: length(HEADER_H),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text(titulo, 13.0, theme.fg_text);
let toggle = View::new(Style {
size: Size {
width: length(140.0_f32),
height: length(HEADER_H),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![segmented_view(
&NavMode::LABELS,
nav.mode.index(),
|i| Msg::NavSetMode(NavMode::from_index(i)),
&SegmentedPalette::from_theme(theme),
)]);
let header = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(HEADER_H),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![titulo_view, toggle]);
// --- Cuerpo: el menú "Abrir con…" si está abierto, si no el navegador (o un
// aviso si no hay datos) ---
let viewport = (panel_h - HEADER_H - PAD * 2.0).max(0.0);
let cuerpo = if let Some(mid) = nav.menu {
open_with_menu(nav, mid, theme)
} else if nav.roots.is_empty() {
aviso_view(nav, theme, viewport)
} else {
let row_h = match nav.mode {
NavMode::Tree => TREE_ROW_H,
NavMode::Graph => GRAPH_ROW_H,
};
let visibles = count_visible(&nav.roots, &nav.expanded);
let content_len = visibles as f32 * row_h + 16.0;
let offset = clamp_offset(nav.scroll, content_len, viewport);
let navv = navigator_view(
NavSpec {
roots: &nav.roots,
mode: nav.mode,
selected: nav.selected,
palette: NavPalette::from_theme(theme),
guides: true,
},
|id| nav.expanded.contains(&id),
Msg::NavToggle,
Msg::NavSelect,
// Right-click sobre un archivo → menú "Abrir con…".
Some(Msg::NavContextMenu),
);
scroll_y(
offset,
content_len,
viewport,
navv,
Msg::NavScroll,
&ScrollPalette::from_theme(theme),
)
};
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: TaffyRect {
left: length(PAD),
right: length(PAD),
top: length(PAD),
bottom: length(PAD),
},
gap: Size {
width: length(0.0_f32),
height: length(PAD),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![header, cuerpo])
}
// =====================================================================
// Backend winit: superficies en absoluto sobre la ventana única
// =====================================================================
/// El rail de un `SurfaceKind::Sidebar`, posicionado en el rect que el layout
/// reservó para él (path winit). `si` es el índice de la superficie.
pub fn sidebar_rail_view(
surface: &Surface,
si: usize,
rect: Rect,
nav: &NavState,
shuma: &ShumaState,
theme: &Theme,
) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: TaffyRect {
left: length(rect.x as f32),
top: length(rect.y as f32),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(rect.w as f32),
height: length(rect.h as f32),
},
..Default::default()
})
// El path winit no conoce el foco (no hay toplevels) → sin dientes hospedados,
// pero el diente de shuma es in-process y sí aparece.
.children(vec![rail_strip(surface, si, rect.w as f32, nav, &[], "", shuma, theme)])
}
/// El panel flotante del diente `ti` desplegado (path winit): flota junto al
/// `rail_rect` (a su derecha si el sidebar está a la izquierda, a su izquierda si
/// está a la derecha).
pub fn nav_panel_view(
surface: &Surface,
ti: usize,
rail_rect: Rect,
screen: (i32, i32),
nav: &NavState,
theme: &Theme,
) -> View<Msg> {
let pw = surface.panel_width;
let (_, sh) = screen;
let h = (rail_rect.h as f32).min(sh as f32);
let y = rail_rect.y as f32;
let x = match surface.anchor {
Anchor::Right => (rail_rect.x as f32 - pw).max(0.0),
_ => (rail_rect.x + rail_rect.w) as f32,
};
View::new(Style {
position: Position::Absolute,
inset: TaffyRect {
left: length(x),
top: length(y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(pw),
height: length(h),
},
..Default::default()
})
.children(vec![panel_inner(surface, ti, h, nav, theme)])
}
// =====================================================================
// Backend layer-shell: la surface del rail crece para alojar el panel
// =====================================================================
/// La vista que llena la layer surface de un `SurfaceKind::Sidebar` de tamaño
/// `(w, h)` px. Colapsada es sólo el rail (`w == thickness`); con un diente
/// abierto la surface creció a `thickness + panel_width` y se pinta rail + panel
/// (el orden depende del anclaje: el rail siempre pegado a su borde). `si` es el
/// índice de la superficie.
pub fn sidebar_surface_view(
surface: &Surface,
si: usize,
w: f32,
h: f32,
nav: &NavState,
hosted: &[HostedTooth],
hosted_app: &str,
shuma: &ShumaState,
theme: &Theme,
) -> View<Msg> {
let thickness = surface.thickness;
let rail = rail_strip(surface, si, thickness, nav, hosted, hosted_app, shuma, theme);
let open_ti = match nav.open {
Some((s, ti)) if s == si => Some(ti),
_ => None,
};
let children = if let Some(ti) = open_ti {
let pw = (w - thickness).max(0.0);
let panel = View::new(Style {
size: Size {
width: length(pw),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![panel_inner(surface, ti, h, nav, theme)]);
// El rail va pegado a su borde: a la izquierda del panel si el sidebar
// está anclado a la izquierda; a la derecha si está a la derecha.
match surface.anchor {
Anchor::Right => vec![panel, rail],
_ => vec![rail, panel],
}
} else {
vec![rail]
};
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: length(w),
height: length(h),
},
..Default::default()
})
.children(children)
}
// =====================================================================
// Auxiliares
// =====================================================================
/// El menú "Abrir con…" sobre el archivo `id`: una fila por app nativa que
/// declare su mime (de `nav.menu_options`), más "el sistema" (`xdg-open`) y
/// "Cancelar". Se pinta en el cuerpo del panel (sin overlay flotante: así
/// funciona idéntico en winit y layer-shell sin necesitar coords del cursor).
fn open_with_menu(nav: &NavState, id: NavId, theme: &Theme) -> View<Msg> {
let path = nav.file_path(id).unwrap_or("");
let name = path.rsplit('/').next().filter(|s| !s.is_empty()).unwrap_or(path);
let mut rows: Vec<View<Msg>> = Vec::new();
rows.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text(format!("Abrir «{name}» con:"), 12.0, theme.fg_muted),
);
for (app_id, label) in &nav.menu_options {
let aid = app_id.clone();
rows.push(menu_button(label, theme).on_click(Msg::NavOpenWith(id, Some(aid))));
}
rows.push(menu_button("El sistema (xdg-open)", theme).on_click(Msg::NavOpenWith(id, None)));
rows.push(menu_button("Cancelar", theme).on_click(Msg::NavMenuCancel));
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: auto(),
},
gap: Size {
width: length(0.0_f32),
height: length(4.0_f32),
},
..Default::default()
})
.children(rows)
}
/// Una fila clickeable del menú "Abrir con…". El caller le cuelga el `on_click`.
fn menu_button(label: &str, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(30.0_f32),
},
align_items: Some(AlignItems::Center),
padding: TaffyRect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.hover_fill(theme.bg_button_hover)
.radius(6.0)
.text(label.to_string(), 13.0, theme.fg_text)
}
/// Un aviso centrado cuando no hay Mónadas que mostrar (conectando, o error).
fn aviso_view(nav: &NavState, theme: &Theme, viewport: f32) -> View<Msg> {
let (texto, color) = match &nav.error {
Some(e) => (e.clone(), theme.fg_muted),
None => ("Conectando con nouser…".to_string(), theme.fg_muted),
};
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(viewport.max(40.0)),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(texto, 12.0, color)
}
/// Cuenta los nodos visibles del bosque dado el conjunto de expandidos — para
/// dimensionar el alto del contenido del scroll.
fn count_visible(roots: &[NavNode], expanded: &HashSet<u64>) -> usize {
fn walk(node: &NavNode, expanded: &HashSet<u64>, acc: &mut usize) {
*acc += 1;
if node.has_children() && expanded.contains(&node.id) {
for c in &node.children {
walk(c, expanded, acc);
}
}
}
let mut acc = 0;
for r in roots {
walk(r, expanded, &mut acc);
}
acc
}
/// El icono de un diente del rail: un glifo vectorial según el nombre declarado
/// en el `SidebarTab` (`monads` → diamante, `files` → cuadrado, otro → círculo),
/// con el color que el rail ya resolvió (acento si activo, atenuado si no).
fn tooth_icon(name: &str, size: f32, color: Color) -> View<Msg> {
// Reusamos la semántica de iconos del navegador para coherencia visual.
let kind = match name {
"monads" | "monadas" | "monad" | "astro" => NavKind::Monad,
"files" | "archivos" | "file" | "dir" | "folder" | "tree" => NavKind::Dir,
"tools" | "group" | "settings" | "system" | "shell" | "terminal" => NavKind::Group,
_ => NavKind::Other,
};
View::new(Style {
size: Size {
width: length(size),
height: length(size),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Point, RoundedRect};
use llimphi_ui::llimphi_raster::peniko::Fill;
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let cx = (rect.x + rect.w * 0.5) as f64;
let cy = (rect.y + rect.h * 0.5) as f64;
let r = (rect.w.min(rect.h) as f64 * 0.38).max(2.0);
match kind {
NavKind::Monad => {
let mut p = BezPath::new();
p.move_to(Point::new(cx, cy - r));
p.line_to(Point::new(cx + r, cy));
p.line_to(Point::new(cx, cy + r));
p.line_to(Point::new(cx - r, cy));
p.close_path();
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &p);
}
NavKind::Group | NavKind::Dir => {
let sq = RoundedRect::new(cx - r, cy - r, cx + r, cy + r, 2.0);
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &sq);
}
NavKind::File | NavKind::Other => {
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
color,
None,
&Circle::new((cx, cy), r * 0.7),
);
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use llimphi_widget_navigator::NavNode;
fn forest() -> Vec<NavNode> {
vec![
NavNode::branch(
1,
"m1",
NavKind::Monad,
vec![NavNode::leaf(11, "a", NavKind::File), NavNode::leaf(12, "b", NavKind::File)],
),
NavNode::leaf(2, "m2", NavKind::Monad),
]
}
#[test]
fn count_visible_respeta_expansion() {
let roots = forest();
// Colapsado: sólo las 2 raíces.
let none = HashSet::new();
assert_eq!(count_visible(&roots, &none), 2);
// Expandida la primera: 2 raíces + 2 hijos.
let mut exp = HashSet::new();
exp.insert(1u64);
assert_eq!(count_visible(&roots, &exp), 4);
}
}
+446
View File
@@ -0,0 +1,446 @@
//! El muestreador del sistema en Linux: arma el [`WidgetCtx`] que alimenta a
//! los widgets de `pata-core` en cada tick.
//!
//! La frontera de la Fase 4: el core no toca el SO; este es el sampler que cada
//! plataforma aporta. En Linux leemos `chrono` para el reloj, `/proc/stat` para
//! la CPU (necesita dos lecturas, por eso es un struct con estado), `/proc/
//! meminfo` para la RAM y `/sys/class/backlight` para el brillo. El volumen
//! (PulseAudio/PipeWire) queda diferido —el medidor sale en 0% hasta entonces—.
use std::io::Read;
use std::time::{Duration, Instant};
use chrono::{Datelike, Local, Timelike, Utc};
use pata_core::widget::{ClockReading, WidgetCtx};
/// Duración del mes sinódico (de luna nueva a luna nueva), en días.
const MES_SINODICO: f64 = 29.530588853;
/// Época de referencia de luna nueva: 2000-01-06 18:14 UTC, en días julianos.
const LUNA_NUEVA_REF_JD: f64 = 2451550.1;
/// Muestreador con estado: guarda la última lectura de `/proc/stat` para poder
/// calcular el uso de CPU como delta entre ticks.
#[derive(Default)]
pub struct Sampler {
/// `(total, idle)` de la lectura anterior de `/proc/stat`, o `None` al inicio.
cpu_prev: Option<(u64, u64)>,
/// Si `true`, el reloj se arma en UTC en vez de la hora local (de
/// `general.timezone = "UTC"`). Paridad con el `TzMode` de mirada-launcher.
utc: bool,
}
impl Sampler {
/// Un sampler nuevo, sin lecturas previas (hora local).
pub fn new() -> Self {
Self::default()
}
/// Un sampler que arma el reloj en UTC si `utc`, o local si no.
pub fn with_utc(utc: bool) -> Self {
Self { utc, ..Self::default() }
}
/// Toma un snapshot completo del sistema.
pub fn sample(&mut self) -> WidgetCtx {
let (ram, ram_used_mb, ram_total_mb) = sample_ram();
let (sun_longitude_deg, moon_phase) = astro_from_jd(jd_from_unix(Utc::now().timestamp()));
let (volume, muted) = sample_volume().unwrap_or((0.0, false));
WidgetCtx {
clock: sample_clock(self.utc),
cpu: self.sample_cpu(),
ram,
ram_used_mb,
ram_total_mb,
volume,
muted,
brightness: sample_brightness().unwrap_or(0.0),
sun_longitude_deg,
moon_phase,
}
}
/// Uso de CPU `0..1` como `1 - idle_delta/total_delta`. La primera vez no
/// hay delta, así que devuelve 0 y guarda la base para el siguiente tick.
fn sample_cpu(&mut self) -> f32 {
let Some((total, idle)) = read_proc_stat() else {
return 0.0;
};
let usage = match self.cpu_prev {
Some((pt, pi)) => {
let dt = total.saturating_sub(pt);
let di = idle.saturating_sub(pi);
if dt > 0 {
(1.0 - di as f32 / dt as f32).clamp(0.0, 1.0)
} else {
0.0
}
}
None => 0.0,
};
self.cpu_prev = Some((total, idle));
usage
}
}
/// Descompone la hora actual (local, o UTC si `utc`) en [`ClockReading`].
fn sample_clock(utc: bool) -> ClockReading {
if utc {
clock_de(Utc::now())
} else {
clock_de(Local::now())
}
}
/// Arma el [`ClockReading`] desde cualquier `DateTime` con timezone.
fn clock_de<Tz: chrono::TimeZone>(now: chrono::DateTime<Tz>) -> ClockReading {
ClockReading {
year: now.year() as u16,
month: now.month() as u8,
day: now.day() as u8,
weekday: now.weekday().num_days_from_sunday() as u8,
hour: now.hour() as u8,
minute: now.minute() as u8,
second: now.second() as u8,
}
}
/// `(fracción_usada, usada_mb, total_mb)` desde `/proc/meminfo`. Si no se puede
/// leer (no-Linux), devuelve ceros.
fn sample_ram() -> (f32, u32, u32) {
let Some((total_kb, avail_kb)) = read_meminfo() else {
return (0.0, 0, 0);
};
let used_kb = total_kb.saturating_sub(avail_kb);
let frac = if total_kb > 0 {
used_kb as f32 / total_kb as f32
} else {
0.0
};
(frac, (used_kb / 1024) as u32, (total_kb / 1024) as u32)
}
/// `(total_kb, available_kb)` desde `/proc/meminfo`.
fn read_meminfo() -> Option<(u64, u64)> {
let text = std::fs::read_to_string("/proc/meminfo").ok()?;
parse_meminfo(&text)
}
/// Extrae `(MemTotal, MemAvailable)` en kB del texto de `/proc/meminfo`.
fn parse_meminfo(text: &str) -> Option<(u64, u64)> {
let mut total = None;
let mut avail = None;
for line in text.lines() {
let mut parts = line.split_whitespace();
match parts.next()? {
"MemTotal:" => total = parts.next()?.parse::<u64>().ok(),
"MemAvailable:" => avail = parts.next()?.parse::<u64>().ok(),
_ => {}
}
if total.is_some() && avail.is_some() {
break;
}
}
Some((total?, avail?))
}
/// `(total_jiffies, idle_jiffies)` de la primera línea `cpu` de `/proc/stat`.
/// `idle` incluye `iowait` (4º campo). `None` si no se puede leer.
fn read_proc_stat() -> Option<(u64, u64)> {
let text = std::fs::read_to_string("/proc/stat").ok()?;
parse_proc_stat(&text)
}
/// Extrae `(total, idle+iowait)` en jiffies de la primera línea `cpu` de
/// `/proc/stat`.
fn parse_proc_stat(text: &str) -> Option<(u64, u64)> {
let line = text.lines().next()?;
let mut parts = line.split_whitespace();
if parts.next()? != "cpu" {
return None;
}
let vals: Vec<u64> = parts.filter_map(|p| p.parse::<u64>().ok()).collect();
if vals.len() < 4 {
return None;
}
let total: u64 = vals.iter().sum();
// idle = idle (índice 3) + iowait (índice 4, si está).
let idle = vals[3] + vals.get(4).copied().unwrap_or(0);
Some((total, idle))
}
/// Día juliano a partir de un timestamp Unix (segundos UTC). El día juliano
/// 2440587.5 corresponde a la época Unix (1970-01-01 00:00 UTC).
fn jd_from_unix(secs: i64) -> f64 {
secs as f64 / 86_400.0 + 2_440_587.5
}
/// `(longitud_eclíptica_sol_deg, fase_lunar)` para un día juliano dado.
///
/// La longitud del Sol usa la fórmula de baja precisión del *Astronomical
/// Almanac* (exacta a ~0.01°, de sobra para el signo zodiacal). La fase lunar
/// es la edad sinódica media desde una luna nueva de referencia, como fracción
/// `0..1` (0 = nueva, 0.5 = llena). No es astronomía de alta precisión —para eso
/// está `cosmos-ephemeris`, que puede sustituir a este sampler— pero alcanza
/// para un widget de barra.
fn astro_from_jd(jd: f64) -> (f32, f32) {
let n = jd - 2_451_545.0; // días desde J2000.0
// Anomalía media del Sol (grados → radianes para los senos).
let g = (357.528 + 0.985_600_3 * n).to_radians();
// Longitud media + ecuación del centro.
let mut lambda = 280.460 + 0.985_647_4 * n + 1.915 * g.sin() + 0.020 * (2.0 * g).sin();
lambda = lambda.rem_euclid(360.0);
// Edad lunar como fracción del ciclo sinódico.
let edad = (jd - LUNA_NUEVA_REF_JD).rem_euclid(MES_SINODICO);
let fase = (edad / MES_SINODICO) as f32;
(lambda as f32, fase)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jd_de_epoca_unix_es_la_referencia() {
assert!((jd_from_unix(0) - 2_440_587.5).abs() < 1e-9);
}
#[test]
fn parse_wpctl_lee_fraccion_y_mute() {
assert_eq!(parse_wpctl("Volume: 0.65"), Some((0.65, false)));
assert_eq!(parse_wpctl("Volume: 0.30 [MUTED]"), Some((0.30, true)));
assert_eq!(parse_wpctl("nada"), None);
}
#[test]
fn preview_clipboard_colapsa_a_una_linea() {
assert_eq!(preview_clipboard(" hola\n mundo\t! "), "hola mundo !");
assert_eq!(preview_clipboard("una sola"), "una sola");
assert_eq!(preview_clipboard(" \n\t "), "");
}
#[test]
fn parse_pactl_pct_toma_el_primer_porcentaje() {
let s = "Volume: front-left: 42598 / 65% / -9.58 dB, front-right: 42598 / 65% / -9.58 dB";
assert_eq!(parse_pactl_pct(s), Some(0.65));
assert_eq!(parse_pactl_pct("Volume: 0 / 0% / -inf dB"), Some(0.0));
assert_eq!(parse_pactl_pct("sin porcentaje"), None);
}
#[test]
fn sol_en_equinoccio_de_marzo_esta_cerca_de_aries_0() {
// 2025-03-20 ~09:01 UTC fue el equinoccio: el Sol cruza 0° (Aries).
// timestamp del 2025-03-20 09:01:00 UTC = 1742461260.
let (lon, _) = astro_from_jd(jd_from_unix(1_742_461_260));
// Cerca de 0°/360°: aceptamos un margen de 1°.
let dist = lon.min(360.0 - lon);
assert!(dist < 1.0, "longitud {lon} no está cerca de 0°");
}
#[test]
fn fase_lunar_esta_en_rango() {
let (_, fase) = astro_from_jd(jd_from_unix(1_742_461_260));
assert!((0.0..=1.0).contains(&fase));
}
#[test]
fn parse_meminfo_extrae_total_y_disponible() {
let txt = "MemTotal: 16252000 kB\n\
MemFree: 1000000 kB\n\
MemAvailable: 8126000 kB\n";
assert_eq!(parse_meminfo(txt), Some((16252000, 8126000)));
}
#[test]
fn parse_meminfo_sin_claves_es_none() {
assert_eq!(parse_meminfo("Foo: 1 kB\n"), None);
}
#[test]
fn parse_proc_stat_suma_total_e_idle_con_iowait() {
// cpu user nice system idle iowait irq softirq …
let txt = "cpu 100 0 50 800 50 0 0 0\ncpu0 ...\n";
// total = 100+0+50+800+50 = 1000 ; idle = 800+50 = 850
assert_eq!(parse_proc_stat(txt), Some((1000, 850)));
}
#[test]
fn parse_proc_stat_otra_primera_linea_es_none() {
assert_eq!(parse_proc_stat("intr 1 2 3\n"), None);
}
#[test]
fn sampler_nuevo_no_tiene_lectura_previa_de_cpu() {
// El primer tick no puede calcular delta (sin base): arranca en None.
assert_eq!(Sampler::new().cpu_prev, None);
}
#[test]
fn delta_de_cpu_da_el_uso_esperado() {
// Base (total=1000, idle=900) → (1100, 950): dt=100, di=50 → 1-0.5 = 0.5.
let (dt, di) = (1100u64 - 1000, 950u64 - 900);
let uso = 1.0 - di as f32 / dt as f32;
assert!((uso - 0.5).abs() < 1e-6);
}
}
/// Brillo `0..1` desde el primer dispositivo en `/sys/class/backlight`. `None`
/// si no hay backlight (escritorio, VM).
fn sample_brightness() -> Option<f32> {
let dir = std::fs::read_dir("/sys/class/backlight").ok()?;
for entry in dir.flatten() {
let base = entry.path();
let cur = std::fs::read_to_string(base.join("brightness"))
.ok()
.and_then(|s| s.trim().parse::<f32>().ok());
let max = std::fs::read_to_string(base.join("max_brightness"))
.ok()
.and_then(|s| s.trim().parse::<f32>().ok());
if let (Some(c), Some(m)) = (cur, max) {
if m > 0.0 {
return Some((c / m).clamp(0.0, 1.0));
}
}
}
None
}
/// `(fracción_volumen, muteado)` del sink por defecto. Prueba PipeWire (`wpctl`)
/// y cae a PulseAudio (`pactl`). `None` si ninguno está. Corre un subproceso por
/// muestreo (~1Hz) — barato a esa frecuencia.
fn sample_volume() -> Option<(f32, bool)> {
if let Some(out) = run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]) {
if let Some(r) = parse_wpctl(&out) {
return Some(r);
}
}
let vol = run("pactl", &["get-sink-volume", "@DEFAULT_SINK@"]).and_then(|o| parse_pactl_pct(&o))?;
let muted = run("pactl", &["get-sink-mute", "@DEFAULT_SINK@"])
.map(|o| o.contains("yes"))
.unwrap_or(false);
Some((vol, muted))
}
/// El texto del portapapeles vía `wl-paste` (wl-clipboard), ya colapsado a una
/// línea. `None` si `wl-paste` no está, si el portapapeles está vacío o no es
/// texto (p. ej. una imagen). Corre un subproceso por muestreo (~1Hz), como el
/// volumen — barato a esa frecuencia.
pub fn leer_clipboard() -> Option<String> {
// `--no-newline`: sin salto final. `--type text/plain`: sólo texto, así una
// imagen en el portapapeles no entra (wl-paste falla y devolvemos None).
let raw = run("wl-paste", &["--no-newline", "--type", "text/plain"])?;
let prev = preview_clipboard(&raw);
(!prev.is_empty()).then_some(prev)
}
/// Colapsa el texto del portapapeles a una sola línea para la barra: saltos y
/// tabs pasan a espacios y los espacios repetidos se comprimen. No trunca —de eso
/// se encarga el render con su `recortar`—.
pub fn preview_clipboard(raw: &str) -> String {
raw.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Corre `cmd args` con un **tope de tiempo** y devuelve su stdout si salió bien
/// dentro del plazo; si se pasa, mata el proceso y devuelve `None`.
///
/// El tope es la diferencia entre "anda" y "se cuelga": herramientas como
/// `wl-paste` (sin `wlr-data-control` en el compositor) o `wpctl`/`pactl` (sin
/// PipeWire/Pulse corriendo, típico en una sesión recién abierta) **bloquean
/// indefinidamente**. Sin timeout, eso congelaba el muestreo —y con él el primer
/// frame del marco— para siempre (pata no llegaba ni a crear su surface GPU).
fn run(cmd: &str, args: &[&str]) -> Option<String> {
const PLAZO: Duration = Duration::from_millis(500);
let mut child = std::process::Command::new(cmd)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let inicio = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
return None;
}
let mut buf = String::new();
child.stdout.take()?.read_to_string(&mut buf).ok()?;
return Some(buf);
}
Ok(None) => {
if inicio.elapsed() >= PLAZO {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(10));
}
Err(_) => return None,
}
}
}
/// Muestrea el sistema en un **hilo aparte** y publica el último snapshot por un
/// canal. Los subprocesos (wpctl/pactl/wl-paste) corren ahí, **nunca en el hilo
/// del bucle de UI**: si uno se cuelga o tarda, el marco sigue pintando y
/// refrescando lo demás. Mismo patrón que el `TrayHandle`.
pub struct SamplerHandle {
rx: std::sync::mpsc::Receiver<Snapshot>,
}
/// Lo que el hilo de muestreo publica cada ~1 s: el contexto de widgets + el
/// preview del portapapeles.
pub type Snapshot = (WidgetCtx, Option<String>);
impl SamplerHandle {
/// Arranca el hilo de muestreo. Toma una muestra al toque y luego cada ~1 s.
/// `utc` arma el reloj en UTC (de `general.timezone = "UTC"`).
pub fn spawn(utc: bool) -> Self {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut sampler = Sampler::with_utc(utc);
loop {
let snapshot = (sampler.sample(), leer_clipboard());
if tx.send(snapshot).is_err() {
break; // la app se fue: cortamos el hilo
}
std::thread::sleep(Duration::from_secs(1));
}
});
Self { rx }
}
/// El snapshot más reciente (drena la cola), o `None` si no llegó nada nuevo
/// desde la última vez. **No bloquea** — pensado para llamar por frame.
pub fn latest(&self) -> Option<Snapshot> {
let mut last = None;
while let Ok(snapshot) = self.rx.try_recv() {
last = Some(snapshot);
}
last
}
}
/// Parsea `wpctl get-volume`: `"Volume: 0.65"` o `"Volume: 0.65 [MUTED]"`.
fn parse_wpctl(s: &str) -> Option<(f32, bool)> {
let rest = s.trim().strip_prefix("Volume:")?;
let muted = rest.contains("MUTED");
let frac = rest.split_whitespace().next()?.parse::<f32>().ok()?;
Some((frac.clamp(0.0, 1.0), muted))
}
/// Parsea el primer porcentaje de `pactl get-sink-volume`
/// (`"Volume: front-left: 42598 / 65% / -9.58 dB ..."`) como fracción `0..1`.
fn parse_pactl_pct(s: &str) -> Option<f32> {
for tok in s.split_whitespace() {
if let Some(num) = tok.strip_suffix('%') {
if let Ok(p) = num.parse::<f32>() {
return Some((p / 100.0).clamp(0.0, 1.0));
}
}
}
None
}
+971
View File
@@ -0,0 +1,971 @@
//! El `shuma_input` y su despliegue **Quake**.
//!
//! La frontera del SDD §5: el marco (`pata`) provee el borde; `shuma` provee el
//! contenido. `shuma_input` es el cabezal que vive en una barra; al activarlo
//! (click o hotkey) el frontend **despliega un drawer** estilo Quake sobre el
//! escritorio, con un input que captura el teclado. Repliega al cerrar.
//!
//! La ejecución del comando es, estrictamente, trabajo de `shuma` (no de
//! `pata`). El puente del SDD §5 ya existe: [`ejecutar`] corre el comando por
//! el **ejecutor real de shuma** (`shuma-exec`) —captura acotada, eventos en
//! streaming— en vez de un `sh -c` pelado. El mecanismo del drawer no cambia.
use llimphi_motion::Tween;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style},
Rect as TaffyRect,
};
use llimphi_ui::View;
use llimphi_widget_scroll::{clamp_offset, scroll_y, ScrollPalette};
use pata_core::WidgetSpec;
use shuma_exec::StageSpec;
use shuma_line::{split_pipeline, tokenize, Dialect, TokenKind};
use crate::Msg;
/// Alto máximo del drawer, como fracción de la pantalla.
const DRAWER_FRAC: f32 = 0.45;
/// Una línea de salida con su naturaleza, para colorearla en la card.
#[derive(Clone, Debug)]
pub struct OutLine {
/// `true` si vino por stderr (se pinta en color de error).
pub err: bool,
pub text: String,
}
/// Una *card* del drawer: un comando ejecutado con sus etapas de pipe, su
/// salida y su código de salida. Es el modelo de paridad con el shell de
/// shuma (cards + etapas clickeables), que el render del marco pinta.
#[derive(Clone, Debug)]
pub struct DrawerBlock {
/// La línea tal cual se tecleó.
pub cmd: String,
/// Etiquetas de cada etapa del pipe (de `shuma-line`) — chips clickeables
/// que revelan la salida capturada (tee) de esa etapa intermedia.
pub stages: Vec<String>,
/// Líneas de salida (stdout/stderr entremezcladas en orden de llegada).
/// Es la salida de la **última** etapa del pipe.
pub lines: Vec<OutLine>,
/// Salida capturada de cada etapa **intermedia** del pipe (tee), indexada
/// por etapa. La última etapa no tiene entrada propia: su stdout es
/// `lines`. Vacío si la línea no corrió como pipe directo simple.
pub stage_lines: Vec<Vec<OutLine>>,
/// Etapa cuya salida capturada está revelada inline (`None` = ninguna).
pub expanded_stage: Option<usize>,
/// Código de salida; `None` mientras el comando sigue corriendo.
pub exit: Option<i32>,
/// `true` si la card está plegada (sólo se ve el encabezado).
pub collapsed: bool,
/// `true` si la card es una consulta a la **IA** (no un comando shell): el
/// encabezado muestra `✦ <prompt>` sin chips de etapa y el cuerpo es la
/// respuesta del modelo. Lo setea [`ShumaState::push_pending_ia`].
pub is_ia: bool,
}
/// El resultado estructurado de una corrida — lo que un hilo de fondo manda de
/// vuelta para rellenar la card pendiente.
#[derive(Clone, Debug)]
pub struct RunResult {
pub lines: Vec<OutLine>,
pub exit: Option<i32>,
/// Salida capturada por etapa intermedia (tee), indexada por etapa. Vacío
/// si la línea no corrió como pipe directo simple (cayó a `sh -c`).
pub stages: Vec<Vec<OutLine>>,
}
/// Las etiquetas de las etapas de pipe de una línea (vía `shuma-line`): el
/// `comando` de cada etapa, o el texto crudo si no se reconoció. Vacío si la
/// línea no tiene pipe (una sola etapa no amerita chips).
pub fn stage_labels(cmd: &str) -> Vec<String> {
let pipeline = split_pipeline(&tokenize(cmd, Dialect::Bash));
if pipeline.stages.len() < 2 {
return Vec::new();
}
pipeline
.stages
.iter()
.map(|s| {
s.command.clone().unwrap_or_else(|| {
// Sin comando reconocido: el primer argumento, o «·».
s.args.first().cloned().unwrap_or_else(|| "·".into())
})
})
.collect()
}
/// Si `cmd` es un pipe «simple» (sólo comandos/args/flags y `|`, sin comillas,
/// variables, redirecciones, globs ni `~`), lo descompone en [`StageSpec`] para
/// correrlo por `Exec::Direct` con **captura por etapa** (tee). Si no, `None`
/// (cae a `sh -c`, sin tee). Espeja `shuma-module-shell::simple_pipe_stages`:
/// cualquier sintaxis que el modo directo no absorbe va al shell.
pub fn simple_pipe_stages(cmd: &str) -> Option<Vec<StageSpec>> {
let tokens = tokenize(cmd, Dialect::Bash);
let simple = !tokens.is_empty()
&& tokens.iter().all(|t| {
matches!(
t.kind,
TokenKind::Command
| TokenKind::Argument
| TokenKind::Flag
| TokenKind::Pipe
| TokenKind::Whitespace
) && !t.text.contains(['*', '?', '[', ']', '{', '}'])
&& !t.text.starts_with('~')
});
if !simple {
return None;
}
let pipeline = split_pipeline(&tokens);
if pipeline.stages.len() < 2 {
return None;
}
let mut stages = Vec::with_capacity(pipeline.stages.len());
for st in &pipeline.stages {
// Una etapa sin comando (línea incompleta, p. ej. termina en `|`)
// → al shell, que reporta el error de sintaxis como toca.
let program = st.command.clone()?;
stages.push(StageSpec { program, args: st.args.clone() });
}
Some(stages)
}
/// El estado del cabezal del shell y su drawer. Vive en el `Model` del frontend
/// —es interacción, no modelo de dominio—, no en `pata-core`.
pub struct ShumaState {
/// `true` cuando el drawer está desplegado.
pub open: bool,
/// El comando que se está escribiendo.
pub buffer: String,
/// Historial de comandos del drawer, uno por *card* — paridad con el
/// shell de shuma (cada `$ cmd` con sus etapas de pipe, su salida y su
/// código). El más reciente va al final.
pub blocks: Vec<DrawerBlock>,
/// `true` mientras el comando corre en segundo plano.
pub pending: bool,
/// Desplazamiento del historial, en px desde arriba (0 = la card más
/// vieja al tope; el máximo deja la más nueva pegada al fondo). El render
/// lo acota; al lanzar/terminar un comando salta al fondo.
pub scroll: f32,
/// Alto del viewport del historial en px — lo cachea el backend en cada
/// render para que el clamp del scroll (en `update`) sea exacto.
pub viewport_h: f32,
/// Hotkey que abre/cierra el drawer (de la prop `hotkey`), o `None`.
pub hotkey: Option<String>,
/// Prompt al frente del input (``, `$`, …).
pub prompt: String,
/// Placeholder cuando el buffer está vacío.
pub placeholder: String,
/// Animación de despliegue `0..1` (0 = replegado, 1 = desplegado).
pub anim: Tween<f32>,
/// `true` si el config declaró algún `shuma_input` (si no, no hay cabezal
/// ni drawer).
pub present: bool,
}
/// Métricas de layout del historial, en px — deben seguir al render
/// (`card_view`) para que el alto estimado sea fiel y el scroll acote bien.
const HEADER_H: f32 = 22.0;
const LINE_H: f32 = 18.0;
const CARD_GAP: f32 = 10.0;
const BODY_GAP: f32 = 2.0;
impl ShumaState {
/// Tope de cards en el historial — más allá, las viejas se descartan.
const MAX_BLOCKS: usize = 12;
/// Alto total estimado del historial, en px — base del clamp del scroll y
/// de la barra. Estimado desde el modelo con [`HEADER_H`]/[`LINE_H`] (el
/// render no devuelve medidas); fiel mientras `card_view` no cambie.
pub fn content_height(&self) -> f32 {
if self.blocks.is_empty() {
return LINE_H; // la pista de uso
}
let mut h = 0.0;
for b in &self.blocks {
h += HEADER_H;
if !b.collapsed {
let mut lines = b.lines.len();
if b.exit.is_none() {
lines += 1; // la línea "…"
}
if matches!(b.exit, Some(c) if c != 0) {
lines += 1; // el footer "exit N"
}
// La etapa revelada (tee) agrega su salida capturada, o una
// línea de aviso si no capturó nada.
if let Some(si) = b.expanded_stage {
let n = b.stage_lines.get(si).map(|l| l.len()).unwrap_or(0);
lines += n.max(1);
}
h += lines as f32 * LINE_H + BODY_GAP;
}
h += CARD_GAP;
}
h
}
/// Suma `delta` px al scroll y lo acota al viewport cacheado. Lo llama el
/// `update` al recibir la rueda o el arrastre de la barra.
pub fn scroll_by(&mut self, delta: f32) {
self.scroll = clamp_offset(self.scroll + delta, self.content_height(), self.viewport_h);
}
/// Pega el scroll al fondo (la card más nueva). Se llama al lanzar y al
/// terminar un comando para que la salida fresca quede a la vista; el
/// render lo acota al máximo real.
fn pin_bottom(&mut self) {
self.scroll = self.content_height();
}
/// Empuja una card nueva en estado «corriendo» para `cmd` (con sus etapas
/// de pipe ya resueltas) y marca el drawer como pendiente. Acota el
/// historial a [`MAX_BLOCKS`]. Lo usan ambos backends al lanzar.
pub fn push_pending(&mut self, cmd: String) {
let stages = stage_labels(&cmd);
self.blocks.push(DrawerBlock {
cmd,
stages,
lines: Vec::new(),
stage_lines: Vec::new(),
expanded_stage: None,
exit: None,
collapsed: false,
is_ia: false,
});
self.trim_and_pin();
}
/// Empuja una card de consulta a la **IA** para `prompt` y marca el drawer
/// como pendiente. Sin chips de etapa (un prompt no es un pipe). El
/// resultado llega por [`finish_last`], igual que un comando shell.
pub fn push_pending_ia(&mut self, prompt: String) {
self.blocks.push(DrawerBlock {
cmd: prompt,
stages: Vec::new(),
lines: Vec::new(),
stage_lines: Vec::new(),
expanded_stage: None,
exit: None,
collapsed: false,
is_ia: true,
});
self.trim_and_pin();
}
/// Acota el historial a [`MAX_BLOCKS`] (descarta las cards más viejas),
/// marca pendiente y pega el scroll al fondo. Compartido por los dos
/// `push_pending*`.
fn trim_and_pin(&mut self) {
if self.blocks.len() > Self::MAX_BLOCKS {
let drop = self.blocks.len() - Self::MAX_BLOCKS;
self.blocks.drain(0..drop);
}
self.pending = true;
self.pin_bottom();
}
/// Rellena la última card (la pendiente) con el resultado de la corrida.
pub fn finish_last(&mut self, res: RunResult) {
self.pending = false;
if let Some(b) = self.blocks.last_mut() {
b.lines = res.lines;
b.stage_lines = res.stages;
b.exit = res.exit;
}
self.pin_bottom();
}
}
impl Default for ShumaState {
fn default() -> Self {
Self {
open: false,
buffer: String::new(),
blocks: Vec::new(),
pending: false,
scroll: 0.0,
viewport_h: 300.0,
hotkey: None,
prompt: "".into(),
placeholder: "shuma".into(),
anim: Tween::idle(0.0),
present: false,
}
}
}
impl ShumaState {
/// Construye el estado desde la spec del `shuma_input` (prompt/placeholder/
/// hotkey). Marca `present = true`.
pub fn from_spec(spec: &WidgetSpec) -> Self {
let hotkey = spec.str_prop("hotkey", "");
Self {
prompt: spec.str_prop("prompt", "").to_string(),
placeholder: spec.str_prop("placeholder", "shuma").to_string(),
hotkey: if hotkey.is_empty() {
None
} else {
Some(hotkey.to_string())
},
present: true,
..Self::default()
}
}
/// `true` si el drawer debe pintarse (abierto o aún animando el cierre).
pub fn visible(&self) -> bool {
self.open || self.anim.value() > 0.01
}
}
/// El cabezal clicable que va en la barra: prompt + buffer/placeholder. Un click
/// despliega el drawer.
pub fn headline_view(state: &ShumaState, theme: &Theme) -> View<Msg> {
let texto = if state.buffer.is_empty() {
state.placeholder.clone()
} else {
state.buffer.clone()
};
let color = if state.buffer.is_empty() {
theme.fg_muted
} else {
theme.fg_text
};
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: length(220.0_f32),
height: length(24.0_f32),
},
padding: TaffyRect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.radius(6.0)
.hover_fill(theme.bg_button_hover)
.on_click(Msg::ShumaToggle)
.children(vec![
chip_text(&state.prompt, 13.0, theme.accent),
chip_text(&texto, 13.0, color),
])
}
/// El drawer desplegado: scrim que cierra al click + panel inferior con el input
/// y la salida. `None` si no hay nada que mostrar.
pub fn drawer_overlay(state: &ShumaState, screen: (i32, i32), theme: &Theme) -> Option<View<Msg>> {
if !state.visible() {
return None;
}
let t = state.anim.value().clamp(0.0, 1.0);
let (_sw, sh) = screen;
let alto = (sh as f32 * DRAWER_FRAC * t).max(1.0);
// Línea del input: prompt + buffer + cursor.
let linea = {
let mut s = state.buffer.clone();
s.push('▏'); // cursor
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![
chip_text(&state.prompt, 16.0, theme.accent),
chip_text(&s, 16.0, theme.fg_text),
])
};
// Cuerpo: las cards del historial (paridad con el shell de shuma). El
// viewport es el panel menos la línea de input y los paddings.
let cuerpo = blocks_view(state, theme, (alto - 76.0).max(40.0));
let panel = View::new(Style {
position: Position::Absolute,
inset: TaffyRect {
left: length(0.0_f32),
right: length(0.0_f32),
top: auto(),
bottom: length(0.0_f32),
},
size: Size {
width: percent(1.0_f32),
height: length(alto),
},
flex_direction: FlexDirection::Column,
padding: TaffyRect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(12.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![linea, cuerpo]);
// Scrim a pantalla completa: oscurece el fondo y cierra al click.
let scrim = View::new(Style {
position: Position::Absolute,
inset: TaffyRect {
left: length(0.0_f32),
top: length(0.0_f32),
right: length(0.0_f32),
bottom: length(0.0_f32),
},
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.alpha(0.55 * t)
.on_click(Msg::ShumaToggle)
.children(vec![panel]);
Some(scrim)
}
/// El **cuerpo** del drawer (sin scrim ni posición absoluta), pensado para
/// llenar el contenedor que le da el backend `wlr-layer-shell`: ahí la propia
/// layer surface ya *es* el panel del Quake (la barra crece hacia arriba), así
/// que no hace falta scrim ni animación. Línea de input (prompt + buffer +
/// cursor) arriba, salida del último comando debajo.
pub fn drawer_body_view(state: &ShumaState, theme: &Theme, viewport_h: f32) -> View<Msg> {
let mut buf = state.buffer.clone();
buf.push('▏'); // cursor
let linea = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![
chip_text(&state.prompt, 16.0, theme.accent),
chip_text(&buf, 16.0, theme.fg_text),
]);
let cuerpo = blocks_view(state, theme, viewport_h);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: TaffyRect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(12.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![linea, cuerpo])
}
/// El cuerpo del drawer: las cards del historial (paridad con el shell de
/// shuma), o la pista de uso si no hay ninguna. Lo comparten los dos backends
/// (winit con scrim y layer-shell). Cada card es un `$ cmd` con sus etapas de
/// pipe clickeables, su salida coloreada por stdout/stderr y su código.
fn blocks_view(state: &ShumaState, theme: &Theme, viewport_h: f32) -> View<Msg> {
let col = Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: auto() },
gap: Size { width: length(0.0_f32), height: length(CARD_GAP) },
..Default::default()
};
let content = if state.blocks.is_empty() {
View::new(col).text(
"Enter pregunta a la IA · prefijo ! o $ corre shell · Esc cierra".to_string(),
13.0,
theme.fg_muted,
)
} else {
let cards = state
.blocks
.iter()
.enumerate()
.map(|(i, b)| card_view(i, b, theme))
.collect();
View::new(col).children(cards)
};
// Área de scroll: el contenido se desplaza y se recorta al viewport; la
// rueda y el arrastre de la barra emiten `ShumaScroll(delta)`. El offset
// se acota acá mismo (el modelo puede guardar uno mayor sin romper nada).
let content_h = state.content_height();
let offset = clamp_offset(state.scroll, content_h, viewport_h);
scroll_y(
offset,
content_h,
viewport_h,
content,
Msg::ShumaScroll,
&ScrollPalette::from_theme(theme),
)
}
/// Una card: encabezado (`$` plegable + etapas de pipe clickeables) y, si no
/// está plegada, la salida de la etapa revelada (tee), las líneas de salida
/// final y el código de salida.
fn card_view(idx: usize, b: &DrawerBlock, theme: &Theme) -> View<Msg> {
// Encabezado: el `$` pliega/despliega; las chips de etapa **intermedia**
// revelan su salida capturada en vivo (tee). La última etapa no se captura
// aparte —su stdout es el cuerpo de la card— así que su chip es pasiva.
// Card de IA: marca `✦` + el prompt, sin chips de etapa.
if b.is_ia {
let header = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(22.0_f32) },
align_items: Some(AlignItems::Center),
gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
..Default::default()
})
.children(vec![
chip_text("", 14.0, theme.accent).on_click(Msg::ShumaCollapse(idx)),
chip_text(&b.cmd, 14.0, theme.fg_text).on_click(Msg::ShumaCollapse(idx)),
]);
let mut col: Vec<View<Msg>> = vec![header];
if !b.collapsed {
if b.exit.is_none() {
col.push(out_line("…pensando", theme.fg_muted));
}
for l in &b.lines {
let c = if l.err { theme.fg_destructive } else { theme.fg_text };
col.push(out_line(&l.text, c));
}
}
return View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: auto() },
gap: Size { width: length(0.0_f32), height: length(2.0_f32) },
..Default::default()
})
.children(col);
}
let mut head: Vec<View<Msg>> = vec![chip_text("$", 14.0, theme.accent)
.on_click(Msg::ShumaCollapse(idx))];
if b.stages.is_empty() {
// Sin pipe: la línea entera re-ejecuta al clickearla.
head.push(chip_text(&b.cmd, 14.0, theme.fg_text).on_click(Msg::ShumaRunLine(b.cmd.clone())));
} else {
let last = b.stages.len() - 1;
for (si, label) in b.stages.iter().enumerate() {
if si > 0 {
head.push(chip_text("|", 14.0, theme.fg_muted));
}
// La etapa revelada se resalta (más clara); clic togglea su revelado.
let revealed = b.expanded_stage == Some(si);
let color = if revealed { theme.fg_text } else { theme.accent };
let chip = chip_text(label, 14.0, color);
head.push(if si < last {
chip.on_click(Msg::ShumaStageToggle(idx, si)).hover_fill(theme.bg_button_hover)
} else {
chip // la última etapa: su salida es el cuerpo de la card
});
}
}
let header = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(22.0_f32) },
align_items: Some(AlignItems::Center),
gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
..Default::default()
})
.children(head);
let mut col: Vec<View<Msg>> = vec![header];
if !b.collapsed {
// Salida capturada de la etapa revelada (tee), indentada y atenuada.
if let Some(si) = b.expanded_stage {
match b.stage_lines.get(si) {
Some(lines) if !lines.is_empty() => {
for l in lines {
let c = if l.err { theme.fg_destructive } else { theme.fg_muted };
col.push(out_line(&format!(" {}", l.text), c));
}
}
_ => col.push(out_line(" (sin salida capturada)", theme.fg_muted)),
}
}
if b.exit.is_none() {
col.push(out_line("", theme.fg_muted));
}
for l in &b.lines {
let c = if l.err { theme.fg_destructive } else { theme.fg_text };
col.push(out_line(&l.text, c));
}
if let Some(code) = b.exit {
if code != 0 {
col.push(out_line(&format!("exit {code}"), theme.fg_destructive));
}
}
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: auto() },
gap: Size { width: length(0.0_f32), height: length(2.0_f32) },
..Default::default()
})
.children(col)
}
/// Una línea de salida a ancho completo.
fn out_line(t: &str, color: llimphi_theme::Color) -> View<Msg> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: auto() },
..Default::default()
})
.text(t.to_string(), 13.0, color)
}
/// Un texto suelto, centrado verticalmente.
fn chip_text(t: &str, size: f32, color: llimphi_theme::Color) -> View<Msg> {
View::new(Style {
size: Size {
width: auto(),
height: length(size + 6.0),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(t.to_string(), size, color)
}
/// El puente pata→shuma (SDD §5): ejecuta `cmd` por el **ejecutor real de
/// shuma** (`shuma-exec`) y devuelve su stdout, o el stderr/código como error.
/// Reúne los eventos en streaming hasta el final; la captura está acotada a
/// [`CAPTURE_CAP`] (lo que excede se marca como truncado). Bloqueante: se
/// llama desde un hilo de fondo (`Handle::spawn` o `std::thread`).
pub fn ejecutar(cmd: &str) -> RunResult {
use shuma_exec::{run, CommandSpec, Exec, RunEvent};
let cwd = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "/".to_string());
// Pipe «simple» → ejecución directa con **captura por etapa** (tee): cada
// etapa intermedia emite su stdout en vivo, sin re-ejecutar (paridad con el
// shell de shuma). Cualquier otra sintaxis cae a `sh -c`, sin tee.
let (spec, n_stages) = match simple_pipe_stages(cmd) {
Some(stages) => {
let n = stages.len();
let spec = CommandSpec {
exec: Exec::Direct { stages },
cwd,
capture_limit: CAPTURE_CAP,
spill_path: None,
stdin_data: None,
capture_stages: true,
};
(spec, n)
}
None => (CommandSpec::shell(cmd, cwd).with_limit(CAPTURE_CAP), 0),
};
let mut lines: Vec<OutLine> = Vec::new();
let mut exit: Option<i32> = None;
let mut stages: Vec<Vec<OutLine>> = vec![Vec::new(); n_stages];
for ev in run(&spec).wait_all() {
match ev {
RunEvent::Stdout(t) => lines.push(OutLine { err: false, text: t }),
// Salida de una etapa intermedia (tee): a su balde, para revelarla
// al clickear la chip de esa etapa. La última etapa va por Stdout.
RunEvent::StageStdout { stage, line } => {
if let Some(buf) = stages.get_mut(stage) {
buf.push(OutLine { err: false, text: line });
}
}
RunEvent::Stderr(t) => lines.push(OutLine { err: true, text: t }),
RunEvent::Exited(c) => exit = Some(c),
RunEvent::Failed(m) => lines.push(OutLine {
err: true,
text: format!("no pude lanzar: {m}"),
}),
RunEvent::Truncated => lines.push(OutLine {
err: true,
text: format!("… (salida truncada a {CAPTURE_CAP} bytes)"),
}),
RunEvent::Spilled(p) => lines.push(OutLine {
err: false,
text: format!("… (salida volcada a {p})"),
}),
// Sólo aparece en modo PTY; el drawer no lo usa.
RunEvent::Bytes(_) => {}
}
}
RunResult { lines, exit, stages }
}
/// Tope de captura del drawer, en bytes — un comando charlatán no infla la RAM.
const CAPTURE_CAP: usize = 256 * 1024;
/// Cómo se clasifica el buffer al hacer submit en el drawer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubmitKind<'a> {
/// Buffer vacío (o sólo espacios): no se hace nada.
Empty,
/// Comando shell — el prefijo explícito `!`/`$` lo fuerza.
Shell(&'a str),
/// Prompt para la IA — el default cuando no hay prefijo.
Ia(&'a str),
}
/// Clasifica el buffer al submit (paridad con el quake de mirada-launcher):
/// `!cmd` o `$cmd` van al shell; vacío no hace nada; **todo lo demás es un
/// prompt para la IA**. Así el drawer es a la vez terminal y asistente.
pub fn classify(buffer: &str) -> SubmitKind<'_> {
let trimmed = buffer.trim();
if let Some(rest) = trimmed.strip_prefix('!').or_else(|| trimmed.strip_prefix('$')) {
SubmitKind::Shell(rest.trim())
} else if trimmed.is_empty() {
SubmitKind::Empty
} else {
SubmitKind::Ia(trimmed)
}
}
/// Consulta a la IA bloqueando — pensado para un hilo de fondo (`Handle::spawn`).
/// `pluma_llm::from_env` autodetecta el backend y cae a Mock sin credenciales
/// (eco determinista, útil para iterar sin red). Devuelve un [`RunResult`] para
/// rellenar la card pendiente igual que un comando: la respuesta va a `lines`
/// (una por renglón), un error va como línea de stderr + `exit 1`.
pub fn preguntar_ia(prompt: &str) -> RunResult {
use pluma_llm::pluma_llm_core::ChatRequest;
let resultado = (|| -> Result<String, String> {
let cli = pluma_llm::from_env().map_err(|e| format!("{e}"))?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("{e}"))?;
rt.block_on(async {
let req = ChatRequest::una_vuelta(prompt, 256)
.con_sistema("Sos un asistente conciso del escritorio. Responde corto.");
cli.complete(&req)
.await
.map(|r| r.content)
.map_err(|e| format!("{e}"))
})
})();
match resultado {
Ok(texto) => RunResult {
lines: texto
.lines()
.map(|l| OutLine { err: false, text: l.to_string() })
.collect(),
exit: Some(0),
stages: Vec::new(),
},
Err(e) => RunResult {
lines: vec![OutLine { err: true, text: e }],
exit: Some(1),
stages: Vec::new(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn una_linea_sin_pipe_no_tiene_chips() {
assert!(stage_labels("ls -la").is_empty());
assert!(stage_labels("").is_empty());
}
#[test]
fn las_etapas_de_un_pipe_son_los_comandos() {
assert_eq!(
stage_labels("ls -la | grep foo | sort"),
vec!["ls".to_string(), "grep".to_string(), "sort".to_string()]
);
}
#[test]
fn classify_rutea_prefijo_a_shell_y_resto_a_ia() {
assert_eq!(classify(""), SubmitKind::Empty);
assert_eq!(classify(" "), SubmitKind::Empty);
assert_eq!(classify("!firefox"), SubmitKind::Shell("firefox"));
assert_eq!(classify("$ ls -la"), SubmitKind::Shell("ls -la"));
assert_eq!(classify("qué hora es?"), SubmitKind::Ia("qué hora es?"));
assert_eq!(classify(" hola "), SubmitKind::Ia("hola"));
}
#[test]
fn push_pending_ia_marca_la_card_sin_etapas() {
let mut s = ShumaState::default();
s.push_pending_ia("explicá el kernel".into());
let last = s.blocks.last().unwrap();
assert!(last.is_ia);
assert!(last.stages.is_empty());
assert!(last.exit.is_none() && s.pending);
}
#[test]
fn un_pipe_simple_se_descompone_en_etapas() {
let stages = simple_pipe_stages("ls -la | grep foo | sort").expect("pipe simple");
assert_eq!(stages.len(), 3);
assert_eq!(stages[0].program, "ls");
assert_eq!(stages[0].args, vec!["-la".to_string()]);
assert_eq!(stages[1].program, "grep");
assert_eq!(stages[2].program, "sort");
}
#[test]
fn sin_pipe_o_con_sintaxis_compleja_no_hay_etapas_directas() {
// Una sola etapa: no amerita modo directo.
assert!(simple_pipe_stages("ls -la").is_none());
// Comillas, variables, redirecciones, globs o `~` → al shell (sin tee).
assert!(simple_pipe_stages("echo \"hola\" | cat").is_none());
assert!(simple_pipe_stages("cat *.rs | wc -l").is_none());
assert!(simple_pipe_stages("echo $HOME | cat").is_none());
// Línea incompleta (termina en `|`).
assert!(simple_pipe_stages("ls |").is_none());
}
#[test]
fn push_pending_resuelve_etapas_y_acota_el_historial() {
let mut s = ShumaState::default();
s.push_pending("a | b".into());
let last = s.blocks.last().unwrap();
assert_eq!(last.stages, vec!["a".to_string(), "b".to_string()]);
assert!(last.exit.is_none() && s.pending);
// Más allá del tope, las viejas se descartan.
for _ in 0..ShumaState::MAX_BLOCKS + 5 {
s.push_pending("x".into());
}
assert_eq!(s.blocks.len(), ShumaState::MAX_BLOCKS);
}
#[test]
fn finish_last_rellena_la_card_pendiente() {
let mut s = ShumaState::default();
s.push_pending("echo hi".into());
s.finish_last(RunResult {
lines: vec![OutLine { err: false, text: "hi".into() }],
exit: Some(0),
stages: Vec::new(),
});
let last = s.blocks.last().unwrap();
assert_eq!(last.exit, Some(0));
assert_eq!(last.lines.len(), 1);
assert!(!s.pending);
}
#[test]
fn finish_last_guarda_la_salida_por_etapa() {
let mut s = ShumaState::default();
s.push_pending("seq 3 | sort".into());
s.finish_last(RunResult {
lines: vec![OutLine { err: false, text: "1".into() }],
exit: Some(0),
stages: vec![
vec![OutLine { err: false, text: "3".into() }],
Vec::new(),
],
});
let last = s.blocks.last().unwrap();
assert_eq!(last.stage_lines.len(), 2);
assert_eq!(last.stage_lines[0][0].text, "3");
// Revelar la etapa 0 agranda el contenido (entra su salida capturada).
let antes = s.content_height();
s.blocks.last_mut().unwrap().expanded_stage = Some(0);
assert!(s.content_height() > antes);
}
}
#[cfg(test)]
mod scroll_tests {
use super::*;
#[test]
fn content_height_crece_con_cards_y_lineas() {
let mut s = ShumaState::default();
let vacio = s.content_height();
s.push_pending("a".into());
s.finish_last(RunResult {
lines: vec![OutLine { err: false, text: "x".into() }; 3],
exit: Some(0),
stages: Vec::new(),
});
assert!(s.content_height() > vacio);
}
#[test]
fn scroll_by_acota_a_cero_y_al_maximo() {
let mut s = ShumaState::default();
s.viewport_h = 50.0;
// Historial alto: varias cards con salida.
for _ in 0..6 {
s.push_pending("cmd".into());
s.finish_last(RunResult {
lines: vec![OutLine { err: false, text: "l".into() }; 4],
exit: Some(0),
stages: Vec::new(),
});
}
let max = (s.content_height() - s.viewport_h).max(0.0);
s.scroll = 0.0;
s.scroll_by(-100.0); // no baja de 0
assert_eq!(s.scroll, 0.0);
s.scroll_by(1e6); // no pasa del máximo
assert!((s.scroll - max).abs() < 0.01);
}
#[test]
fn lanzar_un_comando_pega_el_scroll_al_fondo() {
let mut s = ShumaState::default();
s.viewport_h = 40.0;
for _ in 0..6 {
s.push_pending("c".into());
s.finish_last(RunResult { lines: vec![], exit: Some(0), stages: Vec::new() });
}
s.scroll = 0.0; // el usuario subió a ver lo viejo
s.push_pending("nuevo".into()); // un comando nuevo
// El scroll quedó pegado al fondo (>= el máximo tras el clamp del render).
assert!(s.scroll >= (s.content_height() - s.viewport_h).max(0.0));
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Seguimiento de ventanas abiertas vía **wlr-foreign-toplevel-management**, el
//! protocolo que usan waybar/eww para enumerar y activar toplevels en cualquier
//! compositor wlroots (Hyprland, Sway, river…).
//!
//! El compositor anuncia un `zwlr_foreign_toplevel_handle_v1` por cada ventana y
//! le manda sus atributos (título, app_id, estado) en eventos sueltos que se
//! confirman con `done`. Aquí acumulamos esos atributos en un [`Toplevel`] y los
//! aplicamos de golpe al recibir `done`, para no pintar estados a medias. El
//! cableado Wayland (los `Dispatch`) vive en [`crate::layer`], que es quien tiene
//! el `QueueHandle`; este módulo sólo modela el dato.
//!
//! La activación —traer la ventana al frente— es interacción, igual que el
//! `shuma_input`: por eso `window_list` no pasa por el `build` agnóstico de
//! `pata-core`, sino que lo intercepta el frontend (ver [`crate::SlotWidget`]).
use wayland_protocols_wlr::foreign_toplevel::v1::client::zwlr_foreign_toplevel_handle_v1::{
State, ZwlrForeignToplevelHandleV1,
};
/// El bit `activated` del array de estados que manda el evento `state`.
const ESTADO_ACTIVADO: u32 = State::Activated as u32;
/// El bit `minimized` del array de estados.
const ESTADO_MINIMIZADO: u32 = State::Minimized as u32;
/// Una ventana reportada por el compositor. Los campos `p_*` acumulan lo que
/// llega entre `done`s; [`Toplevel::confirmar`] los vuelca a los definitivos.
pub struct Toplevel {
/// Identificador estable que viaja en [`crate::Msg::ActivateWindow`]. Es un
/// contador local (no el ObjectId, que no es `Clone`-friendly para el `Msg`).
pub id: u32,
/// El handle del protocolo: por él se activa/cierra la ventana.
pub handle: ZwlrForeignToplevelHandleV1,
/// Título de la ventana, ya confirmado.
pub title: String,
/// `app_id` (clase de la app), ya confirmado.
pub app_id: String,
/// `true` si es la ventana activa.
pub activated: bool,
/// `true` si la ventana está minimizada (para atenuarla en la barra).
pub minimized: bool,
p_title: Option<String>,
p_app_id: Option<String>,
p_activated: Option<bool>,
p_minimized: Option<bool>,
}
impl Toplevel {
/// Una ventana recién anunciada, todavía sin atributos.
pub fn new(id: u32, handle: ZwlrForeignToplevelHandleV1) -> Self {
Self {
id,
handle,
title: String::new(),
app_id: String::new(),
activated: false,
minimized: false,
p_title: None,
p_app_id: None,
p_activated: None,
p_minimized: None,
}
}
/// Guarda el título pendiente (evento `title`).
pub fn set_title(&mut self, title: String) {
self.p_title = Some(title);
}
/// Guarda el `app_id` pendiente (evento `app_id`).
pub fn set_app_id(&mut self, app_id: String) {
self.p_app_id = Some(app_id);
}
/// Decodifica el array de estados (`u32` little-endian empaquetados en bytes)
/// y registra si la ventana quedó activa (evento `state`).
pub fn set_state(&mut self, bytes: &[u8]) {
let tiene = |bit: u32| {
bytes
.chunks_exact(4)
.any(|c| u32::from_ne_bytes([c[0], c[1], c[2], c[3]]) == bit)
};
self.p_activated = Some(tiene(ESTADO_ACTIVADO));
self.p_minimized = Some(tiene(ESTADO_MINIMIZADO));
}
/// Aplica lo acumulado (evento `done`). Devuelve `true` si algo cambió, para
/// que el caller sepa si tiene que re-pintar.
pub fn confirmar(&mut self) -> bool {
let mut cambio = false;
if let Some(t) = self.p_title.take() {
if t != self.title {
self.title = t;
cambio = true;
}
}
if let Some(a) = self.p_app_id.take() {
if a != self.app_id {
self.app_id = a;
cambio = true;
}
}
if let Some(act) = self.p_activated.take() {
if act != self.activated {
self.activated = act;
cambio = true;
}
}
if let Some(m) = self.p_minimized.take() {
if m != self.minimized {
self.minimized = m;
cambio = true;
}
}
cambio
}
/// La etiqueta a mostrar: el título si lo hay, si no el `app_id`, si no un
/// genérico. Nunca vacía (un chip vacío no se podría clickear).
pub fn etiqueta(&self) -> String {
if !self.title.is_empty() {
self.title.clone()
} else if !self.app_id.is_empty() {
self.app_id.clone()
} else {
"ventana".to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::WindowEntry;
fn entry(app_id: &str, label: &str) -> WindowEntry {
WindowEntry {
id: 0,
label: label.into(),
app_id: app_id.into(),
active: false,
minimized: false,
}
}
#[test]
fn inicial_toma_la_primera_alfanumerica_del_app_id() {
assert_eq!(entry("firefox", "Mozilla").inicial(), "F");
assert_eq!(entry("org.kde.konsole", "Konsole").inicial(), "O");
// Sin app_id cae al título.
assert_eq!(entry("", "Documento").inicial(), "D");
// Sin nada alfanumérico, un punto medio.
assert_eq!(entry("", "").inicial(), "·");
assert_eq!(entry(" ", "").inicial(), "·");
}
}
/// Lo que el render necesita de cada ventana: el `id` para el click, la etiqueta
/// y si está activa (para resaltarla). Desacopla el pincel del protocolo Wayland.
#[derive(Clone, Debug)]
pub struct WindowEntry {
/// Identificador estable (el de [`Toplevel::id`]).
pub id: u32,
/// Texto a pintar en el chip.
pub label: String,
/// `app_id` de la ventana — para el ícono-badge del task manager.
pub app_id: String,
/// `true` si es la ventana activa (chip resaltado).
pub active: bool,
/// `true` si está minimizada (chip atenuado).
pub minimized: bool,
}
impl WindowEntry {
/// La inicial para el ícono-badge: primera letra del `app_id` (o del título
/// si no hay `app_id`), en mayúscula. `"·"` si no hay nada.
pub fn inicial(&self) -> String {
let fuente = if !self.app_id.is_empty() {
&self.app_id
} else {
&self.label
};
fuente
.chars()
.find(|c| c.is_alphanumeric())
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "·".to_string())
}
}
+452
View File
@@ -0,0 +1,452 @@
//! Bandeja del sistema (`tray`) vía **StatusNotifierItem**, el protocolo D-Bus de
//! KDE/freedesktop que usan los applets modernos (nm-applet, blueman, clientes de
//! chat…).
//!
//! pata actúa como **watcher + host**: posee el nombre well-known
//! `org.kde.StatusNotifierWatcher` y atiende a las apps que registran su item.
//! Como el bucle de pata es bloqueante (sctk) y zbus es async, el tray corre en su
//! **propio hilo** con un runtime tokio current-thread (el workspace fija zbus con
//! la feature `tokio`, no la blocking — mismo patrón que `mirada-portal`). Comparte
//! el snapshot de items con el bucle por `Arc<Mutex>` y recibe los clicks por un
//! canal (como el exec asíncrono del Quake).
//!
//! Alcance del MVP (todo runtime, no verificable sin un Hyprland real):
//! - **Enumera** los items y los **activa** al click (`Activate(0,0)`).
//! - **Íconos**: resuelve el `IconPixmap` (ARGB32 que manda la app por D-Bus) y,
//! si no hay, busca el `IconName` como PNG en los directorios estándar de íconos
//! (búsqueda acotada: hicolor + pixmaps, sólo PNG, sin parsear `index.theme` ni
//! SVG). Si nada resuelve, cae a una etiqueta de texto (título o id).
//! - **No** emite las señales del watcher (sólo le importan a *otros* hosts) ni
//! provee fallback si ya hay un watcher corriendo: si el nombre está tomado, el
//! tray queda vacío y se loguea (el caso de pata como barra única no lo tiene).
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::mpsc;
use zbus::message::Header;
use zbus::{interface, proxy};
/// Un ícono ya decodificado a RGBA8, listo para que el render lo envuelva en una
/// `peniko::Image`. El render no toca D-Bus ni decodifica; sólo pinta.
#[derive(Clone, Debug)]
pub struct TrayIcon {
/// Ancho en píxeles.
pub width: u32,
/// Alto en píxeles.
pub height: u32,
/// Píxeles RGBA8 (`width*height*4` bytes).
pub rgba: Vec<u8>,
}
/// Lo que el render necesita de cada item del tray. `key` (`"bus|path"`) rutea la
/// activación de vuelta al hilo del tray.
#[derive(Clone, Debug)]
pub struct TrayItem {
/// Clave estable `"bus|path"` para la activación.
pub key: String,
/// Texto a pintar (título, o id si no hay título).
pub label: String,
/// Estado SNI (`Active` / `Passive` / `NeedsAttention`).
pub status: String,
/// Ícono ya decodificado, o `None` si no se pudo resolver (cae a texto).
pub icon: Option<TrayIcon>,
}
/// Estado compartido con la interfaz del watcher: los items registrados como
/// `(key, bus, path)`. Lo escribe la interfaz (en el runtime del tray) y lo lee el
/// bucle de refresco; de ahí el `Mutex`.
#[derive(Default)]
struct WatcherState {
items: Vec<(String, String, String)>,
}
/// Órdenes del bucle de pata hacia el hilo del tray. (Soltar el [`TrayHandle`]
/// cierra el canal, lo que termina el hilo: no hace falta una variante de parada.)
enum TrayCmd {
/// Activar el item con esa `key` (click).
Activate(String),
}
/// El asa que el bucle de pata conserva: lee el snapshot de items y manda clicks.
pub struct TrayHandle {
items: Arc<Mutex<Vec<TrayItem>>>,
tx: mpsc::UnboundedSender<TrayCmd>,
}
impl TrayHandle {
/// Arranca el hilo del tray. Devuelve `None` sólo si no se pudo lanzar el hilo;
/// la conexión D-Bus se intenta dentro (si falla, el hilo termina y el tray
/// queda vacío, sin romper la barra).
pub fn spawn() -> Option<Self> {
let items: Arc<Mutex<Vec<TrayItem>>> = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::unbounded_channel::<TrayCmd>();
let items_hilo = items.clone();
std::thread::Builder::new()
.name("pata-tray".into())
.spawn(move || run_tray(items_hilo, rx))
.ok()?;
Some(Self { items, tx })
}
/// El snapshot actual de items para el render.
pub fn items(&self) -> Vec<TrayItem> {
self.items.lock().map(|g| g.clone()).unwrap_or_default()
}
/// Pide activar el item `key` (no bloquea; el hilo del tray hace la llamada).
pub fn activate(&self, key: String) {
let _ = self.tx.send(TrayCmd::Activate(key));
}
}
/// La interfaz `org.kde.StatusNotifierWatcher` que pata expone. Las apps llaman a
/// `RegisterStatusNotifierItem`; guardamos el item normalizado en el estado
/// compartido. Métodos síncronos: zbus los atiende en el runtime del tray.
struct Watcher {
state: Arc<Mutex<WatcherState>>,
}
#[interface(name = "org.kde.StatusNotifierWatcher")]
impl Watcher {
/// Una app registra su item. `service` puede ser un nombre de bus, una ruta de
/// objeto (con el bus = remitente) o la forma combinada `"bus/path"`.
fn register_status_notifier_item(&self, service: &str, #[zbus(header)] hdr: Header<'_>) {
let sender = hdr.sender().map(|s| s.to_string());
if let Some((bus, path)) = split_service(service, sender.as_deref()) {
let key = format!("{bus}|{path}");
let mut st = self.state.lock().unwrap();
if !st.items.iter().any(|(k, _, _)| *k == key) {
st.items.push((key, bus, path));
}
}
}
/// Otro host se registra. pata es su propio host, así que no hace falta nada.
fn register_status_notifier_host(&self, _service: &str) {}
/// La lista de items registrados, en la forma `"bus/path"` que esperan los
/// hosts que consulten el watcher.
#[zbus(property)]
fn registered_status_notifier_items(&self) -> Vec<String> {
self.state
.lock()
.unwrap()
.items
.iter()
.map(|(_, b, p)| format!("{b}{p}"))
.collect()
}
/// Siempre `true`: pata provee el host, así que las apps deben usar SNI.
#[zbus(property)]
fn is_status_notifier_host_registered(&self) -> bool {
true
}
/// Versión del protocolo (0, como la implementación de referencia).
#[zbus(property)]
fn protocol_version(&self) -> i32 {
0
}
}
/// Cliente del item de una app: leemos sus atributos para pintarlo y lo activamos
/// al click.
#[proxy(interface = "org.kde.StatusNotifierItem", assume_defaults = false)]
trait StatusNotifierItem {
#[zbus(property)]
fn id(&self) -> zbus::Result<String>;
#[zbus(property)]
fn title(&self) -> zbus::Result<String>;
#[zbus(property)]
fn icon_name(&self) -> zbus::Result<String>;
/// Íconos embebidos: lista de `(ancho, alto, ARGB32)`. La app los manda por el
/// bus; no requieren buscar en el tema.
#[zbus(property)]
fn icon_pixmap(&self) -> zbus::Result<Vec<(i32, i32, Vec<u8>)>>;
#[zbus(property)]
fn status(&self) -> zbus::Result<String>;
/// Click primario sobre el item.
fn activate(&self, x: i32, y: i32) -> zbus::Result<()>;
}
/// El hilo del tray: levanta un runtime tokio current-thread y corre el bucle
/// async. Si no hay runtime o D-Bus, termina (tray vacío).
fn run_tray(items: Arc<Mutex<Vec<TrayItem>>>, rx: mpsc::UnboundedReceiver<TrayCmd>) {
let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return;
};
rt.block_on(bucle_tray(items, rx));
}
/// El bucle async: arma el watcher y, hasta que cierren el canal, atiende los
/// clicks (respuesta inmediata) o refresca el snapshot de items cada ~1s.
async fn bucle_tray(items: Arc<Mutex<Vec<TrayItem>>>, mut rx: mpsc::UnboundedReceiver<TrayCmd>) {
let state = Arc::new(Mutex::new(WatcherState::default()));
let Some(conn) = build_watcher(state.clone()).await else {
return; // sin D-Bus o nombre tomado: tray vacío
};
let mut tick = tokio::time::interval(Duration::from_secs(1));
loop {
tokio::select! {
cmd = rx.recv() => match cmd {
Some(TrayCmd::Activate(key)) => activar(&conn, &state, &key).await,
None => break, // se soltó el TrayHandle
},
_ = tick.tick() => {}
}
refrescar(&conn, &state, &items).await;
}
}
/// Construye la conexión de sesión sirviendo el watcher y tomando su nombre.
/// `None` si no hay bus de sesión o el nombre ya está tomado por otro watcher.
async fn build_watcher(state: Arc<Mutex<WatcherState>>) -> Option<zbus::Connection> {
let res = zbus::connection::Builder::session()
.ok()?
.serve_at("/StatusNotifierWatcher", Watcher { state })
.ok()?
.name("org.kde.StatusNotifierWatcher")
.ok()?
.build()
.await;
match res {
Ok(c) => Some(c),
Err(e) => {
eprintln!("pata tray · no se pudo ser StatusNotifierWatcher ({e}); ¿ya hay uno? tray vacío");
None
}
}
}
/// Reconstruye el snapshot de items leyendo cada uno por D-Bus; poda los que ya no
/// responden (su app se cerró).
async fn refrescar(
conn: &zbus::Connection,
state: &Arc<Mutex<WatcherState>>,
items_out: &Arc<Mutex<Vec<TrayItem>>>,
) {
let registrados = state.lock().unwrap().items.clone();
let mut snapshot = Vec::new();
let mut vivos = Vec::new();
for (key, bus, path) in registrados {
if let Some((label, status, icon)) = leer_item(conn, &bus, &path).await {
snapshot.push(TrayItem {
key: key.clone(),
label,
status,
icon,
});
vivos.push((key, bus, path));
}
}
state.lock().unwrap().items = vivos;
*items_out.lock().unwrap() = snapshot;
}
/// Lee `(label, status, icon)` de un item. La etiqueta es el título, o el id, o el
/// nombre del ícono —lo primero no vacío—. El ícono sale del `IconPixmap` o, si no,
/// del `IconName` resuelto a PNG. `None` si no se puede leer ni el label ni el id:
/// la app se fue y hay que podar el item.
async fn leer_item(
conn: &zbus::Connection,
bus: &str,
path: &str,
) -> Option<(String, String, Option<TrayIcon>)> {
let proxy = item_proxy(conn, bus, path).await?;
let title = proxy.title().await.ok().filter(|s| !s.is_empty());
let id = proxy.id().await.ok().filter(|s| !s.is_empty());
let icon_name = proxy.icon_name().await.ok().filter(|s| !s.is_empty());
let label = title.or(id).or_else(|| icon_name.clone())?;
let status = proxy
.status()
.await
.ok()
.unwrap_or_else(|| "Active".to_string());
// Ícono: primero el pixmap embebido (no necesita tema); si no, el nombre.
let icon = proxy
.icon_pixmap()
.await
.ok()
.and_then(pixmap_a_icono)
.or_else(|| icon_name.as_deref().and_then(buscar_icono_png));
Some((label, status, icon))
}
/// Activa (click primario) el item con esa `key`, si sigue registrado.
async fn activar(conn: &zbus::Connection, state: &Arc<Mutex<WatcherState>>, key: &str) {
let reg = state
.lock()
.unwrap()
.items
.iter()
.find(|(k, _, _)| k == key)
.cloned();
if let Some((_, bus, path)) = reg {
if let Some(proxy) = item_proxy(conn, &bus, &path).await {
let _ = proxy.activate(0, 0).await;
}
}
}
/// Arma un proxy al item de `bus`/`path`.
async fn item_proxy<'a>(
conn: &zbus::Connection,
bus: &str,
path: &str,
) -> Option<StatusNotifierItemProxy<'a>> {
StatusNotifierItemProxy::builder(conn)
.destination(bus.to_string())
.ok()?
.path(path.to_string())
.ok()?
.build()
.await
.ok()
}
/// Tamaño de ícono que preferimos para la barra (px). Elegimos el pixmap más
/// cercano a esto para no clonar mapas de 256px en cada frame.
const ICONO_OBJETIVO: i64 = 24;
/// Convierte la lista de `IconPixmap` (ARGB32) en un [`TrayIcon`] RGBA8. Elige el
/// pixmap de tamaño más cercano a [`ICONO_OBJETIVO`] entre los válidos. `None` si
/// la lista viene vacía o ningún entry tiene datos consistentes.
fn pixmap_a_icono(pixmaps: Vec<(i32, i32, Vec<u8>)>) -> Option<TrayIcon> {
let (w, h, data) = pixmaps
.into_iter()
.filter(|(w, h, d)| {
*w > 0 && *h > 0 && d.len() >= (*w as usize) * (*h as usize) * 4
})
.min_by_key(|(w, _, _)| (*w as i64 - ICONO_OBJETIVO).abs())?;
let (w, h) = (w as u32, h as u32);
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
// ARGB32 en orden de red (big-endian) → en memoria los bytes son [A,R,G,B];
// peniko espera RGBA8, así que reordenamos a [R,G,B,A].
for px in data.chunks_exact(4) {
rgba.extend_from_slice(&[px[1], px[2], px[3], px[0]]);
}
Some(TrayIcon {
width: w,
height: h,
rgba,
})
}
/// Busca el `IconName` como PNG en los directorios estándar y lo decodifica. Si el
/// nombre ya es una ruta absoluta, la usa directo. Búsqueda acotada: hicolor (por
/// tamaños) + `/usr/share/pixmaps`, sólo PNG, sin `index.theme` ni SVG.
fn buscar_icono_png(name: &str) -> Option<TrayIcon> {
if name.is_empty() {
return None;
}
if name.starts_with('/') {
return decodificar_png(&PathBuf::from(name));
}
for ruta in rutas_candidatas(name) {
if let Some(ic) = decodificar_png(&ruta) {
return Some(ic);
}
}
None
}
/// Las rutas PNG donde puede vivir un ícono temático `name`, en orden de
/// preferencia (tamaños chicos primero, para una barra).
fn rutas_candidatas(name: &str) -> Vec<PathBuf> {
let mut bases: Vec<PathBuf> = Vec::new();
if let Ok(home) = std::env::var("HOME") {
bases.push(PathBuf::from(&home).join(".local/share"));
bases.push(PathBuf::from(&home).join(".icons"));
}
let datadirs =
std::env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
bases.extend(datadirs.split(':').filter(|s| !s.is_empty()).map(PathBuf::from));
let sizes = ["32x32", "24x24", "22x22", "16x16", "48x48"];
let mut rutas = Vec::new();
for base in &bases {
for size in sizes {
rutas.push(
base.join("icons/hicolor")
.join(size)
.join("apps")
.join(format!("{name}.png")),
);
}
}
// Último recurso: el cajón plano de pixmaps.
rutas.push(PathBuf::from(format!("/usr/share/pixmaps/{name}.png")));
rutas
}
/// Decodifica un PNG a [`TrayIcon`] RGBA8, o `None` si no existe / no es válido.
fn decodificar_png(ruta: &std::path::Path) -> Option<TrayIcon> {
let img = image::open(ruta).ok()?.to_rgba8();
let (width, height) = (img.width(), img.height());
Some(TrayIcon {
width,
height,
rgba: img.into_raw(),
})
}
/// Normaliza el argumento de `RegisterStatusNotifierItem` a `(bus, path)`:
/// - empieza con `/` → es una ruta de objeto, el bus es el remitente (Ayatana);
/// - tiene un `/` interno → forma combinada `"bus/path"`;
/// - si no → es un nombre de bus, con la ruta por defecto `/StatusNotifierItem` (KDE).
fn split_service(service: &str, sender: Option<&str>) -> Option<(String, String)> {
if service.starts_with('/') {
Some((sender?.to_string(), service.to_string()))
} else if let Some(idx) = service.find('/') {
Some((service[..idx].to_string(), service[idx..].to_string()))
} else {
Some((service.to_string(), "/StatusNotifierItem".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_service_ruta_usa_el_remitente_como_bus() {
// Ayatana/AppIndicator: registra la ruta, el bus es el remitente.
assert_eq!(
split_service("/org/ayatana/NotificationItem/app", Some(":1.42")),
Some((
":1.42".to_string(),
"/org/ayatana/NotificationItem/app".to_string()
))
);
// Ruta sin remitente conocido: no se puede ubicar.
assert_eq!(split_service("/foo", None), None);
}
#[test]
fn split_service_nombre_de_bus_usa_ruta_por_defecto() {
// KDE: registra el nombre de bus, ruta por defecto.
assert_eq!(
split_service("org.kde.StatusNotifierItem-1234-1", Some(":1.9")),
Some((
"org.kde.StatusNotifierItem-1234-1".to_string(),
"/StatusNotifierItem".to_string()
))
);
}
#[test]
fn split_service_forma_combinada_se_parte() {
assert_eq!(
split_service(":1.9/StatusNotifierItem", None),
Some((":1.9".to_string(), "/StatusNotifierItem".to_string()))
);
}
}