From 8204852e3aa72d653aa41e941fc3a310bd11493f Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 21 May 2026 00:04:11 +0000 Subject: [PATCH] feat(mirada): keymap configurable en RON, recargable en caliente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los atajos de teclado dejan de estar cableados: ahora son un Keymap configurable que vive sólo en el Cerebro. El Cuerpo nunca lo ve — sólo recibe la lista de cadenas a interceptar (GrabKeys) y devuelve la pulsada; es Desktop quien la traduce. Esa separación (qué interceptar vs. qué significa) hace innecesario cualquier candado o Arc. mirada-brain: - keymap.rs — Keymap: from_ron/to_ron, load/save, load_or_init (escribe un archivo por defecto documentado si falta; default sin pisar si está corrupto), default_path (~/.config/mirada/keymap.ron), y watch sobre notify para la recarga en caliente (KeymapWatch). - DesktopAction: Display + FromStr — vocabulario textual estable ("focus-next", "layout:grid", "workspace:3"); evita los guiones que romperían el RON de un enum. - Desktop: with_keymap, set_keymap (cambio en caliente -> nuevo GrabKeys). - Ejemplo keymap-default: imprime el archivo por defecto en RON. Apps: mirada y mirada-compositor (modo embebido) cargan el keymap del usuario al arrancar y lo recargan en caliente cuando el archivo cambia. Disco RON, cable postcard (sólo la lista de cadenas), sin ejecutable configurador. mirada-brain: 17 -> 29 tests. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 19 + Cargo.toml | 1 + crates/apps/mirada-compositor/README.md | 20 +- crates/apps/mirada-compositor/src/main.rs | 44 ++- crates/apps/mirada/src/main.rs | 58 ++- crates/modules/mirada/SDD.md | 30 +- crates/modules/mirada/mirada-brain/Cargo.toml | 4 + .../mirada-brain/examples/keymap-default.rs | 11 + .../modules/mirada/mirada-brain/src/action.rs | 137 +++++++ .../mirada/mirada-brain/src/desktop.rs | 62 +++- .../modules/mirada/mirada-brain/src/keymap.rs | 339 ++++++++++++++++++ crates/modules/mirada/mirada-brain/src/lib.rs | 3 + vamos.txt | 10 + 13 files changed, 713 insertions(+), 25 deletions(-) create mode 100644 crates/modules/mirada/mirada-brain/examples/keymap-default.rs create mode 100644 crates/modules/mirada/mirada-brain/src/keymap.rs diff --git a/Cargo.lock b/Cargo.lock index 30aa9ef..9f8eb55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1589,6 +1589,9 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "bitmaps" @@ -7712,8 +7715,12 @@ dependencies = [ name = "mirada-brain" version = "0.1.0" dependencies = [ + "directories", "mirada-layout", "mirada-protocol", + "notify", + "ron", + "serde", ] [[package]] @@ -10973,6 +10980,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags 2.11.1", + "serde", + "serde_derive", +] + [[package]] name = "roxmltree" version = "0.20.0" diff --git a/Cargo.toml b/Cargo.toml index a40aef7..5c0581a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -305,6 +305,7 @@ serde_json = "1" serde-big-array = "0.5" postcard = { version = "1", features = ["use-std"] } toml = "0.8" +ron = "0.8" bincode = "1" base64 = "0.22" diff --git a/crates/apps/mirada-compositor/README.md b/crates/apps/mirada-compositor/README.md index c48d195..c9b3f54 100644 --- a/crates/apps/mirada-compositor/README.md +++ b/crates/apps/mirada-compositor/README.md @@ -60,10 +60,26 @@ WAYLAND_DISPLAY=wayland-1 foot # o weston-terminal, alacritty, … ``` Las ventanas se teselan solas. El teclado, con la ventana del compositor -enfocada, maneja el escritorio con atajos `Super+…` (los que registra el -Cerebro: foco `Super+j/k`, layout `Super+Tab`, escritorios `Super+1..9`). +enfocada, maneja el escritorio con atajos `Super+…`: foco `Super+j/k`, +ciclar layout `Super+space`, escritorios `Super+1..9`, cerrar `Super+q`. Cierra la ventana del compositor para salir. +## Atajos de teclado + +Los atajos son configurables en RON: `~/.config/mirada/keymap.ron`. En +modo autónomo, el Cuerpo lo carga al arrancar (si no existe, escribe uno +por defecto documentado) y lo **recarga en caliente** — edita el archivo, +guarda, y los atajos cambian sin reiniciar. En modo enlazado el keymap es +asunto del Cerebro (la app `mirada`). + +```sh +cargo run -p mirada-brain --example keymap-default # ver el formato +``` + +El compositor en sí no interpreta atajos: sólo intercepta las +combinaciones que el Cerebro le pide (`GrabKeys`) y le devuelve la +pulsada. *Qué significa* cada una lo decide `mirada-brain`. Ver el SDD. + ## Qué implementa `wl_compositor`, `xdg_shell` (toplevels y popups), `wl_shm`, `wl_seat` diff --git a/crates/apps/mirada-compositor/src/main.rs b/crates/apps/mirada-compositor/src/main.rs index 754d02e..0f5095d 100644 --- a/crates/apps/mirada-compositor/src/main.rs +++ b/crates/apps/mirada-compositor/src/main.rs @@ -59,7 +59,7 @@ use smithay::{ }; use mirada_body::{BodyOp, BodyState}; -use mirada_brain::{BodyEvent, BrainCommand, Desktop}; +use mirada_brain::{BodyEvent, BrainCommand, Desktop, Keymap}; use mirada_link::BodyLink; // --------------------------------------------------------------------- @@ -435,6 +435,10 @@ fn run() -> Result<(), Box> { let mut seat_state = SeatState::new(); let seat = seat_state.new_wl_seat(&dh, "mirada"); + // El keymap del usuario (`~/.config/mirada/keymap.ron`). Sólo lo usa + // el Cerebro embebido; con un Cerebro enlazado, el keymap es asunto suyo. + let keymap_path = Keymap::default_path(); + // Elige el Cerebro: enlazado si `MIRADA_SOCKET` está puesto. let brain = match std::env::var("MIRADA_SOCKET") { Ok(path) => { @@ -445,7 +449,11 @@ fn run() -> Result<(), Box> { } Err(_) => { println!("mirada-compositor · modo autónomo (Cerebro embebido)."); - Brain::Embedded(Desktop::new()) + let keymap = match &keymap_path { + Some(p) => Keymap::load_or_init(p), + None => Keymap::default(), + }; + Brain::Embedded(Desktop::with_keymap(keymap)) } }; @@ -475,6 +483,16 @@ fn run() -> Result<(), Box> { state.apply_commands(vec![grab]); } + // Vigilancia del keymap para recargarlo en caliente — sólo tiene + // sentido con el Cerebro embebido. + let keymap_watch = match (&state.brain, &keymap_path) { + (Brain::Embedded(_), Some(p)) => Keymap::watch(p).ok(), + _ => None, + }; + if keymap_watch.is_some() { + println!("mirada-compositor · vigilando el keymap (recarga en caliente)."); + } + // El backend gráfico va primero. winit abre la ventana del compositor // dentro de tu sesión gráfica anfitriona, y para encontrarla lee // `WAYLAND_DISPLAY` / `DISPLAY` del entorno. Si publicáramos antes @@ -571,6 +589,28 @@ fn run() -> Result<(), Box> { // 2 · Comandos de un Cerebro enlazado. state.brain_poll(); + // 2 bis · Recarga del keymap si el archivo cambió en disco. + if keymap_watch.as_ref().is_some_and(|w| w.changed()) { + if let Some(path) = &keymap_path { + match Keymap::load(path) { + Ok(km) => { + let cmd = if let Brain::Embedded(d) = &mut state.brain { + Some(d.set_keymap(km)) + } else { + None + }; + if let Some(cmd) = cmd { + state.apply_commands(vec![cmd]); + } + println!("mirada-compositor · keymap recargado."); + } + Err(e) => eprintln!( + "mirada-compositor · keymap inválido, conservo el anterior: {e}" + ), + } + } + } + // 3 · Composición de las superficies en sus rectángulos. let size = backend.window_size(); let damage: Rectangle = Rectangle::from_size(size); diff --git a/crates/apps/mirada/src/main.rs b/crates/apps/mirada/src/main.rs index 61b89c9..e1ced6e 100644 --- a/crates/apps/mirada/src/main.rs +++ b/crates/apps/mirada/src/main.rs @@ -25,6 +25,7 @@ //! Shift+j / k mueve la enfocada Ctrl+1..9 enviar a escritorio //! ``` +use std::path::PathBuf; use std::time::Duration; use gpui::{ @@ -32,7 +33,8 @@ use gpui::{ SharedString, Window, }; use mirada_brain::{ - BodyEvent, BrainCommand, Desktop, DesktopAction, LayoutMode, WindowId, WindowPlacement, + BodyEvent, BrainCommand, Desktop, DesktopAction, Keymap, KeymapWatch, LayoutMode, WindowId, + WindowPlacement, }; use mirada_link::BrainLink; use nahual_launcher::launch_app; @@ -62,18 +64,40 @@ struct Mirada { note: SharedString, focus: FocusHandle, focused_once: bool, + /// Ruta del keymap del usuario, para recargarlo en caliente. + keymap_path: Option, + /// Vigía del keymap; `None` en simulación o si no hay archivo. + keymap_watch: Option, } impl Mirada { fn new(cx: &mut Context) -> Self { + // Keymap del usuario (~/.config/mirada/keymap.ron): define los + // atajos que el Cuerpo intercepta y nos devuelve como `Keybind`. + let keymap_path = Keymap::default_path(); + let keymap = match &keymap_path { + Some(p) => Keymap::load_or_init(p), + None => Keymap::default(), + }; + let link = connect_body(); + // Vigilar el keymap sólo tiene sentido con un Cuerpo conectado; + // en simulación, mirada usa las teclas de su propia ventana. + let keymap_watch = if link.is_some() { + keymap_path.as_deref().and_then(|p| Keymap::watch(p).ok()) + } else { + None + }; + let mut app = Self { - desktop: Desktop::new(), + desktop: Desktop::with_keymap(keymap), placements: Vec::new(), next_id: 1, - link: connect_body(), + link, note: SharedString::from("listo"), focus: cx.focus_handle(), focused_once: false, + keymap_path, + keymap_watch, }; if let Some(link) = app.link.as_mut() { // Registra los atajos globales en el Cuerpo. @@ -102,10 +126,15 @@ impl Mirada { Some(link) => link.drain(), None => Vec::new(), }; - if !events.is_empty() { - for ev in events { - app.feed(ev); - } + let had_events = !events.is_empty(); + let keymap_changed = app.keymap_watch.as_ref().is_some_and(|w| w.changed()); + if keymap_changed { + app.reload_keymap(); + } + for ev in events { + app.feed(ev); + } + if had_events || keymap_changed { cx.notify(); } }); @@ -141,6 +170,21 @@ impl Mirada { self.dispatch(cmds); } + /// Recarga el keymap del disco y re-registra los atajos en el Cuerpo. + fn reload_keymap(&mut self) { + let Some(path) = self.keymap_path.clone() else { + return; + }; + match Keymap::load(&path) { + Ok(km) => { + let cmd = self.desktop.set_keymap(km); + self.dispatch(vec![cmd]); + self.note = SharedString::from("keymap recargado"); + } + Err(e) => self.note = SharedString::from(format!("keymap inválido: {e}")), + } + } + /// Reparte los comandos del Cerebro: actualiza lo pintado y, o bien /// los manda al Cuerpo, o bien —en simulación— cierra las ventanas /// por su cuenta (no hay nadie que devuelva el `WindowClosed`). diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md index 08d2534..99099a6 100644 --- a/crates/modules/mirada/SDD.md +++ b/crates/modules/mirada/SDD.md @@ -56,7 +56,8 @@ ejecuta operaciones de geometría". `placements(&Workspace, Rect)`. - **`mirada-brain`** — `Desktop`: salidas, 9 escritorios virtuales, registro de ventanas. `on_event(BodyEvent) -> Vec`; - `DesktopAction` + mapa de teclas estilo *tiling WM* (`Super`). + `DesktopAction` + `Keymap` configurable. `set_keymap` lo cambia en + caliente y devuelve el `GrabKeys` a reenviar. - **`mirada-link`** — `Link` sobre socket Unix; hilo lector de fondo + canal `mpsc` para sondeo no bloqueante. `BrainLink`/`BodyLink`, `connected_pair` (socketpair), `connect`/`listen` por ruta. @@ -69,6 +70,31 @@ ejecuta operaciones de geometría". conecta a un Cuerpo; sin él corre en **simulación** (ventanas sintéticas, teclado de la propia ventana). +## Atajos de teclado configurables + +El keymap vive **sólo en el Cerebro** (`mirada-brain::Keymap`). El Cuerpo +nunca lo ve: recibe únicamente la lista de cadenas a interceptar en un +`GrabKeys`, hace un `Vec::contains` ciego y devuelve la combinación +pulsada como `Keybind`; es `Desktop` quien la traduce a `DesktopAction`. +Esa separación —*qué* interceptar (lista barata, Cuerpo) vs. *qué +significa* (el mapa, Cerebro)— hace innecesario cualquier candado o +`Arc`: el mapa es monohilo y la lista se reemplaza de golpe. + +- **Disco** — RON de texto en `~/.config/mirada/keymap.ron`, editable a + mano y versionable. La app lo crea documentado en el primer arranque; + si está corrupto, avisa y usa el de por defecto sin pisar el archivo. +- **Cable** — sólo viaja la lista de cadenas (`GrabKeys`), vía el marco + `postcard` que ya existe. No hay formato binario de configuración. +- **Vocabulario** — la acción es una cadena estable (`"focus-next"`, + `"layout:grid"`, `"workspace:3"`): `DesktopAction: Display + FromStr`. +- **Recarga en caliente** — `Keymap::watch` (sobre `notify`) vigila el + archivo; al cambiar, el dueño del `Desktop` recarga, llama a + `set_keymap` y reenvía el `GrabKeys`. Sin reiniciar. +- **Configurador** — no hay ejecutable aparte: el editor de texto del + usuario, y la app `mirada` (que a futuro puede dibujar un editor visual + sobre el mismo API `Keymap`). `cargo run -p mirada-brain --example + keymap-default` imprime el archivo por defecto. + ## Dependencias - Todos los `lib` con `#![forbid(unsafe_code)]`. Cero Wayland, cero @@ -78,7 +104,7 @@ ejecuta operaciones de geometría". ## Estado Implementado y verde: `mirada-layout` (22 tests), `mirada-protocol` -(9), `mirada-brain` (17), `mirada-link` (7), `mirada-body` (13), y la +(9), `mirada-brain` (29), `mirada-link` (7), `mirada-body` (13), y la app `mirada` (compila; verificación visual manual). El **Cuerpo** ya existe: `mirada-compositor` es un compositor Wayland diff --git a/crates/modules/mirada/mirada-brain/Cargo.toml b/crates/modules/mirada/mirada-brain/Cargo.toml index 2c06d6a..bc9315b 100644 --- a/crates/modules/mirada/mirada-brain/Cargo.toml +++ b/crates/modules/mirada/mirada-brain/Cargo.toml @@ -10,3 +10,7 @@ description = "mirada — orquestador de escritorio del compositor: mantiene sal [dependencies] mirada-layout = { path = "../mirada-layout" } mirada-protocol = { path = "../mirada-protocol" } +serde = { workspace = true } +ron = { workspace = true } +directories = { workspace = true } +notify = { workspace = true } diff --git a/crates/modules/mirada/mirada-brain/examples/keymap-default.rs b/crates/modules/mirada/mirada-brain/examples/keymap-default.rs new file mode 100644 index 0000000..3429bd8 --- /dev/null +++ b/crates/modules/mirada/mirada-brain/examples/keymap-default.rs @@ -0,0 +1,11 @@ +//! Imprime el keymap por defecto de mirada en formato RON — exactamente +//! lo que la app escribe la primera vez en `~/.config/mirada/keymap.ron`. +//! +//! ```sh +//! cargo run -p mirada-brain --example keymap-default +//! cargo run -p mirada-brain --example keymap-default > ~/.config/mirada/keymap.ron +//! ``` + +fn main() { + print!("{}", mirada_brain::Keymap::default().documented_ron()); +} diff --git a/crates/modules/mirada/mirada-brain/src/action.rs b/crates/modules/mirada/mirada-brain/src/action.rs index 2b54d26..d90d703 100644 --- a/crates/modules/mirada/mirada-brain/src/action.rs +++ b/crates/modules/mirada/mirada-brain/src/action.rs @@ -3,6 +3,13 @@ //! Una [`DesktopAction`] es una orden de alto nivel del usuario, ya //! desligada de la tecla concreta: el [`Desktop`](crate::Desktop) las //! aplica sin saber qué combinación las disparó. +//! +//! Cada acción tiene una **forma textual** estable ([`Display`] / +//! [`FromStr`]) — `"focus-next"`, `"layout:grid"`, `"workspace:3"` — que +//! es el vocabulario del keymap configurable en RON (ver [`crate::keymap`]). + +use std::fmt; +use std::str::FromStr; use mirada_layout::LayoutMode; @@ -34,6 +41,93 @@ pub enum DesktopAction { Quit, } +/// El nombre RON-seguro de un modo de teselado (sin guiones problemáticos +/// para identificadores: aquí van como valor de cadena, no de enum). +fn layout_slug(mode: LayoutMode) -> &'static str { + match mode { + LayoutMode::MasterStack => "master-stack", + LayoutMode::Monocle => "monocle", + LayoutMode::Grid => "grid", + LayoutMode::Columns => "columns", + } +} + +/// Modo de teselado desde su `slug`. +fn layout_from_slug(slug: &str) -> Option { + Some(match slug { + "master-stack" => LayoutMode::MasterStack, + "monocle" => LayoutMode::Monocle, + "grid" => LayoutMode::Grid, + "columns" => LayoutMode::Columns, + _ => return None, + }) +} + +impl fmt::Display for DesktopAction { + /// La forma textual estable de la acción — el vocabulario del keymap. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DesktopAction::FocusNext => f.write_str("focus-next"), + DesktopAction::FocusPrev => f.write_str("focus-prev"), + DesktopAction::MoveForward => f.write_str("move-forward"), + DesktopAction::MoveBackward => f.write_str("move-backward"), + DesktopAction::CloseFocused => f.write_str("close-focused"), + DesktopAction::CycleLayout => f.write_str("cycle-layout"), + DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)), + // Los escritorios se numeran 1-based de cara al usuario. + DesktopAction::SwitchWorkspace(n) => write!(f, "workspace:{}", n + 1), + DesktopAction::SendToWorkspace(n) => write!(f, "send-to-workspace:{}", n + 1), + DesktopAction::Quit => f.write_str("quit"), + } + } +} + +impl FromStr for DesktopAction { + /// Mensaje de error ya formateado, listo para mostrar al usuario. + type Err = String; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + Ok(match s { + "focus-next" => Self::FocusNext, + "focus-prev" => Self::FocusPrev, + "move-forward" => Self::MoveForward, + "move-backward" => Self::MoveBackward, + "close-focused" => Self::CloseFocused, + "cycle-layout" => Self::CycleLayout, + "quit" => Self::Quit, + _ => { + if let Some(slug) = s.strip_prefix("layout:") { + Self::SetLayout( + layout_from_slug(slug) + .ok_or_else(|| format!("modo de teselado desconocido: '{slug}'"))?, + ) + } else if let Some(n) = s.strip_prefix("send-to-workspace:") { + Self::SendToWorkspace(parse_workspace(n)?) + } else if let Some(n) = s.strip_prefix("workspace:") { + Self::SwitchWorkspace(parse_workspace(n)?) + } else { + return Err(format!("acción desconocida: '{s}'")); + } + } + }) + } +} + +/// Parsea el número de escritorio del keymap (1-based) a índice (0-based), +/// acotado a [`WORKSPACE_COUNT`]. +fn parse_workspace(s: &str) -> Result { + let n: usize = s + .trim() + .parse() + .map_err(|_| format!("número de escritorio inválido: '{s}'"))?; + if (1..=WORKSPACE_COUNT).contains(&n) { + Ok(n - 1) + } else { + Err(format!("escritorio fuera de rango (1..={WORKSPACE_COUNT}): {n}")) + } +} + /// Mapa de teclas por defecto, estilo *tiling WM* (modificador `Super`). /// /// Las cadenas deben coincidir literalmente con las que el Cuerpo emite @@ -92,4 +186,47 @@ mod tests { .any(|(_, a)| *a == DesktopAction::SendToWorkspace(n))); } } + + #[test] + fn every_default_action_round_trips_through_its_text_form() { + for (_, action) in default_keymap() { + let text = action.to_string(); + let back: DesktopAction = text.parse().unwrap(); + assert_eq!(action, back, "no redondea: {text}"); + } + } + + #[test] + fn every_layout_mode_round_trips() { + for mode in [ + LayoutMode::MasterStack, + LayoutMode::Monocle, + LayoutMode::Grid, + LayoutMode::Columns, + ] { + let a = DesktopAction::SetLayout(mode); + assert_eq!(a, a.to_string().parse().unwrap()); + } + } + + #[test] + fn workspace_actions_are_one_based_in_text() { + assert_eq!(DesktopAction::SwitchWorkspace(0).to_string(), "workspace:1"); + assert_eq!( + "workspace:1".parse::().unwrap(), + DesktopAction::SwitchWorkspace(0) + ); + assert_eq!( + "send-to-workspace:9".parse::().unwrap(), + DesktopAction::SendToWorkspace(8) + ); + } + + #[test] + fn out_of_range_or_unknown_actions_are_rejected() { + assert!("workspace:0".parse::().is_err()); + assert!("workspace:99".parse::().is_err()); + assert!("layout:fractal".parse::().is_err()); + assert!("teleport".parse::().is_err()); + } } diff --git a/crates/modules/mirada/mirada-brain/src/desktop.rs b/crates/modules/mirada/mirada-brain/src/desktop.rs index 526bd29..67a823a 100644 --- a/crates/modules/mirada/mirada-brain/src/desktop.rs +++ b/crates/modules/mirada/mirada-brain/src/desktop.rs @@ -5,7 +5,8 @@ use std::collections::HashMap; use mirada_layout::{LayoutMode, LayoutParams, Rect, WindowId, Workspace}; use mirada_protocol::{placements, BodyEvent, BrainCommand, OutputId}; -use crate::action::{default_keymap, DesktopAction, WORKSPACE_COUNT}; +use crate::action::{DesktopAction, WORKSPACE_COUNT}; +use crate::keymap::Keymap; /// Lo que el Cerebro sabe de una ventana: su identidad de aplicación. #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -32,8 +33,8 @@ pub struct Desktop { active: usize, /// Identidad de cada ventana conocida. windows: HashMap, - /// Atajos globales → acción. - keymap: Vec<(String, DesktopAction)>, + /// Atajos globales → acción. Configurable, recargable en caliente. + keymap: Keymap, } impl Default for Desktop { @@ -46,6 +47,12 @@ impl Desktop { /// Escritorio recién arrancado: sin salidas ni ventanas, con los /// escritorios virtuales vacíos y el mapa de teclas por defecto. pub fn new() -> Self { + Self::with_keymap(Keymap::default()) + } + + /// Como [`Desktop::new`], pero con un keymap dado — el que la app + /// cargó del archivo de configuración del usuario. + pub fn with_keymap(keymap: Keymap) -> Self { let workspaces = (0..WORKSPACE_COUNT) .map(|_| Workspace::new(LayoutParams::default())) .collect(); @@ -54,14 +61,26 @@ impl Desktop { workspaces, active: 0, windows: HashMap::new(), - keymap: default_keymap(), + keymap, } } /// El comando que registra los atajos globales en el Cuerpo. La app - /// GPUI lo envía una vez, al conectar. + /// lo envía al conectar, y de nuevo tras cada recarga del keymap. pub fn grab_keys(&self) -> BrainCommand { - BrainCommand::GrabKeys(self.keymap.iter().map(|(k, _)| k.clone()).collect()) + BrainCommand::GrabKeys(self.keymap.grab_list()) + } + + /// Reemplaza el keymap en caliente. Devuelve el [`BrainCommand`] que + /// el dueño debe enviar al Cuerpo para reajustar qué teclas intercepta. + pub fn set_keymap(&mut self, keymap: Keymap) -> BrainCommand { + self.keymap = keymap; + self.grab_keys() + } + + /// El keymap vigente — para un HUD o un editor visual de atajos. + pub fn keymap(&self) -> &Keymap { + &self.keymap } /// Geometría de la salida primaria, si hay alguna conectada. @@ -111,12 +130,10 @@ impl Desktop { Vec::new() } } - BodyEvent::Keybind(key) => { - match self.keymap.iter().find(|(k, _)| *k == key).map(|(_, a)| *a) { - Some(action) => self.apply(action), - None => Vec::new(), - } - } + BodyEvent::Keybind(key) => match self.keymap.lookup(&key) { + Some(action) => self.apply(action), + None => Vec::new(), + }, } } @@ -279,6 +296,27 @@ mod tests { } } + #[test] + fn set_keymap_swaps_the_bindings_and_regrabs() { + let mut d = desktop_with_screen(); + for id in [1, 2, 3] { + open(&mut d, id); + } + // El keymap por defecto no usa Alt. + assert!(d.on_event(BodyEvent::Keybind("Alt+x".into())).is_empty()); + // Cargamos un keymap a medida; el comando devuelto re-registra grabs. + let custom = crate::Keymap::from_ron(r#"( bindings: { "Alt+x": "focus-prev" } )"#).unwrap(); + match d.set_keymap(custom) { + BrainCommand::GrabKeys(keys) => assert_eq!(keys, vec!["Alt+x".to_string()]), + other => panic!("se esperaba GrabKeys, no {other:?}"), + } + // Ahora «Alt+x» sí mueve el foco, y «Super+j» ya no. + assert_eq!(d.focused_window(), Some(3)); + d.on_event(BodyEvent::Keybind("Alt+x".into())); + assert_eq!(d.focused_window(), Some(2)); + assert!(d.on_event(BodyEvent::Keybind("Super+j".into())).is_empty()); + } + #[test] fn without_a_screen_nothing_is_placed() { let mut d = Desktop::new(); diff --git a/crates/modules/mirada/mirada-brain/src/keymap.rs b/crates/modules/mirada/mirada-brain/src/keymap.rs new file mode 100644 index 0000000..3d5957f --- /dev/null +++ b/crates/modules/mirada/mirada-brain/src/keymap.rs @@ -0,0 +1,339 @@ +//! El keymap configurable — atajos del escritorio en RON, recargables en +//! caliente. +//! +//! # Dónde vive el keymap +//! +//! Sólo en el Cerebro. El Cuerpo (`mirada-compositor`) **nunca** ve este +//! mapa: lo único que recibe es la lista de cadenas a interceptar +//! ([`grab_list`](Keymap::grab_list)) dentro de un +//! [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys). El +//! Cuerpo hace un `Vec::contains` ciego y devuelve la combinación pulsada +//! como [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); es el +//! [`Desktop`](crate::Desktop) quien la traduce a una +//! [`DesktopAction`]. Esa separación —*qué* interceptar vs. *qué +//! significa*— es la que hace innecesario cualquier candado o `Arc`: +//! el mapa es monohilo aquí y la lista viaja de golpe en un solo mensaje. +//! +//! # Persistencia +//! +//! En disco es RON de texto (`~/.config/mirada/keymap.ron`), editable a +//! mano y versionable. El cable sólo lleva la lista de cadenas; no hay +//! formato binario de configuración. Hay un único ejecutable que hace de +//! "configurador": la app `mirada`, que carga este archivo al arrancar. +//! +//! # Recarga en caliente +//! +//! [`Keymap::watch`] devuelve un [`KeymapWatch`] que vigila el archivo; +//! cuando cambia, el dueño del [`Desktop`](crate::Desktop) recarga el +//! keymap, llama a [`Desktop::set_keymap`](crate::Desktop::set_keymap) y +//! reenvía el `GrabKeys` resultante. Sin reiniciar nada. + +use std::collections::BTreeMap; +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; + +use serde::{Deserialize, Serialize}; + +use crate::action::{default_keymap, DesktopAction}; + +/// Atajos del escritorio: combinación canónica → acción. +/// +/// La combinación es la cadena que canoniza el Cuerpo (`"Super+Shift+j"`, +/// `"Super+space"`…). El keymap es lo único que la traduce a una +/// [`DesktopAction`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Keymap { + bindings: BTreeMap, +} + +impl Default for Keymap { + /// El keymap por defecto, estilo *tiling WM* (ver [`default_keymap`]). + fn default() -> Self { + Self { + bindings: default_keymap().into_iter().collect(), + } + } +} + +impl Keymap { + /// Construye un keymap a partir de pares `(combinación, acción)`. + pub fn from_pairs(pairs: impl IntoIterator) -> Self { + Self { + bindings: pairs.into_iter().collect(), + } + } + + /// La acción asociada a una combinación, si la hay. + pub fn lookup(&self, combo: &str) -> Option { + self.bindings.get(combo).copied() + } + + /// Las combinaciones a interceptar — el contenido de un `GrabKeys`. + pub fn grab_list(&self) -> Vec { + self.bindings.keys().cloned().collect() + } + + /// Todos los atajos, en orden de combinación. + pub fn bindings(&self) -> &BTreeMap { + &self.bindings + } + + /// Cuántos atajos hay. + pub fn len(&self) -> usize { + self.bindings.len() + } + + /// `true` si no hay ningún atajo. + pub fn is_empty(&self) -> bool { + self.bindings.is_empty() + } + + // --- RON ---------------------------------------------------------- + + /// Parsea un keymap desde el texto RON de un archivo de configuración. + pub fn from_ron(text: &str) -> Result { + let file: KeymapFile = ron::from_str(text) + .map_err(|e| KeymapError::Parse(format!("RON inválido: {e}")))?; + let mut bindings = BTreeMap::new(); + for (combo, action) in file.bindings { + let parsed = action + .parse::() + .map_err(|e| KeymapError::Parse(format!("atajo \"{combo}\": {e}")))?; + bindings.insert(combo, parsed); + } + Ok(Keymap { bindings }) + } + + /// Serializa el keymap a RON (sin la cabecera de documentación). + pub fn to_ron(&self) -> String { + let file = KeymapFile { + bindings: self + .bindings + .iter() + .map(|(k, v)| (k.clone(), v.to_string())) + .collect(), + }; + ron::ser::to_string_pretty(&file, ron::ser::PrettyConfig::default()) + .expect("un KeymapFile de cadenas siempre serializa") + } + + // --- Disco -------------------------------------------------------- + + /// La ruta canónica del keymap del usuario: `~/.config/mirada/keymap.ron`. + /// `None` si no se puede determinar el directorio de configuración. + pub fn default_path() -> Option { + directories::ProjectDirs::from("", "", "mirada") + .map(|d| d.config_dir().join("keymap.ron")) + } + + /// Carga un keymap desde un archivo RON. + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path)?; + Keymap::from_ron(&text) + } + + /// El keymap como RON con la cabecera de documentación — exactamente + /// lo que [`save`](Keymap::save) escribe en disco. + pub fn documented_ron(&self) -> String { + format!("{KEYMAP_HEADER}\n{}", self.to_ron()) + } + + /// Escribe el keymap a `path` como RON documentado (con cabecera de + /// comentarios), creando el directorio padre si falta. + pub fn save(&self, path: &Path) -> Result<(), KeymapError> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, self.documented_ron())?; + Ok(()) + } + + /// Carga el keymap del usuario con un fallback amable: + /// + /// - si el archivo no existe, escribe uno por defecto documentado y lo + /// devuelve (así el usuario lo descubre y lo puede editar); + /// - si existe pero está corrupto, avisa por `stderr` y devuelve el + /// keymap por defecto **sin tocar el archivo** (no se pierde el + /// trabajo del usuario por un error de sintaxis). + pub fn load_or_init(path: &Path) -> Keymap { + if path.exists() { + match Keymap::load(path) { + Ok(km) => km, + Err(e) => { + eprintln!( + "mirada · keymap «{}» inválido ({e}); uso el de por defecto.", + path.display() + ); + Keymap::default() + } + } + } else { + let km = Keymap::default(); + match km.save(path) { + Ok(()) => eprintln!("mirada · keymap inicial escrito en {}", path.display()), + Err(e) => eprintln!("mirada · no pude escribir el keymap inicial: {e}"), + } + km + } + } + + /// Vigila el archivo del keymap para recargarlo en caliente. + pub fn watch(path: &Path) -> notify::Result { + use notify::{RecursiveMode, Watcher}; + + let target = path.to_path_buf(); + let (tx, rx) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + if let Ok(event) = res { + // Vigilamos el directorio (los editores reescriben el + // archivo por *rename*); filtramos a nuestro archivo. + if event.paths.iter().any(|p| p == &target) { + let _ = tx.send(()); + } + } + })?; + let dir = path.parent().filter(|d| d.exists()); + watcher.watch(dir.unwrap_or(path), RecursiveMode::NonRecursive)?; + Ok(KeymapWatch { _watcher: watcher, rx }) + } +} + +/// Vigía del archivo de keymap para la recarga en caliente. +/// +/// Mantenlo vivo mientras quieras recargas; al soltarlo, la vigilancia +/// cesa. Consulta [`changed`](KeymapWatch::changed) en tu bucle de eventos. +pub struct KeymapWatch { + _watcher: notify::RecommendedWatcher, + rx: mpsc::Receiver<()>, +} + +impl KeymapWatch { + /// `true` si el archivo cambió desde la última consulta. Coalesce una + /// ráfaga de eventos (un guardado dispara varios) en un solo `true`. + pub fn changed(&self) -> bool { + self.rx.try_iter().count() > 0 + } +} + +/// La forma en disco del keymap — un mapa de cadenas. Las acciones van +/// como texto (`"layout:grid"`) y no como enum, para que el RON sea +/// trivial y los errores se reporten atajo a atajo. +#[derive(Serialize, Deserialize)] +struct KeymapFile { + bindings: BTreeMap, +} + +/// La cabecera de comentarios del archivo que escribe [`Keymap::save`]. +const KEYMAP_HEADER: &str = "\ +// keymap de mirada — atajos del escritorio (carmen). +// +// Formato: \"Combinación\": \"acción\" +// La combinación la canoniza el compositor: Super, Ctrl, Shift, Alt y la +// tecla, en ese orden (p. ej. \"Super+Shift+j\", \"Super+space\"). +// +// Acciones: +// focus-next / focus-prev mueve el foco +// move-forward / move-backward reordena la ventana enfocada +// close-focused cierra la enfocada +// cycle-layout siguiente modo de teselado +// layout:master-stack | layout:monocle | layout:grid | layout:columns +// workspace:N activa el escritorio N (1..9) +// send-to-workspace:N manda la enfocada al escritorio N +// quit apaga el compositor +// +// Edita y guarda: mirada recarga el keymap en caliente, sin reiniciar."; + +/// Un fallo al cargar o guardar un keymap. +#[derive(Debug)] +pub enum KeymapError { + /// El RON no parsea, o una acción no se reconoce. El mensaje ya está + /// formateado para mostrarse al usuario. + Parse(String), + /// Fallo de E/S al leer o escribir el archivo. + Io(io::Error), +} + +impl fmt::Display for KeymapError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + KeymapError::Parse(msg) => f.write_str(msg), + KeymapError::Io(e) => write!(f, "E/S: {e}"), + } + } +} + +impl std::error::Error for KeymapError {} + +impl From for KeymapError { + fn from(e: io::Error) -> Self { + KeymapError::Io(e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mirada_layout::LayoutMode; + + #[test] + fn the_default_keymap_round_trips_through_ron() { + let km = Keymap::default(); + let back = Keymap::from_ron(&km.to_ron()).unwrap(); + assert_eq!(km, back); + } + + #[test] + fn the_saved_file_carries_the_documentation_header() { + let km = Keymap::default(); + let written = km.documented_ron(); + // La cabecera son comentarios — RON los ignora al reparsear. + assert!(written.starts_with("// keymap de mirada")); + assert_eq!(Keymap::from_ron(&written).unwrap(), km); + } + + #[test] + fn grab_list_is_exactly_the_set_of_bound_combos() { + let km = Keymap::default(); + let grabs = km.grab_list(); + assert_eq!(grabs.len(), km.len()); + assert!(grabs.contains(&"Super+j".to_string())); + assert!(grabs.contains(&"Super+Shift+e".to_string())); + } + + #[test] + fn lookup_resolves_a_default_binding() { + let km = Keymap::default(); + assert_eq!(km.lookup("Super+q"), Some(DesktopAction::CloseFocused)); + assert_eq!(km.lookup("Super+t"), Some(DesktopAction::SetLayout(LayoutMode::MasterStack))); + assert_eq!(km.lookup("Super+sin-asignar"), None); + } + + #[test] + fn a_custom_keymap_parses_from_ron() { + let ron = r#"( + bindings: { + "Alt+Return": "cycle-layout", + "Alt+x": "close-focused", + "Alt+3": "workspace:3", + }, + )"#; + let km = Keymap::from_ron(ron).unwrap(); + assert_eq!(km.len(), 3); + assert_eq!(km.lookup("Alt+Return"), Some(DesktopAction::CycleLayout)); + assert_eq!(km.lookup("Alt+3"), Some(DesktopAction::SwitchWorkspace(2))); + } + + #[test] + fn an_unknown_action_names_the_offending_binding() { + let ron = r#"( bindings: { "Super+z": "fly-away" } )"#; + let err = Keymap::from_ron(ron).unwrap_err().to_string(); + assert!(err.contains("Super+z"), "el error debe nombrar el atajo: {err}"); + } + + #[test] + fn malformed_ron_is_rejected() { + assert!(Keymap::from_ron("esto no es ron").is_err()); + } +} diff --git a/crates/modules/mirada/mirada-brain/src/lib.rs b/crates/modules/mirada/mirada-brain/src/lib.rs index 0d3696b..e549d66 100644 --- a/crates/modules/mirada/mirada-brain/src/lib.rs +++ b/crates/modules/mirada/mirada-brain/src/lib.rs @@ -12,14 +12,17 @@ //! //! - [`action`] — las acciones de escritorio y el mapa de teclas. //! - [`desktop`] — el [`Desktop`]: el estado y el bucle `evento → comandos`. +//! - [`keymap`] — el [`Keymap`] configurable en RON, recargable en caliente. #![forbid(unsafe_code)] pub mod action; pub mod desktop; +pub mod keymap; pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT}; pub use desktop::{Desktop, WindowInfo}; +pub use keymap::{Keymap, KeymapError, KeymapWatch}; pub use mirada_layout::{LayoutMode, LayoutParams, Rect, WindowId, Workspace}; pub use mirada_protocol::{BodyEvent, BrainCommand, OutputId, WindowPlacement}; diff --git a/vamos.txt b/vamos.txt index 096862e..b08d6e2 100644 --- a/vamos.txt +++ b/vamos.txt @@ -1002,5 +1002,15 @@ + Atajos de teclado configurables — mirada-brain::Keymap: + El keymap vive sólo en el Cerebro; el Cuerpo nunca lo ve (sólo recibe la lista de cadenas a interceptar, GrabKeys). + Disco: RON de texto en ~/.config/mirada/keymap.ron — editable a mano, versionable, recargado en caliente (notify). + Si falta, la app escribe uno por defecto documentado; si está corrupto, avisa y usa el de por defecto sin pisarlo. + cargo run -p mirada-brain --example keymap-default # imprime el keymap por defecto en RON + Acciones como cadena estable: "focus-next", "layout:grid", "workspace:3" (DesktopAction: Display + FromStr). + Sin ejecutable configurador: el editor de texto del usuario, y la app mirada sobre el mismo API Keymap. + + +