gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Sutichaykuna

⎇ be61ddb6eb441193c4374a243fbb00aed47b42de crates/apps/mirada/src/main.rs Rust · 552 siq'ikuna · 20.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

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