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:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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á"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()?))
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(®, "/v/clip.mp4").map(|a| a.id.as_str()), Some("media"));
|
||||
assert_eq!(handler_for(®, "/s/lib.rs").map(|a| a.id.as_str()), Some("nada"));
|
||||
assert_eq!(handler_for(®, "/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(®, "/d/manual.pdf").is_none());
|
||||
// Sin extensión, ni siquiera hay mime.
|
||||
assert!(handler_for(®, "/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(®, "/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(®, "/d/x.pdf").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+439
@@ -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" }
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sergio
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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.
|
||||
Reference in New Issue
Block a user