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>
3e335df298a9e05620d80b4d85ffc913ff004f9b · by sergio · 2026-05-20T21:07:11Z (5 months ago) · parent f57c61fe3 · browse the tree at this commit
Semantic delta · 16
crates/apps/mirada/src/main.rs
diff --git a/Cargo.lock b/Cargo.lock
index 6a5c778..c4f57db 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7390,6 +7390,17 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "mirada"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "mirada-brain",
+ "mirada-link",
+ "nahual-launcher",
+ "nahual-theme",
+]
+
[[package]]
name = "mirada-brain"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 96c70f1..3dba7f3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -284,6 +284,7 @@ members = [
"crates/apps/badu",
"crates/apps/matilda",
"crates/apps/yachay",
+ "crates/apps/mirada",
]
[workspace.package]
diff --git a/crates/apps/mirada/Cargo.toml b/crates/apps/mirada/Cargo.toml
new file mode 100644
index 0000000..97fad02
--- /dev/null
+++ b/crates/apps/mirada/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "mirada"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada — el Cerebro del compositor: ventana GPUI que tesela el escritorio sobre mirada-brain y manda la geometría al Cuerpo (smithay) por mirada-link. Sin Cuerpo, corre en simulación."
+
+[[bin]]
+name = "mirada"
+path = "src/main.rs"
+
+[dependencies]
+mirada-brain = { path = "../../modules/mirada/mirada-brain" }
+mirada-link = { path = "../../modules/mirada/mirada-link" }
+nahual-theme = { path = "../../modules/nahual/libs/theme" }
+nahual-launcher = { path = "../../modules/nahual/libs/launcher" }
+gpui = { workspace = true }
diff --git a/crates/apps/mirada/src/main.rs b/crates/apps/mirada/src/main.rs
new file mode 100644
index 0000000..61b89c9
--- /dev/null
+++ b/crates/apps/mirada/src/main.rs
@@ -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);
+}