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