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