gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definiciones

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>

b31f98883379a172efd309cd2cec98d16eb591db · por sergio · (hace 5 meses) · padre 8204852e3 · ver el árbol en este commit

Delta semántico · 45

crates/apps/mirada-compositor/src/main.rs

crates/apps/mirada-ctl/src/main.rs

crates/apps/mirada/src/main.rs

crates/modules/mirada/mirada-brain/examples/headless-ctl.rs

crates/modules/mirada/mirada-brain/src/action.rs

crates/modules/mirada/mirada-brain/src/ctl.rs

crates/modules/mirada/mirada-brain/src/desktop.rs

crates/modules/mirada/mirada-brain/src/lib.rs

  • nueva mod ctl pub

14 archivos · +751 −12

diff --git a/Cargo.lock b/Cargo.lock
index 9f8eb55..18ec054 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7733,6 +7733,13 @@ dependencies = [
  "smithay",
 ]
 
+[[package]]
+name = "mirada-ctl"
+version = "0.1.0"
+dependencies = [
+ "mirada-brain",
+]
+
 [[package]]
 name = "mirada-layout"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 5c0581a..5ed1d57 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -287,6 +287,7 @@ members = [
     "crates/apps/yachay",
     "crates/apps/mirada",
     "crates/apps/mirada-compositor",
+    "crates/apps/mirada-ctl",
 ]
 
 [workspace.package]
diff --git a/crates/apps/mirada-compositor/README.md b/crates/apps/mirada-compositor/README.md
index c9b3f54..f3a0b9e 100644
--- a/crates/apps/mirada-compositor/README.md
+++ b/crates/apps/mirada-compositor/README.md
@@ -80,6 +80,21 @@ El compositor en sí no interpreta atajos: sólo intercepta las
 combinaciones que el Cerebro le pide (`GrabKeys`) y le devuelve la
 pulsada. *Qué significa* cada una lo decide `mirada-brain`. Ver el SDD.
 
+## Control externo
+
+En modo autónomo, el compositor abre un socket de control y `mirada-ctl`
+lo maneja desde la terminal — al estilo de `swaymsg`/`hyprctl`:
+
+```sh
+mirada-ctl focus-next            # cambia el foco
+mirada-ctl focus-window 5        # enfoca una ventana concreta
+mirada-ctl workspace 3           # va al escritorio 3
+mirada-ctl windows               # lista las ventanas
+```
+
+En modo enlazado el socket de control lo abre el Cerebro (la app
+`mirada`), no el compositor.
+
 ## Qué implementa
 
 `wl_compositor`, `xdg_shell` (toplevels y popups), `wl_shm`, `wl_seat`
diff --git a/crates/apps/mirada-compositor/src/main.rs b/crates/apps/mirada-compositor/src/main.rs
index 0f5095d..599d048 100644
--- a/crates/apps/mirada-compositor/src/main.rs
+++ b/crates/apps/mirada-compositor/src/main.rs
@@ -59,7 +59,7 @@ use smithay::{
 };
 
 use mirada_body::{BodyOp, BodyState};
-use mirada_brain::{BodyEvent, BrainCommand, Desktop, Keymap};
+use mirada_brain::{BodyEvent, BrainCommand, CtlReply, CtlRequest, CtlServer, Desktop, Keymap};
 use mirada_link::BodyLink;
 
 // ---------------------------------------------------------------------
@@ -132,6 +132,31 @@ impl App {
         }
     }
 
+    /// Atiende una petición del API de control (`mirada-ctl`).
+    fn serve_ctl(&mut self, req: CtlRequest) -> CtlReply {
+        match req {
+            CtlRequest::Do(action) => {
+                let cmds = match &mut self.brain {
+                    Brain::Embedded(d) => Some(d.apply(action)),
+                    Brain::Linked(_) => None,
+                };
+                match cmds {
+                    Some(cmds) => {
+                        self.apply_commands(cmds);
+                        CtlReply::Ok
+                    }
+                    None => CtlReply::Error(
+                        "el Cerebro es externo; usa mirada-ctl contra la app mirada".into(),
+                    ),
+                }
+            }
+            CtlRequest::ListWindows => match &self.brain {
+                Brain::Embedded(d) => CtlReply::Windows(d.window_lines()),
+                Brain::Linked(_) => CtlReply::Error("el Cerebro es externo".into()),
+            },
+        }
+    }
+
     /// Traduce los comandos del Cerebro a operaciones y las ejecuta.
     fn apply_commands(&mut self, cmds: Vec<BrainCommand>) {
         for cmd in cmds {
@@ -493,6 +518,25 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
         println!("mirada-compositor · vigilando el keymap (recarga en caliente).");
     }
 
+    // API de control (mirada-ctl) — sólo con el Cerebro embebido; si es
+    // externo, el socket de control lo abre él.
+    let ctl = match &state.brain {
+        Brain::Embedded(_) => {
+            let path = mirada_brain::ctl::default_socket_path();
+            match CtlServer::bind(&path) {
+                Ok(s) => {
+                    println!("mirada-compositor · API de control en {}", path.display());
+                    Some(s)
+                }
+                Err(e) => {
+                    eprintln!("mirada-compositor · sin API de control: {e}");
+                    None
+                }
+            }
+        }
+        Brain::Linked(_) => None,
+    };
+
     // El backend gráfico va primero. winit abre la ventana del compositor
     // dentro de tu sesión gráfica anfitriona, y para encontrarla lee
     // `WAYLAND_DISPLAY` / `DISPLAY` del entorno. Si publicáramos antes
@@ -611,6 +655,18 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
             }
         }
 
