tawasuyu

tawasuyu/brahman

Rust 467TOML 184Markdown 49JSON 176411 Definiciones

⎇ main crates/apps/mirada/src/main.rs Rust · 607 líneas · 23.0 KB Historia Quién tocó qué crudo

feat(mirada): fullscreen iniciado por el cliente + HUD multi-salida f9c4bf594 · 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 / 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}