tawasuyu

tawasuyu/brahman

Rust 521TOML 202Markdown 57JSON 197505 Definitions

feat(mirada): keymap configurable en RON, recargable en caliente

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 <noreply@anthropic.com>

8204852e3aa72d653aa41e941fc3a310bd11493f · by sergio · (5 months ago) · parent 51398f89c · browse the tree at this commit

Semantic delta · 56

crates/apps/mirada-compositor/src/main.rs

  • body changed fn run

crates/apps/mirada/src/main.rs

crates/modules/mirada/mirada-brain/examples/keymap-default.rs

crates/modules/mirada/mirada-brain/src/desktop.rs

crates/modules/mirada/mirada-brain/src/keymap.rs

crates/modules/mirada/mirada-brain/src/lib.rs

13 files · +713 −25

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<dyn std::error::Error>> {
     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<dyn std::error::Error>> {
         }
         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<dyn std::error::Error>> {
         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<dyn std::error::Error>> {
         // 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<i32, smithay::utils::Physical> = 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<PathBuf>,
+    /// Vigía del keymap; `None` en simulación o si no hay archivo.
+    keymap_watch: Option<KeymapWatch>,
 }
 
 impl Mirada {
     fn new(cx: &mut Context<Self>) -> 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<BrainCommand>`;
-  `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<Out,In>` 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<LayoutMode> {
+    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<Self, String> {
+        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<usize, String> {
+    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::<DesktopAction>().unwrap(),
+            DesktopAction::SwitchWorkspace(0)
+        );
+        assert_eq!(
+            "send-to-workspace:9".parse::<DesktopAction>().unwrap(),
+            DesktopAction::SendToWorkspace(8)
+        );
+    }
+
+    #[test]
+    fn out_of_range_or_unknown_actions_are_rejected() {
+        assert!("workspace:0".parse::<DesktopAction>().is_err());
+        assert!("workspace:99".parse::<DesktopAction>().is_err());
+        assert!("layout:fractal".parse::<DesktopAction>().is_err());
+        assert!("teleport".parse::<DesktopAction>().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<WindowId, WindowInfo>,
-    /// 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<String, DesktopAction>,
+}
+
+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<Item = (String, DesktopAction)>) -> 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<DesktopAction> {
+        self.bindings.get(combo).copied()
+    }
+
+    /// Las combinaciones a interceptar — el contenido de un `GrabKeys`.
+    pub fn grab_list(&self) -> Vec<String> {
+        self.bindings.keys().cloned().collect()
+    }
+
+    /// Todos los atajos, en orden de combinación.
+    pub fn bindings(&self) -> &BTreeMap<String, DesktopAction> {
+        &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<Keymap, KeymapError> {
+        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::<DesktopAction>()
+                .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<PathBuf> {
+        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<Keymap, KeymapError> {
+        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<KeymapWatch> {
+        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<notify::Event>| {
+            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<String, String>,
+}
+
+/// 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<io::Error> 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.
+
+
+