+        // 2 ter · Peticiones del API de control (mirada-ctl).
+        if let Some(ctl) = &ctl {
+            while let Some(mut conn) = ctl.poll() {
+                let reply = match conn.read_request() {
+                    Ok(Some(req)) => state.serve_ctl(req),
+                    Ok(None) => continue,
+                    Err(e) => CtlReply::Error(format!("{e}")),
+                };
+                let _ = conn.reply(&reply);
+            }
+        }
+
         // 3 · Composición de las superficies en sus rectángulos.
         let size = backend.window_size();
         let damage: Rectangle<i32, smithay::utils::Physical> = Rectangle::from_size(size);
diff --git a/crates/apps/mirada-ctl/Cargo.toml b/crates/apps/mirada-ctl/Cargo.toml
new file mode 100644
index 0000000..e57c581
--- /dev/null
+++ b/crates/apps/mirada-ctl/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "mirada-ctl"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada-ctl — control del compositor carmen por línea de comandos (estilo swaymsg/hyprctl): aplica acciones de escritorio y consulta ventanas vía el socket de control de mirada-brain."
+
+[[bin]]
+name = "mirada-ctl"
+path = "src/main.rs"
+
+[dependencies]
+mirada-brain = { path = "../../modules/mirada/mirada-brain" }
diff --git a/crates/apps/mirada-ctl/src/main.rs b/crates/apps/mirada-ctl/src/main.rs
new file mode 100644
index 0000000..572ea59
--- /dev/null
+++ b/crates/apps/mirada-ctl/src/main.rs
@@ -0,0 +1,126 @@
+//! `mirada-ctl` — el control del compositor carmen por línea de comandos.
+//!
+//! Al estilo de `swaymsg` / `hyprctl`: dispara una acción de escritorio o
+//! consulta el estado, hablando con el Cerebro por su socket de control
+//! ([`mirada_brain::ctl`]). El Cerebro es la app `mirada`, o
+//! `mirada-compositor` cuando lleva el Cerebro embebido.
+//!
+//! ```sh
+//! mirada-ctl focus-next            # cambia el foco
+//! mirada-ctl focus-window 5        # enfoca una ventana concreta
+//! mirada-ctl workspace 3           # va al escritorio 3
+//! mirada-ctl layout grid           # fija el modo de teselado
+//! mirada-ctl windows               # lista las ventanas
+//! mirada-ctl actions               # lista las acciones
+//! ```
+
+use std::process::ExitCode;
+
+use mirada_brain::ctl::{self, CtlReply, CtlRequest, WindowLine};
+use mirada_brain::DesktopAction;
+
+fn main() -> ExitCode {
+    let args: Vec<String> = std::env::args().skip(1).collect();
+    match run(&args) {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(msg) => {
+            eprintln!("mirada-ctl: {msg}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+fn run(args: &[String]) -> Result<(), String> {
+    match args.first().map(String::as_str) {
+        None | Some("-h" | "--help" | "help") => {
+            print_help();
+            Ok(())
+        }
+        Some("actions") => {
+            print_actions();
+            Ok(())
+        }
+        Some("windows") => match request(CtlRequest::ListWindows)? {
+            CtlReply::Windows(ws) => {
+                print_windows(&ws);
+                Ok(())
+            }
+            CtlReply::Error(e) => Err(e),
+            CtlReply::Ok => Err("respuesta inesperada del Cerebro".into()),
+        },
+        // Todo lo demás es una acción. `focus-window 5` y `workspace 3`
+        // se unen con `:` a la forma canónica (`focus-window:5`).
+        Some(_) => {
+            let spec = args.join(":");
+            let action: DesktopAction = spec
+                .parse()
+                .map_err(|e| format!("{e}\n  lista de acciones:  mirada-ctl actions"))?;
+            match request(CtlRequest::Do(action))? {
+                CtlReply::Ok => Ok(()),
+                CtlReply::Error(e) => Err(e),
+                CtlReply::Windows(_) => Err("respuesta inesperada del Cerebro".into()),
+            }
+        }
+    }
+}
+
+/// Manda una petición al Cerebro y devuelve su respuesta.
+fn request(req: CtlRequest) -> Result<CtlReply, String> {
+    let path = ctl::default_socket_path();
+    ctl::send_request(&path, &req).map_err(|e| {
+        format!(
+            "no pude hablar con el Cerebro en {} ({e})\n  \
+             ¿está corriendo `mirada` o `mirada-compositor`?",
+            path.display()
+        )
+    })
+}
+
+/// Imprime la lista de ventanas, marcando la enfocada con `*`.
+fn print_windows(windows: &[WindowLine]) {
+    if windows.is_empty() {
+        println!("(no hay ventanas)");
+        return;
+    }
+    for w in windows {
+        let mark = if w.focused { '*' } else { ' ' };
+        println!(
+            "{mark} id {:<4} esc {}  {:<24} {}",
+            w.id, w.workspace, w.app_id, w.title
+        );
+    }
+}
+
+fn print_help() {
+    println!(
+        "mirada-ctl — control del compositor carmen\n\
+         \n\
+         USO:\n  \
+           mirada-ctl <acción>      aplica una acción de escritorio\n  \
+           mirada-ctl windows       lista las ventanas\n  \
+           mirada-ctl actions       lista las acciones disponibles\n\
+         \n\
+         EJEMPLOS:\n  \
+           mirada-ctl focus-next\n  \
+           mirada-ctl focus-window 5\n  \
+           mirada-ctl workspace 3\n  \
+           mirada-ctl layout grid"
+    );
+}
+
+fn print_actions() {
+    println!(
+        "Acciones de mirada-ctl:\n  \
+           focus-next                 mueve el foco a la siguiente ventana\n  \
+           focus-prev                 mueve el foco a la anterior\n  \
+           focus-window <id>          enfoca la ventana <id>  (ver: mirada-ctl windows)\n  \
+           move-forward               adelanta la ventana enfocada en el teselado\n  \
+           move-backward              la atrasa\n  \
+           close-focused              cierra la ventana enfocada\n  \
+           cycle-layout               pasa al siguiente modo de teselado\n  \
+           layout <modo>              master-stack | monocle | grid | columns\n  \
+           workspace <n>              activa el escritorio n (1..9)\n  \
+           send-to-workspace <n>      manda la enfocada al escritorio n\n  \
+           quit                       apaga el compositor"
+    );
+}
diff --git a/crates/apps/mirada/src/main.rs b/crates/apps/mirada/src/main.rs
index e1ced6e..36982a7 100644
--- a/crates/apps/mirada/src/main.rs
+++ b/crates/apps/mirada/src/main.rs
@@ -24,17 +24,21 @@
 //!   j / k        foco siguiente/anterior   1..9           ir a escritorio
 //!   Shift+j / k  mueve la enfocada         Ctrl+1..9      enviar a escritorio
 //! ```
+//!
+//! Los pips de escritorio y las ventanas del lienzo son **clicables**, y
+//! `mirada-ctl` controla el escritorio desde la terminal — ambos pasan
+//! por el mismo `Desktop::apply`.
 
 use std::path::PathBuf;
 use std::time::Duration;
 
 use gpui::{
-    div, hsla, prelude::*, px, Context, FocusHandle, IntoElement, KeyDownEvent, Render,
-    SharedString, Window,
+    div, hsla, prelude::*, px, Context, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
+    Render, SharedString, Window,
 };
 use mirada_brain::{
-    BodyEvent, BrainCommand, Desktop, DesktopAction, Keymap, KeymapWatch, LayoutMode, WindowId,
-    WindowPlacement,
+    BodyEvent, BrainCommand, CtlConn, CtlReply, CtlRequest, CtlServer, Desktop, DesktopAction,
+    Keymap, KeymapWatch, LayoutMode, WindowId, WindowPlacement,
 };
 use mirada_link::BrainLink;
 use nahual_launcher::launch_app;
@@ -68,6 +72,8 @@ struct Mirada {
     keymap_path: Option<PathBuf>,
     /// Vigía del keymap; `None` en simulación o si no hay archivo.
     keymap_watch: Option<KeymapWatch>,
+    /// Socket del API de control externo (`mirada-ctl`).
+    ctl: Option<CtlServer>,
 }
 
 impl Mirada {
@@ -87,6 +93,15 @@ impl Mirada {
         } else {
             None
         };
+        // API de control: mirada siempre posee el Desktop, así que
+        // siempre abre el socket de `mirada-ctl`.
+        let ctl = match CtlServer::bind(&mirada_brain::ctl::default_socket_path()) {
+            Ok(s) => Some(s),
+            Err(e) => {
+                eprintln!("mirada · sin API de control: {e}");
+                None
+            }
+        };
 
         let mut app = Self {
             desktop: Desktop::with_keymap(keymap),
@@ -98,12 +113,12 @@ impl Mirada {
             focused_once: false,
             keymap_path,
             keymap_watch,
+            ctl,
         };
         if let Some(link) = app.link.as_mut() {
             // Registra los atajos globales en el Cuerpo.
             let _ = link.send(&app.desktop.grab_keys());
             app.note = SharedString::from("Cuerpo conectado");
-            app.start_poll(cx);
         } else {
             // Simulación: una pantalla virtual y tres ventanas de muestra.
             app.feed(BodyEvent::OutputAdded { id: 0, width: SCREEN_W, height: SCREEN_H });
@@ -112,6 +127,9 @@ impl Mirada {
             }
             app.note = SharedString::from("simulación — sin Cuerpo");
         }
+        // El sondeo corre siempre: drena el Cuerpo (si lo hay), vigila el
+        // keymap y atiende `mirada-ctl`.
+        app.start_poll(cx);
         app
     }
 
@@ -131,10 +149,11 @@ impl Mirada {
                 if keymap_changed {
                     app.reload_keymap();
                 }
+                let ctl_served = app.poll_ctl();
                 for ev in events {
                     app.feed(ev);
                 }
-                if had_events || keymap_changed {
+                if had_events || keymap_changed || ctl_served {
                     cx.notify();
                 }
             });
@@ -185,6 +204,40 @@ impl Mirada {
         }
     }
 
+    /// Atiende las peticiones pendientes del API de control. Devuelve
+    /// `true` si sirvió alguna (para repintar).
+    fn poll_ctl(&mut self) -> bool {
+        let conns: Vec<CtlConn> = match &self.ctl {
+            Some(ctl) => std::iter::from_fn(|| ctl.poll()).collect(),
+            None => return false,
+        };
+        let mut served = false;
+        for mut conn in conns {
+            let reply = match conn.read_request() {
+                Ok(Some(req)) => {
+                    served = true;
+                    self.serve_ctl(req)
+                }
+                Ok(None) => continue,
+                Err(e) => CtlReply::Error(format!("{e}")),
+            };
+            let _ = conn.reply(&reply);
+        }
+        served
+    }
+
+    /// Resuelve una petición de control: la acción pasa por el mismo
+    /// `apply` que el teclado; la consulta lee el `Desktop`.
+    fn serve_ctl(&mut self, req: CtlRequest) -> CtlReply {
+        match req {
+            CtlRequest::Do(action) => {
+                self.act(action);
+                CtlReply::Ok
+            }
+            CtlRequest::ListWindows => CtlReply::Windows(self.desktop.window_lines()),
+        }
+    }
+
     /// Reparte los comandos del Cerebro: actualiza lo pintado y, o bien
     /// los manda al Cuerpo, o bien —en simulación— cierra las ventanas
     /// por su cuenta (no hay nadie que devuelva el `WindowClosed`).
@@ -299,9 +352,17 @@ impl Render for Mirada {
                 .items_center()
                 .justify_center()
                 .rounded(px(4.))
+                .cursor_pointer()
                 .when(is_active, |d| d.bg(theme.accent))
                 .when(!is_active && load > 0, |d| d.bg(theme.bg_row_hover))
                 .text_color(fg)
+                .on_mouse_down(
+                    MouseButton::Left,
+                    cx.listener(move |app, _, _, cx| {
+                        app.act(DesktopAction::SwitchWorkspace(i));
+                        cx.notify();
+                    }),
+                )
                 .child(SharedString::from(format!("{}", i + 1)))
         });
 
@@ -365,6 +426,7 @@ impl Render for Mirada {
             let border = if p.focused { theme.accent } else { theme.border };
             let tb_bg = if p.focused { theme.accent } else { theme.bg_row_hover };
             let tb_fg = if p.focused { on_accent } else { theme.fg_muted };
+            let pid = p.id;
 
             canvas = canvas.child(
                 div()
@@ -378,6 +440,14 @@ impl Render for Mirada {
                     .bg(win_bg)
                     .rounded(px(5.))
                     .overflow_hidden()
+                    .cursor_pointer()
+                    .on_mouse_down(
+                        MouseButton::Left,
+                        cx.listener(move |app, _, _, cx| {
+                            app.act(DesktopAction::FocusWindow(pid));
+                            cx.notify();
+                        }),
+                    )
                     .flex()
                     .flex_col()
                     .child(
diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md
index 99099a6..efc03b6 100644
--- a/crates/modules/mirada/SDD.md
+++ b/crates/modules/mirada/SDD.md
@@ -27,6 +27,7 @@ ejecuta operaciones de geometría".
 | `mirada-body`      | lib      | Contabilidad del Cuerpo: `BodyState`, traduce comandos a `BodyOp`      |
 | `mirada` (app)     | bin/GPUI | El Cerebro: ventana que tesela el escritorio y manda geometría         |
 | `mirada-compositor`| bin/smithay | El Cuerpo: compositor Wayland real (backend `winit`, anidado)        |
+| `mirada-ctl` (app) | bin/CLI  | Control externo del Cerebro (estilo `swaymsg`): acciones y consultas   |
 
 ## Flujo
 
@@ -68,7 +69,11 @@ ejecuta operaciones de geometría".
 - **`mirada` (app)** — envuelve `Desktop` y lo pinta (barra de
   escritorios + modo + foco, lienzo teselado). Con `MIRADA_SOCKET`
   conecta a un Cuerpo; sin él corre en **simulación** (ventanas
-  sintéticas, teclado de la propia ventana).
+  sintéticas, teclado de la propia ventana). Pips de escritorio y
+  ventanas clicables.
+- **`mirada-ctl` (app)** — CLI de control: parsea la acción de los
+  argumentos (`DesktopAction: FromStr`) y la manda al Cerebro por el
+  socket de control; `windows` y `actions` para consultar.
 
 ## Atajos de teclado configurables
 
@@ -95,6 +100,28 @@ significa* (el mapa, Cerebro)— hace innecesario cualquier candado o
   sobre el mismo API `Keymap`). `cargo run -p mirada-brain --example
   keymap-default` imprime el archivo por defecto.
 
+## API de acciones
+
+Toda acción de escritorio converge en un único embudo:
+`Desktop::apply(DesktopAction) -> Vec<BrainCommand>`. El keymap no es más
+que un front-end (`Keybind` → `lookup` → `apply`); hay otros tres:
+
+- **`DesktopAction::FocusWindow(WindowId)`** — direccionamiento directo de
+  una ventana (no sólo ciclar con `FocusNext`/`Prev`); si está en otro
+  escritorio, salta a él. Lo usan la taskbar y `mirada-ctl`.
+- **HUD interactivo** (app `mirada`) — los pips de escritorio y las
+  ventanas del lienzo son clicables: clic = `apply` de la acción.
+- **`mirada-ctl`** — control externo por línea de comandos
+  (`mirada-ctl focus-next`, `workspace 3`, `windows`). Habla con el
+  Cerebro por un socket Unix aparte; el módulo `mirada-brain::ctl` define
+  `CtlRequest`/`CtlReply` (marco `postcard`), `CtlServer`/`CtlConn` y
+  `send_request`. El Cerebro (la app `mirada` siempre; `mirada-compositor`
+  sólo embebido) abre el socket y atiende en su bucle. `DesktopAction`
+  viaja como enum serializado: contrato tipado de punta a punta.
+
+`cargo run -p mirada-brain --example headless-ctl` levanta un Cerebro sin
+gráficos para ejercitar `mirada-ctl` en modo desatendido.
+
 ## Dependencias
 
 - Todos los `lib` con `#![forbid(unsafe_code)]`. Cero Wayland, cero
@@ -104,8 +131,9 @@ significa* (el mapa, Cerebro)— hace innecesario cualquier candado o
 ## Estado
 
 Implementado y verde: `mirada-layout` (22 tests), `mirada-protocol`
-(9), `mirada-brain` (29), `mirada-link` (7), `mirada-body` (13), y la
-app `mirada` (compila; verificación visual manual).
+(9), `mirada-brain` (37), `mirada-link` (7), `mirada-body` (13), las
+apps `mirada` y `mirada-compositor` (compilan; verificación visual
+manual) y `mirada-ctl` (CLI, probado vía el ejemplo `headless-ctl`).
 
 El **Cuerpo** ya existe: `mirada-compositor` es un compositor Wayland
 teselante real sobre `smithay`, con backend `winit` — corre **anidado**
diff --git a/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs b/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs
new file mode 100644
index 0000000..9848ad8
--- /dev/null
+++ b/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs
@@ -0,0 +1,77 @@
+//! Un Cerebro *headless* para probar el API de control sin gráficos.
+//!
+//! Abre el socket de `mirada-ctl`, arranca un [`Desktop`] con una pantalla
+//! y unas ventanas de muestra, y atiende peticiones en bucle, imprimiendo
+//! el estado tras cada una. Útil para ejercitar `mirada-ctl` en modo
+//! desatendido.
+//!
+//! ```sh
+//! cargo run -p mirada-brain --example headless-ctl   # terminal 1
+//! mirada-ctl windows                                 # terminal 2
+//! mirada-ctl focus-next
+//! mirada-ctl focus-window 2
+//! ```
+
+use std::thread;
+use std::time::Duration;
+
+use mirada_brain::ctl::{self, CtlReply, CtlRequest, CtlServer};
+use mirada_brain::{BodyEvent, BrainCommand, Desktop};
+
+fn main() {
+    let path = ctl::default_socket_path();
+    let server = match CtlServer::bind(&path) {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!("Cerebro headless · no pude abrir el control: {e}");
+            std::process::exit(1);
+        }
+    };
+    println!("Cerebro headless · control en {}", path.display());
+
+    // Una pantalla y tres ventanas de muestra.
+    let mut desktop = Desktop::new();
+    desktop.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+    for id in 1..=3 {
+        desktop.on_event(BodyEvent::WindowOpened {
+            id,
+            app_id: format!("org.brahman.app{id}"),
+            title: format!("ventana {id}"),
+        });
+    }
+    print_state(&desktop);
+    println!("   esperando a mirada-ctl …");
+
+    loop {
+        if let Some(mut conn) = server.poll() {
+            if let Ok(Some(req)) = conn.read_request() {
+                let reply = match req {
+                    CtlRequest::Do(action) => {
+                        let cmds = desktop.apply(action);
+                        // Sin Cuerpo: simulamos nosotros el cierre.
+                        for cmd in cmds {
+                            if let BrainCommand::Close(id) | BrainCommand::Kill(id) = cmd {
+                                desktop.on_event(BodyEvent::WindowClosed { id });
+                            }
+                        }
+                        println!("· {action}");
+                        print_state(&desktop);
+                        CtlReply::Ok
+                    }
+                    CtlRequest::ListWindows => CtlReply::Windows(desktop.window_lines()),
+                };
+                let _ = conn.reply(&reply);
+            }
+        }
+        thread::sleep(Duration::from_millis(16));
+    }
+}
+
+fn print_state(d: &Desktop) {
+    println!(
+        "  escritorio {} · foco {:?} · ventanas/escritorio {:?}",
+        d.active_index() + 1,
+        d.focused_window(),
+        d.workspace_loads(),
+    );
+}
diff --git a/crates/modules/mirada/mirada-brain/src/action.rs b/crates/modules/mirada/mirada-brain/src/action.rs
index d90d703..4d8295b 100644
--- a/crates/modules/mirada/mirada-brain/src/action.rs
+++ b/crates/modules/mirada/mirada-brain/src/action.rs
@@ -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);
+    }
 }
