gioser

tawasuyu/brahman

Rust 405TOML 156Markdown 41JSON 175449 Definiciones

⎇ 13d2ae71fb8d56689e3906ab11012dbdb6f4b0df crates/modules/mirada/mirada-brain/src/keymap.rs Rust · 348 líneas · 12.6 KB Historia Quién tocó qué crudo

feat(mirada): scratchpad — ventana desplegable estilo terminal quake 13d2ae71f · 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//   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}