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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user