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:
@@ -10,3 +10,7 @@ description = "mirada — orquestador de escritorio del compositor: mantiene sal
|
||||
[dependencies]
|
||||
mirada-layout = { path = "../mirada-layout" }
|
||||
mirada-protocol = { path = "../mirada-protocol" }
|
||||
serde = { workspace = true }
|
||||
ron = { workspace = true }
|
||||
directories = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Imprime el keymap por defecto de mirada en formato RON — exactamente
|
||||
//! lo que la app escribe la primera vez en `~/.config/mirada/keymap.ron`.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p mirada-brain --example keymap-default
|
||||
//! cargo run -p mirada-brain --example keymap-default > ~/.config/mirada/keymap.ron
|
||||
//! ```
|
||||
|
||||
fn main() {
|
||||
print!("{}", mirada_brain::Keymap::default().documented_ron());
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
//! Una [`DesktopAction`] es una orden de alto nivel del usuario, ya
|
||||
//! desligada de la tecla concreta: el [`Desktop`](crate::Desktop) las
|
||||
//! aplica sin saber qué combinación las disparó.
|
||||
//!
|
||||
//! Cada acción tiene una **forma textual** estable ([`Display`] /
|
||||
//! [`FromStr`]) — `"focus-next"`, `"layout:grid"`, `"workspace:3"` — que
|
||||
//! es el vocabulario del keymap configurable en RON (ver [`crate::keymap`]).
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use mirada_layout::LayoutMode;
|
||||
|
||||
@@ -34,6 +41,93 @@ pub enum DesktopAction {
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// El nombre RON-seguro de un modo de teselado (sin guiones problemáticos
|
||||
/// para identificadores: aquí van como valor de cadena, no de enum).
|
||||
fn layout_slug(mode: LayoutMode) -> &'static str {
|
||||
match mode {
|
||||
LayoutMode::MasterStack => "master-stack",
|
||||
LayoutMode::Monocle => "monocle",
|
||||
LayoutMode::Grid => "grid",
|
||||
LayoutMode::Columns => "columns",
|
||||
}
|
||||
}
|
||||
|
||||
/// Modo de teselado desde su `slug`.
|
||||
fn layout_from_slug(slug: &str) -> Option<LayoutMode> {
|
||||
Some(match slug {
|
||||
"master-stack" => LayoutMode::MasterStack,
|
||||
"monocle" => LayoutMode::Monocle,
|
||||
"grid" => LayoutMode::Grid,
|
||||
"columns" => LayoutMode::Columns,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
impl fmt::Display for DesktopAction {
|
||||
/// La forma textual estable de la acción — el vocabulario del keymap.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DesktopAction::FocusNext => f.write_str("focus-next"),
|
||||
DesktopAction::FocusPrev => f.write_str("focus-prev"),
|
||||
DesktopAction::MoveForward => f.write_str("move-forward"),
|
||||
DesktopAction::MoveBackward => f.write_str("move-backward"),
|
||||
DesktopAction::CloseFocused => f.write_str("close-focused"),
|
||||
DesktopAction::CycleLayout => f.write_str("cycle-layout"),
|
||||
DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)),
|
||||
// Los escritorios se numeran 1-based de cara al usuario.
|
||||
DesktopAction::SwitchWorkspace(n) => write!(f, "workspace:{}", n + 1),
|
||||
DesktopAction::SendToWorkspace(n) => write!(f, "send-to-workspace:{}", n + 1),
|
||||
DesktopAction::Quit => f.write_str("quit"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DesktopAction {
|
||||
/// Mensaje de error ya formateado, listo para mostrar al usuario.
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, String> {
|
||||
let s = s.trim();
|
||||
Ok(match s {
|
||||
"focus-next" => Self::FocusNext,
|
||||
"focus-prev" => Self::FocusPrev,
|
||||
"move-forward" => Self::MoveForward,
|
||||
"move-backward" => Self::MoveBackward,
|
||||
"close-focused" => Self::CloseFocused,
|
||||
"cycle-layout" => Self::CycleLayout,
|
||||
"quit" => Self::Quit,
|
||||
_ => {
|
||||
if let Some(slug) = s.strip_prefix("layout:") {
|
||||
Self::SetLayout(
|
||||
layout_from_slug(slug)
|
||||
.ok_or_else(|| format!("modo de teselado desconocido: '{slug}'"))?,
|
||||
)
|
||||
} else if let Some(n) = s.strip_prefix("send-to-workspace:") {
|
||||
Self::SendToWorkspace(parse_workspace(n)?)
|
||||
} else if let Some(n) = s.strip_prefix("workspace:") {
|
||||
Self::SwitchWorkspace(parse_workspace(n)?)
|
||||
} else {
|
||||
return Err(format!("acción desconocida: '{s}'"));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsea el número de escritorio del keymap (1-based) a índice (0-based),
|
||||
/// acotado a [`WORKSPACE_COUNT`].
|
||||
fn parse_workspace(s: &str) -> Result<usize, String> {
|
||||
let n: usize = s
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("número de escritorio inválido: '{s}'"))?;
|
||||
if (1..=WORKSPACE_COUNT).contains(&n) {
|
||||
Ok(n - 1)
|
||||
} else {
|
||||
Err(format!("escritorio fuera de rango (1..={WORKSPACE_COUNT}): {n}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapa de teclas por defecto, estilo *tiling WM* (modificador `Super`).
|
||||
///
|
||||
/// Las cadenas deben coincidir literalmente con las que el Cuerpo emite
|
||||
@@ -92,4 +186,47 @@ mod tests {
|
||||
.any(|(_, a)| *a == DesktopAction::SendToWorkspace(n)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_action_round_trips_through_its_text_form() {
|
||||
for (_, action) in default_keymap() {
|
||||
let text = action.to_string();
|
||||
let back: DesktopAction = text.parse().unwrap();
|
||||
assert_eq!(action, back, "no redondea: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_layout_mode_round_trips() {
|
||||
for mode in [
|
||||
LayoutMode::MasterStack,
|
||||
LayoutMode::Monocle,
|
||||
LayoutMode::Grid,
|
||||
LayoutMode::Columns,
|
||||
] {
|
||||
let a = DesktopAction::SetLayout(mode);
|
||||
assert_eq!(a, a.to_string().parse().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_actions_are_one_based_in_text() {
|
||||
assert_eq!(DesktopAction::SwitchWorkspace(0).to_string(), "workspace:1");
|
||||
assert_eq!(
|
||||
"workspace:1".parse::<DesktopAction>().unwrap(),
|
||||
DesktopAction::SwitchWorkspace(0)
|
||||
);
|
||||
assert_eq!(
|
||||
"send-to-workspace:9".parse::<DesktopAction>().unwrap(),
|
||||
DesktopAction::SendToWorkspace(8)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_or_unknown_actions_are_rejected() {
|
||||
assert!("workspace:0".parse::<DesktopAction>().is_err());
|
||||
assert!("workspace:99".parse::<DesktopAction>().is_err());
|
||||
assert!("layout:fractal".parse::<DesktopAction>().is_err());
|
||||
assert!("teleport".parse::<DesktopAction>().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! El keymap configurable — atajos del escritorio en RON, recargables en
|
||||
//! caliente.
|
||||
//!
|
||||
//! # Dónde vive el keymap
|
||||
//!
|
||||
//! Sólo en el Cerebro. El Cuerpo (`mirada-compositor`) **nunca** ve este
|
||||
//! mapa: lo único que recibe es la lista de cadenas a interceptar
|
||||
//! ([`grab_list`](Keymap::grab_list)) dentro de un
|
||||
//! [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys). El
|
||||
//! Cuerpo hace un `Vec::contains` ciego y devuelve la combinación pulsada
|
||||
//! como [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); es el
|
||||
//! [`Desktop`](crate::Desktop) quien la traduce a una
|
||||
//! [`DesktopAction`]. Esa separación —*qué* interceptar vs. *qué
|
||||
//! significa*— es la que hace innecesario cualquier candado o `Arc`:
|
||||
//! el mapa es monohilo aquí y la lista viaja de golpe en un solo mensaje.
|
||||
//!
|
||||
//! # Persistencia
|
||||
//!
|
||||
//! En disco es RON de texto (`~/.config/mirada/keymap.ron`), editable a
|
||||
//! mano y versionable. El cable sólo lleva la lista de cadenas; no hay
|
||||
//! formato binario de configuración. Hay un único ejecutable que hace de
|
||||
//! "configurador": la app `mirada`, que carga este archivo al arrancar.
|
||||
//!
|
||||
//! # Recarga en caliente
|
||||
//!
|
||||
//! [`Keymap::watch`] devuelve un [`KeymapWatch`] que vigila el archivo;
|
||||
//! cuando cambia, el dueño del [`Desktop`](crate::Desktop) recarga el
|
||||
//! keymap, llama a [`Desktop::set_keymap`](crate::Desktop::set_keymap) y
|
||||
//! reenvía el `GrabKeys` resultante. Sin reiniciar nada.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::action::{default_keymap, DesktopAction};
|
||||
|
||||
/// Atajos del escritorio: combinación canónica → acción.
|
||||
///
|
||||
/// La combinación es la cadena que canoniza el Cuerpo (`"Super+Shift+j"`,
|
||||
/// `"Super+space"`…). El keymap es lo único que la traduce a una
|
||||
/// [`DesktopAction`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Keymap {
|
||||
bindings: BTreeMap<String, DesktopAction>,
|
||||
}
|
||||
|
||||
impl Default for Keymap {
|
||||
/// El keymap por defecto, estilo *tiling WM* (ver [`default_keymap`]).
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bindings: default_keymap().into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Keymap {
|
||||
/// Construye un keymap a partir de pares `(combinación, acción)`.
|
||||
pub fn from_pairs(pairs: impl IntoIterator<Item = (String, DesktopAction)>) -> Self {
|
||||
Self {
|
||||
bindings: pairs.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// La acción asociada a una combinación, si la hay.
|
||||
pub fn lookup(&self, combo: &str) -> Option<DesktopAction> {
|
||||
self.bindings.get(combo).copied()
|
||||
}
|
||||
|
||||
/// Las combinaciones a interceptar — el contenido de un `GrabKeys`.
|
||||
pub fn grab_list(&self) -> Vec<String> {
|
||||
self.bindings.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Todos los atajos, en orden de combinación.
|
||||
pub fn bindings(&self) -> &BTreeMap<String, DesktopAction> {
|
||||
&self.bindings
|
||||
}
|
||||
|
||||
/// Cuántos atajos hay.
|
||||
pub fn len(&self) -> usize {
|
||||
self.bindings.len()
|
||||
}
|
||||
|
||||
/// `true` si no hay ningún atajo.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bindings.is_empty()
|
||||
}
|
||||
|
||||
// --- RON ----------------------------------------------------------
|
||||
|
||||
/// Parsea un keymap desde el texto RON de un archivo de configuración.
|
||||
pub fn from_ron(text: &str) -> Result<Keymap, KeymapError> {
|
||||
let file: KeymapFile = ron::from_str(text)
|
||||
.map_err(|e| KeymapError::Parse(format!("RON inválido: {e}")))?;
|
||||
let mut bindings = BTreeMap::new();
|
||||
for (combo, action) in file.bindings {
|
||||
let parsed = action
|
||||
.parse::<DesktopAction>()
|
||||
.map_err(|e| KeymapError::Parse(format!("atajo \"{combo}\": {e}")))?;
|
||||
bindings.insert(combo, parsed);
|
||||
}
|
||||
Ok(Keymap { bindings })
|
||||
}
|
||||
|
||||
/// Serializa el keymap a RON (sin la cabecera de documentación).
|
||||
pub fn to_ron(&self) -> String {
|
||||
let file = KeymapFile {
|
||||
bindings: self
|
||||
.bindings
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.to_string()))
|
||||
.collect(),
|
||||
};
|
||||
ron::ser::to_string_pretty(&file, ron::ser::PrettyConfig::default())
|
||||
.expect("un KeymapFile de cadenas siempre serializa")
|
||||
}
|
||||
|
||||
// --- Disco --------------------------------------------------------
|
||||
|
||||
/// La ruta canónica del keymap del usuario: `~/.config/mirada/keymap.ron`.
|
||||
/// `None` si no se puede determinar el directorio de configuración.
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
directories::ProjectDirs::from("", "", "mirada")
|
||||
.map(|d| d.config_dir().join("keymap.ron"))
|
||||
}
|
||||
|
||||
/// Carga un keymap desde un archivo RON.
|
||||
pub fn load(path: &Path) -> Result<Keymap, KeymapError> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
Keymap::from_ron(&text)
|
||||
}
|
||||
|
||||
/// El keymap como RON con la cabecera de documentación — exactamente
|
||||
/// lo que [`save`](Keymap::save) escribe en disco.
|
||||
pub fn documented_ron(&self) -> String {
|
||||
format!("{KEYMAP_HEADER}\n{}", self.to_ron())
|
||||
}
|
||||
|
||||
/// Escribe el keymap a `path` como RON documentado (con cabecera de
|
||||
/// comentarios), creando el directorio padre si falta.
|
||||
pub fn save(&self, path: &Path) -> Result<(), KeymapError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, self.documented_ron())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Carga el keymap del usuario con un fallback amable:
|
||||
///
|
||||
/// - si el archivo no existe, escribe uno por defecto documentado y lo
|
||||
/// devuelve (así el usuario lo descubre y lo puede editar);
|
||||
/// - si existe pero está corrupto, avisa por `stderr` y devuelve el
|
||||
/// keymap por defecto **sin tocar el archivo** (no se pierde el
|
||||
/// trabajo del usuario por un error de sintaxis).
|
||||
pub fn load_or_init(path: &Path) -> Keymap {
|
||||
if path.exists() {
|
||||
match Keymap::load(path) {
|
||||
Ok(km) => km,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"mirada · keymap «{}» inválido ({e}); uso el de por defecto.",
|
||||
path.display()
|
||||
);
|
||||
Keymap::default()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let km = Keymap::default();
|
||||
match km.save(path) {
|
||||
Ok(()) => eprintln!("mirada · keymap inicial escrito en {}", path.display()),
|
||||
Err(e) => eprintln!("mirada · no pude escribir el keymap inicial: {e}"),
|
||||
}
|
||||
km
|
||||
}
|
||||
}
|
||||
|
||||
/// Vigila el archivo del keymap para recargarlo en caliente.
|
||||
pub fn watch(path: &Path) -> notify::Result<KeymapWatch> {
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
|
||||
let target = path.to_path_buf();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
if let Ok(event) = res {
|
||||
// Vigilamos el directorio (los editores reescriben el
|
||||
// archivo por *rename*); filtramos a nuestro archivo.
|
||||
if event.paths.iter().any(|p| p == &target) {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let dir = path.parent().filter(|d| d.exists());
|
||||
watcher.watch(dir.unwrap_or(path), RecursiveMode::NonRecursive)?;
|
||||
Ok(KeymapWatch { _watcher: watcher, rx })
|
||||
}
|
||||
}
|
||||
|
||||
/// Vigía del archivo de keymap para la recarga en caliente.
|
||||
///
|
||||
/// Mantenlo vivo mientras quieras recargas; al soltarlo, la vigilancia
|
||||
/// cesa. Consulta [`changed`](KeymapWatch::changed) en tu bucle de eventos.
|
||||
pub struct KeymapWatch {
|
||||
_watcher: notify::RecommendedWatcher,
|
||||
rx: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
impl KeymapWatch {
|
||||
/// `true` si el archivo cambió desde la última consulta. Coalesce una
|
||||
/// ráfaga de eventos (un guardado dispara varios) en un solo `true`.
|
||||
pub fn changed(&self) -> bool {
|
||||
self.rx.try_iter().count() > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// La forma en disco del keymap — un mapa de cadenas. Las acciones van
|
||||
/// como texto (`"layout:grid"`) y no como enum, para que el RON sea
|
||||
/// trivial y los errores se reporten atajo a atajo.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct KeymapFile {
|
||||
bindings: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// La cabecera de comentarios del archivo que escribe [`Keymap::save`].
|
||||
const KEYMAP_HEADER: &str = "\
|
||||
// keymap de mirada — atajos del escritorio (carmen).
|
||||
//
|
||||
// Formato: \"Combinación\": \"acción\"
|
||||
// La combinación la canoniza el compositor: Super, Ctrl, Shift, Alt y la
|
||||
// tecla, en ese orden (p. ej. \"Super+Shift+j\", \"Super+space\").
|
||||
//
|
||||
// Acciones:
|
||||
// focus-next / focus-prev mueve el foco
|
||||
// move-forward / move-backward reordena la ventana enfocada
|
||||
// close-focused cierra la enfocada
|
||||
// cycle-layout siguiente modo de teselado
|
||||
// layout:master-stack | layout:monocle | layout:grid | layout:columns
|
||||
// workspace:N activa el escritorio N (1..9)
|
||||
// send-to-workspace:N manda la enfocada al escritorio N
|
||||
// quit apaga el compositor
|
||||
//
|
||||
// Edita y guarda: mirada recarga el keymap en caliente, sin reiniciar.";
|
||||
|
||||
/// Un fallo al cargar o guardar un keymap.
|
||||
#[derive(Debug)]
|
||||
pub enum KeymapError {
|
||||
/// El RON no parsea, o una acción no se reconoce. El mensaje ya está
|
||||
/// formateado para mostrarse al usuario.
|
||||
Parse(String),
|
||||
/// Fallo de E/S al leer o escribir el archivo.
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for KeymapError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
KeymapError::Parse(msg) => f.write_str(msg),
|
||||
KeymapError::Io(e) => write!(f, "E/S: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for KeymapError {}
|
||||
|
||||
impl From<io::Error> for KeymapError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
KeymapError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mirada_layout::LayoutMode;
|
||||
|
||||
#[test]
|
||||
fn the_default_keymap_round_trips_through_ron() {
|
||||
let km = Keymap::default();
|
||||
let back = Keymap::from_ron(&km.to_ron()).unwrap();
|
||||
assert_eq!(km, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_file_carries_the_documentation_header() {
|
||||
let km = Keymap::default();
|
||||
let written = km.documented_ron();
|
||||
// La cabecera son comentarios — RON los ignora al reparsear.
|
||||
assert!(written.starts_with("// keymap de mirada"));
|
||||
assert_eq!(Keymap::from_ron(&written).unwrap(), km);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grab_list_is_exactly_the_set_of_bound_combos() {
|
||||
let km = Keymap::default();
|
||||
let grabs = km.grab_list();
|
||||
assert_eq!(grabs.len(), km.len());
|
||||
assert!(grabs.contains(&"Super+j".to_string()));
|
||||
assert!(grabs.contains(&"Super+Shift+e".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_resolves_a_default_binding() {
|
||||
let km = Keymap::default();
|
||||
assert_eq!(km.lookup("Super+q"), Some(DesktopAction::CloseFocused));
|
||||
assert_eq!(km.lookup("Super+t"), Some(DesktopAction::SetLayout(LayoutMode::MasterStack)));
|
||||
assert_eq!(km.lookup("Super+sin-asignar"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_keymap_parses_from_ron() {
|
||||
let ron = r#"(
|
||||
bindings: {
|
||||
"Alt+Return": "cycle-layout",
|
||||
"Alt+x": "close-focused",
|
||||
"Alt+3": "workspace:3",
|
||||
},
|
||||
)"#;
|
||||
let km = Keymap::from_ron(ron).unwrap();
|
||||
assert_eq!(km.len(), 3);
|
||||
assert_eq!(km.lookup("Alt+Return"), Some(DesktopAction::CycleLayout));
|
||||
assert_eq!(km.lookup("Alt+3"), Some(DesktopAction::SwitchWorkspace(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_action_names_the_offending_binding() {
|
||||
let ron = r#"( bindings: { "Super+z": "fly-away" } )"#;
|
||||
let err = Keymap::from_ron(ron).unwrap_err().to_string();
|
||||
assert!(err.contains("Super+z"), "el error debe nombrar el atajo: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ron_is_rejected() {
|
||||
assert!(Keymap::from_ron("esto no es ron").is_err());
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,17 @@
|
||||
//!
|
||||
//! - [`action`] — las acciones de escritorio y el mapa de teclas.
|
||||
//! - [`desktop`] — el [`Desktop`]: el estado y el bucle `evento → comandos`.
|
||||
//! - [`keymap`] — el [`Keymap`] configurable en RON, recargable en caliente.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod action;
|
||||
pub mod desktop;
|
||||
pub mod keymap;
|
||||
|
||||
pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
|
||||
pub use desktop::{Desktop, WindowInfo};
|
||||
pub use keymap::{Keymap, KeymapError, KeymapWatch};
|
||||
|
||||
pub use mirada_layout::{LayoutMode, LayoutParams, Rect, WindowId, Workspace};
|
||||
pub use mirada_protocol::{BodyEvent, BrainCommand, OutputId, WindowPlacement};
|
||||
|
||||
Reference in New Issue
Block a user