feat(mirada): app del Cerebro — ventana GPUI del compositor

Envuelve mirada-brain::Desktop y lo pinta: barra con escritorios + modo
+ foco, lienzo teselado con marco por ventana. Con MIRADA_SOCKET sondea
un Cuerpo por mirada-link; sin él, simulación con ventanas sintéticas y
teclado (n/w/j/k/tab/1-9). cargo build -p mirada limpio.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 21:07:11 +00:00
parent f57c61fe3e
commit 3e335df298
4 changed files with 435 additions and 0 deletions
+403
View File
@@ -0,0 +1,403 @@
//! `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 g c m layout directo
//! j / k foco siguiente/anterior 1..9 ir a escritorio
//! Shift+j / k mueve la enfocada Ctrl+1..9 enviar a escritorio
//! ```
use std::time::Duration;
use gpui::{
div, hsla, prelude::*, px, Context, FocusHandle, IntoElement, KeyDownEvent, Render,
SharedString, Window,
};
use mirada_brain::{
BodyEvent, BrainCommand, Desktop, DesktopAction, LayoutMode, 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,
}
impl Mirada {
fn new(cx: &mut Context<Self>) -> Self {
let mut app = Self {
desktop: Desktop::new(),
placements: Vec::new(),
next_id: 1,
link: connect_body(),
note: SharedString::from("listo"),
focus: cx.focus_handle(),
focused_once: false,
};
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 });
for _ in 0..3 {
app.open_window();
}
app.note = SharedString::from("simulación — sin Cuerpo");
}
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(),
};
if !events.is_empty() {
for ev in events {
app.feed(ev);
}
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);
}
/// 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),
"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)),
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()
}
/// 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",
}
}
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.))
.when(is_active, |d| d.bg(theme.accent))
.when(!is_active && load > 0, |d| d.bg(theme.bg_row_hover))
.text_color(fg)
.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 };
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()
.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("· superficie del Cuerpo ·"),
),
);
}
// --- 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);
}