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
+31 -3
View File
@@ -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**
@@ -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(),
);
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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();
@@ -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};