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>
This commit is contained in:
sergio
2026-05-21 00:04:11 +00:00
parent 51398f89cf
commit 8204852e3a
13 changed files with 713 additions and 25 deletions
@@ -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();