diff --git a/crates/modules/mirada/mirada-brain/src/ctl.rs b/crates/modules/mirada/mirada-brain/src/ctl.rs
new file mode 100644
index 0000000..7fa1aca
--- /dev/null
+++ b/crates/modules/mirada/mirada-brain/src/ctl.rs
@@ -0,0 +1,225 @@
+//! `ctl` — el API de control externo del Cerebro.
+//!
+//! Mientras el keymap ([`crate::keymap`]) es la cara *configurable* de las
+//! acciones, este módulo es su cara *programable*: deja que otro proceso
+//! —un script, una taskbar, el binario `mirada-ctl`— dispare una
+//! [`DesktopAction`] o consulte el estado, sin tocar el teclado.
+//!
+//! Todo converge igualmente en `Desktop::apply`: una petición de control
+//! no es más que otro front-end del mismo embudo. El transporte es un
+//! socket Unix de petición/respuesta, con el marco `postcard` que ya usa
+//! [`mirada_protocol`]; `DesktopAction` viaja como enum serializado (no
+//! como cadena), así que el contrato es tipado de punta a punta.
+//!
+//! - El Cerebro abre un [`CtlServer`] y atiende [`CtlConn`]s en su bucle.
+//! - El cliente usa [`send_request`] — una petición, una respuesta, cierra.
+
+use std::io::{self, ErrorKind};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use mirada_layout::WindowId;
+use mirada_protocol::{read_frame, write_frame};
+
+use crate::action::DesktopAction;
+
+/// Una orden de un cliente de control al Cerebro.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CtlRequest {
+    /// Aplica una acción de escritorio — el equivalente a pulsar su atajo.
+    Do(DesktopAction),
+    /// Pide la lista de ventanas conocidas, en todos los escritorios.
+    ListWindows,
+}
+
+/// La respuesta del Cerebro a un [`CtlRequest`].
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CtlReply {
+    /// La orden se aplicó.
+    Ok,
+    /// La orden no se pudo aplicar; el motivo, para mostrar al usuario.
+    Error(String),
+    /// La lista pedida con [`CtlRequest::ListWindows`].
+    Windows(Vec<WindowLine>),
+}
+
+/// Una ventana en la vista de `mirada-ctl windows`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WindowLine {
+    /// Id de la ventana — el que se pasa a `focus-window:N`.
+    pub id: WindowId,
+    pub app_id: String,
+    pub title: String,
+    /// Escritorio virtual donde está (1-based).
+    pub workspace: usize,
+    /// `true` si es la ventana enfocada del escritorio activo.
+    pub focused: bool,
+}
+
+/// La ruta del socket de control: `$XDG_RUNTIME_DIR/mirada-ctl.sock`, o
+/// el directorio temporal si esa variable no está.
+pub fn default_socket_path() -> PathBuf {
+    let dir = std::env::var_os("XDG_RUNTIME_DIR")
+        .map(PathBuf::from)
+        .unwrap_or_else(std::env::temp_dir);
+    dir.join("mirada-ctl.sock")
+}
+
+/// El extremo servidor del API de control — lo abre el dueño del
+/// [`Desktop`](crate::Desktop) (la app `mirada`, o `mirada-compositor`
+/// con el Cerebro embebido).
+pub struct CtlServer {
+    listener: UnixListener,
+    path: PathBuf,
+}
+
+impl CtlServer {
+    /// Abre el socket de control en `path`. Si ya hay un Cerebro vivo
+    /// escuchando ahí, falla; si encuentra un socket muerto (de un
+    /// compositor anterior), lo retira y se queda con él.
+    pub fn bind(path: &Path) -> io::Result<Self> {
+        if path.exists() {
+            if UnixStream::connect(path).is_ok() {
+                return Err(io::Error::new(
+                    ErrorKind::AddrInUse,
+                    "ya hay un Cerebro escuchando en el socket de control",
+                ));
+            }
+            let _ = std::fs::remove_file(path);
+        }
+        if let Some(dir) = path.parent() {
+            std::fs::create_dir_all(dir)?;
+        }
+        let listener = UnixListener::bind(path)?;
+        listener.set_nonblocking(true)?;
+        Ok(Self { listener, path: path.to_path_buf() })
+    }
+
+    /// Acepta una conexión pendiente sin bloquear. `None` si no hay
+    /// ninguna — pensado para llamarse cada vuelta del bucle de eventos.
+    pub fn poll(&self) -> Option<CtlConn> {
+        match self.listener.accept() {
+            Ok((stream, _)) => Some(CtlConn { stream }),
+            Err(_) => None,
+        }
+    }
+}
+
+impl Drop for CtlServer {
+    fn drop(&mut self) {
+        // Dejar el socket limpio para el próximo arranque.
+        let _ = std::fs::remove_file(&self.path);
+    }
+}
+
+/// Una conexión de control aceptada: una petición y una respuesta.
+pub struct CtlConn {
+    stream: UnixStream,
+}
+
+impl CtlConn {
+    /// Lee la petición del cliente (bloquea hasta el marco completo; es
+    /// uno solo y llega enseguida).
+    pub fn read_request(&mut self) -> io::Result<Option<CtlRequest>> {
+        self.stream.set_nonblocking(false)?;
+        read_frame(&mut self.stream)
+    }
+
+    /// Envía la respuesta. El cliente cierra al recibirla.
+    pub fn reply(&mut self, reply: &CtlReply) -> io::Result<()> {
+        write_frame(&mut self.stream, reply)
+    }
+}
+
+/// Envía una petición al Cerebro y espera su respuesta. Es el camino que
+/// usa el binario `mirada-ctl`: conecta, pregunta, cierra.
+pub fn send_request(path: &Path, request: &CtlRequest) -> io::Result<CtlReply> {
+    let mut stream = UnixStream::connect(path)?;
+    write_frame(&mut stream, request)?;
+    read_frame(&mut stream)?
+        .ok_or_else(|| io::Error::new(ErrorKind::UnexpectedEof, "el Cerebro cerró sin responder"))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::thread;
+    use std::time::{SystemTime, UNIX_EPOCH};
+
+    /// Una ruta de socket única para un test (los sockets no se pueden
+    /// reabrir; cada test necesita la suya).
+    fn temp_socket(tag: &str) -> PathBuf {
+        let nanos = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_nanos();
+        std::env::temp_dir().join(format!("mirada-ctl-test-{tag}-{nanos}.sock"))
+    }
+
+    #[test]
+    fn default_socket_path_lives_under_a_runtime_dir() {
+        let p = default_socket_path();
+        assert_eq!(p.file_name().unwrap(), "mirada-ctl.sock");
+    }
+
+    #[test]
+    fn a_request_round_trips_over_the_socket() {
+        let path = temp_socket("roundtrip");
+        let server = CtlServer::bind(&path).unwrap();
+
+        // El "Cerebro": atiende una petición y responde.
+        let srv = thread::spawn(move || loop {
+            if let Some(mut conn) = server.poll() {
+                let req = conn.read_request().unwrap().unwrap();
+                let reply = match req {
+                    CtlRequest::Do(DesktopAction::FocusNext) => CtlReply::Ok,
+                    other => CtlReply::Error(format!("inesperado: {other:?}")),
+                };
+                conn.reply(&reply).unwrap();
+                return;
+            }
+            thread::yield_now();
+        });
+
+        let reply = send_request(&path, &CtlRequest::Do(DesktopAction::FocusNext)).unwrap();
+        assert_eq!(reply, CtlReply::Ok);
+        srv.join().unwrap();
+    }
+
+    #[test]
+    fn list_windows_carries_the_window_lines() {
+        let path = temp_socket("windows");
+        let server = CtlServer::bind(&path).unwrap();
+        let lines = vec![WindowLine {
+            id: 7,
+            app_id: "org.brahman.shuma".into(),
+            title: "shell".into(),
+            workspace: 2,
+            focused: true,
+        }];
+        let expected = lines.clone();
+
+        let srv = thread::spawn(move || loop {
+            if let Some(mut conn) = server.poll() {
+                assert_eq!(conn.read_request().unwrap().unwrap(), CtlRequest::ListWindows);
+                conn.reply(&CtlReply::Windows(lines)).unwrap();
+                return;
+            }
+            thread::yield_now();
+        });
+
+        let reply = send_request(&path, &CtlRequest::ListWindows).unwrap();
+        assert_eq!(reply, CtlReply::Windows(expected));
+        srv.join().unwrap();
+    }
+
+    #[test]
+    fn binding_twice_on_a_live_socket_is_refused() {
+        let path = temp_socket("dup");
+        let _first = CtlServer::bind(&path).unwrap();
+        // El primero sigue vivo: el segundo debe rechazarse.
+        assert!(CtlServer::bind(&path).is_err());
+    }
+}
diff --git a/crates/modules/mirada/mirada-brain/src/desktop.rs b/crates/modules/mirada/mirada-brain/src/desktop.rs
index 67a823a..2d91921 100644
--- a/crates/modules/mirada/mirada-brain/src/desktop.rs
+++ b/crates/modules/mirada/mirada-brain/src/desktop.rs
@@ -149,6 +149,20 @@ impl Desktop {
                 self.workspaces[self.active].focus_prev();
                 self.relayout()
             }
