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:
Generated
+11
@@ -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"
|
||||
|
||||
@@ -284,6 +284,7 @@ members = [
|
||||
"crates/apps/badu",
|
||||
"crates/apps/matilda",
|
||||
"crates/apps/yachay",
|
||||
"crates/apps/mirada",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user