gioser

tawasuyu/brahman

Rust 326TOML 123JSON 14Markdown 114554 Definitions

⎇ 8204852e3aa72d653aa41e941fc3a310bd11493f crates/modules/mirada/mirada-brain/src/keymap.rs Rust · 339 lines · 12.0 KB History Who touched what raw

feat(mirada): keymap configurable en RON, recargable en caliente 8204852e3 · sergio ·

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}