gioser

tawasuyu/brahman

Rust 334TOML 129Markdown 39JSON 174745 Definitions

⎇ 4719f7c9f950c56872881be910c853d72a8867b0 crates/apps/mirada/src/main.rs Rust · 536 lines · 20.0 KB History Who touched what raw

feat(mirada): ventanas flotantes — toggle-float 4719f7c9f · sergio ·

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}