gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

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

feat(mirada): API de acciones — mirada-ctl + HUD interactivo b31f98883 · 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//!
28//! Los pips de escritorio y las ventanas del lienzo son **clicables**, y
29//! `mirada-ctl` controla el escritorio desde la terminal — ambos pasan
30//! por el mismo `Desktop::apply`.
31
32use std::path::PathBuf;
33use std::time::Duration;
34
35use gpui::{
36    div, hsla, prelude::*, px, Context, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
37    Render, SharedString, Window,
38};
39use mirada_brain::{
40    BodyEvent, BrainCommand, CtlConn, CtlReply, CtlRequest, CtlServer, Desktop, DesktopAction,
41    Keymap, KeymapWatch, LayoutMode, WindowId, WindowPlacement,
42};
43use mirada_link::BrainLink;
44use nahual_launcher::launch_app;
45use nahual_theme::Theme;
46
47/// Pantalla virtual del modo simulación — coincide con el lienzo.
48const SCREEN_W: i32 = 1280;
49const SCREEN_H: i32 = 720;
50/// Periodo del sondeo del Cuerpo, en ms (~60 Hz).
51const POLL_MS: u64 = 16;
52
53/// Nombres de app ficticios para las ventanas de simulación.
54const APPS: &[&str] = &[
55    "shuma", "fana", "revista", "cosmobiología", "matilda", "yachay", "barra",
56];
57
58/// El Cerebro: el estado del escritorio + lo último colocado + el cable.
59struct Mirada {
60    desktop: Desktop,
61    /// Geometría vigente — lo que se pinta. Es la última `Place` emitida.
62    placements: Vec<WindowPlacement>,
63    /// Contador de ids para las ventanas sintéticas.
64    next_id: WindowId,
65    /// Cable al Cuerpo; `None` en simulación.
66    link: Option<BrainLink>,
67    /// Última acción, para la barra de estado.
68    note: SharedString,
69    focus: FocusHandle,
70    focused_once: bool,
71    /// Ruta del keymap del usuario, para recargarlo en caliente.
72    keymap_path: Option<PathBuf>,
73    /// Vigía del keymap; `None` en simulación o si no hay archivo.
74    keymap_watch: Option<KeymapWatch>,
75    /// Socket del API de control externo (`mirada-ctl`).
76    ctl: Option<CtlServer>,
77}
78
79impl Mirada {
80    fn new(cx: &mut Context<Self>) -> Self {
81        // Keymap del usuario (~/.config/mirada/keymap.ron): define los
82        // atajos que el Cuerpo intercepta y nos devuelve como `Keybind`.
83        let keymap_path = Keymap::default_path();
84        let keymap = match &keymap_path {
85            Some(p) => Keymap::load_or_init(p),
86            None => Keymap::default(),
87        };
88        let link = connect_body();
89        // Vigilar el keymap sólo tiene sentido con un Cuerpo conectado;
90        // en simulación, mirada usa las teclas de su propia ventana.
91        let keymap_watch = if link.is_some() {
92            keymap_path.as_deref().and_then(|p| Keymap::watch(p).ok())
93        } else {
94            None
95        };
96        // API de control: mirada siempre posee el Desktop, así que
97        // siempre abre el socket de `mirada-ctl`.
98        let ctl = match CtlServer::bind(&mirada_brain::ctl::default_socket_path()) {
99            Ok(s) => Some(s),
100            Err(e) => {
101                eprintln!("mirada · sin API de control: {e}");
102                None
103            }
104        };
105
106        let mut app = Self {
107            desktop: Desktop::with_keymap(keymap),
108            placements: Vec::new(),
109            next_id: 1,
110            link,
111            note: SharedString::from("listo"),
112            focus: cx.focus_handle(),
113            focused_once: false,
114            keymap_path,
115            keymap_watch,
116            ctl,
117        };
118        if let Some(link) = app.link.as_mut() {
119            // Registra los atajos globales en el Cuerpo.
120            let _ = link.send(&app.desktop.grab_keys());
121            app.note = SharedString::from("Cuerpo conectado");
122        } else {
123            // Simulación: una pantalla virtual y tres ventanas de muestra.
124            app.feed(BodyEvent::OutputAdded { id: 0, width: SCREEN_W, height: SCREEN_H });
125            for _ in 0..3 {
126                app.open_window();
127            }
128            app.note = SharedString::from("simulación — sin Cuerpo");
129        }
130        // El sondeo corre siempre: drena el Cuerpo (si lo hay), vigila el
131        // keymap y atiende `mirada-ctl`.
132        app.start_poll(cx);
133        app
134    }
135
136    /// Bucle de fondo: drena los eventos del Cuerpo y los procesa.
137    fn start_poll(&self, cx: &mut Context<Self>) {
138        cx.spawn(async move |this, cx| loop {
139            cx.background_executor()
140                .timer(Duration::from_millis(POLL_MS))
141                .await;
142            let alive = this.update(cx, |app, cx| {
143                let events: Vec<BodyEvent> = match app.link.as_ref() {
144                    Some(link) => link.drain(),
145                    None => Vec::new(),
146                };
147                let had_events = !events.is_empty();
148                let keymap_changed = app.keymap_watch.as_ref().is_some_and(|w| w.changed());
149                if keymap_changed {
150                    app.reload_keymap();
151                }
152                let ctl_served = app.poll_ctl();
153                for ev in events {
154                    app.feed(ev);
155                }
156                if had_events || keymap_changed || ctl_served {
157                    cx.notify();
158                }
159            });
160            if alive.is_err() {
161                break; // ventana cerrada
162            }
163        })
164        .detach();
165    }
166
167    /// Abre una ventana sintética (sólo tiene sentido en simulación).
168    fn open_window(&mut self) {
169        let id = self.next_id;
170        self.next_id += 1;
171        let app = APPS[(id as usize) % APPS.len()];
172        self.feed(BodyEvent::WindowOpened {
173            id,
174            app_id: format!("org.brahman.{app}"),
175            title: format!("{app} · ventana {id}"),
176        });
177        self.note = SharedString::from(format!("abierta ventana {id}"));
178    }
179
180    /// Inyecta un evento del Cuerpo en el `Desktop` y despacha la salida.
181    fn feed(&mut self, event: BodyEvent) {
182        let cmds = self.desktop.on_event(event);
183        self.dispatch(cmds);
184    }
185
186    /// Aplica una acción de escritorio (desde una tecla de esta ventana).
187    fn act(&mut self, action: DesktopAction) {
188        let cmds = self.desktop.apply(action);
189        self.dispatch(cmds);
190    }
191
192    /// Recarga el keymap del disco y re-registra los atajos en el Cuerpo.
193    fn reload_keymap(&mut self) {
194        let Some(path) = self.keymap_path.clone() else {
195            return;
196        };
197        match Keymap::load(&path) {
198            Ok(km) => {
199                let cmd = self.desktop.set_keymap(km);
200                self.dispatch(vec![cmd]);
201                self.note = SharedString::from("keymap recargado");
202            }
203            Err(e) => self.note = SharedString::from(format!("keymap inválido: {e}")),
204        }
205    }
206
207    /// Atiende las peticiones pendientes del API de control. Devuelve
208    /// `true` si sirvió alguna (para repintar).
209    fn poll_ctl(&mut self) -> bool {
210        let conns: Vec<CtlConn> = match &self.ctl {
211            Some(ctl) => std::iter::from_fn(|| ctl.poll()).collect(),
212            None => return false,
213        };
214        let mut served = false;
215        for mut conn in conns {
216            let reply = match conn.read_request() {
217                Ok(Some(req)) => {
218                    served = true;
219                    self.serve_ctl(req)
220                }
221                Ok(None) => continue,
222                Err(e) => CtlReply::Error(format!("{e}")),
223            };
224            let _ = conn.reply(&reply);
225        }
226        served
227    }
228
229    /// Resuelve una petición de control: la acción pasa por el mismo
230    /// `apply` que el teclado; la consulta lee el `Desktop`.
231    fn serve_ctl(&mut self, req: CtlRequest) -> CtlReply {
232        match req {
233            CtlRequest::Do(action) => {
234                self.act(action);
235                CtlReply::Ok
236            }
237            CtlRequest::ListWindows => CtlReply::Windows(self.desktop.window_lines()),
238        }
239    }
240
241    /// Reparte los comandos del Cerebro: actualiza lo pintado y, o bien
242    /// los manda al Cuerpo, o bien —en simulación— cierra las ventanas
243    /// por su cuenta (no hay nadie que devuelva el `WindowClosed`).
244    fn dispatch(&mut self, cmds: Vec<BrainCommand>) {
245        for cmd in &cmds {
246            if let BrainCommand::Place(p) = cmd {
247                self.placements = p.clone();
248            }
249        }
250        match self.link.as_mut() {
251            Some(link) => {
252                for cmd in &cmds {
253                    let _ = link.send(cmd);
254                }
255            }
256            None => {
257                for cmd in cmds {
258                    match cmd {
259                        BrainCommand::Close(id) | BrainCommand::Kill(id) => {
260                            self.feed(BodyEvent::WindowClosed { id });
261                        }
262                        _ => {}
263                    }
264                }
265            }
266        }
267    }
268
269    /// Traduce una tecla de la ventana a una acción de escritorio.
270    fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
271        let ks = &event.keystroke;
272        let ctrl = ks.modifiers.control;
273        let shift = ks.modifiers.shift;
274        let connected = self.link.is_some();
275
276        match ks.key.as_str() {
277            "n" if !connected => self.open_window(),
278            "w" => self.act(DesktopAction::CloseFocused),
279            "j" if shift => self.act(DesktopAction::MoveForward),
280            "k" if shift => self.act(DesktopAction::MoveBackward),
281            "j" => self.act(DesktopAction::FocusNext),
282            "k" => self.act(DesktopAction::FocusPrev),
283            "tab" | "space" => self.act(DesktopAction::CycleLayout),
284            "t" => self.act(DesktopAction::SetLayout(LayoutMode::MasterStack)),
285            "m" => self.act(DesktopAction::SetLayout(LayoutMode::Monocle)),
286            "g" => self.act(DesktopAction::SetLayout(LayoutMode::Grid)),
287            "c" => self.act(DesktopAction::SetLayout(LayoutMode::Columns)),
288            d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0" => {
289                let n = (d.as_bytes()[0] - b'1') as usize;
290                if ctrl {
291                    self.act(DesktopAction::SendToWorkspace(n));
292                } else {
293                    self.act(DesktopAction::SwitchWorkspace(n));
294                }
295            }
296            _ => return,
297        }
298        cx.notify();
299    }
300}
301
302/// Conecta con el Cuerpo si `MIRADA_SOCKET` apunta a un socket vivo.
303fn connect_body() -> Option<BrainLink> {
304    let path = std::env::var("MIRADA_SOCKET").ok()?;
305    BrainLink::connect(&path).ok()
306}
307
308/// Nombre legible de un modo de teselado.
309fn mode_name(m: LayoutMode) -> &'static str {
310    match m {
311        LayoutMode::MasterStack => "maestro + pila",
312        LayoutMode::Monocle => "monóculo",
313        LayoutMode::Grid => "rejilla",
314        LayoutMode::Columns => "columnas",
315    }
316}
317
318impl Render for Mirada {
319    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
320        // El lienzo necesita el foco del teclado desde el primer frame.
321        if !self.focused_once {
322            window.focus(&self.focus);
323            self.focused_once = true;
324        }
325
326        let theme = Theme::global(cx).clone();
327        let win_bg = hsla(220.0 / 360.0, 0.16, 0.13, 1.0);
328        let bar_bg = hsla(220.0 / 360.0, 0.20, 0.09, 1.0);
329        let canvas_bg = hsla(220.0 / 360.0, 0.24, 0.05, 1.0);
330        // Texto legible sobre un fondo de acento.
331        let on_accent = hsla(220.0 / 360.0, 0.24, 0.06, 1.0);
332
333        let active = self.desktop.active_index();
334        let mode = self.desktop.active_workspace().params().mode;
335        let loads = self.desktop.workspace_loads();
336        let focused = self.desktop.focused_window();
337
338        // --- Barra superior: identidad + escritorios + modo ----------
339        let pips = loads.iter().enumerate().map(|(i, &load)| {
340            let is_active = i == active;
341            let fg = if is_active {
342                on_accent
343            } else if load > 0 {
344                theme.fg_text
345            } else {
346                theme.fg_disabled
347            };
348            div()
349                .w(px(24.))
350                .h(px(22.))
351                .flex()
352                .items_center()
353                .justify_center()
354                .rounded(px(4.))
355                .cursor_pointer()
356                .when(is_active, |d| d.bg(theme.accent))
357                .when(!is_active && load > 0, |d| d.bg(theme.bg_row_hover))
358                .text_color(fg)
359                .on_mouse_down(
360                    MouseButton::Left,
361                    cx.listener(move |app, _, _, cx| {
362                        app.act(DesktopAction::SwitchWorkspace(i));
363                        cx.notify();
364                    }),
365                )
366                .child(SharedString::from(format!("{}", i + 1)))
367        });
368
369        let focus_label = match focused.and_then(|id| self.desktop.window_info(id)) {
370            Some(info) => info.title.clone(),
371            None => "—".to_string(),
372        };
373
374        let bar = div()
375            .h(px(44.))
376            .flex()
377            .flex_row()
378            .items_center()
379            .gap(px(12.))
380            .px(px(14.))
381            .bg(bar_bg)
382            .text_color(theme.fg_text)
383            .child(div().text_color(theme.accent).child("mirada"))
384            .child(div().text_color(theme.fg_disabled).child("·"))
385            .child(div().flex().flex_row().gap(px(4.)).children(pips))
386            .child(div().text_color(theme.fg_disabled).child("·"))
387            .child(
388                div()
389                    .text_color(theme.fg_muted)
390                    .child(SharedString::from(format!("layout: {}", mode_name(mode)))),
391            )
392            .child(div().flex_1())
393            .child(
394                div()
395                    .text_color(theme.fg_muted)
396                    .child(SharedString::from(format!("foco: {focus_label}"))),
397            );
398
399        // --- Lienzo: el escritorio teselado --------------------------
400        let mut canvas = div()
401            .relative()
402            .w(px(SCREEN_W as f32))
403            .h(px(SCREEN_H as f32))
404            .bg(canvas_bg)
405            .overflow_hidden();
406
407        let visible = self.placements.iter().filter(|p| p.visible).count();
408        if visible == 0 {
409            canvas = canvas.child(
410                div()
411                    .absolute()
412                    .size_full()
413                    .flex()
414                    .items_center()
415                    .justify_center()
416                    .text_color(theme.fg_disabled)
417                    .child("escritorio vacío — pulsa  n  para abrir una ventana"),
418            );
419        }
420        for p in self.placements.iter().filter(|p| p.visible) {
421            let info = self.desktop.window_info(p.id);
422            let title = info
423                .map(|i| i.title.clone())
424                .unwrap_or_else(|| format!("ventana {}", p.id));
425            let app_id = info.map(|i| i.app_id.clone()).unwrap_or_default();
426            let border = if p.focused { theme.accent } else { theme.border };
427            let tb_bg = if p.focused { theme.accent } else { theme.bg_row_hover };
428            let tb_fg = if p.focused { on_accent } else { theme.fg_muted };
429            let pid = p.id;
430
431            canvas = canvas.child(
432                div()
433                    .absolute()
434                    .left(px(p.rect.x as f32))
435                    .top(px(p.rect.y as f32))
436                    .w(px(p.rect.w as f32))
437                    .h(px(p.rect.h as f32))
438                    .border_2()
439                    .border_color(border)
440                    .bg(win_bg)
441                    .rounded(px(5.))
442                    .overflow_hidden()
443                    .cursor_pointer()
444                    .on_mouse_down(
445                        MouseButton::Left,
446                        cx.listener(move |app, _, _, cx| {
447                            app.act(DesktopAction::FocusWindow(pid));
448                            cx.notify();
449                        }),
450                    )
451                    .flex()
452                    .flex_col()
453                    .child(
454                        // Barra de título de la ventana.
455                        div()
456                            .h(px(22.))
457                            .flex()
458                            .items_center()
459                            .px(px(8.))
460                            .bg(tb_bg)
461                            .text_color(tb_fg)
462                            .child(SharedString::from(title)),
463                    )
464                    .child(
465                        // Interior: en el compositor real lo compone el
466                        // Cuerpo (zero-copy); aquí es un marcador.
467                        div()
468                            .flex_1()
469                            .flex()
470                            .flex_col()
471                            .items_center()
472                            .justify_center()
473                            .gap(px(4.))
474                            .text_color(theme.fg_disabled)
475                            .child(SharedString::from(app_id))
476                            .child("· superficie del Cuerpo ·"),
477                    ),
478            );
479        }
480
481        // --- Composición ---------------------------------------------
482        div()
483            .track_focus(&self.focus)
484            .key_context("Mirada")
485            .on_key_down(cx.listener(Self::handle_key))
486            .size_full()
487            .flex()
488            .flex_col()
489            .bg(theme.bg_app)
490            .text_color(theme.fg_text)
491            .child(bar)
492            .child(
493                div()
494                    .flex_1()
495                    .flex()
496                    .items_center()
497                    .justify_center()
498                    .bg(theme.bg_app)
499                    .child(canvas),
500            )
501            .child(
502                // Pie: el estado.
503                div()
504                    .h(px(26.))
505                    .flex()
506                    .items_center()
507                    .px(px(14.))
508                    .bg(bar_bg)
509                    .text_color(theme.fg_disabled)
510                    .child(self.note.clone()),
511            )
512    }
513}
514
515fn main() {
516    launch_app("brahman · mirada", (SCREEN_W as f32, (SCREEN_H + 70) as f32), Mirada::new);
517}