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
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
+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()))
);
}
}
Generated
+7406
View File
File diff suppressed because it is too large Load Diff
+439
View File
@@ -0,0 +1,439 @@
# Cargo.toml raíz STANDALONE de pata — front-door sobre Llimphi.
# Solo el código de pata; Llimphi y lo fundacional por git-dep del monorepo gioser.git.
[workspace]
resolver = "2"
members = [
"02_ruway/pata/pata-config",
"02_ruway/pata/pata-core",
"02_ruway/pata/pata-host",
"02_ruway/pata/pata-llimphi",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
license = "MIT"
authors = ["Sergio <gerencia@jlsoltech.com>"]
publish = false
repository = "https://gitea.gioser.net/sergio/pata"
[workspace.dependencies]
# === Registro de apps / menú global ===
app-bus = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === Serialización ===
serde = { version = "1", features = ["derive"] }
serde_json = "1"
lsp-types = "0.97"
serde-big-array = "0.5"
postcard = { version = "1", features = ["use-std"] }
toml = "0.8"
ron = "0.8"
bincode = "1"
base64 = "0.22"
# === Errores ===
thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores
anyhow = "1"
# === Async ===
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] }
async-trait = "0.1"
futures = "0.3"
# === Observabilidad ===
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# === Linux primitives (arje) ===
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] }
libc = "0.2"
# === IDs / Hash / Crypto ===
ulid = { version = "1", features = ["serde"] }
uuid = { version = "1", features = ["v4", "rng-getrandom"] }
sha2 = "0.10"
blake3 = "1.5"
ed25519-dalek = "2"
aes-gcm = "0.10"
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
# === WASM (arje) ===
# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para
# que el ABI WASM del host sea idéntico en Linux y en bare-metal.
wasmi = "1.0"
wat = "1"
# === Storage / DB ===
sled = "0.34"
rusqlite = { version = "0.31", features = ["bundled", "blob"] }
# === Ingesta de documentos (iniy-ingest: PDF / EPUB) ===
pdf-extract = "0.7"
epub = "2.1"
# === Bulk import Wikipedia (iniy-wiki dump) ===
bzip2 = "0.4"
# === Compresión (minga multi-bundle) ===
zstd = "0.13"
# === HTTP server (iniy-server) ===
axum = "0.7"
tower = "0.5"
# === ANN sobre embeddings (iniy nli --ann) ===
instant-distance = "0.6"
# === P2P (minga) ===
libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
libp2p-stream = "=0.4.0-alpha"
libp2p-allow-block-list = "0.6"
# === SSH (ssh, sandokan RemoteEngine, matilda) ===
russh = "0.54"
# === Math determinista cross-platform (dominium) ===
libm = "0.2"
# === SMF (takiy-midi) ===
# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path.
midly = "0.5"
# === Code parsing (minga) ===
arboard = "3"
ropey = "1.6"
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
tree-sitter-python = "0.23"
tree-sitter-typescript = "0.23"
tree-sitter-javascript = "0.23"
tree-sitter-go = "0.23"
# === FS notify ===
notify = "6.1"
# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) ===
petgraph = "0.6"
# === Image decoding (nahual-image-viewer-llimphi) ===
# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless).
# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app
# los pide específicamente.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# === FUSE (minga-vfs) ===
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime).
fuser = { version = "0.15", default-features = false }
# === CLI / auth (minga) ===
clap = { version = "4", features = ["derive"] }
rpassword = "7"
# === PAM (auth-core) ===
pam = "0.8"
# === D-Bus (arje compat) ===
zbus = { version = "4", default-features = false, features = ["tokio"] }
# === Tests ===
tempfile = "3"
# === Llimphi (motor gráfico soberano) ===
# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24.
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
wgpu = "24"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
vello = "0.5"
taffy = "0.9"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
parley = "0.4"
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
llimphi-ui = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Paleta semántica compartida por las apps y los widgets.
llimphi-theme = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Tweens y helpers de animación sobre el bucle Elm.
llimphi-motion = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
llimphi-icons = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Widgets reusables sobre llimphi-ui — uno por crate.
llimphi-widget-app-header = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-banner = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-clipboard = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-context-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-edit-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-menubar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-list = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-grid = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-slider = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-scroll = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-splitter = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-stat-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-tabs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-command-palette = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-diff-viewer = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-fif = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-file-picker = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-bookmarks = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-mini-map = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-shuma-term = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-symbol-outline = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-plugin-host = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-theme-switcher = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-area = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-editor-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-editor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-editor-lsp = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-input = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-tiled = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-nodegraph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-tree = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-navigator = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
llimphi-widget-wawa-mark = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
# modal, empty, status-bar, shortcuts-help, splash).
llimphi-widget-tooltip = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-spinner = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-progress = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-toast = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-modal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-empty = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-status-bar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-shortcuts-help = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-timeline = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-splash = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Controles de formulario y signaling (switch, segmented, breadcrumb,
# badge, avatar, skeleton, field).
llimphi-widget-switch = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-segmented = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-dock-rail = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-breadcrumb = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-badge = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-avatar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-skeleton = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-field = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Firma visual transversal (gradient sutil + hairline accent).
llimphi-widget-panel = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-panes = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-workspace = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Abstracción Selector — host (paths) + wawa (khipus).
llimphi-module-selector = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === Filesystem helpers ===
directories = "5"
# === Diff line-based (llimphi-module-diff-viewer) ===
# `similar` es la crate de facto: implementa Myers + Patience + LCS,
# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete),
# zero deps fuera de std. La 2.x es estable hace años.
similar = "2"
# === Fuzzy matching (shuma-history) ===
# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct,
# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple
# que necesitamos (Matcher + Pattern + score).
nucleo-matcher = "0.3"
# === Transporte autenticado (shuma-link) ===
# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente
# conoce la pubkey del servidor, server descubre la del cliente y la
# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s.
# La versión 0.9 viene pinneada por libp2p, así nos alineamos.
snow = "0.9"
hex = "0.4"
# === PTY + emulador de terminal (shuma-exec, módulos REPL) ===
# portable-pty aloja un PTY cross-platform; lo usamos para los
# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad.
# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor
# movement + erase + screen state) y mantiene un buffer de pantalla
# renderizable como grid.
portable-pty = "0.9"
vt100 = "0.16"
# === WASM web (gioser) ===
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = "0.3"
glam = "0.30"
# === Markdown (pluma) ===
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
# === Archivos comprimidos (nahual archive viewer) ===
# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos,
# por eso default-features=false alcanza para ZIP. Para tar.gz sí
# descomprimimos en streaming con flate2 (ya declarado arriba), saltando
# los datos de cada entrada — sólo leemos headers.
zip = { version = "2.4", default-features = false }
tar = { version = "0.4", default-features = false }
# === Fuentes (nahual font viewer) ===
# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths.
ttf-parser = "0.25"
# ============================================================
# Intra-workspace deps de nahual (referenciadas por workspace = true)
# ============================================================
nahual-text-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-image-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-thumb-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-gallery-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-video-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-card-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-audio-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-tree-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-hex-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-table-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-markdown-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-archive-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-font-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-map-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-geo-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-viewer-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-file-explorer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# ============================================================
# Intra-workspace deps de pineal (módulo de gráficos)
# ============================================================
pineal-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-render = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-cartesian = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-stream = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-mesh = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-financial = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-polar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-heatmap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-treemap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-flow = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-phosphor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-export = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-hexbin = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-contour = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-bars = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# ============================================================
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
# ============================================================
iniy-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-ingest = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-extract = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-nli = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-nli-llm = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-graph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-store = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === auto: declarados por crates internos faltantes ===
cosmos-coords = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-ephemeris = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-time = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-wcs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === auto: externas de eternal ===
celestial-eop-data = { version = "0.1"}
approx = "0.5"
byteorder = "1.5"
cc = "1.0"
chrono = "0.4"
crc32fast = "1.4"
criterion = "0.5"
csv = "1.4"
flate2 = "1.0"
glob = "0.3"
indicatif = "0.18"
lz4_flex = "0.11"
memmap2 = "0.9"
mockito = "1.0"
ndarray = "0.15"
num-traits = "0.2"
once_cell = "1.19"
parking_lot = "0.12"
png = "0.18"
proptest = "1.4"
quick-xml = "0.31"
rayon = "1.8"
regex = "1.11"
reqwest = "0.12"
tiff = "0.11"
wide = "0.7"
wiremock = "0.6"
# === i18n (rimay-localize) ===
fluent-bundle = "0.15"
unic-langid = { version = "0.9", features = ["macros"] }
sys-locale = "0.3"
# === Servo (puriy-engine) ===
# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever
# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no
# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente
# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer
# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista,
# evita pull de tokio en el engine.
html5ever = "0.39"
markup5ever = "0.39"
markup5ever_rcdom = "0.39"
cssparser = "0.35"
url = "2"
ureq = { version = "2", default-features = false, features = ["tls"] }
# === takiy-synth (SoundFont MIDI) ===
# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador
# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc).
rustysynth = "1.3"
# === takiy-playback (audio device output) ===
# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en
# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para
# abrir el device default y empujar muestras f32 — nada de mezclado
# ni efectos en el callback.
cpal = "0.15"
# === media-source-wav (decoder PCM en disco) ===
# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM
# entero (8/16/24/32) y float (32). Suficiente para abrir samples y
# stems de prueba sin meter ffmpeg/symphonia.
hound = "3.5"
# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) ===
# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre
# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre
# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg)
# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac:
# ese tier patentado entra por shared/foreign-av.
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] }
# === media-source-opus (decoder Opus NATIVO puro-Rust) ===
# Opus es el formato de audio nativo de gioser (par del video AV1). ogg
# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus
# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video.
ogg = "0.9"
opus-wave = "3"
# === media-source-webm (demux nativo Matroska/WebM) ===
# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los
# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1
# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo.
matroska-demuxer = "0.7"
# === git-deps al monorepo (agregados por la extracción) ===
card-sidecar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
chasqui-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pluma-llm = { git = "https://gitea.gioser.net/sergio/gioser.git" }
shuma-exec = { git = "https://gitea.gioser.net/sergio/gioser.git" }
shuma-line = { git = "https://gitea.gioser.net/sergio/gioser.git" }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sergio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
# pata
> Declarative desktop frame — bars, panels, dock, tray, widgets — portable Linux/Wawa, on [Llimphi](https://gitea.gioser.net/sergio/llimphi).
`pata` is the desktop shell frame: declarative bars/panels/dock, builtin widgets (clock/UTC, brightness, volume, clipboard, system tray, gradient meters, astro), a Quake drawer (shell + AI), a KDE-style task manager, conky-style floating cards and a start menu. It hosts other apps as "teeth" via `pata-host`, and runs portable across Linux compositors and the Wawa kernel.
## How dependencies work
Front-door repo: only `pata-*` crates here. Llimphi and everything foundational (the optional AI drawer via `pluma-llm`, networking via `chasqui`, shell exec via `shuma`) are git-dependencies of the [`gioser`](https://gitea.gioser.net/sergio/gioser) monorepo.
## License
MIT. Builds on [Llimphi](https://gitea.gioser.net/sergio/llimphi) + the [gioser](https://gitea.gioser.net/sergio/gioser) suite.