feat(mirada): fullscreen iniciado por el cliente + HUD multi-salida
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 / Shift+n abre ventana / monitor tab / espacio cicla layout
23//! w cierra la enfocada t m g c r d s layout directo
24//! f / Shift+f flota / pantalla completa h / l área maestra −/+
25//! j / k foco siguiente/anterior , / . nmaster −/+
26//! Shift+j / k mueve la enfocada 1..9 ir a escritorio
27//! Enter promueve a maestra Ctrl+1..9 enviar a escritorio
28//! o siguiente monitor ` / Shift+` scratchpad ver/guardar
29//! ```
30//!
31//! Los pips de escritorio y las ventanas del lienzo son **clicables**, y
32//! `mirada-ctl` controla el escritorio desde la terminal — ambos pasan
33//! por el mismo `Desktop::apply`.
34
35use std::path::PathBuf;
36use std::time::Duration;
37
38use gpui::{
39 div, hsla, prelude::*, px, Context, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
40 Render, SharedString, Window,
41};
42use mirada_brain::{
43 BodyEvent, BrainCommand, CtlConn, CtlReply, CtlRequest, CtlServer, Desktop, DesktopAction,
44 Keymap, KeymapWatch, LayoutMode, Rules, WindowId, WindowPlacement,
45};
46use mirada_link::BrainLink;
47use nahual_launcher::launch_app;
48use nahual_theme::Theme;
49
50/// Pantalla virtual del modo simulación — coincide con el lienzo.
51const SCREEN_W: i32 = 1280;
52const SCREEN_H: i32 = 720;
53/// Periodo del sondeo del Cuerpo, en ms (~60 Hz).
54const POLL_MS: u64 = 16;
55
56/// Nombres de app ficticios para las ventanas de simulación.
57const APPS: &[&str] = &[
58 "shuma", "fana", "revista", "cosmobiología", "matilda", "yachay", "barra",
59];
60
61/// El Cerebro: el estado del escritorio + lo último colocado + el cable.
62struct Mirada {
63 desktop: Desktop,
64 /// Geometría vigente — lo que se pinta. Es la última `Place` emitida.
65 placements: Vec<WindowPlacement>,
66 /// Contador de ids para las ventanas sintéticas.
67 next_id: WindowId,
68 /// Cable al Cuerpo; `None` en simulación.
69 link: Option<BrainLink>,
70 /// Última acción, para la barra de estado.
71 note: SharedString,
72 focus: FocusHandle,
73 focused_once: bool,
74 /// Ruta del keymap del usuario, para recargarlo en caliente.
75 keymap_path: Option<PathBuf>,
76 /// Vigía del keymap; `None` en simulación o si no hay archivo.
77 keymap_watch: Option<KeymapWatch>,
78 /// Socket del API de control externo (`mirada-ctl`).
79 ctl: Option<CtlServer>,
80}
81
82impl Mirada {
83 fn new(cx: &mut Context<Self>) -> Self {
84 // Keymap del usuario (~/.config/mirada/keymap.ron): define los
85 // atajos que el Cuerpo intercepta y nos devuelve como `Keybind`.
86 let keymap_path = Keymap::default_path();
87 let keymap = match &keymap_path {
88 Some(p) => Keymap::load_or_init(p),
89 None => Keymap::default(),
90 };
91 let link = connect_body();
92 // Vigilar el keymap sólo tiene sentido con un Cuerpo conectado;
93 // en simulación, mirada usa las teclas de su propia ventana.
94 let keymap_watch = if link.is_some() {
95 keymap_path.as_deref().and_then(|p| Keymap::watch(p).ok())
96 } else {
97 None
98 };
99 // API de control: mirada siempre posee el Desktop, así que
100 // siempre abre el socket de `mirada-ctl`.
101 let ctl = match CtlServer::bind(&mirada_brain::ctl::default_socket_path()) {
102 Ok(s) => Some(s),
103 Err(e) => {
104 eprintln!("mirada · sin API de control: {e}");
105 None
106 }
107 };
108
109 // Reglas de ventana (~/.config/mirada/rules.ron): a qué
110 // escritorio va cada ventana, si flota.
111 let mut desktop = Desktop::with_keymap(keymap);
112 desktop.set_rules(load_user_rules());
113
114 let mut app = Self {
115 desktop,
116 placements: Vec::new(),
117 next_id: 1,
118 link,
119 note: SharedString::from("listo"),
120 focus: cx.focus_handle(),
121 focused_once: false,
122 keymap_path,
123 keymap_watch,
124 ctl,
125 };
126 if let Some(link) = app.link.as_mut() {
127 // Registra los atajos globales en el Cuerpo.
128 let _ = link.send(&app.desktop.grab_keys());
129 app.note = SharedString::from("Cuerpo conectado");
130 } else {
131 // Simulación: una pantalla virtual y tres ventanas de muestra.
132 app.feed(BodyEvent::OutputAdded { id: 0, width: SCREEN_W, height: SCREEN_H });
133 for _ in 0..3 {
134 app.open_window();
135 }
136 app.note = SharedString::from("simulación — sin Cuerpo");
137 }
138 // El sondeo corre siempre: drena el Cuerpo (si lo hay), vigila el
139 // keymap y atiende `mirada-ctl`.
140 app.start_poll(cx);
141 app
142 }
143
144 /// Bucle de fondo: drena los eventos del Cuerpo y los procesa.
145 fn start_poll(&self, cx: &mut Context<Self>) {
146 cx.spawn(async move |this, cx| loop {
147 cx.background_executor()
148 .timer(Duration::from_millis(POLL_MS))
149 .await;
150 let alive = this.update(cx, |app, cx| {
151 let events: Vec<BodyEvent> = match app.link.as_ref() {
152 Some(link) => link.drain(),
153 None => Vec::new(),
154 };
155 let had_events = !events.is_empty();
156 let keymap_changed = app.keymap_watch.as_ref().is_some_and(|w| w.changed());
157 if keymap_changed {
158 app.reload_keymap();
159 }
160 let ctl_served = app.poll_ctl();
161 for ev in events {
162 app.feed(ev);
163 }
164 if had_events || keymap_changed || ctl_served {
165 cx.notify();
166 }
167 });
168 if alive.is_err() {
169 break; // ventana cerrada
170 }
171 })
172 .detach();
173 }
174
175 /// Abre una ventana sintética (sólo tiene sentido en simulación).
176 fn open_window(&mut self) {
177 let id = self.next_id;
178 self.next_id += 1;
179 let app = APPS[(id as usize) % APPS.len()];
180 self.feed(BodyEvent::WindowOpened {
181 id,
182 app_id: format!("org.brahman.{app}"),
183 title: format!("{app} · ventana {id}"),
184 });
185 self.note = SharedString::from(format!("abierta ventana {id}"));
186 }
187
188 /// Inyecta un evento del Cuerpo en el `Desktop` y despacha la salida.
189 fn feed(&mut self, event: BodyEvent) {
190 let cmds = self.desktop.on_event(event);
191 self.dispatch(cmds);
192 }
193
194 /// Aplica una acción de escritorio (desde una tecla de esta ventana).
195 fn act(&mut self, action: DesktopAction) {
196 let cmds = self.desktop.apply(action);
197 self.dispatch(cmds);
198 }
199
200 /// Recarga el keymap del disco y re-registra los atajos en el Cuerpo.
201 fn reload_keymap(&mut self) {
202 let Some(path) = self.keymap_path.clone() else {
203 return;
204 };
205 match Keymap::load(&path) {
206 Ok(km) => {
207 let cmd = self.desktop.set_keymap(km);
208 self.dispatch(vec![cmd]);
209 self.note = SharedString::from("keymap recargado");
210 }
211 Err(e) => self.note = SharedString::from(format!("keymap inválido: {e}")),
212 }
213 }
214
215 /// Atiende las peticiones pendientes del API de control. Devuelve
216 /// `true` si sirvió alguna (para repintar).
217 fn poll_ctl(&mut self) -> bool {
218 let conns: Vec<CtlConn> = match &self.ctl {
219 Some(ctl) => std::iter::from_fn(|| ctl.poll()).collect(),
220 None => return false,
221 };
222 let mut served = false;
223 for mut conn in conns {
224 let reply = match conn.read_request() {
225 Ok(Some(req)) => {
226 served = true;
227 self.serve_ctl(req)
228 }
229 Ok(None) => continue,
230 Err(e) => CtlReply::Error(format!("{e}")),
231 };
232 let _ = conn.reply(&reply);
233 }
234 served
235 }
236
237 /// Resuelve una petición de control: la acción pasa por el mismo
238 /// `apply` que el teclado; la consulta lee el `Desktop`.
239 fn serve_ctl(&mut self, req: CtlRequest) -> CtlReply {
240 match req {
241 CtlRequest::Do(action) => {
242 self.act(action);
243 CtlReply::Ok
244 }
245 CtlRequest::ListWindows => CtlReply::Windows(self.desktop.window_lines()),
246 }
247 }
248
249 /// Reparte los comandos del Cerebro: actualiza lo pintado y, o bien
250 /// los manda al Cuerpo, o bien —en simulación— cierra las ventanas
251 /// por su cuenta (no hay nadie que devuelva el `WindowClosed`).
252 fn dispatch(&mut self, cmds: Vec<BrainCommand>) {
253 for cmd in &cmds {
254 if let BrainCommand::Place(p) = cmd {
255 self.placements = p.clone();
256 }
257 }
258 match self.link.as_mut() {
259 Some(link) => {
260 for cmd in &cmds {
261 let _ = link.send(cmd);
262 }
263 }
264 None => {
265 for cmd in cmds {
266 match cmd {
267 BrainCommand::Close(id) | BrainCommand::Kill(id) => {
268 self.feed(BodyEvent::WindowClosed { id });
269 }
270 _ => {}
271 }
272 }
273 }
274 }
275 }
276
277 /// Traduce una tecla de la ventana a una acción de escritorio.
278 fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
279 let ks = &event.keystroke;
280 let ctrl = ks.modifiers.control;
281 let shift = ks.modifiers.shift;
282 let connected = self.link.is_some();
283
284 match ks.key.as_str() {
285 "n" if shift && !connected => {
286 // Simulación: añade un monitor más, en fila a la derecha.
287 let id = self.desktop.outputs().len() as u32;
288 self.feed(BodyEvent::OutputAdded { id, width: SCREEN_W, height: SCREEN_H });
289 }
290 "n" if !connected => self.open_window(),
291 "w" => self.act(DesktopAction::CloseFocused),
292 "f" if shift => self.act(DesktopAction::ToggleFullscreen),
293 "f" => self.act(DesktopAction::ToggleFloat),
294 "j" if shift => self.act(DesktopAction::MoveForward),
295 "k" if shift => self.act(DesktopAction::MoveBackward),
296 "j" => self.act(DesktopAction::FocusNext),
297 "k" => self.act(DesktopAction::FocusPrev),
298 "tab" | "space" => self.act(DesktopAction::CycleLayout),
299 "t" => self.act(DesktopAction::SetLayout(LayoutMode::MasterStack)),
300 "m" => self.act(DesktopAction::SetLayout(LayoutMode::Monocle)),
301 "g" => self.act(DesktopAction::SetLayout(LayoutMode::Grid)),
302 "c" => self.act(DesktopAction::SetLayout(LayoutMode::Columns)),
303 "r" => self.act(DesktopAction::SetLayout(LayoutMode::Rows)),
304 "d" => self.act(DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
305 "s" => self.act(DesktopAction::SetLayout(LayoutMode::Spiral)),
306 "h" => self.act(DesktopAction::ShrinkMaster),
307 "l" => self.act(DesktopAction::GrowMaster),
308 "o" => self.act(DesktopAction::FocusOutputNext),
309 "`" if shift => self.act(DesktopAction::SendToScratchpad),
310 "`" => self.act(DesktopAction::ToggleScratchpad),
311 "enter" => self.act(DesktopAction::PromoteToMaster),
312 "," => self.act(DesktopAction::IncMaster),
313 "." => self.act(DesktopAction::DecMaster),
314 d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0" => {
315 let n = (d.as_bytes()[0] - b'1') as usize;
316 if ctrl {
317 self.act(DesktopAction::SendToWorkspace(n));
318 } else {
319 self.act(DesktopAction::SwitchWorkspace(n));
320 }
321 }
322 _ => return,
323 }
324 cx.notify();
325 }
326}
327
328/// Conecta con el Cuerpo si `MIRADA_SOCKET` apunta a un socket vivo.
329fn connect_body() -> Option<BrainLink> {
330 let path = std::env::var("MIRADA_SOCKET").ok()?;
331 BrainLink::connect(&path).ok()
332}
333
334/// Carga las reglas de ventana del usuario, o ninguna si no hay archivo.
335fn load_user_rules() -> Rules {
336 match Rules::default_path() {
337 Some(p) => Rules::load_or_default(&p),
338 None => Rules::default(),
339 }
340}
341
342/// Nombre legible de un modo de teselado.
343fn mode_name(m: LayoutMode) -> &'static str {
344 match m {
345 LayoutMode::MasterStack => "maestro + pila",
346 LayoutMode::Monocle => "monóculo",
347 LayoutMode::Grid => "rejilla",
348 LayoutMode::Columns => "columnas",
349 LayoutMode::Rows => "filas",
350 LayoutMode::CenteredMaster => "maestro centrado",
351 LayoutMode::Spiral => "espiral",
352 }
353}
354
355impl Render for Mirada {
356 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
357 // El lienzo necesita el foco del teclado desde el primer frame.
358 if !self.focused_once {
359 window.focus(&self.focus);
360 self.focused_once = true;
361 }
362
363 let theme = Theme::global(cx).clone();
364 let win_bg = hsla(220.0 / 360.0, 0.16, 0.13, 1.0);
365 let bar_bg = hsla(220.0 / 360.0, 0.20, 0.09, 1.0);
366 let canvas_bg = hsla(220.0 / 360.0, 0.24, 0.05, 1.0);
367 // Texto legible sobre un fondo de acento.
368 let on_accent = hsla(220.0 / 360.0, 0.24, 0.06, 1.0);
369
370 let active = self.desktop.active_index();
371 let mode = self.desktop.active_workspace().params().mode;
372 let loads = self.desktop.workspace_loads();
373 let focused = self.desktop.focused_window();
374
375 // --- Barra superior: identidad + escritorios + modo ----------
376 let pips = loads.iter().enumerate().map(|(i, &load)| {
377 let is_active = i == active;
378 let fg = if is_active {
379 on_accent
380 } else if load > 0 {
381 theme.fg_text
382 } else {
383 theme.fg_disabled
384 };
385 div()
386 .w(px(24.))
387 .h(px(22.))
388 .flex()
389 .items_center()
390 .justify_center()
391 .rounded(px(4.))
392 .cursor_pointer()
393 .when(is_active, |d| d.bg(theme.accent))
394 .when(!is_active && load > 0, |d| d.bg(theme.bg_row_hover))
395 .text_color(fg)
396 .on_mouse_down(
397 MouseButton::Left,
398 cx.listener(move |app, _, _, cx| {
399 app.act(DesktopAction::SwitchWorkspace(i));
400 cx.notify();
401 }),
402 )
403 .child(SharedString::from(format!("{}", i + 1)))
404 });
405
406 let focus_label = match focused.and_then(|id| self.desktop.window_info(id)) {
407 Some(info) => info.title.clone(),
408 None => "—".to_string(),
409 };
410
411 let bar = div()
412 .h(px(44.))
413 .flex()
414 .flex_row()
415 .items_center()
416 .gap(px(12.))
417 .px(px(14.))
418 .bg(bar_bg)
419 .text_color(theme.fg_text)
420 .child(div().text_color(theme.accent).child("mirada"))
421 .child(div().text_color(theme.fg_disabled).child("·"))
422 .child(div().flex().flex_row().gap(px(4.)).children(pips))
423 .child(div().text_color(theme.fg_disabled).child("·"))
424 .child(
425 div()
426 .text_color(theme.fg_muted)
427 .child(SharedString::from(format!("layout: {}", mode_name(mode)))),
428 )
429 .child(div().flex_1())
430 .child(
431 div()
432 .text_color(theme.fg_muted)
433 .child(SharedString::from(format!("foco: {focus_label}"))),
434 );
435
436 // --- Lienzo: el escritorio teselado, a escala ----------------
437 // El lienzo es de tamaño fijo; el contenido vive en el espacio
438 // global de las salidas. `scale` encaja ese espacio en el lienzo
439 // — con una sola salida, escala 1:1.
440 let outs = self.desktop.outputs();
441 let (bb_w, bb_h) = if outs.is_empty() {
442 (SCREEN_W as f32, SCREEN_H as f32)
443 } else {
444 let w = outs.iter().map(|o| o.rect.x + o.rect.w).max().unwrap_or(SCREEN_W);
445 let h = outs.iter().map(|o| o.rect.y + o.rect.h).max().unwrap_or(SCREEN_H);
446 (w as f32, h as f32)
447 };
448 let scale = (SCREEN_W as f32 / bb_w)
449 .min(SCREEN_H as f32 / bb_h)
450 .min(1.0);
451
452 let mut canvas = div()
453 .relative()
454 .w(px(SCREEN_W as f32))
455 .h(px(SCREEN_H as f32))
456 .bg(canvas_bg)
457 .overflow_hidden();
458
459 // Un marco por salida, con su número y el escritorio que muestra.
460 for (i, o) in outs.iter().enumerate() {
461 let is_focused_out = i == self.desktop.focused_output();
462 canvas = canvas.child(
463 div()
464 .absolute()
465 .left(px(o.rect.x as f32 * scale))
466 .top(px(o.rect.y as f32 * scale))
467 .w(px(o.rect.w as f32 * scale))
468 .h(px(o.rect.h as f32 * scale))
469 .border_1()
470 .border_color(if is_focused_out {
471 theme.accent
472 } else {
473 theme.border
474 })
475 .child(
476 div()
477 .absolute()
478 .top(px(2.))
479 .left(px(4.))
480 .text_color(theme.fg_disabled)
481 .child(SharedString::from(format!(
482 "salida {} · escritorio {}",
483 i + 1,
484 o.workspace + 1
485 ))),
486 ),
487 );
488 }
489
490 let visible = self.placements.iter().filter(|p| p.visible).count();
491 if visible == 0 {
492 canvas = canvas.child(
493 div()
494 .absolute()
495 .size_full()
496 .flex()
497 .items_center()
498 .justify_center()
499 .text_color(theme.fg_disabled)
500 .child("escritorio vacío — pulsa n para abrir una ventana"),
501 );
502 }
503 for p in self.placements.iter().filter(|p| p.visible) {
504 let info = self.desktop.window_info(p.id);
505 let title = info
506 .map(|i| i.title.clone())
507 .unwrap_or_else(|| format!("ventana {}", p.id));
508 let app_id = info.map(|i| i.app_id.clone()).unwrap_or_default();
509 let border = if p.focused { theme.accent } else { theme.border };
510 let tb_bg = if p.focused { theme.accent } else { theme.bg_row_hover };
511 let tb_fg = if p.focused { on_accent } else { theme.fg_muted };
512 let pid = p.id;
513 let kind_label = if p.fullscreen {
514 "· pantalla completa ·"
515 } else if p.floating {
516 "· ventana flotante ·"
517 } else {
518 "· superficie del Cuerpo ·"
519 };
520
521 canvas = canvas.child(
522 div()
523 .absolute()
524 .left(px(p.rect.x as f32 * scale))
525 .top(px(p.rect.y as f32 * scale))
526 .w(px(p.rect.w as f32 * scale))
527 .h(px(p.rect.h as f32 * scale))
528 .border_2()
529 .border_color(border)
530 .bg(win_bg)
531 .rounded(px(5.))
532 .overflow_hidden()
533 .cursor_pointer()
534 .on_mouse_down(
535 MouseButton::Left,
536 cx.listener(move |app, _, _, cx| {
537 app.act(DesktopAction::FocusWindow(pid));
538 cx.notify();
539 }),
540 )
541 .flex()
542 .flex_col()
543 .child(
544 // Barra de título de la ventana.
545 div()
546 .h(px(22.))
547 .flex()
548 .items_center()
549 .px(px(8.))
550 .bg(tb_bg)
551 .text_color(tb_fg)
552 .child(SharedString::from(title)),
553 )
554 .child(
555 // Interior: en el compositor real lo compone el
556 // Cuerpo (zero-copy); aquí es un marcador.
557 div()
558 .flex_1()
559 .flex()
560 .flex_col()
561 .items_center()
562 .justify_center()
563 .gap(px(4.))
564 .text_color(theme.fg_disabled)
565 .child(SharedString::from(app_id))
566 .child(kind_label),
567 ),
568 );
569 }
570
571 // --- Composición ---------------------------------------------
572 div()
573 .track_focus(&self.focus)
574 .key_context("Mirada")
575 .on_key_down(cx.listener(Self::handle_key))
576 .size_full()
577 .flex()
578 .flex_col()
579 .bg(theme.bg_app)
580 .text_color(theme.fg_text)
581 .child(bar)
582 .child(
583 div()
584 .flex_1()
585 .flex()
586 .items_center()
587 .justify_center()
588 .bg(theme.bg_app)
589 .child(canvas),
590 )
591 .child(
592 // Pie: el estado.
593 div()
594 .h(px(26.))
595 .flex()
596 .items_center()
597 .px(px(14.))
598 .bg(bar_bg)
599 .text_color(theme.fg_disabled)
600 .child(self.note.clone()),
601 )
602 }
603}
604
605fn main() {
606 launch_app("brahman · mirada", (SCREEN_W as f32, (SCREEN_H + 70) as f32), Mirada::new);
607}