gioser

tawasuyu/brahman

Rust 459TOML 177Markdown 49JSON 176207 Sutichaykuna

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 · ruwaq sergio · (5 killa ñawpaq) · tayta f57c61fe3 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 16

crates/apps/mirada/src/main.rs

4 qillqakuna · +435 −0

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);
+}