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:
Generated
+7
@@ -7733,6 +7733,13 @@ dependencies = [
|
|||||||
"smithay",
|
"smithay",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mirada-ctl"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"mirada-brain",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mirada-layout"
|
name = "mirada-layout"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ members = [
|
|||||||
"crates/apps/yachay",
|
"crates/apps/yachay",
|
||||||
"crates/apps/mirada",
|
"crates/apps/mirada",
|
||||||
"crates/apps/mirada-compositor",
|
"crates/apps/mirada-compositor",
|
||||||
|
"crates/apps/mirada-ctl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -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
|
combinaciones que el Cerebro le pide (`GrabKeys`) y le devuelve la
|
||||||
pulsada. *Qué significa* cada una lo decide `mirada-brain`. Ver el SDD.
|
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
|
## Qué implementa
|
||||||
|
|
||||||
`wl_compositor`, `xdg_shell` (toplevels y popups), `wl_shm`, `wl_seat`
|
`wl_compositor`, `xdg_shell` (toplevels y popups), `wl_shm`, `wl_seat`
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ use smithay::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use mirada_body::{BodyOp, BodyState};
|
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;
|
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.
|
/// Traduce los comandos del Cerebro a operaciones y las ejecuta.
|
||||||
fn apply_commands(&mut self, cmds: Vec<BrainCommand>) {
|
fn apply_commands(&mut self, cmds: Vec<BrainCommand>) {
|
||||||
for cmd in cmds {
|
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).");
|
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
|
// El backend gráfico va primero. winit abre la ventana del compositor
|
||||||
// dentro de tu sesión gráfica anfitriona, y para encontrarla lee
|
// dentro de tu sesión gráfica anfitriona, y para encontrarla lee
|
||||||
// `WAYLAND_DISPLAY` / `DISPLAY` del entorno. Si publicáramos antes
|
// `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.
|
// 3 · Composición de las superficies en sus rectángulos.
|
||||||
let size = backend.window_size();
|
let size = backend.window_size();
|
||||||
let damage: Rectangle<i32, smithay::utils::Physical> = Rectangle::from_size(size);
|
let damage: Rectangle<i32, smithay::utils::Physical> = Rectangle::from_size(size);
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,17 +24,21 @@
|
|||||||
//! j / k foco siguiente/anterior 1..9 ir a escritorio
|
//! j / k foco siguiente/anterior 1..9 ir a escritorio
|
||||||
//! Shift+j / k mueve la enfocada Ctrl+1..9 enviar 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::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, hsla, prelude::*, px, Context, FocusHandle, IntoElement, KeyDownEvent, Render,
|
div, hsla, prelude::*, px, Context, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
|
||||||
SharedString, Window,
|
Render, SharedString, Window,
|
||||||
};
|
};
|
||||||
use mirada_brain::{
|
use mirada_brain::{
|
||||||
BodyEvent, BrainCommand, Desktop, DesktopAction, Keymap, KeymapWatch, LayoutMode, WindowId,
|
BodyEvent, BrainCommand, CtlConn, CtlReply, CtlRequest, CtlServer, Desktop, DesktopAction,
|
||||||
WindowPlacement,
|
Keymap, KeymapWatch, LayoutMode, WindowId, WindowPlacement,
|
||||||
};
|
};
|
||||||
use mirada_link::BrainLink;
|
use mirada_link::BrainLink;
|
||||||
use nahual_launcher::launch_app;
|
use nahual_launcher::launch_app;
|
||||||
@@ -68,6 +72,8 @@ struct Mirada {
|
|||||||
keymap_path: Option<PathBuf>,
|
keymap_path: Option<PathBuf>,
|
||||||
/// Vigía del keymap; `None` en simulación o si no hay archivo.
|
/// Vigía del keymap; `None` en simulación o si no hay archivo.
|
||||||
keymap_watch: Option<KeymapWatch>,
|
keymap_watch: Option<KeymapWatch>,
|
||||||
|
/// Socket del API de control externo (`mirada-ctl`).
|
||||||
|
ctl: Option<CtlServer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Mirada {
|
impl Mirada {
|
||||||
@@ -87,6 +93,15 @@ impl Mirada {
|
|||||||
} else {
|
} else {
|
||||||
None
|
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 {
|
let mut app = Self {
|
||||||
desktop: Desktop::with_keymap(keymap),
|
desktop: Desktop::with_keymap(keymap),
|
||||||
@@ -98,12 +113,12 @@ impl Mirada {
|
|||||||
focused_once: false,
|
focused_once: false,
|
||||||
keymap_path,
|
keymap_path,
|
||||||
keymap_watch,
|
keymap_watch,
|
||||||
|
ctl,
|
||||||
};
|
};
|
||||||
if let Some(link) = app.link.as_mut() {
|
if let Some(link) = app.link.as_mut() {
|
||||||
// Registra los atajos globales en el Cuerpo.
|
// Registra los atajos globales en el Cuerpo.
|
||||||
let _ = link.send(&app.desktop.grab_keys());
|
let _ = link.send(&app.desktop.grab_keys());
|
||||||
app.note = SharedString::from("Cuerpo conectado");
|
app.note = SharedString::from("Cuerpo conectado");
|
||||||
app.start_poll(cx);
|
|
||||||
} else {
|
} else {
|
||||||
// Simulación: una pantalla virtual y tres ventanas de muestra.
|
// Simulación: una pantalla virtual y tres ventanas de muestra.
|
||||||
app.feed(BodyEvent::OutputAdded { id: 0, width: SCREEN_W, height: SCREEN_H });
|
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");
|
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
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +149,11 @@ impl Mirada {
|
|||||||
if keymap_changed {
|
if keymap_changed {
|
||||||
app.reload_keymap();
|
app.reload_keymap();
|
||||||
}
|
}
|
||||||
|
let ctl_served = app.poll_ctl();
|
||||||
for ev in events {
|
for ev in events {
|
||||||
app.feed(ev);
|
app.feed(ev);
|
||||||
}
|
}
|
||||||
if had_events || keymap_changed {
|
if had_events || keymap_changed || ctl_served {
|
||||||
cx.notify();
|
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
|
/// Reparte los comandos del Cerebro: actualiza lo pintado y, o bien
|
||||||
/// los manda al Cuerpo, o bien —en simulación— cierra las ventanas
|
/// los manda al Cuerpo, o bien —en simulación— cierra las ventanas
|
||||||
/// por su cuenta (no hay nadie que devuelva el `WindowClosed`).
|
/// por su cuenta (no hay nadie que devuelva el `WindowClosed`).
|
||||||
@@ -299,9 +352,17 @@ impl Render for Mirada {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.rounded(px(4.))
|
.rounded(px(4.))
|
||||||
|
.cursor_pointer()
|
||||||
.when(is_active, |d| d.bg(theme.accent))
|
.when(is_active, |d| d.bg(theme.accent))
|
||||||
.when(!is_active && load > 0, |d| d.bg(theme.bg_row_hover))
|
.when(!is_active && load > 0, |d| d.bg(theme.bg_row_hover))
|
||||||
.text_color(fg)
|
.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)))
|
.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 border = if p.focused { theme.accent } else { theme.border };
|
||||||
let tb_bg = if p.focused { theme.accent } else { theme.bg_row_hover };
|
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 tb_fg = if p.focused { on_accent } else { theme.fg_muted };
|
||||||
|
let pid = p.id;
|
||||||
|
|
||||||
canvas = canvas.child(
|
canvas = canvas.child(
|
||||||
div()
|
div()
|
||||||
@@ -378,6 +440,14 @@ impl Render for Mirada {
|
|||||||
.bg(win_bg)
|
.bg(win_bg)
|
||||||
.rounded(px(5.))
|
.rounded(px(5.))
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
|
.cursor_pointer()
|
||||||
|
.on_mouse_down(
|
||||||
|
MouseButton::Left,
|
||||||
|
cx.listener(move |app, _, _, cx| {
|
||||||
|
app.act(DesktopAction::FocusWindow(pid));
|
||||||
|
cx.notify();
|
||||||
|
}),
|
||||||
|
)
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ ejecuta operaciones de geometría".
|
|||||||
| `mirada-body` | lib | Contabilidad del Cuerpo: `BodyState`, traduce comandos a `BodyOp` |
|
| `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` (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-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
|
## Flujo
|
||||||
|
|
||||||
@@ -68,7 +69,11 @@ ejecuta operaciones de geometría".
|
|||||||
- **`mirada` (app)** — envuelve `Desktop` y lo pinta (barra de
|
- **`mirada` (app)** — envuelve `Desktop` y lo pinta (barra de
|
||||||
escritorios + modo + foco, lienzo teselado). Con `MIRADA_SOCKET`
|
escritorios + modo + foco, lienzo teselado). Con `MIRADA_SOCKET`
|
||||||
conecta a un Cuerpo; sin él corre en **simulación** (ventanas
|
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
|
## 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
|
sobre el mismo API `Keymap`). `cargo run -p mirada-brain --example
|
||||||
keymap-default` imprime el archivo por defecto.
|
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
|
## Dependencias
|
||||||
|
|
||||||
- Todos los `lib` con `#![forbid(unsafe_code)]`. Cero Wayland, cero
|
- Todos los `lib` con `#![forbid(unsafe_code)]`. Cero Wayland, cero
|
||||||
@@ -104,8 +131,9 @@ significa* (el mapa, Cerebro)— hace innecesario cualquier candado o
|
|||||||
## Estado
|
## Estado
|
||||||
|
|
||||||
Implementado y verde: `mirada-layout` (22 tests), `mirada-protocol`
|
Implementado y verde: `mirada-layout` (22 tests), `mirada-protocol`
|
||||||
(9), `mirada-brain` (29), `mirada-link` (7), `mirada-body` (13), y la
|
(9), `mirada-brain` (37), `mirada-link` (7), `mirada-body` (13), las
|
||||||
app `mirada` (compila; verificación visual manual).
|
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
|
El **Cuerpo** ya existe: `mirada-compositor` es un compositor Wayland
|
||||||
teselante real sobre `smithay`, con backend `winit` — corre **anidado**
|
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::fmt;
|
||||||
use std::str::FromStr;
|
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`.
|
/// Número de escritorios virtuales que mantiene el `Desktop`.
|
||||||
pub const WORKSPACE_COUNT: usize = 9;
|
pub const WORKSPACE_COUNT: usize = 9;
|
||||||
|
|
||||||
/// Una orden de escritorio de alto nivel.
|
/// 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 {
|
pub enum DesktopAction {
|
||||||
/// Mueve el foco a la ventana siguiente del escritorio activo.
|
/// Mueve el foco a la ventana siguiente del escritorio activo.
|
||||||
FocusNext,
|
FocusNext,
|
||||||
/// Mueve el foco a la ventana anterior.
|
/// Mueve el foco a la ventana anterior.
|
||||||
FocusPrev,
|
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.
|
/// Adelanta la ventana enfocada en el orden de teselado.
|
||||||
MoveForward,
|
MoveForward,
|
||||||
/// Atrasa la ventana enfocada en el orden de teselado.
|
/// Atrasa la ventana enfocada en el orden de teselado.
|
||||||
@@ -69,6 +78,7 @@ impl fmt::Display for DesktopAction {
|
|||||||
match self {
|
match self {
|
||||||
DesktopAction::FocusNext => f.write_str("focus-next"),
|
DesktopAction::FocusNext => f.write_str("focus-next"),
|
||||||
DesktopAction::FocusPrev => f.write_str("focus-prev"),
|
DesktopAction::FocusPrev => f.write_str("focus-prev"),
|
||||||
|
DesktopAction::FocusWindow(id) => write!(f, "focus-window:{id}"),
|
||||||
DesktopAction::MoveForward => f.write_str("move-forward"),
|
DesktopAction::MoveForward => f.write_str("move-forward"),
|
||||||
DesktopAction::MoveBackward => f.write_str("move-backward"),
|
DesktopAction::MoveBackward => f.write_str("move-backward"),
|
||||||
DesktopAction::CloseFocused => f.write_str("close-focused"),
|
DesktopAction::CloseFocused => f.write_str("close-focused"),
|
||||||
@@ -102,6 +112,12 @@ impl FromStr for DesktopAction {
|
|||||||
layout_from_slug(slug)
|
layout_from_slug(slug)
|
||||||
.ok_or_else(|| format!("modo de teselado desconocido: '{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:") {
|
} else if let Some(n) = s.strip_prefix("send-to-workspace:") {
|
||||||
Self::SendToWorkspace(parse_workspace(n)?)
|
Self::SendToWorkspace(parse_workspace(n)?)
|
||||||
} else if let Some(n) = s.strip_prefix("workspace:") {
|
} else if let Some(n) = s.strip_prefix("workspace:") {
|
||||||
@@ -227,6 +243,14 @@ mod tests {
|
|||||||
assert!("workspace:0".parse::<DesktopAction>().is_err());
|
assert!("workspace:0".parse::<DesktopAction>().is_err());
|
||||||
assert!("workspace:99".parse::<DesktopAction>().is_err());
|
assert!("workspace:99".parse::<DesktopAction>().is_err());
|
||||||
assert!("layout:fractal".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());
|
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.workspaces[self.active].focus_prev();
|
||||||
self.relayout()
|
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 => {
|
DesktopAction::MoveForward => {
|
||||||
self.workspaces[self.active].move_focused_forward();
|
self.workspaces[self.active].move_focused_forward();
|
||||||
self.relayout()
|
self.relayout()
|
||||||
@@ -245,6 +259,26 @@ impl Desktop {
|
|||||||
pub fn workspace_loads(&self) -> Vec<usize> {
|
pub fn workspace_loads(&self) -> Vec<usize> {
|
||||||
self.workspaces.iter().map(Workspace::len).collect()
|
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`].
|
/// 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());
|
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]
|
#[test]
|
||||||
fn without_a_screen_nothing_is_placed() {
|
fn without_a_screen_nothing_is_placed() {
|
||||||
let mut d = Desktop::new();
|
let mut d = Desktop::new();
|
||||||
|
|||||||
@@ -13,14 +13,17 @@
|
|||||||
//! - [`action`] — las acciones de escritorio y el mapa de teclas.
|
//! - [`action`] — las acciones de escritorio y el mapa de teclas.
|
||||||
//! - [`desktop`] — el [`Desktop`]: el estado y el bucle `evento → comandos`.
|
//! - [`desktop`] — el [`Desktop`]: el estado y el bucle `evento → comandos`.
|
||||||
//! - [`keymap`] — el [`Keymap`] configurable en RON, recargable en caliente.
|
//! - [`keymap`] — el [`Keymap`] configurable en RON, recargable en caliente.
|
||||||
|
//! - [`ctl`] — el API de control externo (`mirada-ctl`, taskbars, scripts).
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
pub mod action;
|
pub mod action;
|
||||||
|
pub mod ctl;
|
||||||
pub mod desktop;
|
pub mod desktop;
|
||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
|
|
||||||
pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
|
pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
|
||||||
|
pub use ctl::{CtlConn, CtlReply, CtlRequest, CtlServer, WindowLine};
|
||||||
pub use desktop::{Desktop, WindowInfo};
|
pub use desktop::{Desktop, WindowInfo};
|
||||||
pub use keymap::{Keymap, KeymapError, KeymapWatch};
|
pub use keymap::{Keymap, KeymapError, KeymapWatch};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user