⎇ 3e335df298a9e05620d80b4d85ffc913ff004f9b
crates/apps/mirada/src/main.rs
History
Who touched what
raw
feat(mirada): app del Cerebro — ventana GPUI del compositor
1//! `mirada` — la ventana del Cerebro del compositor.
2//!
3//! Es el "Cerebro" de la arquitectura carmen hecho app GPUI: envuelve
4//! [`mirada_brain::Desktop`] (toda la lógica de teselado y foco) y lo
5//! pinta. La cadena completa:
6//!
7//! ```text
8//! mirada-layout ─► mirada-protocol ─► mirada-brain ─► [esta ventana]
9//! │
10//! mirada-link ─► mirada-compositor (Cuerpo)
11//! ```
12//!
13//! Con un Cuerpo conectado (variable `MIRADA_SOCKET`) sondea sus
14//! [`BodyEvent`]s y le devuelve [`BrainCommand`]s por el socket. Sin
15//! Cuerpo arranca en **simulación**: las ventanas son sintéticas y el
16//! teclado de esta ventana maneja el escritorio — útil para ver el
17//! motor de teselado sin hardware.
18//!
19//! Teclas (simulación):
20//!
21//! ```text
22//! n abre una ventana tab / espacio cicla layout
23//! w cierra la enfocada t g c m layout directo
24//! j / k foco siguiente/anterior 1..9 ir a escritorio
25//! Shift+j / k mueve la enfocada Ctrl+1..9 enviar a escritorio
26//! ```
27
28use std::time::Duration;
29
30use gpui::{
31 div, hsla, prelude::*, px, Context, FocusHandle, IntoElement, KeyDownEvent, Render,
32 SharedString, Window,
33};
34use mirada_brain::{
35 BodyEvent, BrainCommand, Desktop, DesktopAction, LayoutMode, WindowId, WindowPlacement,
36};
37use mirada_link::BrainLink;
38use nahual_launcher::launch_app;
39use nahual_theme::Theme;
40
41/// Pantalla virtual del modo simulación — coincide con el lienzo.
42const SCREEN_W: i32 = 1280;
43const SCREEN_H: i32 = 720;
44/// Periodo del sondeo del Cuerpo, en ms (~60 Hz).
45const POLL_MS: u64 = 16;
46
47/// Nombres de app ficticios para las ventanas de simulación.
48const APPS: &[&str] = &[
49 "shuma", "fana", "revista", "cosmobiología", "matilda", "yachay", "barra",
50];
51
52/// El Cerebro: el estado del escritorio + lo último colocado + el cable.
53struct Mirada {
54 desktop: Desktop,
55 /// Geometría vigente — lo que se pinta. Es la última `Place` emitida.
56 placements: Vec<WindowPlacement>,
57 /// Contador de ids para las ventanas sintéticas.
58 next_id: WindowId,
59 /// Cable al Cuerpo; `None` en simulación.
60 link: Option<BrainLink>,
61 /// Última acción, para la barra de estado.
62 note: SharedString,
63 focus: FocusHandle,
64 focused_once: bool,
65}
66
67impl Mirada {
68 fn new(cx: &mut Context<Self>) -> Self {
69 let mut app = Self {
70 desktop: Desktop::new(),
71 placements: Vec::new(),
72 next_id: 1,
73 link: connect_body(),
74 note: SharedString::from("listo"),
75 focus: cx.focus_handle(),
76 focused_once: false,
77 };
78 if let Some(link) = app.link.as_mut() {
79 // Registra los atajos globales en el Cuerpo.
80 let _ = link.send(&app.desktop.grab_keys());
81 app.note = SharedString::from("Cuerpo conectado");
82 app.start_poll(cx);
83 } else {
84 // Simulación: una pantalla virtual y tres ventanas de muestra.
85 app.feed(BodyEvent::OutputAdded { id: 0, width: SCREEN_W, height: SCREEN_H });
86 for _ in 0..3 {
87 app.open_window();
88 }
89 app.note = SharedString::from("simulación — sin Cuerpo");
90 }
91 app
92 }
93
94 /// Bucle de fondo: drena los eventos del Cuerpo y los procesa.
95 fn start_poll(&self, cx: &mut Context<Self>) {
96 cx.spawn(async move |this, cx| loop {
97 cx.background_executor()
98 .timer(Duration::from_millis(POLL_MS))
99 .await;
100 let alive = this.update(cx, |app, cx| {
101 let events: Vec<BodyEvent> = match app.link.as_ref() {
102 Some(link) => link.drain(),
103 None => Vec::new(),
104 };
105 if !events.is_empty() {
106 for ev in events {
107 app.feed(ev);
108 }
109 cx.notify();
110 }
111 });
112 if alive.is_err() {
113 break; // ventana cerrada
114 }
115 })
116 .detach();
117 }
118
119 /// Abre una ventana sintética (sólo tiene sentido en simulación).
120 fn open_window(&mut self) {
121 let id = self.next_id;
122 self.next_id += 1;
123 let app = APPS[(id as usize) % APPS.len()];
124 self.feed(BodyEvent::WindowOpened {
125 id,
126 app_id: format!("org.brahman.{app}"),
127 title: format!("{app} · ventana {id}"),
128 });
129 self.note = SharedString::from(format!("abierta ventana {id}"));
130 }
131
132 /// Inyecta un evento del Cuerpo en el `Desktop` y despacha la salida.
133 fn feed(&mut self, event: BodyEvent) {
134 let cmds = self.desktop.on_event(event);
135 self.dispatch(cmds);
136 }
137
138 /// Aplica una acción de escritorio (desde una tecla de esta ventana).
139 fn act(&mut self, action: DesktopAction) {
140 let cmds = self.desktop.apply(action);
141 self.dispatch(cmds);
142 }
143
144 /// Reparte los comandos del Cerebro: actualiza lo pintado y, o bien
145 /// los manda al Cuerpo, o bien —en simulación— cierra las ventanas
146 /// por su cuenta (no hay nadie que devuelva el `WindowClosed`).
147 fn dispatch(&mut self, cmds: Vec<BrainCommand>) {
148 for cmd in &cmds {
149 if let BrainCommand::Place(p) = cmd {
150 self.placements = p.clone();
151 }
152 }
153 match self.link.as_mut() {
154 Some(link) => {
155 for cmd in &cmds {
156 let _ = link.send(cmd);
157 }
158 }
159 None => {
160 for cmd in cmds {
161 match cmd {
162 BrainCommand::Close(id) | BrainCommand::Kill(id) => {
163 self.feed(BodyEvent::WindowClosed { id });
164 }
165 _ => {}
166 }
167 }
168 }
169 }
170 }
171
172 /// Traduce una tecla de la ventana a una acción de escritorio.
173 fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
174 let ks = &event.keystroke;
175 let ctrl = ks.modifiers.control;
176 let shift = ks.modifiers.shift;
177 let connected = self.link.is_some();
178
179 match ks.key.as_str() {
180 "n" if !connected => self.open_window(),
181 "w" => self.act(DesktopAction::CloseFocused),
182 "j" if shift => self.act(DesktopAction::MoveForward),
183 "k" if shift => self.act(DesktopAction::MoveBackward),
184 "j" => self.act(DesktopAction::FocusNext),
185 "k" => self.act(DesktopAction::FocusPrev),
186 "tab" | "space" => self.act(DesktopAction::CycleLayout),
187 "t" => self.act(DesktopAction::SetLayout(LayoutMode::MasterStack)),
188 "m" => self.act(DesktopAction::SetLayout(LayoutMode::Monocle)),
189 "g" => self.act(DesktopAction::SetLayout(LayoutMode::Grid)),
190 "c" => self.act(DesktopAction::SetLayout(LayoutMode::Columns)),
191 d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0" => {
192 let n = (d.as_bytes()[0] - b'1') as usize;
193 if ctrl {
194 self.act(DesktopAction::SendToWorkspace(n));
195 } else {
196 self.act(DesktopAction::SwitchWorkspace(n));
197 }
198 }
199 _ => return,
200 }
201 cx.notify();
202 }
203}
204
205/// Conecta con el Cuerpo si `MIRADA_SOCKET` apunta a un socket vivo.
206fn connect_body() -> Option<BrainLink> {
207 let path = std::env::var("MIRADA_SOCKET").ok()?;
208 BrainLink::connect(&path).ok()
209}
210
211/// Nombre legible de un modo de teselado.
212fn mode_name(m: LayoutMode) -> &'static str {
213 match m {
214 LayoutMode::MasterStack => "maestro + pila",
215 LayoutMode::Monocle => "monóculo",
216 LayoutMode::Grid => "rejilla",
217 LayoutMode::Columns => "columnas",
218 }
219}
220
221impl Render for Mirada {
222 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
223 // El lienzo necesita el foco del teclado desde el primer frame.
224 if !self.focused_once {
225 window.focus(&self.focus);
226 self.focused_once = true;
227 }
228
229 let theme = Theme::global(cx).clone();
230 let win_bg = hsla(220.0 / 360.0, 0.16, 0.13, 1.0);
231 let bar_bg = hsla(220.0 / 360.0, 0.20, 0.09, 1.0);
232 let canvas_bg = hsla(220.0 / 360.0, 0.24, 0.05, 1.0);
233 // Texto legible sobre un fondo de acento.
234 let on_accent = hsla(220.0 / 360.0, 0.24, 0.06, 1.0);
235
236 let active = self.desktop.active_index();
237 let mode = self.desktop.active_workspace().params().mode;
238 let loads = self.desktop.workspace_loads();
239 let focused = self.desktop.focused_window();
240
241 // --- Barra superior: identidad + escritorios + modo ----------
242 let pips = loads.iter().enumerate().map(|(i, &load)| {
243 let is_active = i == active;
244 let fg = if is_active {
245 on_accent
246 } else if load > 0 {
247 theme.fg_text
248 } else {
249 theme.fg_disabled
250 };
251 div()
252 .w(px(24.))
253 .h(px(22.))
254 .flex()
255 .items_center()
256 .justify_center()
257 .rounded(px(4.))
258 .when(is_active, |d| d.bg(theme.accent))
259 .when(!is_active && load > 0, |d| d.bg(theme.bg_row_hover))
260 .text_color(fg)
261 .child(SharedString::from(format!("{}", i + 1)))
262 });
263
264 let focus_label = match focused.and_then(|id| self.desktop.window_info(id)) {
265 Some(info) => info.title.clone(),
266 None => "—".to_string(),
267 };
268
269 let bar = div()
270 .h(px(44.))
271 .flex()
272 .flex_row()
273 .items_center()
274 .gap(px(12.))
275 .px(px(14.))
276 .bg(bar_bg)
277 .text_color(theme.fg_text)
278 .child(div().text_color(theme.accent).child("mirada"))
279 .child(div().text_color(theme.fg_disabled).child("·"))
280 .child(div().flex().flex_row().gap(px(4.)).children(pips))
281 .child(div().text_color(theme.fg_disabled).child("·"))
282 .child(
283 div()
284 .text_color(theme.fg_muted)
285 .child(SharedString::from(format!("layout: {}", mode_name(mode)))),
286 )
287 .child(div().flex_1())
288 .child(
289 div()
290 .text_color(theme.fg_muted)
291 .child(SharedString::from(format!("foco: {focus_label}"))),
292 );
293
294 // --- Lienzo: el escritorio teselado --------------------------
295 let mut canvas = div()
296 .relative()
297 .w(px(SCREEN_W as f32))
298 .h(px(SCREEN_H as f32))
299 .bg(canvas_bg)
300 .overflow_hidden();
301
302 let visible = self.placements.iter().filter(|p| p.visible).count();
303 if visible == 0 {
304 canvas = canvas.child(
305 div()
306 .absolute()
307 .size_full()
308 .flex()
309 .items_center()
310 .justify_center()
311 .text_color(theme.fg_disabled)
312 .child("escritorio vacío — pulsa n para abrir una ventana"),
313 );
314 }
315 for p in self.placements.iter().filter(|p| p.visible) {
316 let info = self.desktop.window_info(p.id);
317 let title = info
318 .map(|i| i.title.clone())
319 .unwrap_or_else(|| format!("ventana {}", p.id));
320 let app_id = info.map(|i| i.app_id.clone()).unwrap_or_default();
321 let border = if p.focused { theme.accent } else { theme.border };
322 let tb_bg = if p.focused { theme.accent } else { theme.bg_row_hover };
323 let tb_fg = if p.focused { on_accent } else { theme.fg_muted };
324
325 canvas = canvas.child(
326 div()
327 .absolute()
328 .left(px(p.rect.x as f32))
329 .top(px(p.rect.y as f32))
330 .w(px(p.rect.w as f32))
331 .h(px(p.rect.h as f32))
332 .border_2()
333 .border_color(border)
334 .bg(win_bg)
335 .rounded(px(5.))
336 .overflow_hidden()
337 .flex()
338 .flex_col()
339 .child(
340 // Barra de título de la ventana.
341 div()
342 .h(px(22.))
343 .flex()
344 .items_center()
345 .px(px(8.))
346 .bg(tb_bg)
347 .text_color(tb_fg)
348 .child(SharedString::from(title)),
349 )
350 .child(
351 // Interior: en el compositor real lo compone el
352 // Cuerpo (zero-copy); aquí es un marcador.
353 div()
354 .flex_1()
355 .flex()
356 .flex_col()
357 .items_center()
358 .justify_center()
359 .gap(px(4.))
360 .text_color(theme.fg_disabled)
361 .child(SharedString::from(app_id))
362 .child("· superficie del Cuerpo ·"),
363 ),
364 );
365 }
366
367 // --- Composición ---------------------------------------------
368 div()
369 .track_focus(&self.focus)
370 .key_context("Mirada")
371 .on_key_down(cx.listener(Self::handle_key))
372 .size_full()
373 .flex()
374 .flex_col()
375 .bg(theme.bg_app)
376 .text_color(theme.fg_text)
377 .child(bar)
378 .child(
379 div()
380 .flex_1()
381 .flex()
382 .items_center()
383 .justify_center()
384 .bg(theme.bg_app)
385 .child(canvas),
386 )
387 .child(
388 // Pie: el estado.
389 div()
390 .h(px(26.))
391 .flex()
392 .items_center()
393 .px(px(14.))
394 .bg(bar_bg)
395 .text_color(theme.fg_disabled)
396 .child(self.note.clone()),
397 )
398 }
399}
400
401fn main() {
402 launch_app("brahman · mirada", (SCREEN_W as f32, (SCREEN_H + 70) as f32), Mirada::new);
403}