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>
Semantic delta · 539
02_ruway/pata/pata-config/src/lib.rs
- new fn candidate_paths pub
- new fn candidate_paths_respeta_xdg
- new fn load pub
- new fn load_from_str pub
- new fn load_from_str_parsea_dos_superficies
- new mod tests
- new fn toml_invalido_es_error_no_panic
02_ruway/pata/pata-config/src/main.rs
- new fn anchor_str
- new fn ctx_muestra
- new fn kind_str
- new fn main
- new fn parse_wxh
- new fn print_slot
- new fn render_view
02_ruway/pata/pata-core/src/config.rs
- new enum Anchor pub
- new struct Config pub
- new struct FloatingCard pub
- new struct General pub
- new enum Prop pub
- new struct SidebarTab pub
- new struct Surface pub
- new enum SurfaceKind pub
- new struct WidgetSpec pub
- new fn anchor_horizontalidad
- new fn as_bool pub
- new fn as_num pub
- new fn as_str pub
- new fn bar pub
- new fn bool_prop pub
- new fn config_default_esta_vacio
- new fn default
- new fn default
- new fn default_gap
- new fn default_padding
- new fn default_panel_width
- new fn default_rail_thickness
- new fn default_thickness
- new fn default_timezone
- new fn defaults_de_anchor_y_kind
- new fn dock pub
- new fn es_horizontal pub
- new fn new pub
- new fn new pub
- new fn num_prop pub
- new fn preset pub
- new fn preset_tiene_barra_top_y_shell_bottom
- new fn prop_helpers_y_promocion_int_a_num
- new fn sidebar pub
- new fn sidebar_lleva_dientes_y_ancho_de_panel
- new fn str_prop pub
- new fn surface_constructores
- new mod tests
- new fn with pub
02_ruway/pata/pata-core/src/layout.rs
- new struct Frame pub
- new struct Placed pub
- new struct Rect pub
- new fn barra_autohide_no_reserva
- new fn barra_top_reserva_su_franja
- new fn barras_verticales_reservan_ancho
- new fn dock_no_reserva_y_se_pega_al_borde
- new fn dos_barras_top_se_apilan
- new fn es_visible pub
- new fn new pub
- new fn panel_ocupa_el_area_libre_sin_reservar
- new fn pantalla
- new fn resolve pub
- new fn shrink
- new fn sidebar_autohide_no_reserva
- new fn sidebar_reserva_su_rail_como_una_barra_vertical
- new fn sin_superficies_el_area_es_la_pantalla
- new fn strip
- new mod tests
- new fn top_solida_mas_shell_autohide_solo_reserva_la_top
02_ruway/pata/pata-core/src/lib.rs
02_ruway/pata/pata-core/src/widget.rs
- new struct Astro pub
- new struct Clock pub
- new struct ClockReading pub
- new def FASES_LUNA
- new struct Meter pub
- new enum MeterSource pub
- new struct Placeholder pub
- new def SIGNOS
- new struct StartButton pub
- new trait Widget pub
- new struct WidgetCtx pub
- new enum WidgetView pub
- new fn astro_mapea_longitud_a_signo_y_grado
- new fn astro_normaliza_longitud_negativa_y_redondea_luna
- new fn astro_respeta_props_degree_moon_name
- new fn build pub
- new fn build_all pub
- new fn build_despacha_los_builtins
- new fn cpu_meter_emite_fraccion_y_porcentaje
- new fn ctx
- new fn empuja_dos
- new fn format_time
- new fn from_spec pub
- new fn from_spec pub
- new fn from_spec pub
- new fn from_spec pub
- new fn kind_desconocido_cae_a_placeholder
- new fn label_por_defecto
- new fn leyenda_memoria
- new fn medianoche_en_12h_es_las_12
- new fn meter_label_override
- new fn meter_oculta_label_con_show_label_false
- new fn new pub
- new fn porcentaje
- new fn ram_meter_leyenda_en_gib
- new fn reloj_12h_fecha_y_literales
- new fn reloj_formatea_default_y_segundos
- new fn start_button_muestra_label_default_y_override
- new mod tests
- new fn tick
- new fn tick
- new fn tick
- new fn tick
- new fn tick
- new fn view
- new fn view
- new fn view
- new fn view
- new fn view
- new fn volumen_muteado_dice_muted
02_ruway/pata/pata-core/src/wire.rs
- new struct WireCard pub
- new struct WireConfig pub
- new enum WireProp pub
- new struct WireSurface pub
- new struct WireTab pub
- new struct WireWidget pub
- new fn a_wire
- new fn config_rico
- new fn de_wire
- new fn from
- new fn from
- new fn from
- new fn from
- new fn from
- new fn from
- new fn from
- new fn from
- new fn from
- new fn from
- new fn from
- new fn from
- new fn round_trip_postcard
- new fn round_trip_sin_perdida
- new mod tests
02_ruway/pata/pata-core/tests/toml_contract.rs
02_ruway/pata/pata-host/src/client.rs
- new struct HostClient pub
- new fn connect pub
- new fn drop
- new fn update pub
02_ruway/pata/pata-host/src/lib.rs
- new enum AppMsg pub
- new struct HostedTooth pub
- new def SOCKET_ENV pub
- new def SOCKET_NAME pub
- new enum ShellMsg pub
- new mod client
- new fn frame_roundtrip_sobre_buffer
- new fn new pub
- new fn postcard_err
- new fn read_frame pub
- new mod server
- new fn socket_path pub
- new fn socket_path_honra_env
- new mod tests
- new fn wire_roundtrip_appmsg
- new fn wire_roundtrip_shellmsg
- new fn write_frame pub
02_ruway/pata/pata-host/src/server.rs
- new struct AppReg
- new struct HostServer pub
- new struct Shared
- new fn activate pub
- new fn any_registered pub
- new fn bump
- new fn handle_conn
- new fn revision pub
- new fn snapshot pub
- new fn spawn pub
02_ruway/pata/pata-host/tests/roundtrip.rs
- new fn esperar
- new fn registro_activacion_y_baja
02_ruway/pata/pata-llimphi/src/keys.rs
- new fn etiqueta_vacia_o_desconocida_es_none
- new fn matches pub
- new fn matches_falla_cerrado
- new fn parse pub
- new fn parsea_teclas_de_funcion_y_caracter
- new mod tests
02_ruway/pata/pata-llimphi/src/layer.rs
- new struct CardState
- new def DRAWER_H
- new struct LayerApp
- new struct LayerDrag
- new def MENU_H
- new struct Panel
- new struct PanelGpu
- new struct RenderCache
- new fn activar_ventana
- new fn anchor_y_size
- new fn cerrar_sidebar
- new fn cerrar_ventana
- new fn closed
- new fn configure
- new macro diag
- new fn draw
- new fn ensure_gpu
- new fn enter
- new fn event
- new fn event
- new fn focused_app_id
- new fn frame
- new fn handle_msg
- new fn hide_tooltip
- new fn lanzar_app
- new fn latido
- new fn leave
- new fn marcar_shuma_dirty
- new fn marcar_sidebars_dirty
- new fn marcar_todo_dirty
- new fn maybe_sample
- new fn nav_toggle
- new fn new_capability
- new fn new_output
- new fn new_seat
- new fn output_destroyed
- new fn output_state
- new fn panel_de
- new fn pointer_frame
- new fn poll_exec
- new fn poll_host
- new fn poll_nav
- new fn press_key
- new fn registry
- new fn release_key
- new fn remove_capability
- new fn remove_seat
- new fn run pub
- new fn scale_factor_changed
- new fn seat_state
- new fn set_menu_open
- new fn set_shuma_open
- new fn set_sidebar_open
- new fn shuma_submit
- new fn sidebar_panel_de
- new fn surface_enter
- new fn surface_leave
- new fn toplevel_por_id
- new fn transform_changed
- new fn update_modifiers
- new fn update_output
- new fn update_tooltip
- new fn window_entries
02_ruway/pata/pata-llimphi/src/lib.rs
- new struct Model pub
- new type Model
- new enum Msg pub
- new type Msg
- new def PANTALLA
- new struct PataApp pub
- new enum SlotWidget pub
- new struct SurfaceWidgets pub
- new fn animar_shuma
- new fn app_id
- new fn config_tiene_navigator pub
- new fn config_tiene_widget pub
- new fn construir
- new fn construir_cards pub
- new fn core_mut
- new fn init
- new fn initial_size
- new mod keys pub
- new mod layer pub
- new mod nouser pub
- new fn on_key
- new mod open pub
- new mod render pub
- new mod sampler pub
- new fn shell_quote pub
- new mod shuma pub
- new fn spawn_cmd pub
- new fn tick_widgets
- new fn title
- new mod toplevel pub
- new mod tray pub
- new fn update
- new fn usa_utc pub
- new fn view
- new fn view_overlay
02_ruway/pata/pata-llimphi/src/main.rs
- new fn main
02_ruway/pata/pata-llimphi/src/nouser.rs
- new def DISCOVERY_TIMEOUT
- new enum MembersOutcome pub
- new struct NavState pub
- new enum NavTarget pub
- new enum PollOutcome pub
- new def QUERY_TIMEOUT
- new def REFRESH_INTERVAL pub
- new fn apply_members pub
- new fn apply_members_reemplaza_placeholder_por_archivos
- new fn apply_monads pub
- new fn apply_monads_construye_raices_con_placeholder_si_hay_cardinalidad
- new fn apply_monads_descarta_miembros_de_monadas_muertas
- new fn close_menu pub
- new fn default
- new fn file_label
- new fn file_label_toma_el_ultimo_componente
- new fn file_nav_id
- new fn file_path pub
- new fn fnv1a
- new fn is_open pub
- new fn list_resp
- new fn monad_nav_id
- new fn monad_view
- new fn monada_vacia_no_tiene_chevron
- new fn nav_id_determinista_y_separado_por_tag
- new fn needs_resolve pub
- new fn open_menu pub
- new fn placeholder_nav_id
- new fn poll pub
- new fn rebuild
- new fn resolve pub
- new fn resolve_socket
- new mod tests
- new fn toggle_tab pub
- new fn toggle_tab_abre_y_cierra
02_ruway/pata/pata-llimphi/src/open.rs
- new enum Opened pub
- new fn extension_de_un_punto_en_directorio_no_confunde
- new fn handler_for pub
- new fn handlers_for_path pub
- new fn handlers_for_path_lista_opciones_del_menu
- new fn mime_for_path pub
- new fn mime_por_extension_comun
- new fn open_file pub
- new fn open_system pub
- new fn open_with_id pub
- new fn registro_ejemplo
- new fn rutea_a_la_app_nativa_por_mime
- new fn sin_extension_o_desconocida_es_none
- new fn sin_handler_nativo_cae_al_sistema
- new mod tests
02_ruway/pata/pata-llimphi/src/render.rs
- new def BARRA_W
- new struct BarData pub
- new def CAPTION_W
- new def CLIPBOARD_PREVIEW_MAX
- new def START_MENU_W
- new def TRAY_ICON_PX
- new def TRAY_LABEL_MAX
- new def WINDOW_LABEL_MAX
- new def WIN_BADGE_PX
- new fn aclarar
- new fn app_row
- new fn bar_view pub
- new fn caption_fija
- new fn card_view pub
- new fn card_view_absolute
- new fn chip
- new fn clipboard_view
- new fn etiqueta
- new fn meter_view
- new fn recortar
- new fn root pub
- new fn shuma_open_view pub
- new mod sidebar
- new fn slots_de
- new fn start_button_view
- new fn start_menu_body pub
- new fn start_menu_overlay pub
- new fn start_menu_view pub
- new fn surface_view
- new mod tests
- new fn tooltip_de_texto_y_vacio
- new fn tooltip_de_un_medidor_junta_etiqueta_y_leyenda
- new fn tooltip_view pub
- new fn tray_icon_node
- new fn tray_view
- new fn widget_tooltip pub
- new fn widget_view pub
- new fn window_button
- new fn window_list_view
02_ruway/pata/pata-llimphi/src/render/sidebar.rs
- new def GRAPH_ROW_H
- new def HEADER_H
- new def PAD
- new def TREE_ROW_H
- new fn aviso_view
- new fn count_visible
- new fn count_visible_respeta_expansion
- new fn forest
- new fn hosted_rail
- new fn menu_button
- new fn nav_panel_view pub
- new fn open_with_menu
- new fn panel_inner
- new fn rail_separator
- new fn rail_strip
- new fn rail_widget
- new fn shuma_rail
- new fn sidebar_rail_view pub
- new fn sidebar_surface_view pub
- new mod tests
- new fn tooth_icon
- new fn walk
02_ruway/pata/pata-llimphi/src/sampler.rs
- new def LUNA_NUEVA_REF_JD
- new def MES_SINODICO
- new def PLAZO
- new struct Sampler pub
- new struct SamplerHandle pub
- new type Snapshot pub
- new fn astro_from_jd
- new fn clock_de
- new fn delta_de_cpu_da_el_uso_esperado
- new fn fase_lunar_esta_en_rango
- new fn jd_de_epoca_unix_es_la_referencia
- new fn jd_from_unix
- new fn latest pub
- new fn leer_clipboard pub
- new fn new pub
- new fn parse_meminfo
- new fn parse_meminfo_extrae_total_y_disponible
- new fn parse_meminfo_sin_claves_es_none
- new fn parse_pactl_pct
- new fn parse_pactl_pct_toma_el_primer_porcentaje
- new fn parse_proc_stat
- new fn parse_proc_stat_otra_primera_linea_es_none
- new fn parse_proc_stat_suma_total_e_idle_con_iowait
- new fn parse_wpctl
- new fn parse_wpctl_lee_fraccion_y_mute
- new fn preview_clipboard pub
- new fn preview_clipboard_colapsa_a_una_linea
- new fn read_meminfo
- new fn read_proc_stat
- new fn run
- new fn sample pub
- new fn sample_brightness
- new fn sample_clock
- new fn sample_cpu
- new fn sample_ram
- new fn sample_volume
- new fn sampler_nuevo_no_tiene_lectura_previa_de_cpu
- new fn sol_en_equinoccio_de_marzo_esta_cerca_de_aries_0
- new fn spawn pub
- new mod tests
- new fn with_utc pub
02_ruway/pata/pata-llimphi/src/shuma.rs
- new def BODY_GAP
- new def CAPTURE_CAP
- new def CARD_GAP
- new def DRAWER_FRAC
- new struct DrawerBlock pub
- new def HEADER_H
- new def LINE_H
- new def MAX_BLOCKS
- new struct OutLine pub
- new struct RunResult pub
- new struct ShumaState pub
- new enum SubmitKind pub
- new fn blocks_view
- new fn card_view
- new fn chip_text
- new fn classify pub
- new fn classify_rutea_prefijo_a_shell_y_resto_a_ia
- new fn content_height pub
- new fn content_height_crece_con_cards_y_lineas
- new fn default
- new fn drawer_body_view pub
- new fn drawer_overlay pub
- new fn ejecutar pub
- new fn finish_last pub
- new fn finish_last_guarda_la_salida_por_etapa
- new fn finish_last_rellena_la_card_pendiente
- new fn from_spec pub
- new fn headline_view pub
- new fn lanzar_un_comando_pega_el_scroll_al_fondo
- new fn las_etapas_de_un_pipe_son_los_comandos
- new fn out_line
- new fn pin_bottom
- new fn preguntar_ia pub
- new fn push_pending pub
- new fn push_pending_ia pub
- new fn push_pending_ia_marca_la_card_sin_etapas
- new fn push_pending_resuelve_etapas_y_acota_el_historial
- new fn scroll_by pub
- new fn scroll_by_acota_a_cero_y_al_maximo
- new mod scroll_tests
- new fn simple_pipe_stages pub
- new fn sin_pipe_o_con_sintaxis_compleja_no_hay_etapas_directas
- new fn stage_labels pub
- new mod tests
- new fn trim_and_pin
- new fn un_pipe_simple_se_descompone_en_etapas
- new fn una_linea_sin_pipe_no_tiene_chips
- new fn visible pub
02_ruway/pata/pata-llimphi/src/toplevel.rs
- new def ESTADO_ACTIVADO
- new def ESTADO_MINIMIZADO
- new struct Toplevel pub
- new struct WindowEntry pub
- new fn confirmar pub
- new fn entry
- new fn etiqueta pub
- new fn inicial pub
- new fn inicial_toma_la_primera_alfanumerica_del_app_id
- new fn new pub
- new fn set_app_id pub
- new fn set_state pub
- new fn set_title pub
- new mod tests
02_ruway/pata/pata-llimphi/src/tray.rs
- new def ICONO_OBJETIVO
- new trait StatusNotifierItem
- new enum TrayCmd
- new struct TrayHandle pub
- new struct TrayIcon pub
- new struct TrayItem pub
- new struct Watcher
- new struct WatcherState
- new fn activar
- new fn activate pub
- new fn bucle_tray
- new fn build_watcher
- new fn buscar_icono_png
- new fn decodificar_png
- new fn is_status_notifier_host_registered
- new fn item_proxy
- new fn items pub
- new fn leer_item
- new fn pixmap_a_icono
- new fn protocol_version
- new fn refrescar
- new fn register_status_notifier_host
- new fn register_status_notifier_item
- new fn registered_status_notifier_items
- new fn run_tray
- new fn rutas_candidatas
- new fn spawn pub
- new fn split_service
- new fn split_service_forma_combinada_se_parte
- new fn split_service_nombre_de_bus_usa_ruta_por_defecto
- new fn split_service_ruta_usa_el_remitente_como_bus
- new mod tests
37 files · +18266 −0
- .gitignore +3 −0
- 02_ruway/pata/LEEME.md +19 −0
- 02_ruway/pata/README.md +37 −0
- 02_ruway/pata/SDD.md +536 −0
- 02_ruway/pata/pata-config/Cargo.toml +16 −0
- 02_ruway/pata/pata-config/assets/launcher.toml +112 −0
- 02_ruway/pata/pata-config/src/lib.rs +92 −0
- 02_ruway/pata/pata-config/src/main.rs +196 −0
- 02_ruway/pata/pata-core/Cargo.toml +28 −0
- 02_ruway/pata/pata-core/src/config.rs +491 −0
- 02_ruway/pata/pata-core/src/layout.rs +289 −0
- 02_ruway/pata/pata-core/src/lib.rs +47 −0
- 02_ruway/pata/pata-core/src/widget.rs +663 −0
- 02_ruway/pata/pata-core/src/wire.rs +320 −0
- 02_ruway/pata/pata-core/tests/toml_contract.rs +129 −0
- 02_ruway/pata/pata-host/Cargo.toml +12 −0
- 02_ruway/pata/pata-host/src/client.rs +65 −0
- 02_ruway/pata/pata-host/src/lib.rs +179 −0
- 02_ruway/pata/pata-host/src/server.rs +157 −0
- 02_ruway/pata/pata-host/tests/roundtrip.rs +68 −0
- 02_ruway/pata/pata-llimphi/Cargo.toml +73 −0
- 02_ruway/pata/pata-llimphi/src/keys.rs +74 −0
- 02_ruway/pata/pata-llimphi/src/layer.rs +1761 −0
- 02_ruway/pata/pata-llimphi/src/lib.rs +652 −0
- 02_ruway/pata/pata-llimphi/src/main.rs +28 −0
- 02_ruway/pata/pata-llimphi/src/nouser.rs +454 −0
- 02_ruway/pata/pata-llimphi/src/open.rs +214 −0
- 02_ruway/pata/pata-llimphi/src/render.rs +996 −0
- 02_ruway/pata/pata-llimphi/src/render/sidebar.rs +622 −0
- 02_ruway/pata/pata-llimphi/src/sampler.rs +446 −0
- 02_ruway/pata/pata-llimphi/src/shuma.rs +971 −0
- 02_ruway/pata/pata-llimphi/src/toplevel.rs +187 −0
- 02_ruway/pata/pata-llimphi/src/tray.rs +452 −0
- Cargo.lock +7406 −0
- Cargo.toml +439 −0
- LICENSE +21 −0
- README.md +11 −0
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/02_ruway/pata/LEEME.md b/02_ruway/pata/LEEME.md
new file mode 100644
index 0000000..cac846d
--- /dev/null
+++ b/02_ruway/pata/LEEME.md
@@ -0,0 +1,19 @@
+# pata
+
+> El marco del escritorio: barras, paneles y dock declarativos — widgets que
+> colocás donde quieras, desde un archivo de config. El mismo modelo en Linux y
+> en Wawa.
+
+`pata` (quechua: *borde, repisa, andén*) es la capa de chrome del escritorio
+gioser. No es el compositor (`mirada`) ni el shell (`shuma`): es el marco
+configurable que rodea a las ventanas. Desde un archivo desplegás **barras**,
+**paneles** y un **dock**, y dentro acomodás widgets — botón inicio, lista de
+ventanas abiertas, clipboard / volumen / brillo, tray, reloj, un widget
+**astro** (posición zodiacal del sol + ciclo lunar) y el input del shell que
+despliega `shuma` estilo Quake.
+
+El modelo vive en `pata-core`, agnóstico y `no_std`, así que el mismo marco
+corre como frontend Llimphi en Linux (sobre el compositor `mirada`) y desde el
+kernel launcher de Wawa.
+
+Definición canónica y plan por fases: [`SDD.md`](SDD.md).
diff --git a/02_ruway/pata/README.md b/02_ruway/pata/README.md
new file mode 100644
index 0000000..9b9cdad
--- /dev/null
+++ b/02_ruway/pata/README.md
@@ -0,0 +1,37 @@
+# pata
+
+> The desktop frame: declarative bars, panels and a dock — widgets you place
+> anywhere, from one config file. The same model on Linux and on Wawa.
+
+`pata` (Quechua: *edge, ledge, terrace*) is the chrome layer of the gioser
+desktop. It is **not** the compositor (`mirada`) nor the shell (`shuma`): it is
+the configurable frame that surrounds the windows. From a config file you deploy
+**bars**, **panels** and a **dock**, and inside them arrange widgets — start
+button, open-window list, clipboard / volume / brightness, tray, clock, an
+**astro** widget (the Sun's zodiac position + lunar cycle), and the shell input
+that unfolds `shuma` Quake-style.
+
+The model lives in `pata-core`, agnostic and `no_std`, so the very same frame
+runs as a Llimphi frontend on Linux (over the `mirada` compositor) and from the
+Wawa kernel launcher.
+
+See [`SDD.md`](SDD.md) for the canonical definition and the phase plan.
+
+## Crates
+
+| Crate | Role |
+|---|---|
+| [`pata-core`](pata-core/) | Agnostic model + layout: `Config → [Surface] → slots → [WidgetSpec]` and `resolve(config, screen) → Frame`. `no_std + alloc`. |
+| [`pata-config`](pata-config/) | Linux loader (std): reads the user's TOML from XDG paths into the model. Ships the `pata` inspector binary. |
+
+(`pata-llimphi` rendering and the Wawa launcher land in later phases.)
+
+## Try it
+
+```sh
+cargo run -p pata-config --bin pata -- \
+ --config 02_ruway/pata/pata-config/assets/launcher.toml --screen 1920x1080
+```
+
+Prints how the frame resolves: each surface's rect, whether it reserves a strip,
+its widgets per slot, and the work area left for windows.
diff --git a/02_ruway/pata/SDD.md b/02_ruway/pata/SDD.md
new file mode 100644
index 0000000..ff952cb
--- /dev/null
+++ b/02_ruway/pata/SDD.md
@@ -0,0 +1,536 @@
+# SDD — `pata`, el marco del escritorio
+
+> Estado: **Fase 8b** (layer-shell sobre wlroots: barras, Quake, clicks,
+> `window_list`). Este documento es la fuente autoritativa de qué es `pata` y
+> dónde termina, por encima de README.
+
+## 0. El problema que resuelve
+
+El escritorio de gioser tenía el concepto de "launcher" **triplicado y mal
+delimitado**: `mirada-launcher-llimphi` (la barra), `shuma-shell-llimphi` (un
+chasis con tabs) y `shuma-module-launcher` (un módulo lista-de-apps) competían
+por el mismo rol sin una frontera clara. Correr cualquiera bajo el compositor
+daba ventanas sueltas en vez de un escritorio coherente.
+
+`pata` fija la frontera: es **una sola capa**, la del *marco* (chrome) del
+escritorio, desacoplada del compositor y del shell, y portable entre Linux y
+wawa.
+
+## 1. Las tres capas (no se solapan)
+
+| Capa | Quechua/es | Qué es | Qué **no** es |
+|---|---|---|---|
+| **mirada** | mirar | El **compositor**: el Cuerpo Wayland/DRM. Tesela, acopla franjas, decora, enruta input. | No dibuja barras ni widgets. |
+| **shuma** | — | El **shell**: input + terminal + módulos. Se asoma como una barra inferior auto-escondible; al escribir, despliega el resto estilo Quake. | No es el chrome del escritorio; es un inquilino del marco. |
+| **pata** | borde, repisa, andén | El **marco**: barras, paneles y dock declarados desde un archivo de config, con widgets colocables en cualquier slot. Hospeda el input de shuma. | No compone ventanas (eso es mirada) ni ejecuta comandos (eso es shuma). |
+
+Regla mnemónica: **mirada** pone las ventanas, **pata** pone el marco
+alrededor, **shuma** es la boca por la que le hablás al sistema.
+
+## 2. Forma del dominio
+
+```
+pata-core Modelo agnóstico (no_std + alloc): el esquema declarativo
+ (Config → [Surface] → slots → [WidgetSpec]) + el layout
+ (resolve: config+pantalla → superficies colocadas + work_area).
+ No pinta, no toca el SO. Cruza a wawa por `path`, como mirada-layout.
+pata-config Loader Linux (std): lee el TOML del usuario desde las rutas XDG y
+ lo materializa en el modelo. El límite std→no_std del marco. Trae
+ el binario `pata` para inspeccionar la geometría resuelta. En wawa
+ este rol lo cumple akasha, no este crate.
+pata-llimphi Frontend Linux: monta pata-core sobre Llimphi. Cada Surface es
+ una ventana Wayland que el compositor mirada acopla; despacha
+ los widgets builtin; el shuma_input despliega shuma. (Fase 3,
+ hereda de mirada-launcher-llimphi.)
+pata (wawa) El kernel launcher de wawa consume el MISMO pata-core y pinta
+ sobre el framebuffer. (Fase 7.)
+```
+
+UIs intercambiables sobre un `*-core` agnóstico — la regla dura del repo. El
+modelo se escribe una vez; Linux y wawa son dos pinceles.
+
+## 3. El modelo (`pata-core`)
+
+- **`Config`** = `general` + `Vec<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).
diff --git a/02_ruway/pata/pata-config/Cargo.toml b/02_ruway/pata/pata-config/Cargo.toml
new file mode 100644
index 0000000..134a3cf
--- /dev/null
+++ b/02_ruway/pata/pata-config/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "pata-config"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pata — la capa de carga (Linux): lee el TOML del usuario desde las rutas XDG y lo materializa en el modelo no_std de pata-core. Es el límite std→no_std del marco; en wawa este rol lo cumple akasha, no este crate. Trae el binario `pata` para inspeccionar cómo resuelve un config sobre una pantalla dada."
+
+[[bin]]
+name = "pata"
+path = "src/main.rs"
+
+[dependencies]
+pata-core = { path = "../pata-core" }
+toml = { workspace = true }
diff --git a/02_ruway/pata/pata-config/assets/launcher.toml b/02_ruway/pata/pata-config/assets/launcher.toml
new file mode 100644
index 0000000..a802b07
--- /dev/null
+++ b/02_ruway/pata/pata-config/assets/launcher.toml
@@ -0,0 +1,112 @@
+# Ejemplo de marco de pata. Copialo a ~/.config/pata/launcher.toml y editá.
+#
+# Un marco es una lista de superficies (bar | panel | dock). Cada una se ancla
+# a un borde (top/bottom/left/right) y reparte widgets en sus slots
+# start/center/end. Las barras sólidas reservan su franja; autohide/dock/panel
+# flotan. Inspeccioná el resultado con:
+# cargo run -p pata-config --bin pata -- --config este.toml --screen 1920x1080
+
+[general]
+timezone = "auto" # "auto" lo detecta del sistema; o un IANA como "America/Lima"
+
+# --- Barra superior: el menú clásico tipo waybar/eww ---
+[[surfaces]]
+kind = "bar"
+anchor = "top"
+thickness = 32
+gap = 16
+padding = 12
+
+[[surfaces.start]]
+kind = "start_button"
+label = "⊞"
+# Sin `exec`, el clic abre el menú nativo de apps (descubiertas de
+# ~/.config/gioser/apps/*.toml). Para delegar a un lanzador externo en su lugar,
+# fijá p. ej. `exec = "wofi --show drun"`.
+
+[[surfaces.start]]
+kind = "clock"
+format = "%H:%M"
+
+[[surfaces.center]]
+kind = "window_list"
+
+# El racimo de indicadores a la derecha — orden libre.
+[[surfaces.end]]
+kind = "astro" # posición zodiacal del sol + ciclo lunar (aporte)
+moon = true
+
+[[surfaces.end]]
+kind = "clipboard" # preview del texto copiado (vía wl-paste)
+# Click → selector de historial. Ejemplo con cliphist + wofi:
+exec = "cliphist list | wofi --dmenu | cliphist decode | wl-copy"
+
+[[surfaces.end]]
+kind = "volume"
+exec = "pavucontrol" # click en el medidor → mezclador de audio
+
+[[surfaces.end]]
+kind = "brightness"
+
+[[surfaces.end]]
+kind = "tray"
+
+[[surfaces.end]]
+kind = "ram_meter"
+
+[[surfaces.end]]
+kind = "cpu_meter"
+
+# --- Panel: tarjetas flotantes estilo conky sobre el escritorio ---
+# Un panel no reserva franja: sus tarjetas flotan en (x, y) con tamaño (w, h).
+# En layer-shell cada tarjeta es su propia surface en la capa de fondo.
+[[surfaces]]
+kind = "panel"
+
+[[surfaces.cards]]
+x = 40
+y = 80
+w = 220
+h = 110
+title = "sistema"
+
+[[surfaces.cards.widgets]]
+kind = "cpu_meter"
+
+[[surfaces.cards.widgets]]
+kind = "ram_meter"
+
+# --- Sidebar: rail de dientes acoplable con el navegador de Mónadas (Fase 11) ---
+# Colapsado es sólo el rail (una franja de `thickness` px pegada al borde); al
+# clickear un diente despliega su panel (de `panel_width` px) sobre el escritorio
+# con el navegador de las Mónadas de nouser y sus archivos, conmutable
+# árbol/grafo. Requiere un daemon nouser vivo (descubierto por el broker o por el
+# socket default); sin él, el panel muestra "Conectando con nouser…".
+#
+# Abrir un archivo (right-click) lo enruta a la app nativa que declare su mime, o
+# a `xdg-open` si ninguna lo hace. Para que las apps de la suite lo reciban, copiá
+# sus manifiestos a ~/.config/gioser/apps/ (hay ejemplos en
+# shared/app-bus/assets/apps/: media.toml, nada.toml).
+[[surfaces]]
+kind = "sidebar"
+anchor = "left"
+thickness = 44
+panel_width = 300
+
+[[surfaces.tabs]]
+icon = "monads"
+label = "Mónadas"
+content = { kind = "navigator", source = "nouser" }
+
+# --- Shell: barra inferior autoescondible con el input de shuma ---
+# Al escribir, el frontend despliega el resto de shuma estilo Quake.
+[[surfaces]]
+kind = "bar"
+anchor = "bottom"
+thickness = 40
+autohide = true
+
+[[surfaces.center]]
+kind = "shuma_input"
+hotkey = "F12"
+placeholder = "› preguntá, lanzá, navegá"
diff --git a/02_ruway/pata/pata-config/src/lib.rs b/02_ruway/pata/pata-config/src/lib.rs
new file mode 100644
index 0000000..f427e63
--- /dev/null
+++ b/02_ruway/pata/pata-config/src/lib.rs
@@ -0,0 +1,92 @@
+//! `pata-config` — el loader del marco en Linux.
+//!
+//! `pata-core` es `no_std` y no sabe leer archivos; este crate es el puente al
+//! disco: busca el TOML del usuario en las rutas XDG, lo parsea al modelo y, si
+//! no hay nada, cae al [`Config::preset`]. En wawa este rol lo cumple akasha
+//! —el config llega direccionado por contenido—, no este crate.
+
+use std::path::PathBuf;
+
+pub use pata_core::{layout::resolve, Config, Frame, Rect};
+
+/// Las rutas donde se busca `launcher.toml`, en orden de prioridad:
+/// `$XDG_CONFIG_HOME/pata/` y luego `$HOME/.config/pata/`.
+pub fn candidate_paths() -> Vec<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());
+ }
+}
diff --git a/02_ruway/pata/pata-config/src/main.rs b/02_ruway/pata/pata-config/src/main.rs
new file mode 100644
index 0000000..6da3e6c
--- /dev/null
+++ b/02_ruway/pata/pata-config/src/main.rs
@@ -0,0 +1,196 @@
+//! `pata` — inspector del marco.
+//!
+//! Carga el `launcher.toml` del usuario (o el preset) y muestra cómo `pata`
+//! resuelve las superficies sobre una pantalla dada: el rect de cada barra/
+//! dock/panel, si reserva franja, sus widgets por slot, y el área de trabajo
+//! que le queda al compositor. Sirve para autorear configs y ver la geometría
+//! sin levantar la UI.
+//!
+//! Con `--widgets` además materializa cada widget y lo `tick`ea con un contexto
+//! de muestra, mostrando el view-model que el frontend recibiría —los `kind`s
+//! que el core aún no implementa salen como `placeholder`—.
+//!
+//! ```sh
+//! cargo run -p pata-config --bin pata -- --screen 1920x1080
+//! cargo run -p pata-config --bin pata -- --config ./mi.toml --screen 2560x1440
+//! cargo run -p pata-config --bin pata -- --widgets
+//! ```
+
+use std::process::ExitCode;
+
+use pata_core::widget::{self, ClockReading, WidgetCtx, WidgetView};
+use pata_core::{Config, Rect, Surface, SurfaceKind, WidgetSpec};
+
+fn main() -> ExitCode {
+ let args: Vec<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()?))
+}
diff --git a/02_ruway/pata/pata-core/Cargo.toml b/02_ruway/pata/pata-core/Cargo.toml
new file mode 100644
index 0000000..159715c
--- /dev/null
+++ b/02_ruway/pata/pata-core/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "pata-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pata — el marco declarativo del escritorio: barras, paneles y dock con widgets colocables desde un archivo de config. Núcleo agnóstico (no_std + alloc) que comparten el frontend Llimphi (Linux, sobre el compositor mirada) y el kernel launcher de wawa. No conoce a Llimphi, ni a Wayland, ni al framebuffer: sólo el modelo."
+
+[dependencies]
+# pata-core cruza la frontera a wawa por `path` (como mirada-layout): mantiene
+# sus dependencias autocontenidas y `no_std`. `serde` es opcional —el modelo se
+# construye y consulta sin (de)serializar—; los loaders de cada plataforma
+# (TOML en Linux, akasha en wawa) activan la feature. `default-features = false`
+# evita arrastrar `std` aun con serde activo.
+serde = { version = "1", optional = true, default-features = false, features = ["derive", "alloc"] }
+
+[features]
+default = ["serde"]
+serde = ["dep:serde"]
+
+[dev-dependencies]
+# Sólo para los tests: fija el contrato del config en disco (Linux usa TOML).
+# No entra en la build `no_std` —dev-deps no cruzan a wawa—.
+toml = "0.8"
+# Verifica el round-trip del espejo `wire` por postcard (el codec de akasha en
+# wawa): postcard no soporta flatten/untagged, de ahí el espejo. Sólo en tests.
+postcard = { version = "1", features = ["alloc"] }
diff --git a/02_ruway/pata/pata-core/src/config.rs b/02_ruway/pata/pata-core/src/config.rs
new file mode 100644
index 0000000..a52fcab
--- /dev/null
+++ b/02_ruway/pata/pata-core/src/config.rs
@@ -0,0 +1,491 @@
+//! El esquema declarativo de `pata`.
+//!
+//! El archivo de config manda; el código no asume superficies ni widgets que
+//! el config no nombre. Un [`Config`] es **una lista de [`Surface`]s** —no un
+//! único panel—: el usuario despliega tantas barras, paneles y docks como
+//! quiera, ancla cada uno a un borde, y reparte los widgets en sus slots
+//! (`start` / `center` / `end`) con total libertad.
+//!
+//! El modelo es agnóstico del formato en disco: en Linux un loader TOML
+//! deserializa directo a estos tipos (vía `serde`); en wawa el config llega por
+//! akasha. Los valores de propiedad de cada widget se guardan como [`Prop`],
+//! un enum cerrado y `no_std` —ni `toml::Value` ni nada atado a una
+//! plataforma—.
+
+use alloc::collections::BTreeMap;
+use alloc::string::{String, ToString};
+use alloc::vec;
+use alloc::vec::Vec;
+
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+/// El borde de la pantalla al que se ancla una superficie.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
+pub enum Anchor {
+ /// Pegada al borde superior — la posición clásica de una barra de menú.
+ #[default]
+ Top,
+ /// Pegada al borde inferior — donde suele ir el dock o el shell.
+ Bottom,
+ /// Pegada al borde izquierdo (superficie vertical).
+ Left,
+ /// Pegada al borde derecho (superficie vertical).
+ Right,
+}
+
+impl Anchor {
+ /// `true` si la superficie se extiende horizontalmente (top/bottom): su
+ /// grosor es alto y sus slots se reparten en X. `false` para left/right.
+ pub fn es_horizontal(&self) -> bool {
+ matches!(self, Anchor::Top | Anchor::Bottom)
+ }
+}
+
+/// Qué clase de superficie del marco se está describiendo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
+pub enum SurfaceKind {
+ /// Barra fina pegada a un borde (estilo waybar/eww): tres slots
+ /// (`start` / `center` / `end`) con widgets en línea. Reserva su franja al
+ /// compositor para que las ventanas no la tapen.
+ #[default]
+ Bar,
+ /// Panel: un área donde flotan [`FloatingCard`]s posicionadas en píxeles
+ /// (estilo conky). No reserva franja; vive sobre el escritorio.
+ Panel,
+ /// Dock: lanzadores y/o ventanas abiertas, centrado en su borde y del
+ /// tamaño justo de su contenido. Puede autoesconderse.
+ Dock,
+ /// Sidebar acoplable: un **rail de dientes** vertical pegado a un borde
+ /// (left/right). Cada diente ([`SidebarTab`]) despliega un panel con su
+ /// widget de contenido (lógica de launcher: colapsado = sólo el rail,
+ /// activar un diente despliega su panel sobre el escritorio). El rail
+ /// reserva su grosor como una barra; si `autohide`, no reserva y reaparece
+ /// al rozar el borde. El panel desplegado flota, no reserva.
+ Sidebar,
+}
+
+/// El valor de una propiedad de widget, agnóstico del formato en disco. El
+/// loader de cada plataforma (TOML, RON, akasha) deserializa a esto; los
+/// widgets lo leen con los helpers de [`WidgetSpec`].
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[cfg_attr(feature = "serde", serde(untagged))]
+pub enum Prop {
+ /// Booleano (`true` / `false`).
+ Bool(bool),
+ /// Entero — se prueba antes que [`Prop::Num`] para no perder la
+ /// distinción int/float de formatos como TOML.
+ Int(i64),
+ /// Número de punto flotante.
+ Num(f64),
+ /// Cadena de texto.
+ Str(String),
+}
+
+impl Prop {
+ /// La prop como `&str`, si lo es.
+ pub fn as_str(&self) -> Option<&str> {
+ match self {
+ Prop::Str(s) => Some(s),
+ _ => None,
+ }
+ }
+
+ /// La prop como número: un [`Prop::Int`] se promueve a `f64`.
+ pub fn as_num(&self) -> Option<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");
+ }
+}
diff --git a/02_ruway/pata/pata-core/src/layout.rs b/02_ruway/pata/pata-core/src/layout.rs
new file mode 100644
index 0000000..698cf86
--- /dev/null
+++ b/02_ruway/pata/pata-core/src/layout.rs
@@ -0,0 +1,289 @@
+//! Resolución geométrica del marco: de [`Config`] + pantalla a superficies
+//! colocadas en píxeles + el **área de trabajo** que queda libre.
+//!
+//! Es pura geometría —`no_std`, determinista, sin servidor gráfico—. Dos
+//! consumidores la necesitan:
+//!
+//! - el **frontend** (Llimphi / framebuffer wawa), para saber dónde pintar cada
+//! barra/dock/panel;
+//! - el **compositor** (`mirada`), para saber qué franja reservar: el
+//! [`Frame::work_area`] es exactamente el rectángulo donde teselar las
+//! ventanas, ya descontadas las barras sólidas.
+//!
+//! Reglas de reserva:
+//! - una **Bar** no-`autohide` reserva su grosor del borde y encoge el área;
+//! - una **Bar** `autohide`, un **Dock** y un **Panel** *no* reservan: flotan
+//! sobre el escritorio (su rect se calcula, pero el área de trabajo no cambia).
+
+use alloc::vec::Vec;
+
+use crate::config::{Anchor, Config, SurfaceKind};
+
+/// Un rectángulo en píxeles de pantalla. Origen `(0,0)` arriba-izquierda; `x`
+/// crece a la derecha, `y` hacia abajo. Propio de `pata` —no depende de
+/// `mirada`— para que el marco sea independiente del compositor.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Rect {
+ pub x: i32,
+ pub y: i32,
+ pub w: i32,
+ pub h: i32,
+}
+
+impl Rect {
+ pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
+ Self { x, y, w, h }
+ }
+
+ /// `true` si tiene ancho y alto positivos.
+ pub fn es_visible(&self) -> bool {
+ self.w > 0 && self.h > 0
+ }
+}
+
+/// Una superficie ya colocada: su índice en `config.surfaces` y su rect.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Placed {
+ /// Índice dentro de [`Config::surfaces`], para recuperar sus widgets.
+ pub index: usize,
+ /// Rectángulo en píxeles donde va la superficie.
+ pub rect: Rect,
+ /// `true` si reservó franja (encogió el área de trabajo).
+ pub reserva: bool,
+}
+
+/// El resultado de resolver el marco: las superficies colocadas y el área que
+/// queda para las ventanas.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Frame {
+ /// Superficies en el mismo orden que `config.surfaces`.
+ pub surfaces: Vec<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());
+ }
+}
diff --git a/02_ruway/pata/pata-core/src/lib.rs b/02_ruway/pata/pata-core/src/lib.rs
new file mode 100644
index 0000000..9f1c848
--- /dev/null
+++ b/02_ruway/pata/pata-core/src/lib.rs
@@ -0,0 +1,47 @@
+//! `pata-core` — el modelo del marco del escritorio.
+//!
+//! `pata` (quechua: borde, repisa, andén) es la capa que dibuja el *marco* del
+//! escritorio: las **barras**, los **paneles** y el **dock** que rodean a las
+//! ventanas, y los **widgets** que viven dentro. No es el compositor (eso es
+//! `mirada`) ni el shell (eso es `shuma`): es el chrome configurable que ambos
+//! mundos comparten.
+//!
+//! Este crate es sólo el **modelo**, deliberadamente tonto y portable:
+//!
+//! - [`config`] — el esquema declarativo. Un [`Config`] es una lista de
+//! [`Surface`]s (bar/panel/dock), cada una anclada a un borde y con widgets
+//! colocables en sus slots. Es lo que un archivo de config (TOML en Linux,
+//! akasha en wawa) materializa.
+//! - [`layout`] — la geometría: resuelve un `Config` + pantalla en superficies
+//! colocadas en píxeles + el área de trabajo que queda para las ventanas.
+//! - [`widget`] — la lógica de datos de cada widget: un [`config::WidgetSpec`]
+//! se materializa en un [`widget::Widget`] vivo que, alimentado por un
+//! snapshot del sistema, emite un view-model agnóstico del pincel.
+//!
+//! No pinta, no toca el SO, no sabe quién lo renderiza. Por eso es `no_std` +
+//! `alloc`: el mismo modelo sirve al frontend Llimphi sobre Linux y al kernel
+//! launcher de wawa (`x86_64-unknown-none`), que aporta su propio allocator.
+//! La regla del repo: si un tipo se comparte entre kernel y userspace, vive
+//! sin `std`.
+
+#![cfg_attr(not(test), no_std)]
+#![forbid(unsafe_code)]
+
+extern crate alloc;
+
+pub mod config;
+pub mod layout;
+pub mod widget;
+/// Espejo postcard-safe del modelo, para el cruce a wawa por akasha. Sólo con la
+/// feature `serde` (el kernel la activa; el camino TOML de Linux no lo necesita).
+#[cfg(feature = "serde")]
+pub mod wire;
+
+pub use config::{
+ Anchor, Config, FloatingCard, General, Prop, SidebarTab, Surface, SurfaceKind, WidgetSpec,
+};
+pub use layout::{resolve, Frame, Placed, Rect};
+pub use widget::{
+ build, build_all, Astro, Clock, ClockReading, Meter, MeterSource, Placeholder, StartButton,
+ Widget, WidgetCtx, WidgetView,
+};
diff --git a/02_ruway/pata/pata-core/src/widget.rs b/02_ruway/pata/pata-core/src/widget.rs
new file mode 100644
index 0000000..9911dd8
--- /dev/null
+++ b/02_ruway/pata/pata-core/src/widget.rs
@@ -0,0 +1,663 @@
+//! El modelo de widget de `pata`: **lógica de datos sin pincel**.
+//!
+//! Un [`WidgetSpec`] (lo que el config declara) se materializa en un objeto
+//! [`Widget`] vivo. El widget no sabe dibujar: cada `tick` refresca su estado a
+//! partir de un [`WidgetCtx`] —un snapshot agnóstico del sistema que el host
+//! muestrea (reloj, CPU, RAM, volumen, brillo…)— y `view` emite un
+//! [`WidgetView`], un view-model que describe *qué* mostrar (texto, medidor,
+//! placeholder) sin decir *cómo*. El frontend (Llimphi en Linux, framebuffer en
+//! wawa) traduce ese view-model a su pincel.
+//!
+//! La frontera está donde tiene que estar: el core es `no_std` y determinista,
+//! así que **no lee el reloj ni los contadores del kernel** —eso son syscalls—.
+//! El host los muestrea y los entrega en el [`WidgetCtx`]; el core sólo formatea
+//! y compone. Misma lógica de datos para los dos mundos; cada uno aporta su
+//! sampler y su pincel.
+
+use alloc::boxed::Box;
+use alloc::format;
+use alloc::string::{String, ToString};
+use alloc::vec::Vec;
+
+use crate::config::WidgetSpec;
+
+/// Lectura del reloj descompuesta. El host la rellena desde su fuente de tiempo
+/// (en Linux, la zona horaria de `general.timezone`); el core sólo la formatea.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct ClockReading {
+ /// Año con siglo (p. ej. `2026`).
+ pub year: u16,
+ /// Mes `1..=12`.
+ pub month: u8,
+ /// Día del mes `1..=31`.
+ pub day: u8,
+ /// Día de la semana, `0` = domingo … `6` = sábado.
+ pub weekday: u8,
+ /// Hora `0..=23`.
+ pub hour: u8,
+ /// Minuto `0..=59`.
+ pub minute: u8,
+ /// Segundo `0..=59`.
+ pub second: u8,
+}
+
+/// El snapshot del sistema que alimenta a los widgets en cada `tick`. El host
+/// lo muestrea (vía sysfs/PulseAudio en Linux, vía el kernel en wawa) y lo pasa
+/// por valor: el core no toca el SO. Todos los campos arrancan en cero, así que
+/// un frontend puede llenar sólo lo que le importe.
+#[derive(Debug, Clone, Copy, PartialEq, Default)]
+pub struct WidgetCtx {
+ /// Hora actual ya descompuesta.
+ pub clock: ClockReading,
+ /// Uso de CPU, fracción `0.0..=1.0`.
+ pub cpu: f32,
+ /// Uso de RAM, fracción `0.0..=1.0`.
+ pub ram: f32,
+ /// RAM usada en MiB (para la leyenda del medidor).
+ pub ram_used_mb: u32,
+ /// RAM total en MiB.
+ pub ram_total_mb: u32,
+ /// Volumen, fracción `0.0..=1.0`.
+ pub volume: f32,
+ /// `true` si el audio está silenciado.
+ pub muted: bool,
+ /// Brillo de pantalla, fracción `0.0..=1.0`.
+ pub brightness: f32,
+ /// Longitud eclíptica del Sol en grados `0..360` — la posición zodiacal. El
+ /// host la computa (en Linux, vía una efeméride; ver `pata-llimphi::sampler`);
+ /// el widget [`Astro`] la mapea a signo + grado.
+ pub sun_longitude_deg: f32,
+ /// Fase lunar como fracción del ciclo sinódico `0.0..=1.0`: `0` = luna nueva,
+ /// `0.5` = llena, de vuelta a `1` = nueva.
+ pub moon_phase: f32,
+}
+
+/// El view-model que un widget emite: describe qué pintar sin atarse a ningún
+/// pincel. El frontend hace el match y lo traduce a su backend gráfico.
+#[derive(Debug, Clone, PartialEq)]
+pub enum WidgetView {
+ /// Nada que pintar (un widget que aún no tiene datos).
+ Empty,
+ /// Una línea de texto: el reloj, una etiqueta.
+ Text(String),
+ /// Un medidor: `fraction` en `0.0..=1.0`, una `caption` ya formateada y una
+ /// `label` opcional (el nombre corto, p. ej. `"CPU"`).
+ Meter {
+ /// Etiqueta corta, o `None` si el widget la oculta.
+ label: Option<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 { .. }));
+ }
+ }
+}
diff --git a/02_ruway/pata/pata-core/src/wire.rs b/02_ruway/pata/pata-core/src/wire.rs
new file mode 100644
index 0000000..cdfbce6
--- /dev/null
+++ b/02_ruway/pata/pata-core/src/wire.rs
@@ -0,0 +1,320 @@
+//! Representación **postcard-safe** del marco para akasha (wawa).
+//!
+//! El modelo de [`Config`](crate::Config) está afinado para el loader TOML de
+//! Linux: `WidgetSpec.props` usa `#[serde(flatten)]` (para que las props vivan al
+//! nivel del `kind`) y [`Prop`] es `#[serde(untagged)]` (para que TOML infiera el
+//! tipo). Ambos atributos **rompen postcard** —el codec de akasha—, que no es
+//! auto-descriptivo y no soporta `deserialize_any`.
+//!
+//! Este módulo define un espejo *plano y etiquetado* del modelo: [`WireConfig`]
+//! es exactamente la misma información, pero serializable con cualquier formato
+//! binario no auto-descriptivo. El kernel de wawa serializa el `WireConfig` con
+//! postcard, lo guarda en el grafo direccionado por contenido y lo lee de vuelta
+//! —el config del marco viaja por akasha como cualquier otro objeto—. En Linux
+//! el camino TOML sigue usando `Config` directo; este espejo es sólo para el
+//! cruce a wawa.
+//!
+//! Las conversiones son **sin pérdida**: `Config → WireConfig → Config` devuelve
+//! el mismo modelo (lo fija un test).
+
+use alloc::string::String;
+use alloc::vec::Vec;
+
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+use crate::config::{
+ Anchor, Config, FloatingCard, General, Prop, SidebarTab, Surface, SurfaceKind, WidgetSpec,
+};
+
+/// Valor de propiedad **etiquetado** (a diferencia de [`Prop`], que es
+/// `untagged`): así postcard puede deserializarlo sin auto-descripción.
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub enum WireProp {
+ Bool(bool),
+ Int(i64),
+ Num(f64),
+ Str(String),
+}
+
+impl From<&Prop> for WireProp {
+ fn from(p: &Prop) -> Self {
+ match p {
+ Prop::Bool(b) => WireProp::Bool(*b),
+ Prop::Int(i) => WireProp::Int(*i),
+ Prop::Num(n) => WireProp::Num(*n),
+ Prop::Str(s) => WireProp::Str(s.clone()),
+ }
+ }
+}
+
+impl From<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);
+ }
+}
diff --git a/02_ruway/pata/pata-core/tests/toml_contract.rs b/02_ruway/pata/pata-core/tests/toml_contract.rs
new file mode 100644
index 0000000..108b729
--- /dev/null
+++ b/02_ruway/pata/pata-core/tests/toml_contract.rs
@@ -0,0 +1,129 @@
+//! El contrato del config en disco: un TOML real debe deserializar al modelo
+//! de `pata-core` sin pérdida. Linux carga TOML; este test fija que el esquema
+//! (superficies múltiples, slots, props `flatten` + `Prop` untagged) sobrevive
+//! el viaje. Vive como test de integración porque `toml` es `std` y dev-only.
+
+use pata_core::{Anchor, Config, Prop, SurfaceKind};
+
+#[test]
+fn deserializa_un_marco_completo_desde_toml() {
+ let src = r#"
+ [general]
+ timezone = "America/Lima"
+
+ [[surfaces]]
+ kind = "bar"
+ anchor = "top"
+ thickness = 28
+
+ [[surfaces.start]]
+ kind = "start_button"
+
+ [[surfaces.start]]
+ kind = "clock"
+ format = "%H:%M"
+ size = 14
+
+ [[surfaces.end]]
+ kind = "astro"
+ moon = true
+ lat = -12.04
+
+ [[surfaces]]
+ kind = "bar"
+ anchor = "bottom"
+ autohide = true
+
+ [[surfaces.center]]
+ kind = "shuma_input"
+ hotkey = "F12"
+ "#;
+
+ let cfg: Config = toml::from_str(src).expect("el TOML debe parsear al modelo");
+
+ assert_eq!(cfg.general.timezone, "America/Lima");
+ assert_eq!(cfg.surfaces.len(), 2);
+
+ let top = &cfg.surfaces[0];
+ assert_eq!(top.kind, SurfaceKind::Bar);
+ assert_eq!(top.anchor, Anchor::Top);
+ assert_eq!(top.thickness, 28.0);
+ assert_eq!(top.start.len(), 2);
+ assert_eq!(top.start[0].kind, "start_button");
+ // Props heterogéneas (string + int) llegan por flatten/untagged.
+ assert_eq!(top.start[1].str_prop("format", "?"), "%H:%M");
+ assert_eq!(top.start[1].num_prop("size", 0.0), 14.0);
+
+ let astro = &top.end[0];
+ assert_eq!(astro.kind, "astro");
+ assert!(astro.bool_prop("moon", false));
+ assert_eq!(astro.num_prop("lat", 0.0), -12.04);
+
+ let shell = &cfg.surfaces[1];
+ assert_eq!(shell.anchor, Anchor::Bottom);
+ assert!(shell.autohide);
+ assert_eq!(shell.center[0].kind, "shuma_input");
+ assert_eq!(shell.center[0].str_prop("hotkey", "?"), "F12");
+}
+
+#[test]
+fn props_desconocidas_se_conservan() {
+ let src = r#"
+ [[surfaces]]
+ anchor = "right"
+
+ [[surfaces.start]]
+ kind = "custom"
+ color = "rebeccapurple"
+ ratio = 0.42
+ veces = 3
+ "#;
+ let cfg: Config = toml::from_str(src).unwrap();
+ let w = &cfg.surfaces[0].start[0];
+ assert_eq!(w.str_prop("color", "?"), "rebeccapurple");
+ assert_eq!(w.num_prop("ratio", 0.0), 0.42);
+ assert_eq!(w.num_prop("veces", 0.0), 3.0);
+ // anchor sin kind de superficie cae al default Bar.
+ assert_eq!(cfg.surfaces[0].kind, SurfaceKind::Bar);
+}
+
+#[test]
+fn deserializa_un_sidebar_con_dientes_navegador() {
+ // El rail (Fase 11): un SurfaceKind::Sidebar con `tabs` cuyo `content` es un
+ // WidgetSpec con props flatten (la fuente de datos del navegador).
+ let src = r#"
+ [[surfaces]]
+ kind = "sidebar"
+ anchor = "left"
+ thickness = 44
+ panel_width = 300
+
+ [[surfaces.tabs]]
+ icon = "monads"
+ label = "Mónadas"
+ content = { kind = "navigator", source = "nouser" }
+ "#;
+ let cfg: Config = toml::from_str(src).expect("el sidebar debe parsear");
+ let sb = &cfg.surfaces[0];
+ assert_eq!(sb.kind, SurfaceKind::Sidebar);
+ assert_eq!(sb.anchor, Anchor::Left);
+ assert_eq!(sb.thickness, 44.0);
+ assert_eq!(sb.panel_width, 300.0);
+ assert_eq!(sb.tabs.len(), 1);
+ assert_eq!(sb.tabs[0].icon, "monads");
+ assert_eq!(sb.tabs[0].label, "Mónadas");
+ assert_eq!(sb.tabs[0].content.kind, "navigator");
+ // La prop del contenido llega por flatten/untagged.
+ assert_eq!(sb.tabs[0].content.str_prop("source", "?"), "nouser");
+}
+
+#[test]
+fn marco_minimo_y_vacio() {
+ // Sin superficies declaradas: config válido y vacío.
+ let cfg: Config = toml::from_str("").unwrap();
+ assert!(cfg.surfaces.is_empty());
+ assert_eq!(cfg.general.timezone, "auto");
+
+ // Un Prop entero no se confunde con float al volver a leerse.
+ let _ = Prop::Int(1);
+}
diff --git a/02_ruway/pata/pata-host/Cargo.toml b/02_ruway/pata/pata-host/Cargo.toml
new file mode 100644
index 0000000..a7e6462
--- /dev/null
+++ b/02_ruway/pata/pata-host/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "pata-host"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pata-host — protocolo del rail hospedado: una app le presta sus dientes (sidebar) al marco pata cuando tiene foco, y recibe de vuelta qué diente se activó. Socket Unix dedicado + marco postcard con prefijo de longitud. Lado app (HostClient) + lado shell (HostServer)."
+
+[dependencies]
+serde = { workspace = true }
+postcard = { workspace = true }
diff --git a/02_ruway/pata/pata-host/src/client.rs b/02_ruway/pata/pata-host/src/client.rs
new file mode 100644
index 0000000..376c676
--- /dev/null
+++ b/02_ruway/pata/pata-host/src/client.rs
@@ -0,0 +1,65 @@
+//! Lado **app** del rail hospedado: la app se conecta, registra sus dientes y
+//! recibe las activaciones por un callback.
+
+use std::os::unix::net::UnixStream;
+
+use crate::{read_frame, socket_path, write_frame, AppMsg, HostedTooth, ShellMsg};
+
+/// Cliente del rail hospedado, vive en la app. Mantiene la conexión a pata; un
+/// hilo lector entrega las activaciones por el callback dado en [`HostClient::connect`].
+/// Al soltarse (`Drop`) manda `Bye`.
+pub struct HostClient {
+ write: UnixStream,
+}
+
+impl HostClient {
+ /// Se conecta al socket de pata, registra `app_id`/`title`/`teeth` y arranca el
+ /// hilo lector. `on_activate(tooth)` se invoca (en ese hilo) cada vez que el
+ /// usuario clickea un diente en el rail de pata. `None` si pata no está
+ /// escuchando — la app sigue andando con su propio rail.
+ ///
+ /// `app_id` **debe** ser el mismo que el compositor reporta para la ventana de
+ /// la app (el `app_id()` del trait `App` de llimphi), para que pata correlacione
+ /// foco ↔ dientes.
+ pub fn connect<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);
+ }
+}
diff --git a/02_ruway/pata/pata-host/src/lib.rs b/02_ruway/pata/pata-host/src/lib.rs
new file mode 100644
index 0000000..5a46de2
--- /dev/null
+++ b/02_ruway/pata/pata-host/src/lib.rs
@@ -0,0 +1,179 @@
+//! `pata-host` — el **rail hospedado**: el protocolo por el que una app le presta
+//! sus "dientes" (su sidebar) al marco `pata` mientras tiene foco, y recibe de
+//! vuelta qué diente activó el usuario.
+//!
+//! La idea (visión del autor): una app como `cosmos` puede dejar de pintar su
+//! propio rail y quedar como **puro lienzo**; sus herramientas aparecen en el rail
+//! global de pata cuando la app está enfocada. Al clickear un diente en pata, el
+//! comando vuelve a la app, que muestra ese panel sobre su propio canvas. pata
+//! sólo hospeda el **rail** (los dientes) — no los paneles ricos de la app.
+//!
+//! ## Transporte
+//!
+//! Un socket Unix dedicado ([`socket_path`], default
+//! `$XDG_RUNTIME_DIR/pata-sidebar.sock`). pata escucha ([`HostServer`]); las apps
+//! se conectan ([`HostClient`]). Cada conexión es un stream con marco
+//! **prefijo-de-longitud + postcard** (igual que `mirada-link`):
+//!
+//! ```text
+//! app → shell: [u32 LE len][postcard AppMsg]… (Register, Update, Bye)
+//! shell → app: [u32 LE len][postcard ShellMsg]… (Activate)
+//! ```
+//!
+//! pata correlaciona el `app_id` que la app declara en `Register` con el `app_id`
+//! del toplevel enfocado (que ya conoce vía wlr-foreign-toplevel). Cuando coinciden,
+//! pinta los dientes de esa app; al clickear uno, le manda `Activate{tooth}`.
+
+#![forbid(unsafe_code)]
+
+use std::io::{self, Read, Write};
+use std::path::PathBuf;
+
+use serde::{de::DeserializeOwned, Deserialize, Serialize};
+
+// =====================================================================
+// Wire types
+// =====================================================================
+
+/// Un diente que la app presta al rail de pata: id opaco (asignado por la app),
+/// nombre de icono (mismo vocabulario abierto que `pata_core::SidebarTab::icon`)
+/// y etiqueta (tooltip).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct HostedTooth {
+ /// Identificador del diente, opaco para pata; vuelve tal cual en [`ShellMsg::Activate`].
+ pub id: u32,
+ /// Nombre del icono (p. ej. `"folder"`, `"tools"`, `"astro"`).
+ pub icon: String,
+ /// Etiqueta corta (tooltip del diente).
+ pub label: String,
+}
+
+impl HostedTooth {
+ pub fn new(id: u32, icon: impl Into<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());
+ }
+}
diff --git a/02_ruway/pata/pata-host/src/server.rs b/02_ruway/pata/pata-host/src/server.rs
new file mode 100644
index 0000000..cf215a3
--- /dev/null
+++ b/02_ruway/pata/pata-host/src/server.rs
@@ -0,0 +1,157 @@
+//! Lado **shell** del rail hospedado: pata escucha el socket, acumula los dientes
+//! que registran las apps y les reenvía las activaciones.
+
+use std::collections::HashMap;
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+
+use crate::{read_frame, socket_path, write_frame, AppMsg, HostedTooth, ShellMsg};
+
+/// Una app registrada: su título, sus dientes y la mitad de escritura de su
+/// conexión (para mandarle `Activate`).
+struct AppReg {
+ title: String,
+ teeth: Vec<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);
+}
diff --git a/02_ruway/pata/pata-host/tests/roundtrip.rs b/02_ruway/pata/pata-host/tests/roundtrip.rs
new file mode 100644
index 0000000..25326bd
--- /dev/null
+++ b/02_ruway/pata/pata-host/tests/roundtrip.rs
@@ -0,0 +1,68 @@
+//! Integración server↔client sobre un socket Unix real: el registro de dientes
+//! llega al server, una activación vuelve a la app, y la baja se infiere al
+//! soltar el cliente.
+//!
+//! Un solo `#[test]` a propósito: el path del socket se fija por env (proceso-
+//! global), así que dos tests en paralelo competirían por él.
+
+use std::sync::mpsc;
+use std::time::{Duration, Instant};
+
+use pata_host::{HostClient, HostServer, HostedTooth};
+
+/// Reintenta `f` hasta que devuelva `Some` o venza `dur`.
+fn esperar<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);
+}
diff --git a/02_ruway/pata/pata-llimphi/Cargo.toml b/02_ruway/pata/pata-llimphi/Cargo.toml
new file mode 100644
index 0000000..b9d3618
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/Cargo.toml
@@ -0,0 +1,73 @@
+[package]
+name = "pata-llimphi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pata — el frontend Linux: monta el modelo de pata-core sobre Llimphi. Resuelve la geometría de las superficies (barras/paneles/dock) y pinta cada widget traduciendo su view-model agnóstico al backend gráfico. Muestrea el sistema (reloj, CPU, RAM, volumen, brillo) en un WidgetCtx que alimenta a los widgets. Corre sobre el compositor mirada."
+
+[[bin]]
+name = "pata-llimphi"
+path = "src/main.rs"
+
+[lib]
+path = "src/lib.rs"
+
+[dependencies]
+pata-core = { path = "../pata-core" }
+pata-config = { path = "../pata-config" }
+# Rail hospedado (sidebar delegado): pata hospeda los dientes de la app enfocada
+# y le reenvía las activaciones. Lado servidor del protocolo.
+pata-host = { path = "../pata-host" }
+# El puente pata→shuma (SDD §5): el marco provee el borde, shuma la ejecución.
+# El drawer Quake corre los comandos por el ejecutor real de shuma (captura
+# acotada, streaming) en vez de un `sh -c` pelado.
+shuma-exec = { workspace = true }
+# El cerebro de la línea de shuma (agnóstico de GUI): descompone el comando en
+# etapas de pipe para las chips clickeables del drawer.
+shuma-line = { workspace = true }
+# IA en el drawer Quake: el submit sin prefijo `!`/`$` va al LLM (fachada
+# transparente de pluma; cae a Mock sin credenciales). Paridad con el quake de
+# mirada-launcher, que pata reemplaza (SDD Fase 10).
+pluma-llm = { workspace = true }
+# Registro de apps para el menú del botón de inicio (descubre ~/.config/gioser/
+# apps/*.toml). El menú nativo reemplaza a depender de wofi/rofi externos.
+app-bus = { workspace = true }
+llimphi-ui = { workspace = true }
+# Área de scroll del historial del drawer (clip + offset + barra arrastrable).
+llimphi-widget-scroll = { workspace = true }
+# Sidebars acoplables (Fase 11c): rail de dientes + navegador de Mónadas/archivos
+# + toggle Árbol/Grafo. El plano de datos lo provee nouser vía chasqui-card.
+llimphi-widget-dock-rail = { workspace = true }
+llimphi-widget-navigator = { workspace = true }
+llimphi-widget-segmented = { workspace = true }
+# Protocolo de query de nouser (list_monads / resolve_monad) — fuente
+# autoritativa de qué archivos componen una Mónada. Importamos sólo los wire
+# types + cliente blocking, sin arrastrar el engine (notify/sled/blake3).
+chasqui-card = { workspace = true }
+# Descubrimiento del socket del daemon vía broker brahman (Card consumer +
+# await_provider_blocking), con fallback al default path. Mismo patrón que
+# chasqui-explorer-llimphi.
+card-sidecar = { workspace = true }
+# MonadId = Ulid: derivamos NavId deterministas de sus bytes; los tests crean ids.
+ulid = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-motion = { workspace = true }
+chrono = { workspace = true }
+# Backend wlr-layer-shell (Linux/Wayland): hace que pata se siente al nivel de
+# eww/waybar — layer surface anclada con exclusive zone, no una ventana cliente.
+smithay-client-toolkit = "0.19"
+wayland-client = "0.31"
+# Lista de ventanas (widget `window_list`): wlr-foreign-toplevel-management, el
+# protocolo que usan waybar/eww para enumerar y activar toplevels en wlroots.
+wayland-protocols-wlr = { version = "0.3", features = ["client"] }
+# Bandeja del sistema (widget `tray`): StatusNotifierItem sobre D-Bus. pata corre
+# como watcher+host en un hilo aparte con zbus async sobre un runtime tokio
+# current-thread (el workspace fija zbus con la feature `tokio`, no la blocking).
+zbus = { workspace = true }
+tokio = { workspace = true }
+# Decodificar PNG de íconos del tray resueltos por nombre (IconName).
+image = { workspace = true }
+raw-window-handle = { workspace = true }
+pollster = { workspace = true }
diff --git a/02_ruway/pata/pata-llimphi/src/keys.rs b/02_ruway/pata/pata-llimphi/src/keys.rs
new file mode 100644
index 0000000..712afae
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/keys.rs
@@ -0,0 +1,74 @@
+//! Parser mínimo `string → Key` para los hotkeys declarados en el config.
+//!
+//! Cubre teclas únicas: `F1..F12`, `Escape/Esc`, `Enter/Return`, `Tab`,
+//! `Space`, `Backspace`, o un carácter suelto (`a`, `/`). Sin modifiers
+//! (`Ctrl+Space`) por ahora — falla cerrado si no parsea.
+
+use llimphi_ui::{Key, NamedKey};
+
+/// Convierte una etiqueta tipo `"F12"` al `Key` correspondiente.
+pub fn parse(label: &str) -> Option<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)));
+ }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/layer.rs b/02_ruway/pata/pata-llimphi/src/layer.rs
new file mode 100644
index 0000000..3902436
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/layer.rs
@@ -0,0 +1,1761 @@
+//! Backend `wlr-layer-shell`: hace que `pata` se siente **al nivel de eww/
+//! waybar** en cualquier compositor wlroots (Hyprland, Sway, river…), no como
+//! una ventana cliente.
+//!
+//! Una *layer surface* se ancla a un borde y declara una *exclusive zone* —el
+//! compositor le reserva esa franja y tesela el resto alrededor—, igual que eww.
+//! Aquí: nos conectamos a Wayland con `smithay-client-toolkit`, creamos **una
+//! layer surface por cada superficie `Bar`** de la config (cada una anclada a su
+//! borde con su exclusive zone), sacamos su `wgpu::Surface` de los punteros raw
+//! del `wl_surface`/`wl_display` (envuelta en [`RawSurface`]) y la pintamos
+//! reusando el pipeline de Llimphi (`mount → compute → paint → render`).
+//!
+//! **Estado**: pinta todas las barras de la config (varios bordes a la vez),
+//! con input (teclado/clicks), drawer Quake, window_list, clipboard y tray.
+//! Verificado en Hyprland. No se verifica headless: se itera en un compositor
+//! real.
+//!
+//! **Gotcha Vulkan WSI + smithay (mirada):** NO reconfigurar el swapchain por
+//! cuadro. [`Self::draw`] llama a `surface.resize(w, h)` cada frame; por eso
+//! `RawSurface::resize` es no-op cuando el tamaño no cambia. Reconfigurar el
+//! swapchain reconstruye el `wl_buffer` y destruye el recién presentado antes de
+//! que el compositor lo componga — wlroots lo tolera, smithay no (la barra queda
+//! negra, el compositor ve `buffer=None`). Ver commit `b8747b90`.
+
+use std::error::Error;
+use std::ffi::c_void;
+use std::ptr::NonNull;
+
+use raw_window_handle::{
+ RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle,
+};
+use smithay_client_toolkit::{
+ compositor::{CompositorHandler, CompositorState, Region},
+ delegate_compositor, delegate_keyboard, delegate_layer, delegate_output, delegate_pointer,
+ delegate_registry, delegate_seat,
+ output::{OutputHandler, OutputState},
+ registry::{ProvidesRegistryState, RegistryState},
+ registry_handlers,
+ seat::{
+ keyboard::{KeyEvent as KbEvent, KeyboardHandler, Keysym, Modifiers},
+ pointer::{PointerEvent, PointerEventKind, PointerHandler, BTN_LEFT, BTN_RIGHT},
+ Capability, SeatHandler, SeatState,
+ },
+ shell::{
+ wlr_layer::{
+ Anchor as LayerAnchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler,
+ LayerSurface, LayerSurfaceConfigure,
+ },
+ WaylandSurface,
+ },
+};
+use wayland_client::{
+ event_created_child,
+ globals::registry_queue_init,
+ protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface},
+ Connection, Dispatch, Proxy, QueueHandle,
+};
+use wayland_protocols_wlr::foreign_toplevel::v1::client::{
+ zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1},
+ zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1, EVT_TOPLEVEL_OPCODE},
+};
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_compositor::{
+ hit_test_click, hit_test_hover, hit_test_scroll, measure_text_node, mount, paint, DragFn,
+ DragPhase, Mounted,
+};
+use llimphi_ui::llimphi_hal::{wgpu, Hal, RawSurface, Surface as _};
+use llimphi_ui::llimphi_layout::{taffy, ComputedLayout, LayoutTree};
+use llimphi_ui::llimphi_raster::{peniko::color::palette, vello, Renderer};
+use llimphi_ui::llimphi_text::Typesetter;
+
+use pata_core::config::FloatingCard;
+use pata_core::widget::{Widget, WidgetCtx};
+use pata_core::{Anchor, Config, SurfaceKind};
+
+use crate::nouser::{self, MembersOutcome, NavState, PollOutcome};
+use crate::sampler::SamplerHandle;
+use pata_host::HostServer;
+use crate::toplevel::{Toplevel, WindowEntry};
+use crate::tray::TrayHandle;
+use crate::{render, Model, Msg};
+
+use std::sync::mpsc::{Receiver, Sender};
+
+/// Traza de diagnóstico gateada por `PATA_DIAG` (cualquier valor la enciende).
+/// Para depurar el camino layer-shell en hardware sin recompilar A/B:
+/// `PATA_DIAG=1 pata-llimphi 2>&1 | tee /tmp/pata.log`.
+macro_rules! diag {
+ ($($a:tt)*) => {
+ if std::env::var_os("PATA_DIAG").is_some() {
+ eprintln!($($a)*);
+ }
+ };
+}
+
+/// El estado wgpu de **una** layer surface (una barra). El `Hal` (instancia +
+/// device de wgpu) se comparte entre todas las barras, en [`LayerApp::hal`].
+struct PanelGpu {
+ surface: RawSurface,
+ renderer: Renderer,
+ typesetter: Typesetter,
+ scene: vello::Scene,
+ layout: LayoutTree,
+}
+
+/// El árbol pintado en el último frame de un panel, para hacer hit-test de los
+/// clicks (qué nodo está bajo el puntero y qué `on_click` dispara).
+struct RenderCache {
+ mounted: Mounted<Msg>,
+ computed: ComputedLayout,
+}
+
+/// Un arrastre en curso sobre un nodo arrastrable (p. ej. un nodo del grafo del
+/// navegador, que selecciona al soltar). El backend layer-shell rastrea el press→
+/// move→release mínimo para invocar el handler `draggable` del nodo —el bucle
+/// winit lo hace nativo; acá lo replicamos a mano para que el modo grafo
+/// seleccione también bajo Wayland.
+struct LayerDrag {
+ /// El handler del nodo: `Fn(DragPhase, dx, dy) -> Option<Msg>`.
+ handler: DragFn<Msg>,
+ /// Última posición del puntero, para el delta de cada `Move`.
+ last: (f32, f32),
+}
+
+/// El estado de una tarjeta flotante (estilo conky) montada como su propia layer
+/// surface: la spec (título/tamaño) + sus widgets vivos. La diferencia con una
+/// barra es que vive en `Layer::Bottom` sobre el escritorio, no reserva franja y
+/// no toma teclado.
+struct CardState {
+ spec: FloatingCard,
+ widgets: Vec<Box<dyn Widget>>,
+}
+
+/// Una layer surface de pata: o una **barra** anclada a un borde (con sus tres
+/// slots), o una **tarjeta flotante** (`card`). En ambos casos lleva su propio
+/// estado wgpu y su cache de hit-test.
+struct Panel {
+ /// Índice de su superficie en `cfg.surfaces` (la barra, o el `Panel` dueño de
+ /// la tarjeta).
+ idx: usize,
+ /// `Some` si esta surface es una tarjeta flotante; `None` si es una barra.
+ card: Option<CardState>,
+ layer: LayerSurface,
+ /// El árbol del último frame (para hit-test de clicks).
+ cache: Option<RenderCache>,
+ width: u32,
+ height: u32,
+ /// `true` cuando hay algo nuevo que pintar (cambió el muestreo o el tamaño).
+ dirty: bool,
+ /// Nodo bajo el puntero en este panel (para `hover_fill` y, a futuro,
+ /// tooltips). `None` si el puntero no está sobre ningún nodo hovereable.
+ hover_idx: Option<usize>,
+ gpu: Option<PanelGpu>,
+}
+
+/// Alto del drawer Quake cuando se despliega (px). El compositor lo clampa a la
+/// salida; la barra crece hacia arriba hasta este alto.
+const DRAWER_H: u32 = 420;
+
+/// Alto de la barra superior cuando despliega el menú de inicio (px): crece hacia
+/// abajo hasta este alto, manteniendo su exclusive zone en el grosor de la barra.
+const MENU_H: u32 = 480;
+
+/// El cliente Wayland del backend layer-shell.
+struct LayerApp {
+ registry_state: RegistryState,
+ output_state: OutputState,
+ seat_state: SeatState,
+ conn: Connection,
+ /// `Hal` compartido (una instancia/device de wgpu para todas las barras).
+ hal: Option<Hal>,
+ keyboard: Option<wl_keyboard::WlKeyboard>,
+ pointer: Option<wl_pointer::WlPointer>,
+ /// El seat (para activar ventanas: `activate(seat)` lo exige).
+ seat: Option<wl_seat::WlSeat>,
+ /// El manager de wlr-foreign-toplevel, si el compositor lo expone. `None` en
+ /// compositores sin el protocolo: el `window_list` queda vacío, sin romper.
+ /// Se guarda para mantener vivo el binding (de él cuelgan los eventos de cada
+ /// toplevel), aunque no se vuelva a leer.
+ #[allow(dead_code)]
+ toplevel_mgr: Option<ZwlrForeignToplevelManagerV1>,
+ /// Las ventanas abiertas que reporta el compositor.
+ toplevels: Vec<Toplevel>,
+ /// Contador para asignar [`Toplevel::id`] estables.
+ next_toplevel_id: u32,
+ /// Texto del portapapeles (una línea), para el widget `clipboard`. Se
+ /// re-muestrea con el resto del sistema (~1Hz) vía `wl-paste`.
+ clipboard: Option<String>,
+ /// La bandeja del sistema (StatusNotifierItem), corriendo en su propio hilo.
+ /// `None` si la config no tiene ningún widget `tray`.
+ tray: Option<TrayHandle>,
+ theme: Theme,
+ cfg: Config,
+ surfaces: Vec<crate::SurfaceWidgets>,
+ shuma: crate::shuma::ShumaState,
+ /// Índice (en `panels`) de la barra que hospeda el `shuma_input`, si hay.
+ shuma_panel: Option<usize>,
+ /// Grosor original (px) de esa barra — al que vuelve al replegar el drawer.
+ shuma_bar_px: u32,
+ /// Registro de apps para el menú de inicio (descubierto del dir canónico).
+ registry: app_bus::AppRegistry,
+ /// `true` cuando el menú de inicio está desplegado.
+ menu_open: bool,
+ /// Índice (en `panels`) de la barra que hospeda el `start_button`, si hay.
+ menu_panel: Option<usize>,
+ /// Grosor original (px) de esa barra — al que vuelve al replegar el menú.
+ menu_bar_px: u32,
+ /// Muestreador del sistema en su propio hilo (subprocesos wpctl/wl-paste sin
+ /// tocar el bucle de UI). Publica un snapshot ~1Hz; `maybe_sample` lo recoge.
+ sampler: SamplerHandle,
+ /// Último snapshot del sistema recogido del hilo de muestreo.
+ ctx: WidgetCtx,
+ /// Comando del Quake corriendo en un hilo: su resultado llega por aquí. El
+ /// latido del frame-callback lo sondea (`try_recv`) sin bloquear el loop.
+ exec_rx: Option<std::sync::mpsc::Receiver<crate::shuma::RunResult>>,
+ /// Estado del sidebar navegador (Mónadas de nouser). Vacío si la config no
+ /// declara un navegador.
+ nav: NavState,
+ /// Canal por donde el hilo de poll de `list_monads` entrega resultados (~2s).
+ /// `None` si la config no tiene navegador (no se arranca el hilo).
+ nav_rx: Option<Receiver<PollOutcome>>,
+ /// Canal para que los hilos one-shot de `resolve_monad` entreguen miembros.
+ members_tx: Sender<MembersOutcome>,
+ members_rx: Receiver<MembersOutcome>,
+ /// Arrastre en curso (selección de nodo del grafo). `None` si no se arrastra.
+ drag: Option<LayerDrag>,
+ /// Servidor del rail hospedado: apps que prestan sus dientes a pata mientras
+ /// tienen foco. `None` si la config no tiene ningún sidebar donde alojarlos.
+ host: Option<HostServer>,
+ /// Última revisión vista del `host` (para detectar altas/bajas/updates y
+ /// re-pintar los sidebars).
+ last_host_rev: u64,
+ /// Una layer surface por cada barra de la config.
+ panels: Vec<Panel>,
+ /// Índice (en `panels`) de la surface del **tooltip flotante**: una layer
+ /// surface en `Overlay`, reubicada al hover. `None` si no se creó.
+ tooltip_pi: Option<usize>,
+ /// Texto del tooltip actualmente visible (`None` = oculto).
+ tooltip_text: Option<String>,
+ exit: bool,
+}
+
+/// El anclaje sctk + el tamaño `(w, h)` pedido para un borde y grosor. El eje
+/// libre va en 0 → el compositor lo estira al ancho/alto de la salida.
+fn anchor_y_size(anchor: Anchor, thickness: u32) -> (LayerAnchor, (u32, u32)) {
+ match anchor {
+ Anchor::Top => (
+ LayerAnchor::TOP | LayerAnchor::LEFT | LayerAnchor::RIGHT,
+ (0, thickness),
+ ),
+ Anchor::Bottom => (
+ LayerAnchor::BOTTOM | LayerAnchor::LEFT | LayerAnchor::RIGHT,
+ (0, thickness),
+ ),
+ Anchor::Left => (
+ LayerAnchor::LEFT | LayerAnchor::TOP | LayerAnchor::BOTTOM,
+ (thickness, 0),
+ ),
+ Anchor::Right => (
+ LayerAnchor::RIGHT | LayerAnchor::TOP | LayerAnchor::BOTTOM,
+ (thickness, 0),
+ ),
+ }
+}
+
+/// Levanta el backend layer-shell. Devuelve error si no hay sesión Wayland o el
+/// compositor no expone `wlr-layer-shell` (en ese caso el caller cae a winit).
+pub fn run() -> Result<(), Box<dyn Error>> {
+ let cfg = pata_config::load();
+ let bars: Vec<usize> = cfg
+ .surfaces
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| s.kind == SurfaceKind::Bar)
+ .map(|(i, _)| i)
+ .collect();
+ // Los sidebars (Fase 11) también se anclan como layer surfaces propias.
+ let sidebars: Vec<usize> = cfg
+ .surfaces
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| s.kind == SurfaceKind::Sidebar)
+ .map(|(i, _)| i)
+ .collect();
+ if bars.is_empty() && sidebars.is_empty() {
+ return Err("pata · la config no tiene ninguna superficie anclable (bar/sidebar)".into());
+ }
+ diag!(
+ "pata diag · backend LAYER-SHELL arranca · {} barra(s) + {} sidebar(s)",
+ bars.len(),
+ sidebars.len()
+ );
+
+ let conn = Connection::connect_to_env()?;
+ let (globals, mut event_queue) = registry_queue_init(&conn)?;
+ let qh: QueueHandle<LayerApp> = event_queue.handle();
+
+ let compositor = CompositorState::bind(&globals, &qh)?;
+ let layer_shell = LayerShell::bind(&globals, &qh)?;
+
+ // El manager de ventanas (window_list): opcional. Si el compositor no lo
+ // expone, el widget queda vacío en vez de fallar el arranque.
+ let toplevel_mgr = globals
+ .bind::<ZwlrForeignToplevelManagerV1, _, _>(&qh, 1..=3, ())
+ .ok();
+ if toplevel_mgr.is_none() {
+ eprintln!("pata layer · el compositor no expone wlr-foreign-toplevel; window_list vacío");
+ }
+
+ // El tray sólo arranca (y toma el nombre del watcher) si la config lo pide.
+ let tray = crate::config_tiene_widget(&cfg, "tray")
+ .then(TrayHandle::spawn)
+ .flatten();
+
+ // Plano de datos del sidebar: un hilo que poolea `list_monads` cada ~2s y
+ // entrega por canal (mismo patrón que el sampler/exec — el bucle Wayland lo
+ // sondea sin bloquear). Sólo arranca si la config declara un navegador.
+ let nav_rx = crate::config_tiene_navigator(&cfg).then(|| {
+ let (tx, rx) = std::sync::mpsc::channel::<PollOutcome>();
+ std::thread::spawn(move || {
+ let mut socket = None;
+ loop {
+ let outcome = nouser::poll(socket.clone());
+ socket = match &outcome {
+ PollOutcome::Ok { socket: s, .. } => Some(s.clone()),
+ PollOutcome::Failed(_) => None,
+ };
+ if tx.send(outcome).is_err() {
+ break; // el bucle de UI terminó
+ }
+ std::thread::sleep(nouser::REFRESH_INTERVAL);
+ }
+ });
+ rx
+ });
+ let (members_tx, members_rx) = std::sync::mpsc::channel::<MembersOutcome>();
+
+ let (surfaces, shuma) = Model::construir(&cfg);
+ // El sampler en UTC si la config lo pide (se lee antes de mover `cfg` al app).
+ let utc = crate::usa_utc(&cfg);
+ let mut app = LayerApp {
+ registry_state: RegistryState::new(&globals),
+ output_state: OutputState::new(&globals, &qh),
+ seat_state: SeatState::new(&globals, &qh),
+ conn,
+ hal: None,
+ keyboard: None,
+ pointer: None,
+ seat: None,
+ toplevel_mgr,
+ toplevels: Vec::new(),
+ next_toplevel_id: 0,
+ clipboard: None,
+ tray,
+ theme: Theme::dark(),
+ cfg,
+ surfaces,
+ shuma,
+ // Se calculan después, una vez creados los panels.
+ shuma_panel: None,
+ shuma_bar_px: 40,
+ registry: app_bus::AppRegistry::discover(),
+ menu_open: false,
+ menu_panel: None,
+ menu_bar_px: 32,
+ sampler: SamplerHandle::spawn(utc),
+ ctx: WidgetCtx::default(),
+ exec_rx: None,
+ nav: NavState::default(),
+ nav_rx,
+ members_tx,
+ members_rx,
+ drag: None,
+ // El rail hospedado sólo tiene sentido si hay un sidebar donde alojar los
+ // dientes de la app enfocada.
+ host: (!sidebars.is_empty()).then(HostServer::spawn).flatten(),
+ last_host_rev: 0,
+ panels: Vec::new(),
+ tooltip_pi: None,
+ tooltip_text: None,
+ exit: false,
+ };
+
+ // Roundtrip para que `OutputState` reciba `wl_output.geometry` + el
+ // `xdg_output.name` de cada monitor: lo necesitamos para resolver
+ // `Surface::output` (nombre del conector) a un `wl_output` real antes
+ // de pedir cada layer surface. SCTK publica el nombre en el segundo
+ // roundtrip (xdg-output llega después del wl_output base).
+ event_queue.roundtrip(&mut app)?;
+ event_queue.roundtrip(&mut app)?;
+
+ // Mapa `nombre del conector → wl_output` (ej. `"HDMI-A-1" → WlOutput`).
+ // Sin nombre (compositor sin xdg-output) la entrada se omite — esa
+ // salida sólo es alcanzable con `output: ""` (= primario, sin hint).
+ let mut outputs_by_name: std::collections::HashMap<String, wl_output::WlOutput> =
+ std::collections::HashMap::new();
+ for out in app.output_state.outputs() {
+ if let Some(info) = app.output_state.info(&out) {
+ if let Some(name) = info.name {
+ outputs_by_name.insert(name, out);
+ }
+ }
+ }
+ diag!("pata diag · outputs descubiertos: {:?}", outputs_by_name.keys().collect::<Vec<_>>());
+
+ // Resuelve `output: String` de la config a `Option<&wl_output>`. Vacío =
+ // None (el compositor decide). Nombre desconocido = None + aviso (la
+ // surface cae al primario en vez de fallar el arranque).
+ let resolve_output =
+ |name: &str| -> Option<wl_output::WlOutput> {
+ if name.is_empty() {
+ return None;
+ }
+ if let Some(o) = outputs_by_name.get(name) {
+ return Some(o.clone());
+ }
+ eprintln!("pata layer · output «{name}» no conectado; cae al primario");
+ None
+ };
+
+ // Una layer surface por barra: anclada a su borde, con su exclusive zone.
+ for &idx in &bars {
+ let s = &app.cfg.surfaces[idx];
+ let thickness = s.thickness.max(1.0) as u32;
+ let (sctk_anchor, size) = anchor_y_size(s.anchor, thickness);
+ let wl_surface = compositor.create_surface(&qh);
+ let layer = layer_shell.create_layer_surface(
+ &qh,
+ wl_surface,
+ Layer::Top,
+ Some("pata".to_string()),
+ resolve_output(&s.output).as_ref(),
+ );
+ layer.set_anchor(sctk_anchor);
+ layer.set_size(size.0, size.1);
+ layer.set_exclusive_zone(thickness as i32);
+ layer.commit();
+ app.panels.push(Panel {
+ idx,
+ card: None,
+ layer,
+ cache: None,
+ width: size.0.max(1),
+ height: thickness,
+ dirty: true,
+ hover_idx: None,
+ gpu: None,
+ });
+ }
+
+ // Una layer surface por sidebar (Fase 11): rail anclado al borde vertical con
+ // exclusive zone = su grosor. Colapsado mide `thickness`; al abrir un diente
+ // la surface CRECE en ancho (a `thickness + panel_width`) manteniendo la
+ // exclusive zone, así el panel flota sobre el área de trabajo sin recolocar el
+ // teselado — el mismo truco del drawer de shuma, pero en el eje horizontal.
+ for &idx in &sidebars {
+ let s = &app.cfg.surfaces[idx];
+ let thickness = s.thickness.max(1.0) as u32;
+ let (sctk_anchor, size) = anchor_y_size(s.anchor, thickness);
+ let wl_surface = compositor.create_surface(&qh);
+ let layer = layer_shell.create_layer_surface(
+ &qh,
+ wl_surface,
+ Layer::Top,
+ Some("pata-sidebar".to_string()),
+ resolve_output(&s.output).as_ref(),
+ );
+ layer.set_anchor(sctk_anchor);
+ layer.set_size(size.0, size.1);
+ layer.set_exclusive_zone(thickness as i32);
+ // Sin teclado: la navegación es por clic (como las barras). Se cierra el
+ // panel volviendo a clickear el diente.
+ layer.set_keyboard_interactivity(KeyboardInteractivity::None);
+ layer.commit();
+ app.panels.push(Panel {
+ idx,
+ card: None,
+ layer,
+ cache: None,
+ width: thickness,
+ height: size.1.max(1),
+ dirty: true,
+ hover_idx: None,
+ gpu: None,
+ });
+ }
+
+ // Tarjetas flotantes (estilo conky): cada `card` de una superficie `Panel`
+ // es su propia layer surface en `Layer::Bottom` (sobre el escritorio,
+ // debajo de las ventanas), anclada a la esquina superior-izquierda con
+ // margen (x, y) y del tamaño (w, h) de la tarjeta. No reserva franja ni
+ // toma teclado. Heredan el `output` del Panel padre.
+ for (idx, s) in app.cfg.surfaces.iter().enumerate() {
+ if s.kind != SurfaceKind::Panel {
+ continue;
+ }
+ let panel_output = resolve_output(&s.output);
+ for card in &s.cards {
+ let (cw, ch) = (card.w.max(1.0) as u32, card.h.max(1.0) as u32);
+ let wl_surface = compositor.create_surface(&qh);
+ let layer = layer_shell.create_layer_surface(
+ &qh,
+ wl_surface,
+ Layer::Bottom,
+ Some("pata-card".to_string()),
+ panel_output.as_ref(),
+ );
+ layer.set_anchor(LayerAnchor::TOP | LayerAnchor::LEFT);
+ layer.set_size(cw, ch);
+ // Margen: (top, right, bottom, left). (x, y) desde la esquina sup-izq.
+ layer.set_margin(card.y as i32, 0, 0, card.x as i32);
+ layer.set_exclusive_zone(0);
+ layer.set_keyboard_interactivity(KeyboardInteractivity::None);
+ layer.commit();
+ let widgets = card.widgets.iter().map(pata_core::widget::build).collect();
+ app.panels.push(Panel {
+ idx,
+ card: Some(CardState { spec: card.clone(), widgets }),
+ layer,
+ cache: None,
+ width: cw,
+ height: ch,
+ dirty: true,
+ hover_idx: None,
+ gpu: None,
+ });
+ }
+ }
+
+ // ¿Qué barra hospeda el shuma_input? Esa recibe foco de teclado al clickearla
+ // (OnDemand) para poder desplegar el Quake y escribir.
+ app.shuma_panel = app.panels.iter().position(|p| {
+ let s = &app.cfg.surfaces[p.idx];
+ s.start
+ .iter()
+ .chain(&s.center)
+ .chain(&s.end)
+ .any(|w| w.kind == "shuma_input")
+ });
+ app.shuma_bar_px = app
+ .shuma_panel
+ .map(|pi| app.cfg.surfaces[app.panels[pi].idx].thickness.max(1.0) as u32)
+ .unwrap_or(40);
+ if let Some(pi) = app.shuma_panel {
+ // Barra cerrada: NO pide teclado. Con `OnDemand` el compositor
+ // consumía el primer click para darle foco (de ahí «dos clicks para
+ // desplegar»); con `None` el click va directo a togglear.
+ app.panels[pi]
+ .layer
+ .set_keyboard_interactivity(KeyboardInteractivity::None);
+ app.panels[pi].layer.commit();
+ }
+
+ // La surface del tooltip flotante: una layer surface en Overlay (sobre todo),
+ // anclada arriba-izquierda, sin teclado ni zona exclusiva y con **región de
+ // input vacía** (no roba clicks ni hover). Arranca 1×1 fuera de vista; al
+ // hover se redimensiona y reubica con `set_margin`. Sin buffer hasta el primer
+ // tooltip → no se mapea (invisible). Sin output específico: la pone el
+ // compositor donde caiga el puntero.
+ app.tooltip_pi = {
+ let wl_surface = compositor.create_surface(&qh);
+ if let Ok(region) = Region::new(&compositor) {
+ // Región vacía (sin add): el tooltip nunca intercepta el puntero.
+ wl_surface.set_input_region(Some(region.wl_region()));
+ }
+ let layer = layer_shell.create_layer_surface(
+ &qh,
+ wl_surface,
+ Layer::Overlay,
+ Some("pata-tooltip".to_string()),
+ None,
+ );
+ layer.set_anchor(LayerAnchor::TOP | LayerAnchor::LEFT);
+ layer.set_size(1, 1);
+ layer.set_margin(100_000, 0, 0, 0); // fuera de vista
+ layer.set_exclusive_zone(0);
+ layer.set_keyboard_interactivity(KeyboardInteractivity::None);
+ layer.commit();
+ app.panels.push(Panel {
+ idx: 0,
+ card: None,
+ layer,
+ cache: None,
+ width: 1,
+ height: 1,
+ dirty: false,
+ hover_idx: None,
+ gpu: None,
+ });
+ Some(app.panels.len() - 1)
+ };
+
+ // ¿Qué barra hospeda el start_button? Esa crece hacia abajo al desplegar el
+ // menú de inicio (mismo truco que shuma, hacia el otro lado).
+ app.menu_panel = app.panels.iter().position(|p| {
+ let s = &app.cfg.surfaces[p.idx];
+ s.start
+ .iter()
+ .chain(&s.center)
+ .chain(&s.end)
+ .any(|w| w.kind == "start_button")
+ });
+ app.menu_bar_px = app
+ .menu_panel
+ .map(|pi| app.cfg.surfaces[app.panels[pi].idx].thickness.max(1.0) as u32)
+ .unwrap_or(32);
+
+ while !app.exit {
+ if let Err(e) = event_queue.blocking_dispatch(&mut app) {
+ // El compositor cerró la conexión (se apagó / Ctrl+Alt+Backspace):
+ // es una salida normal, no una falla del backend. Devolvemos Ok para
+ // que el caller NO caiga a la ventana winit (que paniquearía al no
+ // encontrar compositor). La caída a winit es sólo para cuando el
+ // layer-shell no arranca de entrada (X11, sin wlr-layer-shell).
+ eprintln!("pata layer · el compositor cerró la conexión: {e}");
+ break;
+ }
+ }
+ Ok(())
+}
+
+impl LayerApp {
+ /// Índice del panel cuya layer surface es `surface`.
+ fn panel_de(&self, surface: &wl_surface::WlSurface) -> Option<usize> {
+ self.panels
+ .iter()
+ .position(|p| p.layer.wl_surface() == surface)
+ }
+
+ /// Marca la barra de shuma para re-pintar (tras teclear, etc.).
+ fn marcar_shuma_dirty(&mut self) {
+ if let Some(pi) = self.shuma_panel {
+ self.panels[pi].dirty = true;
+ }
+ }
+
+ /// Marca todas las barras para re-pintar (p. ej. cambió la lista de ventanas).
+ fn marcar_todo_dirty(&mut self) {
+ for p in &mut self.panels {
+ p.dirty = true;
+ }
+ }
+
+ /// La lista de ventanas para el render del `window_list`, desde los toplevels
+ /// que reporta el compositor.
+ fn window_entries(&self) -> Vec<WindowEntry> {
+ self.toplevels
+ .iter()
+ .map(|t| WindowEntry {
+ id: t.id,
+ label: t.etiqueta(),
+ app_id: t.app_id.clone(),
+ active: t.activated,
+ minimized: t.minimized,
+ })
+ .collect()
+ }
+
+ /// El toplevel con ese `id`, si sigue abierto.
+ fn toplevel_por_id(&self, id: u32) -> Option<&Toplevel> {
+ self.toplevels.iter().find(|t| t.id == id)
+ }
+
+ /// Despliega o repliega el drawer Quake: agranda/encoge la layer surface de
+ /// la barra de shuma hacia arriba (su exclusive zone queda en el grosor de la
+ /// barra, así no recoloca el teselado) y toma/suelta el foco de teclado.
+ fn set_shuma_open(&mut self, open: bool) {
+ let Some(pi) = self.shuma_panel else { return };
+ if self.shuma.open == open {
+ return;
+ }
+ self.shuma.open = open;
+ let h = if open { DRAWER_H } else { self.shuma_bar_px };
+ let layer = &self.panels[pi].layer;
+ layer.set_size(0, h);
+ // Abierto: foco Exclusive para escribir. Cerrado: `None` — no
+ // retiene el teclado, así una app lanzada (kitty) lo recibe.
+ layer.set_keyboard_interactivity(if open {
+ KeyboardInteractivity::Exclusive
+ } else {
+ KeyboardInteractivity::None
+ });
+ layer.commit();
+ // El cache de hit-test es del layout viejo; invalidarlo evita que el
+ // click siguiente pegue contra el árbol previo («no se guarda»). Se
+ // re-arma en el próximo frame con la geometría nueva.
+ self.panels[pi].cache = None;
+ self.panels[pi].dirty = true;
+ }
+
+ /// Despliega/repliega el menú de inicio: agranda/encoge hacia abajo la layer
+ /// surface de la barra del `start_button` (su exclusive zone queda en el
+ /// grosor de la barra, así no recoloca el teselado). No toma teclado (clics).
+ fn set_menu_open(&mut self, open: bool) {
+ let Some(pi) = self.menu_panel else { return };
+ if self.menu_open == open {
+ return;
+ }
+ self.menu_open = open;
+ let h = if open { MENU_H } else { self.menu_bar_px };
+ let layer = &self.panels[pi].layer;
+ layer.set_size(0, h);
+ layer.commit();
+ // Invalida el cache de hit-test (geometría vieja) — igual que shuma.
+ self.panels[pi].cache = None;
+ self.panels[pi].dirty = true;
+ }
+
+ /// Actualiza el tooltip flotante para el nodo `node_idx` bajo el cursor en el
+ /// panel `pi`: si ese nodo tiene texto de tooltip, redimensiona y reubica la
+ /// surface del tooltip bajo el widget y la marca para pintar; si no, la
+ /// oculta. Reposiciona sólo al cambiar de nodo (no sigue al cursor). Pinta de
+ /// inmediato (`draw`) porque los eventos llegan por OTRA surface.
+ fn update_tooltip(&mut self, pi: usize, node_idx: Option<usize>, qh: &QueueHandle<Self>) {
+ let Some(tpi) = self.tooltip_pi else { return };
+ if pi == tpi {
+ return;
+ }
+ // Texto + rect del nodo hovereado, desde el cache de hit-test del panel.
+ let info = node_idx.and_then(|i| {
+ let c = self.panels[pi].cache.as_ref()?;
+ let node = c.mounted.nodes.get(i)?;
+ let text = node.tooltip.clone()?;
+ let rect = c.computed.get(node.id)?;
+ Some((text, rect))
+ });
+ match info {
+ Some((text, rect)) => {
+ // Posición: bajo el widget. La barra superior (la única con
+ // widgets hovereables) está en y=0, así que su alto da el offset.
+ let x = rect.x.max(0.0) as i32;
+ let y = self.panels[pi].height as i32 + 4;
+ // Tamaño estimado (no medimos texto acá): ~8px/glifo + padding.
+ let w = (text.chars().count() as u32 * 8 + 16).clamp(24, 600);
+ let h = 24u32;
+ self.tooltip_text = Some(text);
+ {
+ let layer = &self.panels[tpi].layer;
+ layer.set_size(w, h);
+ layer.set_margin(y, 0, 0, x);
+ layer.commit();
+ }
+ self.panels[tpi].width = w;
+ self.panels[tpi].height = h;
+ self.panels[tpi].dirty = true;
+ self.draw(tpi, qh);
+ }
+ None => self.hide_tooltip(qh),
+ }
+ }
+
+ /// Oculta el tooltip: lo empuja fuera de vista (1×1 con margen enorme) y lo
+ /// re-pinta una vez ahí. No hace nada si ya está oculto.
+ fn hide_tooltip(&mut self, qh: &QueueHandle<Self>) {
+ let Some(tpi) = self.tooltip_pi else { return };
+ if self.tooltip_text.is_none() {
+ return;
+ }
+ self.tooltip_text = None;
+ {
+ let layer = &self.panels[tpi].layer;
+ layer.set_size(1, 1);
+ layer.set_margin(100_000, 0, 0, 0);
+ layer.commit();
+ }
+ self.panels[tpi].width = 1;
+ self.panels[tpi].height = 1;
+ self.panels[tpi].dirty = true;
+ self.draw(tpi, qh);
+ }
+
+ /// Lanza una app del menú por su `id` y cierra el menú. Sólo `Exec` spawnea;
+ /// `Action`/`Wasm` los despacharía un host con chasis (acá no aplica).
+ fn lanzar_app(&mut self, id: String) {
+ if let Some(app) = self.registry.get(&id) {
+ let _ = app.spawn();
+ }
+ self.set_menu_open(false);
+ }
+
+ /// Enter en el drawer: corre el comando por el **ejecutor real de shuma**
+ /// (`shuma::ejecutar`, sobre `shuma-exec`) en un hilo de fondo y muestra su
+ /// salida en el drawer — el puente pata→shuma del SDD §5. El hilo manda el
+ /// `Result` por un canal que `poll_exec` sondea cada frame; la UI no se
+ /// bloquea. El drawer **queda abierto** (sigue Exclusive, podés encadenar
+ /// comandos y leer la salida); se cierra con Esc. Para lanzar una app GUI y
+ /// olvidarte, está el launcher (clic en un ítem) o `Super+p`.
+ fn shuma_submit(&mut self) {
+ let cmd = std::mem::take(&mut self.shuma.buffer);
+ if cmd.trim().is_empty() {
+ self.marcar_shuma_dirty();
+ return;
+ }
+ self.shuma.push_pending(cmd.clone());
+ let (tx, rx) = std::sync::mpsc::channel();
+ std::thread::spawn(move || {
+ let _ = tx.send(crate::shuma::ejecutar(&cmd));
+ });
+ self.exec_rx = Some(rx);
+ self.marcar_shuma_dirty();
+ }
+
+ /// Sondea (sin bloquear) si el comando del Quake terminó; si sí, guarda su
+ /// salida y re-pinta. Se llama en cada frame (el latido del shuma corre a
+ /// ~60fps, así que el resultado aparece a los ~16ms de terminar).
+ fn poll_exec(&mut self) {
+ let got = self.exec_rx.as_ref().and_then(|rx| rx.try_recv().ok());
+ if let Some(res) = got {
+ self.shuma.finish_last(res);
+ self.exec_rx = None;
+ self.marcar_shuma_dirty();
+ }
+ }
+
+ /// Sondea (sin bloquear) el plano de datos del sidebar: aplica el último poll
+ /// de `list_monads` y cualquier `resolve_monad` que haya terminado. Si cambió
+ /// algo, marca las superficies sidebar para re-pintar. Se llama en cada frame.
+ fn poll_nav(&mut self) {
+ let mut cambios = false;
+ // Drena el poll de Mónadas (nos quedamos con el último si hay varios).
+ if let Some(rx) = self.nav_rx.as_ref() {
+ let mut ultimo = None;
+ while let Ok(o) = rx.try_recv() {
+ ultimo = Some(o);
+ }
+ if let Some(outcome) = ultimo {
+ match outcome {
+ PollOutcome::Ok { socket, resp } => {
+ self.nav.socket = Some(socket);
+ self.nav.apply_monads(*resp);
+ }
+ PollOutcome::Failed(e) => {
+ self.nav.socket = None;
+ self.nav.error = Some(e);
+ }
+ }
+ cambios = true;
+ }
+ }
+ // Drena los miembros resueltos.
+ while let Ok(outcome) = self.members_rx.try_recv() {
+ match outcome {
+ MembersOutcome::Ok { monad, members } => self.nav.apply_members(monad, members),
+ MembersOutcome::Failed(e) => self.nav.error = Some(e),
+ }
+ cambios = true;
+ }
+ if cambios {
+ self.marcar_sidebars_dirty();
+ }
+ }
+
+ /// El `app_id` del toplevel que tiene foco ahora, si hay alguno.
+ fn focused_app_id(&self) -> Option<&str> {
+ self.toplevels
+ .iter()
+ .find(|t| t.activated)
+ .map(|t| t.app_id.as_str())
+ }
+
+ /// Sondea el rail hospedado: si cambió su revisión (un alta/baja/update de
+ /// dientes de alguna app), re-pinta los sidebars. El cambio de foco ya re-pinta
+ /// vía `marcar_todo_dirty` (eventos de toplevel).
+ fn poll_host(&mut self) {
+ let Some(h) = &self.host else { return };
+ let rev = h.revision();
+ if rev != self.last_host_rev {
+ self.last_host_rev = rev;
+ self.marcar_sidebars_dirty();
+ }
+ }
+
+ /// Marca todas las superficies sidebar para re-pintar.
+ fn marcar_sidebars_dirty(&mut self) {
+ for p in &mut self.panels {
+ if p.card.is_none() && self.cfg.surfaces[p.idx].kind == SurfaceKind::Sidebar {
+ p.dirty = true;
+ }
+ }
+ }
+
+ /// Índice (en `panels`) de la layer surface del sidebar `si`.
+ fn sidebar_panel_de(&self, si: usize) -> Option<usize> {
+ self.panels.iter().position(|p| p.idx == si && p.card.is_none())
+ }
+
+ /// Activa/repliega el diente `(si, ti)`: actualiza el estado y **redimensiona**
+ /// la layer surface del sidebar (crece a `thickness + panel_width` al abrir,
+ /// vuelve a `thickness` al cerrar). La exclusive zone no cambia, así el panel
+ /// flota sobre el área de trabajo (drawer horizontal).
+ fn set_sidebar_open(&mut self, si: usize, ti: usize) {
+ self.nav.toggle_tab(si, ti);
+ let Some(pi) = self.sidebar_panel_de(si) else {
+ return;
+ };
+ let s = &self.cfg.surfaces[si];
+ let thickness = s.thickness.max(1.0) as u32;
+ let abierto = matches!(self.nav.open, Some((s2, _)) if s2 == si);
+ let w = if abierto {
+ thickness + s.panel_width.max(1.0) as u32
+ } else {
+ thickness
+ };
+ {
+ let layer = &self.panels[pi].layer;
+ layer.set_size(w, 0);
+ layer.commit();
+ }
+ // El cache de hit-test es del layout viejo; invalidarlo (igual que shuma).
+ self.panels[pi].cache = None;
+ self.panels[pi].dirty = true;
+ }
+
+ /// Cierra el panel del sidebar (si alguno está abierto) y encoge su surface.
+ fn cerrar_sidebar(&mut self) {
+ if let Some((si, ti)) = self.nav.open {
+ self.set_sidebar_open(si, ti); // toggle del abierto = cerrar
+ }
+ }
+
+ /// Expande/colapsa un nodo del navegador; al abrir una Mónada sin miembros
+ /// resueltos lanza su `resolve_monad` en un hilo one-shot (entrega por canal).
+ fn nav_toggle(&mut self, id: u64) {
+ if self.nav.expanded.contains(&id) {
+ self.nav.expanded.remove(&id);
+ } else {
+ self.nav.expanded.insert(id);
+ if let (Some(mid), Some(sock)) =
+ (self.nav.needs_resolve(id), self.nav.socket.clone())
+ {
+ let tx = self.members_tx.clone();
+ std::thread::spawn(move || {
+ let _ = tx.send(nouser::resolve(sock, mid));
+ });
+ }
+ }
+ self.marcar_sidebars_dirty();
+ }
+
+ /// Recoge el último snapshot del hilo de muestreo (no bloquea). Si llegó uno
+ /// nuevo, `tick`ea los widgets y marca todas las barras para re-pintar. El
+ /// muestreo en sí (subprocesos que pueden colgarse) vive en `SamplerHandle`,
+ /// nunca acá: el bucle de UI no se bloquea aunque wpctl/wl-paste se cuelguen.
+ fn maybe_sample(&mut self) {
+ let Some((ctx, clipboard)) = self.sampler.latest() else {
+ return;
+ };
+ self.ctx = ctx;
+ self.clipboard = clipboard;
+ for sw in &mut self.surfaces {
+ for w in sw.core_mut() {
+ w.tick(&ctx);
+ }
+ }
+ // Las tarjetas flotantes tienen sus widgets en su propio Panel.
+ for p in &mut self.panels {
+ if let Some(c) = p.card.as_mut() {
+ for w in &mut c.widgets {
+ w.tick(&ctx);
+ }
+ }
+ p.dirty = true;
+ }
+ }
+
+ /// Crea el estado wgpu de un panel sobre los punteros raw de Wayland
+ /// (`wl_display` + `wl_surface`). El `Hal` se comparte; lo crea el primer panel
+ /// **eligiendo el adaptador compatible con su surface** (el dispositivo que
+ /// mirada compone) — clave en multi-GPU/Optimus. Los paneles siguientes reusan
+ /// ese `Hal` (mismo compositor → mismo dispositivo, el adaptador ya sirve).
+ fn ensure_gpu(&mut self, pi: usize) {
+ if self.panels[pi].gpu.is_some() {
+ return;
+ }
+ let display_ptr = self.conn.backend().display_ptr() as *mut c_void;
+ let surface_ptr = self.panels[pi].layer.wl_surface().id().as_ptr() as *mut c_void;
+ let (w, h) = (self.panels[pi].width, self.panels[pi].height);
+ let display_handle = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(
+ NonNull::new(display_ptr).expect("wl_display ptr"),
+ ));
+ let window_handle = RawWindowHandle::Wayland(WaylandWindowHandle::new(
+ NonNull::new(surface_ptr).expect("wl_surface ptr"),
+ ));
+ // SAFETY: los handles apuntan a objetos Wayland que `self` mantiene vivos
+ // (la conexión y la layer surface) durante toda la vida de la surface.
+ let make_target = || wgpu::SurfaceTargetUnsafe::RawHandle {
+ raw_display_handle: display_handle,
+ raw_window_handle: window_handle,
+ };
+
+ let surface = if self.hal.is_none() {
+ // Primer panel: crea el Hal pidiendo el adaptador compatible con ESTA
+ // surface (no `HighPerformance` a ciegas, que en Optimus agarraría la
+ // GPU equivocada → 0 formatos).
+ match pollster::block_on(unsafe { Hal::new_for_raw_surface(make_target, w, h) }) {
+ Ok((hal, surface)) => {
+ self.hal = Some(hal);
+ surface
+ }
+ Err(e) => {
+ eprintln!("pata layer · panel {pi} sin gpu: {e}");
+ return;
+ }
+ }
+ } else {
+ // Paneles siguientes: reusan el Hal ya creado.
+ let hal = self.hal.as_ref().expect("hal");
+ let wgpu_surface = match unsafe { hal.instance.create_surface_unsafe(make_target()) } {
+ Ok(s) => s,
+ Err(e) => {
+ eprintln!("pata layer · panel {pi} sin gpu: {e}");
+ return;
+ }
+ };
+ // Sin formatos la WSI no soporta esta surface: en vez de paniquear,
+ // dejamos el panel sin gpu (no pinta) y seguimos — un panel roto no
+ // tira todo el marco. Reintenta en el próximo `draw`.
+ match RawSurface::from_surface(hal, wgpu_surface, w, h) {
+ Ok(s) => s,
+ Err(e) => {
+ eprintln!("pata layer · panel {pi} sin gpu: {e}");
+ return;
+ }
+ }
+ };
+ let hal = self.hal.as_ref().expect("hal");
+ diag!(
+ "pata diag · panel {pi} surface creada {w}x{h} · backend={:?} format={:?}",
+ hal.adapter.get_info().backend,
+ surface.format(),
+ );
+ let renderer = Renderer::new(hal).expect("renderer");
+ self.panels[pi].gpu = Some(PanelGpu {
+ surface,
+ renderer,
+ typesetter: Typesetter::new(),
+ scene: vello::Scene::new(),
+ layout: LayoutTree::new(),
+ });
+ }
+
+ /// Mantiene vivo el latido de un panel: pide su siguiente frame-callback.
+ fn latido(&self, pi: usize, qh: &QueueHandle<Self>) {
+ let surface = self.panels[pi].layer.wl_surface();
+ surface.frame(qh, surface.clone());
+ surface.commit();
+ }
+
+ /// Avanza el frame de un panel: re-muestrea ~1Hz (compartido) y pinta sólo si
+ /// hay algo nuevo; entre cambios sólo mantiene el latido.
+ fn draw(&mut self, pi: usize, qh: &QueueHandle<Self>) {
+ self.maybe_sample();
+ self.poll_exec();
+ self.poll_nav();
+ self.poll_host();
+ self.ensure_gpu(pi);
+
+ if !self.panels[pi].dirty {
+ self.latido(pi, qh);
+ return;
+ }
+
+ let idx = self.panels[pi].idx;
+ let (w, h) = (self.panels[pi].width, self.panels[pi].height);
+ let windows = self.window_entries();
+ let tray_items = self.tray.as_ref().map(|t| t.items()).unwrap_or_default();
+ let data = render::BarData {
+ windows: &windows,
+ clipboard: self.clipboard.as_deref(),
+ tray: &tray_items,
+ };
+ // Una tarjeta flotante pinta su contenido (relleno de su surface); la
+ // barra de shuma desplegada pinta el drawer (cuerpo + cabezal); el resto
+ // pinta su barra normal.
+ let view = if self.tooltip_pi == Some(pi) {
+ // La surface del tooltip: pinta la cajita con el texto actual (o vacía
+ // cuando está oculta fuera de vista).
+ render::tooltip_view(self.tooltip_text.as_deref().unwrap_or(""), &self.theme)
+ } else if let Some(c) = self.panels[pi].card.as_ref() {
+ render::card_view(&c.spec, &c.widgets, &self.theme)
+ } else if self.menu_panel == Some(pi) && self.menu_open {
+ render::start_menu_view(
+ &self.cfg.surfaces[idx],
+ &self.surfaces[idx],
+ &self.shuma,
+ &data,
+ &self.theme,
+ self.menu_bar_px as f32,
+ self.registry.all(),
+ )
+ } else if self.shuma_panel == Some(pi) && self.shuma.open {
+ // Viewport del historial: la surface menos la barra, la línea de
+ // input y los paddings. Lo cacheamos para que el clamp del scroll
+ // en `update` (rueda/arrastre) sea exacto.
+ let vh = (h as f32 - self.shuma_bar_px as f32 - 60.0).max(40.0);
+ self.shuma.viewport_h = vh;
+ render::shuma_open_view(
+ &self.cfg.surfaces[idx],
+ &self.surfaces[idx],
+ &self.shuma,
+ &data,
+ &self.theme,
+ self.shuma_bar_px as f32,
+ vh,
+ )
+ } else if self.cfg.surfaces[idx].kind == SurfaceKind::Sidebar {
+ // Dientes hospedados de la app enfocada (si registró alguno en el host).
+ let hosted = {
+ let app = self.focused_app_id().map(|s| s.to_string());
+ match (app, self.host.as_ref()) {
+ (Some(id), Some(h)) => h.snapshot(&id).map(|(_, teeth)| (id, teeth)),
+ _ => None,
+ }
+ };
+ let (hosted_app, hosted_teeth): (&str, &[pata_host::HostedTooth]) = match &hosted {
+ Some((id, teeth)) => (id.as_str(), teeth.as_slice()),
+ None => ("", &[]),
+ };
+ render::sidebar_surface_view(
+ &self.cfg.surfaces[idx],
+ idx,
+ w as f32,
+ h as f32,
+ &self.nav,
+ hosted_teeth,
+ hosted_app,
+ &self.shuma,
+ &self.theme,
+ )
+ } else {
+ render::bar_view(
+ &self.cfg.surfaces[idx],
+ &self.surfaces[idx],
+ &self.shuma,
+ &data,
+ &self.theme,
+ )
+ };
+
+ let hover_idx = self.panels[pi].hover_idx;
+ let hal = self.hal.as_ref().expect("hal");
+ let gpu = match self.panels[pi].gpu.as_mut() {
+ Some(g) => g,
+ None => {
+ self.latido(pi, qh);
+ return;
+ }
+ };
+ gpu.surface.resize(w, h);
+ let frame = match gpu.surface.acquire() {
+ Ok(f) => f,
+ Err(_) => {
+ // Soltamos el préstamo mutable de `gpu` antes de tocar `self`.
+ let _ = gpu;
+ self.latido(pi, qh);
+ return;
+ }
+ };
+ gpu.layout.clear();
+ let mounted: Mounted<Msg> = mount(&mut gpu.layout, view);
+ let computed = {
+ let ts = &mut gpu.typesetter;
+ let tmap = &mounted.text_measures;
+ gpu.layout
+ .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
+ match tmap.get(&nid) {
+ Some(tm) => measure_text_node(ts, tm, known, avail),
+ None => taffy::Size::ZERO,
+ }
+ })
+ .expect("layout")
+ };
+ gpu.scene.reset();
+ paint(&mut gpu.scene, &mounted, &computed, &mut gpu.typesetter, hover_idx, None);
+ if let Err(e) = gpu.renderer.render(hal, &gpu.scene, &frame, palette::css::BLACK) {
+ eprintln!("pata layer · render: {e}");
+ }
+ gpu.surface.present(frame, hal);
+ diag!("pata diag · present panel {pi} {w}x{h}");
+
+ // Recién con el cuadro presentado damos el panel por limpio: si la
+ // adquisición hubiera fallado, `dirty` sigue puesto y el próximo
+ // frame-callback reintenta (no esperamos al re-muestreo de 1 Hz).
+ self.panels[pi].dirty = false;
+ // Guarda el árbol pintado para el hit-test de los clicks.
+ self.panels[pi].cache = Some(RenderCache { mounted, computed });
+ self.latido(pi, qh);
+ }
+
+ /// Aplica el `Msg` que produjo un click: togglear shuma (su cabezal) o lanzar
+ /// el comando de un widget con `exec`. El resto no sale de un click.
+ fn handle_msg(&mut self, msg: Msg) {
+ match msg {
+ Msg::ShumaToggle => self.set_shuma_open(!self.shuma.open),
+ // Clic en una etapa de pipe de una card: re-ejecuta la línea
+ // truncada en el drawer (lo abre si estaba cerrado).
+ Msg::ShumaRunLine(line) => {
+ if !line.trim().is_empty() {
+ if !self.shuma.open {
+ self.set_shuma_open(true);
+ }
+ self.shuma.buffer = line;
+ self.shuma_submit();
+ }
+ }
+ // Clic en el `$` de una card: la pliega/despliega.
+ Msg::ShumaCollapse(idx) => {
+ if let Some(b) = self.shuma.blocks.get_mut(idx) {
+ b.collapsed = !b.collapsed;
+ self.marcar_shuma_dirty();
+ }
+ }
+ Msg::ShumaScroll(delta) => {
+ self.shuma.scroll_by(delta);
+ self.marcar_shuma_dirty();
+ }
+ Msg::Spawn(cmd) => crate::spawn_cmd(&cmd),
+ Msg::StartToggle => self.set_menu_open(!self.menu_open),
+ Msg::LaunchApp(id) => self.lanzar_app(id),
+ Msg::ActivateWindow(id) => self.activar_ventana(id),
+ Msg::CloseWindow(id) => self.cerrar_ventana(id),
+ Msg::TrayActivate(key) => {
+ if let Some(t) = &self.tray {
+ t.activate(key);
+ }
+ }
+ // --- Sidebar navegador (Fase 11c-layer) ---
+ Msg::NavTabActivate(si, ti) => self.set_sidebar_open(si, ti),
+ Msg::NavClosePanel => self.cerrar_sidebar(),
+ Msg::NavSetMode(m) => {
+ self.nav.mode = m;
+ self.marcar_sidebars_dirty();
+ }
+ Msg::NavSelect(id) => {
+ self.nav.selected = Some(id);
+ self.marcar_sidebars_dirty();
+ }
+ Msg::NavToggle(id) => self.nav_toggle(id),
+ Msg::NavContextMenu(id) => {
+ // Fase 11d-extra: right-click sobre archivo abre el menú "Abrir con…".
+ if let Some(path) = self.nav.file_path(id).map(str::to_owned) {
+ let opts = crate::open::handlers_for_path(&self.registry, &path);
+ self.nav.open_menu(id, opts);
+ self.marcar_sidebars_dirty();
+ }
+ }
+ Msg::NavOpenWith(id, app_id) => {
+ if let Some(path) = self.nav.file_path(id).map(str::to_owned) {
+ match app_id {
+ Some(aid) => {
+ let _ = crate::open::open_with_id(&self.registry, &aid, &path);
+ }
+ None => {
+ let _ = crate::open::open_system(&path);
+ }
+ }
+ }
+ self.nav.close_menu();
+ self.marcar_sidebars_dirty();
+ }
+ Msg::NavMenuCancel => {
+ self.nav.close_menu();
+ self.marcar_sidebars_dirty();
+ }
+ Msg::HostToothActivate(app_id, tooth) => {
+ // Reenvía el clic del diente hospedado a la app enfocada; ella
+ // muestra ese panel sobre su propio canvas.
+ if let Some(h) = &self.host {
+ h.activate(&app_id, tooth);
+ }
+ }
+ Msg::NavScroll(delta) => {
+ self.nav.scroll = (self.nav.scroll + delta).max(0.0);
+ self.marcar_sidebars_dirty();
+ }
+ Msg::Quit => self.exit = true,
+ _ => {}
+ }
+ }
+
+ /// Click en una ventana del task manager (estilo KDE): si ya está activa, la
+ /// **minimiza**; si no, la trae al frente (y la desminimiza). Sin seat (raro)
+ /// no hace nada. El compositor responde con `state`/`done` que actualiza el
+ /// resaltado y el atenuado.
+ fn activar_ventana(&mut self, id: u32) {
+ let Some(seat) = self.seat.clone() else { return };
+ if let Some(t) = self.toplevel_por_id(id) {
+ if t.activated {
+ t.handle.set_minimized();
+ } else {
+ t.handle.unset_minimized();
+ t.handle.activate(&seat);
+ }
+ }
+ }
+
+ /// Cierra la ventana `id` (clic derecho en su chip del task manager). El
+ /// compositor manda `closed` que la retira de la lista.
+ fn cerrar_ventana(&mut self, id: u32) {
+ if let Some(t) = self.toplevel_por_id(id) {
+ t.handle.close();
+ }
+ }
+}
+
+impl CompositorHandler for LayerApp {
+ fn scale_factor_changed(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_surface::WlSurface,
+ _: i32,
+ ) {
+ }
+
+ fn transform_changed(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_surface::WlSurface,
+ _: wl_output::Transform,
+ ) {
+ }
+
+ fn frame(
+ &mut self,
+ _: &Connection,
+ qh: &QueueHandle<Self>,
+ surface: &wl_surface::WlSurface,
+ _: u32,
+ ) {
+ if let Some(pi) = self.panel_de(surface) {
+ self.draw(pi, qh);
+ }
+ }
+
+ fn surface_enter(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_surface::WlSurface,
+ _: &wl_output::WlOutput,
+ ) {
+ }
+
+ fn surface_leave(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_surface::WlSurface,
+ _: &wl_output::WlOutput,
+ ) {
+ }
+}
+
+impl LayerShellHandler for LayerApp {
+ fn closed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &LayerSurface) {
+ // Cerrar cualquier barra cierra el marco entero.
+ self.exit = true;
+ }
+
+ fn configure(
+ &mut self,
+ _: &Connection,
+ qh: &QueueHandle<Self>,
+ layer: &LayerSurface,
+ configure: LayerSurfaceConfigure,
+ _: u32,
+ ) {
+ let (cw, ch) = configure.new_size;
+ let Some(pi) = self.panel_de(layer.wl_surface()) else {
+ return;
+ };
+ diag!("pata diag · configure panel {pi} new_size={cw}x{ch}");
+ // El compositor nos da el tamaño definitivo (el eje libre ya resuelto).
+ if cw > 0 {
+ self.panels[pi].width = cw;
+ }
+ if ch > 0 {
+ self.panels[pi].height = ch;
+ }
+ self.panels[pi].dirty = true; // tamaño nuevo → re-pintar
+ self.draw(pi, qh);
+ }
+}
+
+impl OutputHandler for LayerApp {
+ fn output_state(&mut self) -> &mut OutputState {
+ &mut self.output_state
+ }
+ fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
+ fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
+ fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
+}
+
+impl SeatHandler for LayerApp {
+ fn seat_state(&mut self) -> &mut SeatState {
+ &mut self.seat_state
+ }
+ fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
+ // Guardamos el seat para poder activar ventanas (`activate(seat)`).
+ if self.seat.is_none() {
+ self.seat = Some(seat);
+ }
+ }
+
+ fn new_capability(
+ &mut self,
+ _: &Connection,
+ qh: &QueueHandle<Self>,
+ seat: wl_seat::WlSeat,
+ capability: Capability,
+ ) {
+ match capability {
+ Capability::Keyboard if self.keyboard.is_none() => {
+ if let Ok(kbd) = self.seat_state.get_keyboard(qh, &seat, None) {
+ self.keyboard = Some(kbd);
+ }
+ }
+ Capability::Pointer if self.pointer.is_none() => {
+ if let Ok(ptr) = self.seat_state.get_pointer(qh, &seat) {
+ self.pointer = Some(ptr);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn remove_capability(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: wl_seat::WlSeat,
+ capability: Capability,
+ ) {
+ match capability {
+ Capability::Keyboard => {
+ if let Some(k) = self.keyboard.take() {
+ k.release();
+ }
+ }
+ Capability::Pointer => {
+ if let Some(p) = self.pointer.take() {
+ p.release();
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
+}
+
+impl KeyboardHandler for LayerApp {
+ fn enter(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_keyboard::WlKeyboard,
+ _: &wl_surface::WlSurface,
+ _: u32,
+ _: &[u32],
+ _: &[Keysym],
+ ) {
+ }
+
+ fn leave(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_keyboard::WlKeyboard,
+ _: &wl_surface::WlSurface,
+ _: u32,
+ ) {
+ }
+
+ fn press_key(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_keyboard::WlKeyboard,
+ _: u32,
+ event: KbEvent,
+ ) {
+ // El teclado sólo nos importa con el drawer abierto (foco Exclusive).
+ if !self.shuma.open {
+ return;
+ }
+ match event.keysym {
+ Keysym::Escape => self.set_shuma_open(false),
+ Keysym::BackSpace => {
+ self.shuma.buffer.pop();
+ self.marcar_shuma_dirty();
+ }
+ Keysym::Return | Keysym::KP_Enter => self.shuma_submit(),
+ _ => {
+ if let Some(txt) = event.utf8 {
+ if !txt.is_empty() && !txt.chars().any(|c| c.is_control()) {
+ self.shuma.buffer.push_str(&txt);
+ self.marcar_shuma_dirty();
+ }
+ }
+ }
+ }
+ }
+
+ fn release_key(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_keyboard::WlKeyboard,
+ _: u32,
+ _: KbEvent,
+ ) {
+ }
+
+ fn update_modifiers(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_keyboard::WlKeyboard,
+ _: u32,
+ _: Modifiers,
+ _: u32,
+ ) {
+ }
+}
+
+impl PointerHandler for LayerApp {
+ fn pointer_frame(
+ &mut self,
+ _: &Connection,
+ qh: &QueueHandle<Self>,
+ _: &wl_pointer::WlPointer,
+ events: &[PointerEvent],
+ ) {
+ for e in events {
+ // Hover: el nodo bajo el puntero da feedback (`hover_fill`) y, si
+ // tiene texto de tooltip, lo muestra en la surface flotante. El
+ // layer-shell no trackeaba hover (pasaba `None` a `paint`), así que el
+ // realce estaba muerto en todas las barras.
+ match e.kind {
+ PointerEventKind::Motion { .. } => {
+ // Drag en curso: el delta va al handler del nodo (Move). El
+ // nodegraph del navegador no reposiciona (devuelve None en
+ // Move); selecciona al soltar (End).
+ if self.drag.is_some() {
+ let (px, py) = (e.position.0 as f32, e.position.1 as f32);
+ let (handler, last) = {
+ let d = self.drag.as_ref().unwrap();
+ (d.handler.clone(), d.last)
+ };
+ if let Some(d) = self.drag.as_mut() {
+ d.last = (px, py);
+ }
+ if let Some(msg) = (handler)(DragPhase::Move, px - last.0, py - last.1) {
+ self.handle_msg(msg);
+ }
+ continue;
+ }
+ if let Some(pi) = self.panel_de(&e.surface) {
+ let (px, py) = (e.position.0 as f32, e.position.1 as f32);
+ let nuevo = self.panels[pi]
+ .cache
+ .as_ref()
+ .and_then(|c| hit_test_hover(&c.mounted, &c.computed, px, py));
+ if self.panels[pi].hover_idx != nuevo {
+ self.panels[pi].hover_idx = nuevo;
+ self.panels[pi].dirty = true;
+ self.update_tooltip(pi, nuevo, qh);
+ }
+ }
+ continue;
+ }
+ PointerEventKind::Leave { .. } => {
+ if let Some(pi) = self.panel_de(&e.surface) {
+ if self.panels[pi].hover_idx.is_some() {
+ self.panels[pi].hover_idx = None;
+ self.panels[pi].dirty = true;
+ }
+ }
+ self.hide_tooltip(qh);
+ continue;
+ }
+ _ => {}
+ }
+ // Rueda sobre el historial del drawer: el nodo de scroll bajo el
+ // cursor consume el delta y emite `ShumaScroll`. Convención de
+ // signo igual que llimphi-ui (wayland y winit traen el eje y con
+ // signos opuestos, así que acá NO se niega).
+ if let PointerEventKind::Axis { vertical, .. } = e.kind {
+ let dy = if vertical.discrete != 0 {
+ vertical.discrete as f32
+ } else {
+ vertical.absolute as f32 / 20.0
+ };
+ if dy != 0.0 {
+ let (px, py) = (e.position.0 as f32, e.position.1 as f32);
+ if let Some(pi) = self.panel_de(&e.surface) {
+ let msg = self.panels[pi].cache.as_ref().and_then(|c| {
+ hit_test_scroll(&c.mounted, &c.computed, px, py)
+ .and_then(|i| c.mounted.nodes.get(i))
+ .and_then(|n| n.on_scroll.as_ref().and_then(|h| h(0.0, dy)))
+ });
+ if let Some(msg) = msg {
+ self.handle_msg(msg);
+ }
+ }
+ }
+ continue;
+ }
+ // Soltar el botón izquierdo termina un drag en curso: el handler del
+ // nodo recibe `End` y emite su Msg (p. ej. seleccionar el nodo del
+ // grafo). Los deltas en End los ignoran los consumidores.
+ if let PointerEventKind::Release { button, .. } = e.kind {
+ if button == BTN_LEFT {
+ if let Some(d) = self.drag.take() {
+ if let Some(msg) = (d.handler)(DragPhase::End, 0.0, 0.0) {
+ self.handle_msg(msg);
+ }
+ }
+ }
+ continue;
+ }
+ if let PointerEventKind::Press { button, .. } = e.kind {
+ if button != BTN_LEFT && button != BTN_RIGHT {
+ continue;
+ }
+ // Hit-test: qué nodo está bajo el puntero y qué handler dispara.
+ // El izquierdo usa `on_click` (cabezal de shuma, activar ventana,
+ // lanzar exec); el derecho `on_right_click` (cerrar ventana del
+ // task manager). El click ya dio foco de teclado.
+ let Some(pi) = self.panel_de(&e.surface) else {
+ continue;
+ };
+ let (px, py) = (e.position.0 as f32, e.position.1 as f32);
+ let derecho = button == BTN_RIGHT;
+ // Nodo arrastrable bajo el press (izquierdo): arranca un drag y NO
+ // lo tratamos como click (el nodegraph selecciona al soltar).
+ if !derecho {
+ let handler = self.panels[pi].cache.as_ref().and_then(|c| {
+ let i = hit_test_click(&c.mounted, &c.computed, px, py)?;
+ c.mounted.nodes.get(i)?.drag.clone()
+ });
+ if let Some(handler) = handler {
+ self.drag = Some(LayerDrag { handler, last: (px, py) });
+ continue;
+ }
+ }
+ let msg = self.panels[pi].cache.as_ref().and_then(|c| {
+ let i = hit_test_click(&c.mounted, &c.computed, px, py)?;
+ let n = c.mounted.nodes.get(i)?;
+ // Primero el handler simple (`on_click`/`on_right_click`); si no
+ // hay, el `*_at` (coords locales al nodo) — lo usan widgets que
+ // coexisten con drag, como los dientes del rail. Paridad con el
+ // bucle winit, que también prioriza así.
+ if derecho {
+ if let Some(m) = n.on_right_click.clone() {
+ return Some(m);
+ }
+ let at = n.on_right_click_at.as_ref()?;
+ let r = c.computed.get(n.id)?;
+ at(px - r.x, py - r.y, r.w, r.h)
+ } else {
+ if let Some(m) = n.on_click.clone() {
+ return Some(m);
+ }
+ let at = n.on_click_at.as_ref()?;
+ let r = c.computed.get(n.id)?;
+ at(px - r.x, py - r.y, r.w, r.h)
+ }
+ });
+ if let Some(msg) = msg {
+ self.handle_msg(msg);
+ }
+ }
+ }
+ }
+}
+
+impl ProvidesRegistryState for LayerApp {
+ fn registry(&mut self) -> &mut RegistryState {
+ &mut self.registry_state
+ }
+ registry_handlers![OutputState, SeatState];
+}
+
+/// El manager de ventanas: anuncia un toplevel nuevo (creando su handle hijo) y
+/// el fin del servicio. `event_created_child!` declara cómo enrutar el handle que
+/// nace en el evento `toplevel` (sin esto, wayland-client paniquea al recibirlo).
+impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for LayerApp {
+ fn event(
+ state: &mut Self,
+ _mgr: &ZwlrForeignToplevelManagerV1,
+ event: zwlr_foreign_toplevel_manager_v1::Event,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ ) {
+ use zwlr_foreign_toplevel_manager_v1::Event;
+ match event {
+ Event::Toplevel { toplevel } => {
+ let id = state.next_toplevel_id;
+ state.next_toplevel_id = state.next_toplevel_id.wrapping_add(1);
+ state.toplevels.push(Toplevel::new(id, toplevel));
+ }
+ Event::Finished => {
+ state.toplevels.clear();
+ state.marcar_todo_dirty();
+ }
+ _ => {}
+ }
+ }
+
+ event_created_child!(LayerApp, ZwlrForeignToplevelManagerV1, [
+ EVT_TOPLEVEL_OPCODE => (ZwlrForeignToplevelHandleV1, ()),
+ ]);
+}
+
+/// Un handle de toplevel: el compositor le manda título / app_id / estado en
+/// eventos sueltos y los confirma con `done`; `closed` lo retira. Acumulamos en
+/// el [`Toplevel`] y aplicamos en `done` para no pintar estados a medias.
+impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for LayerApp {
+ fn event(
+ state: &mut Self,
+ handle: &ZwlrForeignToplevelHandleV1,
+ event: zwlr_foreign_toplevel_handle_v1::Event,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ ) {
+ use zwlr_foreign_toplevel_handle_v1::Event;
+ let pos = state.toplevels.iter().position(|t| &t.handle == handle);
+ let Some(i) = pos else { return };
+ match event {
+ Event::Title { title } => state.toplevels[i].set_title(title),
+ Event::AppId { app_id } => state.toplevels[i].set_app_id(app_id),
+ Event::State { state: estados } => state.toplevels[i].set_state(&estados),
+ Event::Done => {
+ if state.toplevels[i].confirmar() {
+ state.marcar_todo_dirty();
+ }
+ }
+ Event::Closed => {
+ let t = state.toplevels.remove(i);
+ t.handle.destroy();
+ state.marcar_todo_dirty();
+ }
+ _ => {}
+ }
+ }
+}
+
+delegate_compositor!(LayerApp);
+delegate_output!(LayerApp);
+delegate_layer!(LayerApp);
+delegate_seat!(LayerApp);
+delegate_keyboard!(LayerApp);
+delegate_pointer!(LayerApp);
+delegate_registry!(LayerApp);
diff --git a/02_ruway/pata/pata-llimphi/src/lib.rs b/02_ruway/pata/pata-llimphi/src/lib.rs
new file mode 100644
index 0000000..d8bbdde
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/lib.rs
@@ -0,0 +1,652 @@
+//! `pata-llimphi` — el frontend Linux del marco.
+//!
+//! Monta el modelo agnóstico de [`pata_core`] sobre Llimphi. El reparto de
+//! responsabilidades es la regla dura del repo (UIs intercambiables sobre un
+//! `*-core` agnóstico):
+//!
+//! - **`pata-core`** decide *qué* mostrar: resuelve la geometría
+//! ([`pata_core::layout::resolve`]) y, por cada [`WidgetSpec`], materializa un
+//! [`Widget`] que emite un view-model ([`WidgetView`]) en cada `tick`.
+//! - **este crate** decide *cómo*: muestrea el sistema en un
+//! [`WidgetCtx`](pata_core::widget::WidgetCtx) (ver [`sampler`]) y traduce el
+//! view-model a `View<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,
+ }
+ }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/main.rs b/02_ruway/pata/pata-llimphi/src/main.rs
new file mode 100644
index 0000000..0808a1f
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/main.rs
@@ -0,0 +1,28 @@
+//! Binario del frontend `pata`: levanta el marco.
+//!
+//! Elige el backend de windowing:
+//! - **`wlr-layer-shell`** (default en Wayland): pata se ancla como una *layer
+//! surface* al nivel de eww/waybar, con exclusive zone — el compositor le
+//! reserva su franja. Es lo que querés en Hyprland/Sway/river.
+//! - **winit** (fallback): una ventana normal. Sirve en X11, o si el compositor
+//! no expone `wlr-layer-shell`, o forzándolo con `PATA_BACKEND=winit`.
+//!
+//! ```sh
+//! cargo run -p pata-llimphi --release # layer-shell si hay Wayland
+//! PATA_BACKEND=winit cargo run -p pata-llimphi # fuerza ventana winit
+//! ```
+
+fn main() {
+ let forzar_winit = std::env::var("PATA_BACKEND").as_deref() == Ok("winit");
+ let hay_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
+
+ if !forzar_winit && hay_wayland {
+ match pata_llimphi::layer::run() {
+ Ok(()) => return,
+ Err(e) => {
+ eprintln!("pata · backend layer-shell falló ({e}); caigo a ventana winit.");
+ }
+ }
+ }
+ llimphi_ui::run::<pata_llimphi::PataApp>();
+}
diff --git a/02_ruway/pata/pata-llimphi/src/nouser.rs b/02_ruway/pata/pata-llimphi/src/nouser.rs
new file mode 100644
index 0000000..a43c75d
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/nouser.rs
@@ -0,0 +1,454 @@
+//! `nouser` — el **plano de datos** del sidebar navegador (Fase 11c).
+//!
+//! El sidebar de `pata` muestra las **Mónadas** de nouser y sus archivos en un
+//! navegador conmutable árbol/grafo ([`llimphi_widget_navigator`]). nouser es la
+//! **fuente autoritativa** de qué archivos componen una Mónada (no el filesystem
+//! por su cuenta — decisión del autor); por eso el nivel de archivos se resuelve
+//! por el query de nouser (`chasqui_card::query`) y no leyendo directorios.
+//!
+//! Este módulo:
+//! - descubre el socket del daemon (broker brahman → fallback al default path),
+//! igual que `chasqui-explorer-llimphi`;
+//! - consulta `list_monads` (poll liviano) y `resolve_monad` (miembros bajo
+//! demanda al expandir una Mónada);
+//! - construye el bosque de [`NavNode`]s que el widget pinta, manteniendo el
+//! estado de UI (modo, selección, expansión, diente desplegado) en el caller.
+//!
+//! La asignación de [`NavId`] es **determinista** (hash del `MonadId`/path) para
+//! que la expansión y la selección sobrevivan a un re-poll sin parpadear.
+
+use std::collections::{HashMap, HashSet};
+use std::path::PathBuf;
+use std::time::Duration;
+
+use card_sidecar::{await_provider_blocking, build_consumer_card};
+use chasqui_card::query::client as qclient;
+use chasqui_card::query::{
+ transport, FileView, ListMonadsResponse, MonadView, FLOW_MONAD_LIST, FLOW_TYPE_NAME,
+};
+use chasqui_card::MonadId;
+use llimphi_widget_navigator::{NavId, NavKind, NavMode, NavNode};
+
+/// Timeout para descubrir el provider por el broker.
+const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3);
+/// Timeout de un query single-shot al daemon.
+const QUERY_TIMEOUT: Duration = Duration::from_secs(2);
+/// Cada cuánto se repolea `list_monads`.
+pub const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
+
+// =====================================================================
+// Mapeo NavId → qué representa
+// =====================================================================
+
+/// Qué representa un nodo del navegador, para resolver miembros (al expandir una
+/// Mónada) y para abrir con la app que corresponda (Fase 11d).
+#[derive(Debug, Clone)]
+pub enum NavTarget {
+ /// Una Mónada de nouser, por su id.
+ Monad(MonadId),
+ /// Un archivo miembro, por su ruta.
+ File(String),
+}
+
+/// Hash FNV-1a de 64 bits — determinista y sin dependencias, suficiente para
+/// derivar un [`NavId`] estable de un identificador opaco.
+fn fnv1a(tag: u8, bytes: &[u8]) -> u64 {
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
+ h ^= tag as u64;
+ h = h.wrapping_mul(0x0000_0100_0000_01b3);
+ for &b in bytes {
+ h ^= b as u64;
+ h = h.wrapping_mul(0x0000_0100_0000_01b3);
+ }
+ h
+}
+
+/// [`NavId`] de una Mónada (tag 1).
+fn monad_nav_id(id: &MonadId) -> NavId {
+ fnv1a(1, &id.to_bytes())
+}
+
+/// [`NavId`] del placeholder "cargando…" de una Mónada aún no resuelta (tag 2).
+fn placeholder_nav_id(id: &MonadId) -> NavId {
+ fnv1a(2, &id.to_bytes())
+}
+
+/// [`NavId`] de un archivo, por su ruta (tag 3).
+fn file_nav_id(path: &str) -> NavId {
+ fnv1a(3, path.as_bytes())
+}
+
+/// El último componente de una ruta (su "nombre"), o la ruta entera si no tiene
+/// separadores. Para la etiqueta de la fila — el path completo va al tooltip /
+/// al abrir.
+fn file_label(path: &str) -> String {
+ path.rsplit('/').next().filter(|s| !s.is_empty()).unwrap_or(path).to_string()
+}
+
+// =====================================================================
+// Estado del navegador
+// =====================================================================
+
+/// Estado del sidebar navegador. Vive en el `Model` del frontend; el widget es
+/// render-only y lo consulta cada `view`.
+pub struct NavState {
+ /// Diente desplegado: `(surface_idx, tab_idx)`. `None` = rail colapsado, sin
+ /// panel.
+ pub open: Option<(usize, usize)>,
+ /// Modo de visualización activo (compartido entre dientes).
+ pub mode: NavMode,
+ /// Nodo seleccionado (resaltado).
+ pub selected: Option<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);
+ }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/open.rs b/02_ruway/pata/pata-llimphi/src/open.rs
new file mode 100644
index 0000000..3b83c69
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/open.rs
@@ -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());
+ }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/render.rs b/02_ruway/pata/pata-llimphi/src/render.rs
new file mode 100644
index 0000000..bb9fc85
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/render.rs
@@ -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);
+ }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/render/sidebar.rs b/02_ruway/pata/pata-llimphi/src/render/sidebar.rs
new file mode 100644
index 0000000..4e2d3d8
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/render/sidebar.rs
@@ -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);
+ }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/sampler.rs b/02_ruway/pata/pata-llimphi/src/sampler.rs
new file mode 100644
index 0000000..59180ac
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/sampler.rs
@@ -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
+}
diff --git a/02_ruway/pata/pata-llimphi/src/shuma.rs b/02_ruway/pata/pata-llimphi/src/shuma.rs
new file mode 100644
index 0000000..7e7bf74
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/shuma.rs
@@ -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));
+ }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/toplevel.rs b/02_ruway/pata/pata-llimphi/src/toplevel.rs
new file mode 100644
index 0000000..1655913
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/toplevel.rs
@@ -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())
+ }
+}
diff --git a/02_ruway/pata/pata-llimphi/src/tray.rs b/02_ruway/pata/pata-llimphi/src/tray.rs
new file mode 100644
index 0000000..a12c351
--- /dev/null
+++ b/02_ruway/pata/pata-llimphi/src/tray.rs
@@ -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 n
The patch goes on, but was cut here: fetch the commit with git to see it whole.