+            DesktopAction::FocusWindow(id) => {
+                // En el escritorio activo basta enfocar; si la ventana
+                // está en otro, saltamos a ese escritorio.
+                if self.workspaces[self.active].focus_window(id) {
+                    return self.relayout();
+                }
+                for n in 0..self.workspaces.len() {
+                    if n != self.active && self.workspaces[n].focus_window(id) {
+                        self.active = n;
+                        return self.relayout();
+                    }
+                }
+                Vec::new()
+            }
             DesktopAction::MoveForward => {
                 self.workspaces[self.active].move_focused_forward();
                 self.relayout()
@@ -245,6 +259,26 @@ impl Desktop {
     pub fn workspace_loads(&self) -> Vec<usize> {
         self.workspaces.iter().map(Workspace::len).collect()
     }
+
+    /// Una vista de todas las ventanas conocidas, en todos los
+    /// escritorios — la base de `mirada-ctl windows` y de una taskbar.
+    pub fn window_lines(&self) -> Vec<crate::ctl::WindowLine> {
+        let mut lines = Vec::new();
+        for (n, ws) in self.workspaces.iter().enumerate() {
+            let ws_focus = ws.focused();
+            for &id in ws.windows() {
+                let info = self.windows.get(&id);
+                lines.push(crate::ctl::WindowLine {
+                    id,
+                    app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
+                    title: info.map(|i| i.title.clone()).unwrap_or_default(),
+                    workspace: n + 1,
+                    focused: n == self.active && ws_focus == Some(id),
+                });
+            }
+        }
+        lines
+    }
 }
 
 /// El siguiente modo en el ciclo de [`DesktopAction::CycleLayout`].
@@ -317,6 +351,48 @@ mod tests {
         assert!(d.on_event(BodyEvent::Keybind("Super+j".into())).is_empty());
     }
 
+    #[test]
+    fn focus_window_addresses_a_specific_window() {
+        let mut d = desktop_with_screen();
+        for id in [1, 2, 3] {
+            open(&mut d, id);
+        }
+        assert_eq!(d.focused_window(), Some(3));
+        d.apply(DesktopAction::FocusWindow(1));
+        assert_eq!(d.focused_window(), Some(1));
+    }
+
+    #[test]
+    fn focus_window_jumps_to_the_workspace_that_holds_it() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        // Manda la 2 al escritorio 3; seguimos en el 1.
+        d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
+        assert_eq!(d.active_index(), 0);
+        // Enfocar la 2 nos lleva a su escritorio.
+        d.apply(DesktopAction::FocusWindow(2));
+        assert_eq!(d.active_index(), 2);
+        assert_eq!(d.focused_window(), Some(2));
+    }
+
+    #[test]
+    fn window_lines_cover_every_window_with_its_workspace() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2);
+        d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); // la 2 al esc. 3
+        let lines = d.window_lines();
+        assert_eq!(lines.len(), 2);
+        let w1 = lines.iter().find(|l| l.id == 1).unwrap();
+        let w2 = lines.iter().find(|l| l.id == 2).unwrap();
+        assert_eq!(w1.workspace, 1);
+        assert_eq!(w2.workspace, 3);
+        // La 1 quedó enfocada en el escritorio activo (el 1).
+        assert!(w1.focused);
+        assert!(!w2.focused);
+    }
+
     #[test]
     fn without_a_screen_nothing_is_placed() {
         let mut d = Desktop::new();
diff --git a/crates/modules/mirada/mirada-brain/src/lib.rs b/crates/modules/mirada/mirada-brain/src/lib.rs
index e549d66..3a6c409 100644
--- a/crates/modules/mirada/mirada-brain/src/lib.rs
+++ b/crates/modules/mirada/mirada-brain/src/lib.rs
@@ -13,14 +13,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.
+//! - [`ctl`] — el API de control externo (`mirada-ctl`, taskbars, scripts).
 
 #![forbid(unsafe_code)]
 
 pub mod action;
+pub mod ctl;
 pub mod desktop;
 pub mod keymap;
 
 pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
+pub use ctl::{CtlConn, CtlReply, CtlRequest, CtlServer, WindowLine};
 pub use desktop::{Desktop, WindowInfo};
 pub use keymap::{Keymap, KeymapError, KeymapWatch};
 
diff --git a/vamos.txt b/vamos.txt
index b08d6e2..779a6c2 100644
--- a/vamos.txt
+++ b/vamos.txt
@@ -1012,5 +1012,20 @@
 
 
 
+  API de acciones — mirada-ctl + HUD interactivo:
+  Toda acción converge en Desktop::apply(DesktopAction); el keymap es sólo un front-end más.
+  mirada-ctl — control externo por CLI (estilo swaymsg/hyprctl):
+  mirada-ctl focus-next          # cambia el foco
+  mirada-ctl focus-window 5      # enfoca una ventana concreta (FocusWindow: salta de escritorio si hace falta)
+  mirada-ctl workspace 3         # va al escritorio 3
+  mirada-ctl windows             # lista las ventanas (id, escritorio, app, título)
+  mirada-ctl actions             # lista las acciones disponibles
+  Socket de control aparte (mirada-brain::ctl: CtlRequest/CtlReply, marco postcard). Lo abre el Cerebro:
+  siempre la app mirada; mirada-compositor sólo en modo embebido.
+  HUD interactivo: en la app mirada, pips de escritorio y ventanas del lienzo son clicables.
+  cargo run -p mirada-brain --example headless-ctl   # Cerebro sin gráficos para probar mirada-ctl
+
+
+