gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definitions

⎇ 3e335df298a9e05620d80b4d85ffc913ff004f9b crates/apps/mirada/src/main.rs Rust · 403 lines · 14.6 KB History Who touched what raw

feat(mirada): app del Cerebro — ventana GPUI del compositor 3e335df29 · 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 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
28use std::time::Duration;
29
30use gpui::{
31    div, hsla, prelude::*, px, Context, FocusHandle, IntoElement, KeyDownEvent, Render,
32    SharedString, Window,
33};
34use mirada_brain::{
35    BodyEvent, BrainCommand, Desktop, DesktopAction, LayoutMode, WindowId, WindowPlacement,
36};
37use mirada_link::BrainLink;
38use nahual_launcher::launch_app;
39use nahual_theme::Theme;
40
41/// Pantalla virtual del modo simulación — coincide con el lienzo.
42const SCREEN_W: i32 = 1280;
43const SCREEN_H: i32 = 720;
44/// Periodo del sondeo del Cuerpo, en ms (~60 Hz).
45const POLL_MS: u64 = 16;
46
47/// Nombres de app ficticios para las ventanas de simulación.
48const APPS: &[&str] = &[
49    "shuma", "fana", "revista", "cosmobiología", "matilda", "yachay", "barra",
50];
51
52/// El Cerebro: el estado del escritorio + lo último colocado + el cable.
53struct Mirada {
54    desktop: Desktop,
55    /// Geometría vigente — lo que se pinta. Es la última `Place` emitida.
56    placements: Vec<WindowPlacement>,
57    /// Contador de ids para las ventanas sintéticas.
58    next_id: WindowId,
59    /// Cable al Cuerpo; `None` en simulación.
60    link: Option<BrainLink>,
61    /// Última acción, para la barra de estado.
62    note: SharedString,
63    focus: FocusHandle,
64    focused_once: bool,
65}
66
67impl Mirada {
68    fn new(cx: &mut Context<Self>) -> Self {
69        let mut app = Self {
70            desktop: Desktop::new(),
71            placements: Vec::new(),
72            next_id: 1,
73            link: connect_body(),
74            note: SharedString::from("listo"),
75            focus: cx.focus_handle(),
76            focused_once: false,
77        };
78        if let Some(link) = app.link.as_mut() {
79            // Registra los atajos globales en el Cuerpo.
80            let _ = link.send(&app.desktop.grab_keys());
81            app.note = SharedString::from("Cuerpo conectado");
82            app.start_poll(cx);
83        } else {
84            // Simulación: una pantalla virtual y tres ventanas de muestra.
85            app.feed(BodyEvent::OutputAdded { id: 0, width: SCREEN_W, height: SCREEN_H });
86            for _ in 0..3 {
87                app.open_window();
88            }
89            app.note = SharedString::from("simulación — sin Cuerpo");
90        }
91        app
92    }
93
94    /// Bucle de fondo: drena los eventos del Cuerpo y los procesa.
95    fn start_poll(&self, cx: &mut Context<Self>) {
96        cx.spawn(async move |this, cx| loop {
97            cx.background_executor()
98                .timer(Duration::from_millis(POLL_MS))
99                .await;
100            let alive = this.update(cx, |app, cx| {
101                let events: Vec<BodyEvent> = match app.link.as_ref() {
102                    Some(link) => link.drain(),
103                    None => Vec::new(),
104                };
105                if !events.is_empty() {
106                    for ev in events {
107                        app.feed(ev);
108                    }
109                    cx.notify();
110                }
111            });
112            if alive.is_err() {
113                break; // ventana cerrada
114            }
115        })
116        .detach();
117    }
118
119    /// Abre una ventana sintética (sólo tiene sentido en simulación).
120    fn open_window(&mut self) {
121        let id = self.next_id;
122        self.next_id += 1;
123        let app = APPS[(id as usize) % APPS.len()];
124        self.feed(BodyEvent::WindowOpened {
125            id,
126            app_id: format!("org.brahman.{app}"),
127            title: format!("{app} · ventana {id}"),
128        });
129        self.note = SharedString::from(format!("abierta ventana {id}"));
130    }
131
132    /// Inyecta un evento del Cuerpo en el `Desktop` y despacha la salida.
133    fn feed(&mut self, event: BodyEvent) {
134        let cmds = self.desktop.on_event(event);
135        self.dispatch(cmds);
136    }
137
138    /// Aplica una acción de escritorio (desde una tecla de esta ventana).
139    fn act(&mut self, action: DesktopAction) {
140        let cmds = self.desktop.apply(action);
141        self.dispatch(cmds);
142    }
143
144    /// Reparte los comandos del Cerebro: actualiza lo pintado y, o bien
145    /// los manda al Cuerpo, o bien —en simulación— cierra las ventanas
146    /// por su cuenta (no hay nadie que devuelva el `WindowClosed`).
147    fn dispatch(&mut self, cmds: Vec<BrainCommand>) {
148        for cmd in &cmds {
149            if let BrainCommand::Place(p) = cmd {
150                self.placements = p.clone();
151            }
152        }
153        match self.link.as_mut() {
154            Some(link) => {
155                for cmd in &cmds {
156                    let _ = link.send(cmd);
157                }
158            }
159            None => {
160                for cmd in cmds {
161                    match cmd {
162                        BrainCommand::Close(id) | BrainCommand::Kill(id) => {
163                            self.feed(BodyEvent::WindowClosed { id });
164                        }
165                        _ => {}
166                    }
167                }
168            }
169        }
170    }
171
172    /// Traduce una tecla de la ventana a una acción de escritorio.
173    fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
174        let ks = &event.keystroke;
175        let ctrl = ks.modifiers.control;
176        let shift = ks.modifiers.shift;
177        let connected = self.link.is_some();
178
179        match ks.key.as_str() {
180            "n" if !connected => self.open_window(),
181            "w" => self.act(DesktopAction::CloseFocused),
182            "j" if shift => self.act(DesktopAction::MoveForward),
183            "k" if shift => self.act(DesktopAction::MoveBackward),
184            "j" => self.act(DesktopAction::FocusNext),
185            "k" => self.act(DesktopAction::FocusPrev),
186            "tab" | "space" => self.act(DesktopAction::CycleLayout),
187            "t" => self.act(DesktopAction::SetLayout(LayoutMode::MasterStack)),
188            "m" => self.act(DesktopAction::SetLayout(LayoutMode::Monocle)),
189            "g" => self.act(DesktopAction::SetLayout(LayoutMode::Grid)),
190            "c" => self.act(DesktopAction::SetLayout(LayoutMode::Columns)),
191            d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0" => {
192                let n = (d.as_bytes()[0] - b'1') as usize;
193                if ctrl {
194                    self.act(DesktopAction::SendToWorkspace(n));
195                } else {
196                    self.act(DesktopAction::SwitchWorkspace(n));
197                }
198            }
199            _ => return,
200        }
201        cx.notify();
202    }
203}
204
205/// Conecta con el Cuerpo si `MIRADA_SOCKET` apunta a un socket vivo.
206fn connect_body() -> Option<BrainLink> {
207    let path = std::env::var("MIRADA_SOCKET").ok()?;
208    BrainLink::connect(&path).ok()
209}
210
211/// Nombre legible de un modo de teselado.
212fn mode_name(m: LayoutMode) -> &'static str {
213    match m {
214        LayoutMode::MasterStack => "maestro + pila",
215        LayoutMode::Monocle => "monóculo",
216        LayoutMode::Grid => "rejilla",
217        LayoutMode::Columns => "columnas",
218    }
219}
220
221impl Render for Mirada {
222    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
223        // El lienzo necesita el foco del teclado desde el primer frame.
224        if !self.focused_once {
225            window.focus(&self.focus);
226            self.focused_once = true;
227        }
228
229        let theme = Theme::global(cx).clone();
230        let win_bg = hsla(220.0 / 360.0, 0.16, 0.13, 1.0);
231        let bar_bg = hsla(220.0 / 360.0, 0.20, 0.09, 1.0);
232        let canvas_bg = hsla(220.0 / 360.0, 0.24, 0.05, 1.0);
233        // Texto legible sobre un fondo de acento.
234        let on_accent = hsla(220.0 / 360.0, 0.24, 0.06, 1.0);
235
236        let active = self.desktop.active_index();
237        let mode = self.desktop.active_workspace().params().mode;
238        let loads = self.desktop.workspace_loads();
239        let focused = self.desktop.focused_window();
240
241        // --- Barra superior: identidad + escritorios + modo ----------
242        let pips = loads.iter().enumerate().map(|(i, &load)| {
243            let is_active = i == active;
244            let fg = if is_active {
245                on_accent
246            } else if load > 0 {
247                theme.fg_text
248            } else {
249                theme.fg_disabled
250            };
251            div()
252                .w(px(24.))
253                .h(px(22.))
254                .flex()
255                .items_center()
256                .justify_center()
257                .rounded(px(4.))
258                .when(is_active, |d| d.bg(theme.accent))
259                .when(!is_active && load > 0, |d| d.bg(theme.bg_row_hover))
260                .text_color(fg)
261                .child(SharedString::from(format!("{}", i + 1)))
262        });
263
264        let focus_label = match focused.and_then(|id| self.desktop.window_info(id)) {
265            Some(info) => info.title.clone(),
266            None => "—".to_string(),
267        };
268
269        let bar = div()
270            .h(px(44.))
271            .flex()
272            .flex_row()
273            .items_center()
274            .gap(px(12.))
275            .px(px(14.))
276            .bg(bar_bg)
277            .text_color(theme.fg_text)
278            .child(div().text_color(theme.accent).child("mirada"))
279            .child(div().text_color(theme.fg_disabled).child("·"))
280            .child(div().flex().flex_row().gap(px(4.)).children(pips))
281            .child(div().text_color(theme.fg_disabled).child("·"))
282            .child(
283                div()
284                    .text_color(theme.fg_muted)
285                    .child(SharedString::from(format!("layout: {}", mode_name(mode)))),
286            )
287            .child(div().flex_1())
288            .child(
289                div()
290                    .text_color(theme.fg_muted)
291                    .child(SharedString::from(format!("foco: {focus_label}"))),
292            );
293
294        // --- Lienzo: el escritorio teselado --------------------------
295        let mut canvas = div()
296            .relative()
297            .w(px(SCREEN_W as f32))
298            .h(px(SCREEN_H as f32))
299            .bg(canvas_bg)
300            .overflow_hidden();
301
302        let visible = self.placements.iter().filter(|p| p.visible).count();
303        if visible == 0 {
304            canvas = canvas.child(
305                div()
306                    .absolute()
307                    .size_full()
308                    .flex()
309                    .items_center()
310                    .justify_center()
311                    .text_color(theme.fg_disabled)
312                    .child("escritorio vacío — pulsa  n  para abrir una ventana"),
313            );
314        }
315        for p in self.placements.iter().filter(|p| p.visible) {
316            let info = self.desktop.window_info(p.id);
317            let title = info
318                .map(|i| i.title.clone())
319                .unwrap_or_else(|| format!("ventana {}", p.id));
320            let app_id = info.map(|i| i.app_id.clone()).unwrap_or_default();
321            let border = if p.focused { theme.accent } else { theme.border };
322            let tb_bg = if p.focused { theme.accent } else { theme.bg_row_hover };
323            let tb_fg = if p.focused { on_accent } else { theme.fg_muted };
324
325            canvas = canvas.child(
326                div()
327                    .absolute()
328                    .left(px(p.rect.x as f32))
329                    .top(px(p.rect.y as f32))
330                    .w(px(p.rect.w as f32))
331                    .h(px(p.rect.h as f32))
332                    .border_2()
333                    .border_color(border)
334                    .bg(win_bg)
335                    .rounded(px(5.))
336                    .overflow_hidden()
337                    .flex()
338                    .flex_col()
339                    .child(
340                        // Barra de título de la ventana.
341                        div()
342                            .h(px(22.))
343                            .flex()
344                            .items_center()
345                            .px(px(8.))
346                            .bg(tb_bg)
347                            .text_color(tb_fg)
348                            .child(SharedString::from(title)),
349                    )
350                    .child(
351                        // Interior: en el compositor real lo compone el
352                        // Cuerpo (zero-copy); aquí es un marcador.
353                        div()
354                            .flex_1()
355                            .flex()
356                            .flex_col()
357                            .items_center()
358                            .justify_center()
359                            .gap(px(4.))
360                            .text_color(theme.fg_disabled)
361                            .child(SharedString::from(app_id))
362                            .child("· superficie del Cuerpo ·"),
363                    ),
364            );
365        }
366
367        // --- Composición ---------------------------------------------
368        div()
369            .track_focus(&self.focus)
370            .key_context("Mirada")
371            .on_key_down(cx.listener(Self::handle_key))
372            .size_full()
373            .flex()
374            .flex_col()
375            .bg(theme.bg_app)
376            .text_color(theme.fg_text)
377            .child(bar)
378            .child(
379                div()
380                    .flex_1()
381                    .flex()
382                    .items_center()
383                    .justify_center()
384                    .bg(theme.bg_app)
385                    .child(canvas),
386            )
387            .child(
388                // Pie: el estado.
389                div()
390                    .h(px(26.))
391                    .flex()
392                    .items_center()
393                    .px(px(14.))
394                    .bg(bar_bg)
395                    .text_color(theme.fg_disabled)
396                    .child(self.note.clone()),
397            )
398    }
399}
400
401fn main() {
402    launch_app("brahman · mirada", (SCREEN_W as f32, (SCREEN_H + 70) as f32), Mirada::new);
403}