⎇ 13d2ae71fb8d56689e3906ab11012dbdb6f4b0df
crates/modules/mirada/mirada-brain/src/keymap.rs
Historia
Quién tocó qué
crudo
feat(mirada): scratchpad — ventana desplegable estilo terminal quake
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// toggle-float alterna flotante / teselada
241// toggle-fullscreen alterna pantalla completa
242// send-to-scratchpad guarda la enfocada en el scratchpad
243// toggle-scratchpad invoca / oculta la del scratchpad
244// cycle-layout siguiente modo de teselado
245// layout:<modo> master-stack | centered-master | spiral
246// grid | columns | rows | monocle
247// grow-master / shrink-master redimensiona el área maestra
248// inc-master / dec-master nº de ventanas maestras (nmaster)
249// promote-to-master la enfocada al puesto maestro
250// focus-output-next pasa el foco al siguiente monitor
251// workspace:N activa el escritorio N (1..9)
252// send-to-workspace:N manda la enfocada al escritorio N
253// quit apaga el compositor
254//
255// Edita y guarda: mirada recarga el keymap en caliente, sin reiniciar.";
256
257/// Un fallo al cargar o guardar un keymap.
258#[derive(Debug)]
259pub enum KeymapError {
260 /// El RON no parsea, o una acción no se reconoce. El mensaje ya está
261 /// formateado para mostrarse al usuario.
262 Parse(String),
263 /// Fallo de E/S al leer o escribir el archivo.
264 Io(io::Error),
265}
266
267impl fmt::Display for KeymapError {
268 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269 match self {
270 KeymapError::Parse(msg) => f.write_str(msg),
271 KeymapError::Io(e) => write!(f, "E/S: {e}"),
272 }
273 }
274}
275
276impl std::error::Error for KeymapError {}
277
278impl From<io::Error> for KeymapError {
279 fn from(e: io::Error) -> Self {
280 KeymapError::Io(e)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use mirada_layout::LayoutMode;
288
289 #[test]
290 fn the_default_keymap_round_trips_through_ron() {
291 let km = Keymap::default();
292 let back = Keymap::from_ron(&km.to_ron()).unwrap();
293 assert_eq!(km, back);
294 }
295
296 #[test]
297 fn the_saved_file_carries_the_documentation_header() {
298 let km = Keymap::default();
299 let written = km.documented_ron();
300 // La cabecera son comentarios — RON los ignora al reparsear.
301 assert!(written.starts_with("// keymap de mirada"));
302 assert_eq!(Keymap::from_ron(&written).unwrap(), km);
303 }
304
305 #[test]
306 fn grab_list_is_exactly_the_set_of_bound_combos() {
307 let km = Keymap::default();
308 let grabs = km.grab_list();
309 assert_eq!(grabs.len(), km.len());
310 assert!(grabs.contains(&"Super+j".to_string()));
311 assert!(grabs.contains(&"Super+Shift+e".to_string()));
312 }
313
314 #[test]
315 fn lookup_resolves_a_default_binding() {
316 let km = Keymap::default();
317 assert_eq!(km.lookup("Super+q"), Some(DesktopAction::CloseFocused));
318 assert_eq!(km.lookup("Super+t"), Some(DesktopAction::SetLayout(LayoutMode::MasterStack)));
319 assert_eq!(km.lookup("Super+sin-asignar"), None);
320 }
321
322 #[test]
323 fn a_custom_keymap_parses_from_ron() {
324 let ron = r#"(
325 bindings: {
326 "Alt+Return": "cycle-layout",
327 "Alt+x": "close-focused",
328 "Alt+3": "workspace:3",
329 },
330 )"#;
331 let km = Keymap::from_ron(ron).unwrap();
332 assert_eq!(km.len(), 3);
333 assert_eq!(km.lookup("Alt+Return"), Some(DesktopAction::CycleLayout));
334 assert_eq!(km.lookup("Alt+3"), Some(DesktopAction::SwitchWorkspace(2)));
335 }
336
337 #[test]
338 fn an_unknown_action_names_the_offending_binding() {
339 let ron = r#"( bindings: { "Super+z": "fly-away" } )"#;
340 let err = Keymap::from_ron(ron).unwrap_err().to_string();
341 assert!(err.contains("Super+z"), "el error debe nombrar el atajo: {err}");
342 }
343
344 #[test]
345 fn malformed_ron_is_rejected() {
346 assert!(Keymap::from_ron("esto no es ron").is_err());
347 }
348}