feat(mirada): API de acciones — mirada-ctl + HUD interactivo

Toda acción de escritorio converge en Desktop::apply(DesktopAction); el
keymap era sólo un front-end. Esta tanda añade los otros tres.

- DesktopAction::FocusWindow(WindowId): direccionamiento directo de una
  ventana (no sólo ciclar); si está en otro escritorio, salta a él.
  DesktopAction pasa a ser Serialize/Deserialize (postcard) además de
  Display/FromStr.

- mirada-brain::ctl: el API de control externo. CtlRequest/CtlReply
  (marco postcard), CtlServer/CtlConn no bloqueantes y send_request.
  El Cerebro abre el socket y atiende en su bucle: la app mirada
  siempre, mirada-compositor sólo con el Cerebro embebido.

- mirada-ctl: CLI de control estilo swaymsg/hyprctl —
  `mirada-ctl focus-next | focus-window 5 | workspace 3 | windows`.
  Parsea la acción de los argumentos vía FromStr.

- HUD interactivo en la app mirada: pips de escritorio y ventanas del
  lienzo clicables (SwitchWorkspace / FocusWindow).

- Ejemplo headless-ctl: un Cerebro sin gráficos para probar mirada-ctl
  en modo desatendido. Verificado end-to-end.

mirada-brain: 29 -> 37 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-21 00:20:10 +00:00
parent 8204852e3a
commit b31f988833
14 changed files with 751 additions and 12 deletions
@@ -11,18 +11,27 @@
use std::fmt;
use std::str::FromStr;
use mirada_layout::LayoutMode;
use serde::{Deserialize, Serialize};
use mirada_layout::{LayoutMode, WindowId};
/// Número de escritorios virtuales que mantiene el `Desktop`.
pub const WORKSPACE_COUNT: usize = 9;
/// Una orden de escritorio de alto nivel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
///
/// 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)]
pub enum DesktopAction {
/// Mueve el foco a la ventana siguiente del escritorio activo.
FocusNext,
/// Mueve el foco a la ventana anterior.
FocusPrev,
/// Enfoca una ventana concreta por su id; si está en otro escritorio,
/// salta a él. Para clics de taskbar o `mirada-ctl focus-window`.
FocusWindow(WindowId),
/// Adelanta la ventana enfocada en el orden de teselado.
MoveForward,
/// Atrasa la ventana enfocada en el orden de teselado.
@@ -69,6 +78,7 @@ impl fmt::Display for DesktopAction {
match self {
DesktopAction::FocusNext => f.write_str("focus-next"),
DesktopAction::FocusPrev => f.write_str("focus-prev"),
DesktopAction::FocusWindow(id) => write!(f, "focus-window:{id}"),
DesktopAction::MoveForward => f.write_str("move-forward"),
DesktopAction::MoveBackward => f.write_str("move-backward"),
DesktopAction::CloseFocused => f.write_str("close-focused"),
@@ -102,6 +112,12 @@ impl FromStr for DesktopAction {
layout_from_slug(slug)
.ok_or_else(|| format!("modo de teselado desconocido: '{slug}'"))?,
)
} else if let Some(id) = s.strip_prefix("focus-window:") {
Self::FocusWindow(
id.trim()
.parse()
.map_err(|_| format!("id de ventana inválido: '{id}'"))?,
)
} 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:") {
@@ -227,6 +243,14 @@ mod tests {
assert!("workspace:0".parse::<DesktopAction>().is_err());
assert!("workspace:99".parse::<DesktopAction>().is_err());
assert!("layout:fractal".parse::<DesktopAction>().is_err());
assert!("focus-window:abc".parse::<DesktopAction>().is_err());
assert!("teleport".parse::<DesktopAction>().is_err());
}
#[test]
fn focus_window_round_trips_with_its_id() {
let a = DesktopAction::FocusWindow(42);
assert_eq!(a.to_string(), "focus-window:42");
assert_eq!("focus-window:42".parse::<DesktopAction>().unwrap(), a);
}
}