Files
brahman/crates/apps/mirada/src/main.rs
T
sergio be61ddb6eb feat(mirada): pantalla completa real — toggle-fullscreen
ToggleFullscreen (Super+Shift+f) lleva la ventana enfocada a pantalla
completa: cubre toda la salida sin gap, oculta al resto y se lleva el
foco. Distinto del modo Monocle (un modo de teselado): es un estado
por ventana que ignora el layout.

- Workspace.fullscreen: Option<WindowId>; set_fullscreen / fullscreen();
  remove() lo limpia si se cierra esa ventana.
- placements() da a la fullscreen el rect completo y marca al resto
  visible: false. WindowPlacement y BodyOp::Configure llevan
  fullscreen: bool.
- mirada-compositor fija el estado xdg_toplevel::Fullscreen en la
  superficie, para que el cliente lo sepa.
- Cableado en keymap, HUD de mirada y mirada-ctl.

Verificado end-to-end con headless-ctl. mirada-protocol 10->11,
mirada-brain 51->52.

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

553 lines
20 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 abre una ventana 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
//! ```
//!
//! 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 !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),
"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 --------------------------
let mut canvas = div()
.relative()
.w(px(SCREEN_W as f32))
.h(px(SCREEN_H as f32))
.bg(canvas_bg)
.overflow_hidden();
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))
.top(px(p.rect.y as f32))
.w(px(p.rect.w as f32))
.h(px(p.rect.h as f32))
.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);
}