Files
brahman/crates/apps/mirada/src/main.rs
T
sergio f9c4bf594e feat(mirada): fullscreen iniciado por el cliente + HUD multi-salida
Dos remates de la tanda WM.

Fullscreen del cliente:
- BodyEvent::FullscreenRequest { id, fullscreen }. mirada-compositor
  implementa XdgShellHandler::fullscreen_request / unfullscreen_request
  y avisa al Cerebro; Desktop::on_event fija el fullscreen en el
  escritorio que tiene la ventana. Así un reproductor o un juego que
  llama a xdg set_fullscreen entra a pantalla completa solo.

HUD multi-salida (app mirada):
- El lienzo dibuja todas las salidas a escala (encaja su caja
  envolvente en el lienzo fijo; con una salida, 1:1), cada una con su
  marco y su número/escritorio. En simulación, Shift+n añade un monitor.

mirada-brain 63->65 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:32:08 +00:00

608 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `mirada` — la ventana del Cerebro del compositor.
//!
//! Es el "Cerebro" de la arquitectura carmen hecho app GPUI: envuelve
//! [`mirada_brain::Desktop`] (toda la lógica de teselado y foco) y lo
//! pinta. La cadena completa:
//!
//! ```text
//! mirada-layout ─► mirada-protocol ─► mirada-brain ─► [esta ventana]
//! │
//! mirada-link ─► mirada-compositor (Cuerpo)
//! ```
//!
//! Con un Cuerpo conectado (variable `MIRADA_SOCKET`) sondea sus
//! [`BodyEvent`]s y le devuelve [`BrainCommand`]s por el socket. Sin
//! Cuerpo arranca en **simulación**: las ventanas son sintéticas y el
//! teclado de esta ventana maneja el escritorio — útil para ver el
//! motor de teselado sin hardware.
//!
//! Teclas (simulación):
//!
//! ```text
//! n / Shift+n abre ventana / monitor tab / espacio cicla layout
//! w cierra la enfocada t m g c r d s layout directo
//! f / Shift+f flota / pantalla completa h / l área maestra /+
//! j / k foco siguiente/anterior , / . nmaster /+
//! Shift+j / k mueve la enfocada 1..9 ir a escritorio
//! Enter promueve a maestra Ctrl+1..9 enviar a escritorio
//! o siguiente monitor ` / Shift+` scratchpad ver/guardar
//! ```
//!
//! 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, MouseButton,
Render, SharedString, Window,
};
use mirada_brain::{
BodyEvent, BrainCommand, CtlConn, CtlReply, CtlRequest, CtlServer, Desktop, DesktopAction,
Keymap, KeymapWatch, LayoutMode, Rules, WindowId, WindowPlacement,
};
use mirada_link::BrainLink;
use nahual_launcher::launch_app;
use nahual_theme::Theme;
/// Pantalla virtual del modo simulación — coincide con el lienzo.
const SCREEN_W: i32 = 1280;
const SCREEN_H: i32 = 720;
/// Periodo del sondeo del Cuerpo, en ms (~60 Hz).
const POLL_MS: u64 = 16;
/// Nombres de app ficticios para las ventanas de simulación.
const APPS: &[&str] = &[
"shuma", "fana", "revista", "cosmobiología", "matilda", "yachay", "barra",
];
/// El Cerebro: el estado del escritorio + lo último colocado + el cable.
struct Mirada {
desktop: Desktop,
/// Geometría vigente — lo que se pinta. Es la última `Place` emitida.
placements: Vec<WindowPlacement>,
/// Contador de ids para las ventanas sintéticas.
next_id: WindowId,
/// Cable al Cuerpo; `None` en simulación.
link: Option<BrainLink>,
/// Última acción, para la barra de estado.
note: SharedString,
focus: FocusHandle,
focused_once: bool,
/// Ruta del keymap del usuario, para recargarlo en caliente.
keymap_path: Option<PathBuf>,
/// Vigía del keymap; `None` en simulación o si no hay archivo.
keymap_watch: Option<KeymapWatch>,
/// Socket del API de control externo (`mirada-ctl`).
ctl: Option<CtlServer>,
}
impl Mirada {
fn new(cx: &mut Context<Self>) -> Self {
// Keymap del usuario (~/.config/mirada/keymap.ron): define los
// atajos que el Cuerpo intercepta y nos devuelve como `Keybind`.
let keymap_path = Keymap::default_path();
let keymap = match &keymap_path {
Some(p) => Keymap::load_or_init(p),
None => Keymap::default(),
};
let link = connect_body();
// Vigilar el keymap sólo tiene sentido con un Cuerpo conectado;
// en simulación, mirada usa las teclas de su propia ventana.
let keymap_watch = if link.is_some() {
keymap_path.as_deref().and_then(|p| Keymap::watch(p).ok())
} 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
}
};
// Reglas de ventana (~/.config/mirada/rules.ron): a qué
// escritorio va cada ventana, si flota.
let mut desktop = Desktop::with_keymap(keymap);
desktop.set_rules(load_user_rules());
let mut app = Self {
desktop,
placements: Vec::new(),
next_id: 1,
link,
note: SharedString::from("listo"),
focus: cx.focus_handle(),
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");
} else {
// Simulación: una pantalla virtual y tres ventanas de muestra.
app.feed(BodyEvent::OutputAdded { id: 0, width: SCREEN_W, height: SCREEN_H });
for _ in 0..3 {
app.open_window();
}
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
}
/// Bucle de fondo: drena los eventos del Cuerpo y los procesa.
fn start_poll(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| loop {
cx.background_executor()
.timer(Duration::from_millis(POLL_MS))
.await;
let alive = this.update(cx, |app, cx| {
let events: Vec<BodyEvent> = match app.link.as_ref() {
Some(link) => link.drain(),
None => Vec::new(),
};
let had_events = !events.is_empty();
let keymap_changed = app.keymap_watch.as_ref().is_some_and(|w| w.changed());
if keymap_changed {
app.reload_keymap();
}
let ctl_served = app.poll_ctl();
for ev in events {
app.feed(ev);
}
if had_events || keymap_changed || ctl_served {
cx.notify();
}
});
if alive.is_err() {
break; // ventana cerrada
}
})
.detach();
}
/// Abre una ventana sintética (sólo tiene sentido en simulación).
fn open_window(&mut self) {
let id = self.next_id;
self.next_id += 1;
let app = APPS[(id as usize) % APPS.len()];
self.feed(BodyEvent::WindowOpened {
id,
app_id: format!("org.brahman.{app}"),
title: format!("{app} · ventana {id}"),
});
self.note = SharedString::from(format!("abierta ventana {id}"));
}
/// Inyecta un evento del Cuerpo en el `Desktop` y despacha la salida.
fn feed(&mut self, event: BodyEvent) {
let cmds = self.desktop.on_event(event);
self.dispatch(cmds);
}
/// Aplica una acción de escritorio (desde una tecla de esta ventana).
fn act(&mut self, action: DesktopAction) {
let cmds = self.desktop.apply(action);
self.dispatch(cmds);
}
/// Recarga el keymap del disco y re-registra los atajos en el Cuerpo.
fn reload_keymap(&mut self) {
let Some(path) = self.keymap_path.clone() else {
return;
};
match Keymap::load(&path) {
Ok(km) => {
let cmd = self.desktop.set_keymap(km);
self.dispatch(vec![cmd]);
self.note = SharedString::from("keymap recargado");
}
Err(e) => self.note = SharedString::from(format!("keymap inválido: {e}")),
}
}
/// Atiende las peticiones pendientes del API de control. Devuelve
/// `true` si sirvió alguna (para repintar).
fn poll_ctl(&mut self) -> bool {
let conns: Vec<CtlConn> = match &self.ctl {
Some(ctl) => std::iter::from_fn(|| ctl.poll()).collect(),
None => return false,
};
let mut served = false;
for mut conn in conns {
let reply = match conn.read_request() {
Ok(Some(req)) => {
served = true;
self.serve_ctl(req)
}
Ok(None) => continue,
Err(e) => CtlReply::Error(format!("{e}")),
};
let _ = conn.reply(&reply);
}
served
}
/// Resuelve una petición de control: la acción pasa por el mismo
/// `apply` que el teclado; la consulta lee el `Desktop`.
fn serve_ctl(&mut self, req: CtlRequest) -> CtlReply {
match req {
CtlRequest::Do(action) => {
self.act(action);
CtlReply::Ok
}
CtlRequest::ListWindows => CtlReply::Windows(self.desktop.window_lines()),
}
}
/// Reparte los comandos del Cerebro: actualiza lo pintado y, o bien
/// los manda al Cuerpo, o bien —en simulación— cierra las ventanas
/// por su cuenta (no hay nadie que devuelva el `WindowClosed`).
fn dispatch(&mut self, cmds: Vec<BrainCommand>) {
for cmd in &cmds {
if let BrainCommand::Place(p) = cmd {
self.placements = p.clone();
}
}
match self.link.as_mut() {
Some(link) => {
for cmd in &cmds {
let _ = link.send(cmd);
}
}
None => {
for cmd in cmds {
match cmd {
BrainCommand::Close(id) | BrainCommand::Kill(id) => {
self.feed(BodyEvent::WindowClosed { id });
}
_ => {}
}
}
}
}
}
/// Traduce una tecla de la ventana a una acción de escritorio.
fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
let ks = &event.keystroke;
let ctrl = ks.modifiers.control;
let shift = ks.modifiers.shift;
let connected = self.link.is_some();
match ks.key.as_str() {
"n" if shift && !connected => {
// Simulación: añade un monitor más, en fila a la derecha.
let id = self.desktop.outputs().len() as u32;
self.feed(BodyEvent::OutputAdded { id, width: SCREEN_W, height: SCREEN_H });
}
"n" if !connected => self.open_window(),
"w" => self.act(DesktopAction::CloseFocused),
"f" if shift => self.act(DesktopAction::ToggleFullscreen),
"f" => self.act(DesktopAction::ToggleFloat),
"j" if shift => self.act(DesktopAction::MoveForward),
"k" if shift => self.act(DesktopAction::MoveBackward),
"j" => self.act(DesktopAction::FocusNext),
"k" => self.act(DesktopAction::FocusPrev),
"tab" | "space" => self.act(DesktopAction::CycleLayout),
"t" => self.act(DesktopAction::SetLayout(LayoutMode::MasterStack)),
"m" => self.act(DesktopAction::SetLayout(LayoutMode::Monocle)),
"g" => self.act(DesktopAction::SetLayout(LayoutMode::Grid)),
"c" => self.act(DesktopAction::SetLayout(LayoutMode::Columns)),
"r" => self.act(DesktopAction::SetLayout(LayoutMode::Rows)),
"d" => self.act(DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
"s" => self.act(DesktopAction::SetLayout(LayoutMode::Spiral)),
"h" => self.act(DesktopAction::ShrinkMaster),
"l" => self.act(DesktopAction::GrowMaster),
"o" => self.act(DesktopAction::FocusOutputNext),
"`" if shift => self.act(DesktopAction::SendToScratchpad),
"`" => self.act(DesktopAction::ToggleScratchpad),
"enter" => self.act(DesktopAction::PromoteToMaster),
"," => self.act(DesktopAction::IncMaster),
"." => self.act(DesktopAction::DecMaster),
d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0" => {
let n = (d.as_bytes()[0] - b'1') as usize;
if ctrl {
self.act(DesktopAction::SendToWorkspace(n));
} else {
self.act(DesktopAction::SwitchWorkspace(n));
}
}
_ => return,
}
cx.notify();
}
}
/// Conecta con el Cuerpo si `MIRADA_SOCKET` apunta a un socket vivo.
fn connect_body() -> Option<BrainLink> {
let path = std::env::var("MIRADA_SOCKET").ok()?;
BrainLink::connect(&path).ok()
}
/// Carga las reglas de ventana del usuario, o ninguna si no hay archivo.
fn load_user_rules() -> Rules {
match Rules::default_path() {
Some(p) => Rules::load_or_default(&p),
None => Rules::default(),
}
}
/// Nombre legible de un modo de teselado.
fn mode_name(m: LayoutMode) -> &'static str {
match m {
LayoutMode::MasterStack => "maestro + pila",
LayoutMode::Monocle => "monóculo",
LayoutMode::Grid => "rejilla",
LayoutMode::Columns => "columnas",
LayoutMode::Rows => "filas",
LayoutMode::CenteredMaster => "maestro centrado",
LayoutMode::Spiral => "espiral",
}
}
impl Render for Mirada {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// El lienzo necesita el foco del teclado desde el primer frame.
if !self.focused_once {
window.focus(&self.focus);
self.focused_once = true;
}
let theme = Theme::global(cx).clone();
let win_bg = hsla(220.0 / 360.0, 0.16, 0.13, 1.0);
let bar_bg = hsla(220.0 / 360.0, 0.20, 0.09, 1.0);
let canvas_bg = hsla(220.0 / 360.0, 0.24, 0.05, 1.0);
// Texto legible sobre un fondo de acento.
let on_accent = hsla(220.0 / 360.0, 0.24, 0.06, 1.0);
let active = self.desktop.active_index();
let mode = self.desktop.active_workspace().params().mode;
let loads = self.desktop.workspace_loads();
let focused = self.desktop.focused_window();
// --- Barra superior: identidad + escritorios + modo ----------
let pips = loads.iter().enumerate().map(|(i, &load)| {
let is_active = i == active;
let fg = if is_active {
on_accent
} else if load > 0 {
theme.fg_text
} else {
theme.fg_disabled
};
div()
.w(px(24.))
.h(px(22.))
.flex()
.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)))
});
let focus_label = match focused.and_then(|id| self.desktop.window_info(id)) {
Some(info) => info.title.clone(),
None => "".to_string(),
};
let bar = div()
.h(px(44.))
.flex()
.flex_row()
.items_center()
.gap(px(12.))
.px(px(14.))
.bg(bar_bg)
.text_color(theme.fg_text)
.child(div().text_color(theme.accent).child("mirada"))
.child(div().text_color(theme.fg_disabled).child("·"))
.child(div().flex().flex_row().gap(px(4.)).children(pips))
.child(div().text_color(theme.fg_disabled).child("·"))
.child(
div()
.text_color(theme.fg_muted)
.child(SharedString::from(format!("layout: {}", mode_name(mode)))),
)
.child(div().flex_1())
.child(
div()
.text_color(theme.fg_muted)
.child(SharedString::from(format!("foco: {focus_label}"))),
);
// --- Lienzo: el escritorio teselado, a escala ----------------
// El lienzo es de tamaño fijo; el contenido vive en el espacio
// global de las salidas. `scale` encaja ese espacio en el lienzo
// — con una sola salida, escala 1:1.
let outs = self.desktop.outputs();
let (bb_w, bb_h) = if outs.is_empty() {
(SCREEN_W as f32, SCREEN_H as f32)
} else {
let w = outs.iter().map(|o| o.rect.x + o.rect.w).max().unwrap_or(SCREEN_W);
let h = outs.iter().map(|o| o.rect.y + o.rect.h).max().unwrap_or(SCREEN_H);
(w as f32, h as f32)
};
let scale = (SCREEN_W as f32 / bb_w)
.min(SCREEN_H as f32 / bb_h)
.min(1.0);
let mut canvas = div()
.relative()
.w(px(SCREEN_W as f32))
.h(px(SCREEN_H as f32))
.bg(canvas_bg)
.overflow_hidden();
// Un marco por salida, con su número y el escritorio que muestra.
for (i, o) in outs.iter().enumerate() {
let is_focused_out = i == self.desktop.focused_output();
canvas = canvas.child(
div()
.absolute()
.left(px(o.rect.x as f32 * scale))
.top(px(o.rect.y as f32 * scale))
.w(px(o.rect.w as f32 * scale))
.h(px(o.rect.h as f32 * scale))
.border_1()
.border_color(if is_focused_out {
theme.accent
} else {
theme.border
})
.child(
div()
.absolute()
.top(px(2.))
.left(px(4.))
.text_color(theme.fg_disabled)
.child(SharedString::from(format!(
"salida {} · escritorio {}",
i + 1,
o.workspace + 1
))),
),
);
}
let visible = self.placements.iter().filter(|p| p.visible).count();
if visible == 0 {
canvas = canvas.child(
div()
.absolute()
.size_full()
.flex()
.items_center()
.justify_center()
.text_color(theme.fg_disabled)
.child("escritorio vacío — pulsa n para abrir una ventana"),
);
}
for p in self.placements.iter().filter(|p| p.visible) {
let info = self.desktop.window_info(p.id);
let title = info
.map(|i| i.title.clone())
.unwrap_or_else(|| format!("ventana {}", p.id));
let app_id = info.map(|i| i.app_id.clone()).unwrap_or_default();
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;
let kind_label = if p.fullscreen {
"· pantalla completa ·"
} else if p.floating {
"· ventana flotante ·"
} else {
"· superficie del Cuerpo ·"
};
canvas = canvas.child(
div()
.absolute()
.left(px(p.rect.x as f32 * scale))
.top(px(p.rect.y as f32 * scale))
.w(px(p.rect.w as f32 * scale))
.h(px(p.rect.h as f32 * scale))
.border_2()
.border_color(border)
.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(
// Barra de título de la ventana.
div()
.h(px(22.))
.flex()
.items_center()
.px(px(8.))
.bg(tb_bg)
.text_color(tb_fg)
.child(SharedString::from(title)),
)
.child(
// Interior: en el compositor real lo compone el
// Cuerpo (zero-copy); aquí es un marcador.
div()
.flex_1()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap(px(4.))
.text_color(theme.fg_disabled)
.child(SharedString::from(app_id))
.child(kind_label),
),
);
}
// --- Composición ---------------------------------------------
div()
.track_focus(&self.focus)
.key_context("Mirada")
.on_key_down(cx.listener(Self::handle_key))
.size_full()
.flex()
.flex_col()
.bg(theme.bg_app)
.text_color(theme.fg_text)
.child(bar)
.child(
div()
.flex_1()
.flex()
.items_center()
.justify_center()
.bg(theme.bg_app)
.child(canvas),
)
.child(
// Pie: el estado.
div()
.h(px(26.))
.flex()
.items_center()
.px(px(14.))
.bg(bar_bg)
.text_color(theme.fg_disabled)
.child(self.note.clone()),
)
}
}
fn main() {
launch_app("brahman · mirada", (SCREEN_W as f32, (SCREEN_H + 70) as f32), Mirada::new);
}