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) { for cmd in cmds { @@ -493,6 +518,25 @@ fn run() -> Result<(), Box> { 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> { } } + // 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 = 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 = 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 { + 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 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 enfoca la ventana (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 master-stack | monocle | grid | columns\n \ + workspace activa el escritorio n (1..9)\n \ + send-to-workspace 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, /// Vigía del keymap; `None` en simulación o si no hay archivo. keymap_watch: Option, + /// Socket del API de control externo (`mirada-ctl`). + ctl: Option, } 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 = 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`. 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::().is_err()); assert!("workspace:99".parse::().is_err()); assert!("layout:fractal".parse::().is_err()); + assert!("focus-window:abc".parse::().is_err()); assert!("teleport".parse::().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::().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), +} + +/// 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 { + 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 { + 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> { + 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 { + 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 { 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 { + 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 + + +