feat(mirada): acción spawn — lanzar programas desde el compositor

Un escritorio sin forma de abrir una terminal no es usable. Ahora el
keymap puede lanzar programas:

- `mirada-protocol`: nuevo `BrainCommand::Spawn(String)`.
- `mirada-brain`: `DesktopAction::Spawn(String)` con forma textual
  `spawn:<comando>` (`Display`/`FromStr`); `Desktop::apply` la traduce
  a `BrainCommand::Spawn`. El keymap por defecto trae
  `Super+Shift+Return` → `spawn:foot`. `DesktopAction` deja de ser
  `Copy` (lleva el comando) — `Keymap::lookup` clona en vez de copiar.
- `mirada-body`: `BodyOp::Spawn(String)`.
- `mirada-compositor`: `exec_op` ejecuta el spawn con un helper
  `spawn_command` (`sh -c`, hereda `WAYLAND_DISPLAY`), que también
  recoge el lanzamiento de `MIRADA_STARTUP` — antes duplicado.

`spawn:foot --title x` también funciona desde `mirada-ctl`. Tests
nuevos del round-trip textual y del flujo atajo→comando.

Nota: un keymap.ron ya existente no recibe el atajo nuevo; hay que
añadir la línea a mano o borrar el archivo para regenerarlo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-21 03:59:37 +00:00
parent 90bffec3f1
commit fb3091d995
10 changed files with 76 additions and 16 deletions
@@ -23,7 +23,9 @@ pub const WORKSPACE_COUNT: usize = 9;
/// Es serializable (`postcard`) para viajar por el API de control
/// ([`crate::ctl`]) y tiene una forma textual estable ([`Display`] /
/// [`FromStr`]) para el keymap y `mirada-ctl`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
///
/// No es `Copy`: [`Spawn`](DesktopAction::Spawn) lleva su comando.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DesktopAction {
/// Mueve el foco a la ventana siguiente del escritorio activo.
FocusNext,
@@ -66,6 +68,9 @@ pub enum DesktopAction {
SendToWorkspace(usize),
/// Mueve el foco a la siguiente salida (monitor).
FocusOutputNext,
/// Lanza un programa — abre una terminal, un navegador, lo que sea.
/// El comando se pasa a `sh -c` en el Cuerpo.
Spawn(String),
/// Apaga el compositor.
Quit,
}
@@ -123,6 +128,7 @@ impl fmt::Display for DesktopAction {
DesktopAction::SwitchWorkspace(n) => write!(f, "workspace:{}", n + 1),
DesktopAction::SendToWorkspace(n) => write!(f, "send-to-workspace:{}", n + 1),
DesktopAction::FocusOutputNext => f.write_str("focus-output-next"),
DesktopAction::Spawn(cmd) => write!(f, "spawn:{cmd}"),
DesktopAction::Quit => f.write_str("quit"),
}
}
@@ -168,6 +174,12 @@ impl FromStr for DesktopAction {
Self::SendToWorkspace(parse_workspace(n)?)
} else if let Some(n) = s.strip_prefix("workspace:") {
Self::SwitchWorkspace(parse_workspace(n)?)
} else if let Some(cmd) = s.strip_prefix("spawn:") {
let cmd = cmd.trim();
if cmd.is_empty() {
return Err("spawn: necesita un comando".into());
}
Self::Spawn(cmd.to_string())
} else {
return Err(format!("acción desconocida: '{s}'"));
}
@@ -218,6 +230,7 @@ pub fn default_keymap() -> Vec<(String, DesktopAction)> {
("Super+l".into(), DesktopAction::GrowMaster),
("Super+o".into(), DesktopAction::FocusOutputNext),
("Super+Return".into(), DesktopAction::PromoteToMaster),
("Super+Shift+Return".into(), DesktopAction::Spawn("foot".into())),
("Super+,".into(), DesktopAction::IncMaster),
("Super+.".into(), DesktopAction::DecMaster),
("Super+Shift+e".into(), DesktopAction::Quit),
@@ -254,10 +267,10 @@ mod tests {
for n in 0..WORKSPACE_COUNT {
assert!(map
.iter()
.any(|(_, a)| *a == DesktopAction::SwitchWorkspace(n)));
.any(|(_, a)| a == &DesktopAction::SwitchWorkspace(n)));
assert!(map
.iter()
.any(|(_, a)| *a == DesktopAction::SendToWorkspace(n)));
.any(|(_, a)| a == &DesktopAction::SendToWorkspace(n)));
}
}
@@ -306,4 +319,17 @@ mod tests {
assert_eq!(a.to_string(), "focus-window:42");
assert_eq!("focus-window:42".parse::<DesktopAction>().unwrap(), a);
}
#[test]
fn spawn_round_trips_keeping_the_whole_command() {
let a = DesktopAction::Spawn("foot --title diario".into());
assert_eq!(a.to_string(), "spawn:foot --title diario");
assert_eq!(a.to_string().parse::<DesktopAction>().unwrap(), a);
}
#[test]
fn spawn_without_a_command_is_rejected() {
assert!("spawn:".parse::<DesktopAction>().is_err());
assert!("spawn: ".parse::<DesktopAction>().is_err());
}
}
@@ -387,6 +387,7 @@ impl Desktop {
Vec::new()
}
}
DesktopAction::Spawn(cmd) => vec![BrainCommand::Spawn(cmd)],
DesktopAction::Quit => vec![BrainCommand::Shutdown],
}
}
@@ -876,6 +877,13 @@ mod tests {
assert_eq!(p.rect, target);
}
#[test]
fn a_spawn_keybind_becomes_a_spawn_command() {
let mut d = desktop_with_screen();
let cmds = d.on_event(BodyEvent::Keybind("Super+Shift+Return".into()));
assert_eq!(cmds, vec![BrainCommand::Spawn("foot".into())]);
}
#[test]
fn dragging_an_unknown_window_does_nothing() {
let mut d = desktop_with_screen();
@@ -67,7 +67,7 @@ impl Keymap {
/// La acción asociada a una combinación, si la hay.
pub fn lookup(&self, combo: &str) -> Option<DesktopAction> {
self.bindings.get(combo).copied()
self.bindings.get(combo).cloned()
}
/// Las combinaciones a interceptar — el contenido de un `GrabKeys`.
@@ -250,6 +250,7 @@ const KEYMAP_HEADER: &str = "\
// focus-output-next pasa el foco al siguiente monitor
// workspace:N activa el escritorio N (1..9)
// send-to-workspace:N manda la enfocada al escritorio N
// spawn:<comando> lanza un programa (p. ej. spawn:foot)
// quit apaga el compositor
//
// Edita y guarda: mirada recarga el keymap en caliente, sin reiniciar.";