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
+15
View File
@@ -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`
+57 -1
View File
@@ -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);
+16
View File
@@ -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" }
+126
View File
@@ -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"
);
}
+76 -6
View File
@@ -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(