⎇ 8204852e3aa72d653aa41e941fc3a310bd11493f
crates/modules/mirada/mirada-brain/src/keymap.rs
History
Who touched what
raw
feat(mirada): keymap configurable en RON, recargable en caliente
1//! El keymap configurable — atajos del escritorio en RON, recargables en
2//! caliente.
3//!
4//! # Dónde vive el keymap
5//!
6//! Sólo en el Cerebro. El Cuerpo (`mirada-compositor`) **nunca** ve este
7//! mapa: lo único que recibe es la lista de cadenas a interceptar
8//! ([`grab_list`](Keymap::grab_list)) dentro de un
9//! [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys). El
10//! Cuerpo hace un `Vec::contains` ciego y devuelve la combinación pulsada
11//! como [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); es el
12//! [`Desktop`](crate::Desktop) quien la traduce a una
13//! [`DesktopAction`]. Esa separación —*qué* interceptar vs. *qué
14//! significa*— es la que hace innecesario cualquier candado o `Arc`:
15//! el mapa es monohilo aquí y la lista viaja de golpe en un solo mensaje.
16//!
17//! # Persistencia
18//!
19//! En disco es RON de texto (`~/.config/mirada/keymap.ron`), editable a
20//! mano y versionable. El cable sólo lleva la lista de cadenas; no hay
21//! formato binario de configuración. Hay un único ejecutable que hace de
22//! "configurador": la app `mirada`, que carga este archivo al arrancar.
23//!
24//! # Recarga en caliente
25//!
26//! [`Keymap::watch`] devuelve un [`KeymapWatch`] que vigila el archivo;
27//! cuando cambia, el dueño del [`Desktop`](crate::Desktop) recarga el
28//! keymap, llama a [`Desktop::set_keymap`](crate::Desktop::set_keymap) y
29//! reenvía el `GrabKeys` resultante. Sin reiniciar nada.
30
31use std::collections::BTreeMap;
32use std::fmt;
33use std::io;
34use std::path::{Path, PathBuf};
35use std::sync::mpsc;
36
37use serde::{Deserialize, Serialize};
38
39use crate::action::{default_keymap, DesktopAction};
40
41/// Atajos del escritorio: combinación canónica → acción.
42///
43/// La combinación es la cadena que canoniza el Cuerpo (`"Super+Shift+j"`,
44/// `"Super+space"`…). El keymap es lo único que la traduce a una
45/// [`DesktopAction`].
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Keymap {
48 bindings: BTreeMap<String, DesktopAction>,
49}
50
51impl Default for Keymap {
52 /// El keymap por defecto, estilo *tiling WM* (ver [`default_keymap`]).
53 fn default() -> Self {
54 Self {
55 bindings: default_keymap().into_iter().collect(),
56 }
57 }
58}
59
60impl Keymap {
61 /// Construye un keymap a partir de pares `(combinación, acción)`.
62 pub fn from_pairs(pairs: impl IntoIterator<Item = (String, DesktopAction)>) -> Self {
63 Self {
64 bindings: pairs.into_iter().collect(),
65 }
66 }
67
68 /// La acción asociada a una combinación, si la hay.
69 pub fn lookup(&self, combo: &str) -> Option<DesktopAction> {
70 self.bindings.get(combo).copied()
71 }
72
73 /// Las combinaciones a interceptar — el contenido de un `GrabKeys`.
74 pub fn grab_list(&self) -> Vec<String> {
75 self.bindings.keys().cloned().collect()
76 }
77
78 /// Todos los atajos, en orden de combinación.
79 pub fn bindings(&self) -> &BTreeMap<String, DesktopAction> {
80 &self.bindings
81 }
82
83 /// Cuántos atajos hay.
84 pub fn len(&self) -> usize {
85 self.bindings.len()
86 }
87
88 /// `true` si no hay ningún atajo.
89 pub fn is_empty(&self) -> bool {
90 self.bindings.is_empty()
91 }
92
93 // --- RON ----------------------------------------------------------
94
95 /// Parsea un keymap desde el texto RON de un archivo de configuración.
96 pub fn from_ron(text: &str) -> Result<Keymap, KeymapError> {
97 let file: KeymapFile = ron::from_str(text)
98 .map_err(|e| KeymapError::Parse(format!("RON inválido: {e}")))?;
99 let mut bindings = BTreeMap::new();
100 for (combo, action) in file.bindings {
101 let parsed = action
102 .parse::<DesktopAction>()
103 .map_err(|e| KeymapError::Parse(format!("atajo \"{combo}\": {e}")))?;
104 bindings.insert(combo, parsed);
105 }
106 Ok(Keymap { bindings })
107 }
108
109 /// Serializa el keymap a RON (sin la cabecera de documentación).
110 pub fn to_ron(&self) -> String {
111 let file = KeymapFile {
112 bindings: self
113 .bindings
114 .iter()
115 .map(|(k, v)| (k.clone(), v.to_string()))
116 .collect(),
117 };
118 ron::ser::to_string_pretty(&file, ron::ser::PrettyConfig::default())
119 .expect("un KeymapFile de cadenas siempre serializa")
120 }
121
122 // --- Disco --------------------------------------------------------
123
124 /// La ruta canónica del keymap del usuario: `~/.config/mirada/keymap.ron`.
125 /// `None` si no se puede determinar el directorio de configuración.
126 pub fn default_path() -> Option<PathBuf> {
127 directories::ProjectDirs::from("", "", "mirada")
128 .map(|d| d.config_dir().join("keymap.ron"))
129 }
130
131 /// Carga un keymap desde un archivo RON.
132 pub fn load(path: &Path) -> Result<Keymap, KeymapError> {
133 let text = std::fs::read_to_string(path)?;
134 Keymap::from_ron(&text)
135 }
136
137 /// El keymap como RON con la cabecera de documentación — exactamente
138 /// lo que [`save`](Keymap::save) escribe en disco.
139 pub fn documented_ron(&self) -> String {
140 format!("{KEYMAP_HEADER}\n{}", self.to_ron())
141 }
142
143 /// Escribe el keymap a `path` como RON documentado (con cabecera de
144 /// comentarios), creando el directorio padre si falta.
145 pub fn save(&self, path: &Path) -> Result<(), KeymapError> {
146 if let Some(dir) = path.parent() {
147 std::fs::create_dir_all(dir)?;
148 }
149 std::fs::write(path, self.documented_ron())?;
150 Ok(())
151 }
152
153 /// Carga el keymap del usuario con un fallback amable:
154 ///
155 /// - si el archivo no existe, escribe uno por defecto documentado y lo
156 /// devuelve (así el usuario lo descubre y lo puede editar);
157 /// - si existe pero está corrupto, avisa por `stderr` y devuelve el
158 /// keymap por defecto **sin tocar el archivo** (no se pierde el
159 /// trabajo del usuario por un error de sintaxis).
160 pub fn load_or_init(path: &Path) -> Keymap {
161 if path.exists() {
162 match Keymap::load(path) {
163 Ok(km) => km,
164 Err(e) => {
165 eprintln!(
166 "mirada · keymap «{}» inválido ({e}); uso el de por defecto.",
167 path.display()
168 );
169 Keymap::default()
170 }
171 }
172 } else {
173 let km = Keymap::default();
174 match km.save(path) {
175 Ok(()) => eprintln!("mirada · keymap inicial escrito en {}", path.display()),
176 Err(e) => eprintln!("mirada · no pude escribir el keymap inicial: {e}"),
177 }
178 km
179 }
180 }
181
182 /// Vigila el archivo del keymap para recargarlo en caliente.
183 pub fn watch(path: &Path) -> notify::Result<KeymapWatch> {
184 use notify::{RecursiveMode, Watcher};
185
186 let target = path.to_path_buf();
187 let (tx, rx) = mpsc::channel();
188 let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
189 if let Ok(event) = res {
190 // Vigilamos el directorio (los editores reescriben el
191 // archivo por *rename*); filtramos a nuestro archivo.
192 if event.paths.iter().any(|p| p == &target) {
193 let _ = tx.send(());
194 }
195 }
196 })?;
197 let dir = path.parent().filter(|d| d.exists());
198 watcher.watch(dir.unwrap_or(path), RecursiveMode::NonRecursive)?;
199 Ok(KeymapWatch { _watcher: watcher, rx })
200 }
201}
202
203/// Vigía del archivo de keymap para la recarga en caliente.
204///
205/// Mantenlo vivo mientras quieras recargas; al soltarlo, la vigilancia
206/// cesa. Consulta [`changed`](KeymapWatch::changed) en tu bucle de eventos.
207pub struct KeymapWatch {
208 _watcher: notify::RecommendedWatcher,
209 rx: mpsc::Receiver<()>,
210}
211
212impl KeymapWatch {
213 /// `true` si el archivo cambió desde la última consulta. Coalesce una
214 /// ráfaga de eventos (un guardado dispara varios) en un solo `true`.
215 pub fn changed(&self) -> bool {
216 self.rx.try_iter().count() > 0
217 }
218}
219
220/// La forma en disco del keymap — un mapa de cadenas. Las acciones van
221/// como texto (`"layout:grid"`) y no como enum, para que el RON sea
222/// trivial y los errores se reporten atajo a atajo.
223#[derive(Serialize, Deserialize)]
224struct KeymapFile {
225 bindings: BTreeMap<String, String>,
226}
227
228/// La cabecera de comentarios del archivo que escribe [`Keymap::save`].
229const KEYMAP_HEADER: &str = "\
230// keymap de mirada — atajos del escritorio (carmen).
231//
232// Formato: \"Combinación\": \"acción\"
233// La combinación la canoniza el compositor: Super, Ctrl, Shift, Alt y la
234// tecla, en ese orden (p. ej. \"Super+Shift+j\", \"Super+space\").
235//
236// Acciones:
237// focus-next / focus-prev mueve el foco
238// move-forward / move-backward reordena la ventana enfocada
239// close-focused cierra la enfocada
240// cycle-layout siguiente modo de teselado
241// layout:master-stack | layout:monocle | layout:grid | layout:columns
242// workspace:N activa el escritorio N (1..9)
243// send-to-workspace:N manda la enfocada al escritorio N
244// quit apaga el compositor
245//
246// Edita y guarda: mirada recarga el keymap en caliente, sin reiniciar.";
247
248/// Un fallo al cargar o guardar un keymap.
249#[derive(Debug)]
250pub enum KeymapError {
251 /// El RON no parsea, o una acción no se reconoce. El mensaje ya está
252 /// formateado para mostrarse al usuario.
253 Parse(String),
254 /// Fallo de E/S al leer o escribir el archivo.
255 Io(io::Error),
256}
257
258impl fmt::Display for KeymapError {
259 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260 match self {
261 KeymapError::Parse(msg) => f.write_str(msg),
262 KeymapError::Io(e) => write!(f, "E/S: {e}"),
263 }
264 }
265}
266
267impl std::error::Error for KeymapError {}
268
269impl From<io::Error> for KeymapError {
270 fn from(e: io::Error) -> Self {
271 KeymapError::Io(e)
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use mirada_layout::LayoutMode;
279
280 #[test]
281 fn the_default_keymap_round_trips_through_ron() {
282 let km = Keymap::default();
283 let back = Keymap::from_ron(&km.to_ron()).unwrap();
284 assert_eq!(km, back);
285 }
286
287 #[test]
288 fn the_saved_file_carries_the_documentation_header() {
289 let km = Keymap::default();
290 let written = km.documented_ron();
291 // La cabecera son comentarios — RON los ignora al reparsear.
292 assert!(written.starts_with("// keymap de mirada"));
293 assert_eq!(Keymap::from_ron(&written).unwrap(), km);
294 }
295
296 #[test]
297 fn grab_list_is_exactly_the_set_of_bound_combos() {
298 let km = Keymap::default();
299 let grabs = km.grab_list();
300 assert_eq!(grabs.len(), km.len());
301 assert!(grabs.contains(&"Super+j".to_string()));
302 assert!(grabs.contains(&"Super+Shift+e".to_string()));
303 }
304
305 #[test]
306 fn lookup_resolves_a_default_binding() {
307 let km = Keymap::default();
308 assert_eq!(km.lookup("Super+q"), Some(DesktopAction::CloseFocused));
309 assert_eq!(km.lookup("Super+t"), Some(DesktopAction::SetLayout(LayoutMode::MasterStack)));
310 assert_eq!(km.lookup("Super+sin-asignar"), None);
311 }
312
313 #[test]
314 fn a_custom_keymap_parses_from_ron() {
315 let ron = r#"(
316 bindings: {
317 "Alt+Return": "cycle-layout",
318 "Alt+x": "close-focused",
319 "Alt+3": "workspace:3",
320 },
321 )"#;
322 let km = Keymap::from_ron(ron).unwrap();
323 assert_eq!(km.len(), 3);
324 assert_eq!(km.lookup("Alt+Return"), Some(DesktopAction::CycleLayout));
325 assert_eq!(km.lookup("Alt+3"), Some(DesktopAction::SwitchWorkspace(2)));
326 }
327
328 #[test]
329 fn an_unknown_action_names_the_offending_binding() {
330 let ron = r#"( bindings: { "Super+z": "fly-away" } )"#;
331 let err = Keymap::from_ron(ron).unwrap_err().to_string();
332 assert!(err.contains("Super+z"), "el error debe nombrar el atajo: {err}");
333 }
334
335 #[test]
336 fn malformed_ron_is_rejected() {
337 assert!(Keymap::from_ron("esto no es ron").is_err());
338 }
339}