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