//! `mirada` — la ventana del Cerebro del compositor. //! //! Es el "Cerebro" de la arquitectura carmen hecho app GPUI: envuelve //! [`mirada_brain::Desktop`] (toda la lógica de teselado y foco) y lo //! pinta. La cadena completa: //! //! ```text //! mirada-layout ─► mirada-protocol ─► mirada-brain ─► [esta ventana] //! │ //! mirada-link ─► mirada-compositor (Cuerpo) //! ``` //! //! Con un Cuerpo conectado (variable `MIRADA_SOCKET`) sondea sus //! [`BodyEvent`]s y le devuelve [`BrainCommand`]s por el socket. Sin //! Cuerpo arranca en **simulación**: las ventanas son sintéticas y el //! teclado de esta ventana maneja el escritorio — útil para ver el //! motor de teselado sin hardware. //! //! Teclas (simulación): //! //! ```text //! n / Shift+n abre ventana / monitor tab / espacio cicla layout //! w cierra la enfocada t m g c r d s layout directo //! f / Shift+f flota / pantalla completa h / l área maestra −/+ //! j / k foco siguiente/anterior , / . nmaster −/+ //! Shift+j / k mueve la enfocada 1..9 ir a escritorio //! Enter promueve a maestra Ctrl+1..9 enviar a escritorio //! o siguiente monitor ` / Shift+` scratchpad ver/guardar //! ``` //! //! Los pips de escritorio y las ventanas del lienzo son **clicables**, y //! `mirada-ctl` controla el escritorio desde la terminal — ambos pasan //! por el mismo `Desktop::apply`. use std::path::PathBuf; use std::time::Duration; use gpui::{ div, hsla, prelude::*, px, Context, FocusHandle, IntoElement, KeyDownEvent, MouseButton, Render, SharedString, Window, }; use mirada_brain::{ BodyEvent, BrainCommand, CtlConn, CtlReply, CtlRequest, CtlServer, Desktop, DesktopAction, Keymap, KeymapWatch, LayoutMode, Rules, WindowId, WindowPlacement, }; use mirada_link::BrainLink; use nahual_launcher::launch_app; use nahual_theme::Theme; /// Pantalla virtual del modo simulación — coincide con el lienzo. const SCREEN_W: i32 = 1280; const SCREEN_H: i32 = 720; /// Periodo del sondeo del Cuerpo, en ms (~60 Hz). const POLL_MS: u64 = 16; /// Nombres de app ficticios para las ventanas de simulación. const APPS: &[&str] = &[ "shuma", "fana", "revista", "cosmobiología", "matilda", "yachay", "barra", ]; /// El Cerebro: el estado del escritorio + lo último colocado + el cable. struct Mirada { desktop: Desktop, /// Geometría vigente — lo que se pinta. Es la última `Place` emitida. placements: Vec, /// Contador de ids para las ventanas sintéticas. next_id: WindowId, /// Cable al Cuerpo; `None` en simulación. link: Option, /// Última acción, para la barra de estado. note: SharedString, focus: FocusHandle, focused_once: bool, /// Ruta del keymap del usuario, para recargarlo en caliente. keymap_path: Option, /// Vigía del keymap; `None` en simulación o si no hay archivo. keymap_watch: Option, /// Socket del API de control externo (`mirada-ctl`). ctl: Option, } impl Mirada { fn new(cx: &mut Context) -> Self { // Keymap del usuario (~/.config/mirada/keymap.ron): define los // atajos que el Cuerpo intercepta y nos devuelve como `Keybind`. let keymap_path = Keymap::default_path(); let keymap = match &keymap_path { Some(p) => Keymap::load_or_init(p), None => Keymap::default(), }; let link = connect_body(); // Vigilar el keymap sólo tiene sentido con un Cuerpo conectado; // en simulación, mirada usa las teclas de su propia ventana. let keymap_watch = if link.is_some() { keymap_path.as_deref().and_then(|p| Keymap::watch(p).ok()) } else { None }; // API de control: mirada siempre posee el Desktop, así que // siempre abre el socket de `mirada-ctl`. let ctl = match CtlServer::bind(&mirada_brain::ctl::default_socket_path()) { Ok(s) => Some(s), Err(e) => { eprintln!("mirada · sin API de control: {e}"); None } }; // Reglas de ventana (~/.config/mirada/rules.ron): a qué // escritorio va cada ventana, si flota. let mut desktop = Desktop::with_keymap(keymap); desktop.set_rules(load_user_rules()); let mut app = Self { desktop, placements: Vec::new(), next_id: 1, link, note: SharedString::from("listo"), focus: cx.focus_handle(), focused_once: false, keymap_path, keymap_watch, ctl, }; if let Some(link) = app.link.as_mut() { // Registra los atajos globales en el Cuerpo. let _ = link.send(&app.desktop.grab_keys()); app.note = SharedString::from("Cuerpo conectado"); } else { // Simulación: una pantalla virtual y tres ventanas de muestra. app.feed(BodyEvent::OutputAdded { id: 0, width: SCREEN_W, height: SCREEN_H }); for _ in 0..3 { app.open_window(); } app.note = SharedString::from("simulación — sin Cuerpo"); } // El sondeo corre siempre: drena el Cuerpo (si lo hay), vigila el // keymap y atiende `mirada-ctl`. app.start_poll(cx); app } /// Bucle de fondo: drena los eventos del Cuerpo y los procesa. fn start_poll(&self, cx: &mut Context) { cx.spawn(async move |this, cx| loop { cx.background_executor() .timer(Duration::from_millis(POLL_MS)) .await; let alive = this.update(cx, |app, cx| { let events: Vec = match app.link.as_ref() { Some(link) => link.drain(), None => Vec::new(), }; let had_events = !events.is_empty(); let keymap_changed = app.keymap_watch.as_ref().is_some_and(|w| w.changed()); if keymap_changed { app.reload_keymap(); } let ctl_served = app.poll_ctl(); for ev in events { app.feed(ev); } if had_events || keymap_changed || ctl_served { cx.notify(); } }); if alive.is_err() { break; // ventana cerrada } }) .detach(); } /// Abre una ventana sintética (sólo tiene sentido en simulación). fn open_window(&mut self) { let id = self.next_id; self.next_id += 1; let app = APPS[(id as usize) % APPS.len()]; self.feed(BodyEvent::WindowOpened { id, app_id: format!("org.brahman.{app}"), title: format!("{app} · ventana {id}"), }); self.note = SharedString::from(format!("abierta ventana {id}")); } /// Inyecta un evento del Cuerpo en el `Desktop` y despacha la salida. fn feed(&mut self, event: BodyEvent) { let cmds = self.desktop.on_event(event); self.dispatch(cmds); } /// Aplica una acción de escritorio (desde una tecla de esta ventana). fn act(&mut self, action: DesktopAction) { let cmds = self.desktop.apply(action); self.dispatch(cmds); } /// Recarga el keymap del disco y re-registra los atajos en el Cuerpo. fn reload_keymap(&mut self) { let Some(path) = self.keymap_path.clone() else { return; }; match Keymap::load(&path) { Ok(km) => { let cmd = self.desktop.set_keymap(km); self.dispatch(vec![cmd]); self.note = SharedString::from("keymap recargado"); } Err(e) => self.note = SharedString::from(format!("keymap inválido: {e}")), } } /// Atiende las peticiones pendientes del API de control. Devuelve /// `true` si sirvió alguna (para repintar). fn poll_ctl(&mut self) -> bool { let conns: Vec = match &self.ctl { Some(ctl) => std::iter::from_fn(|| ctl.poll()).collect(), None => return false, }; let mut served = false; for mut conn in conns { let reply = match conn.read_request() { Ok(Some(req)) => { served = true; self.serve_ctl(req) } Ok(None) => continue, Err(e) => CtlReply::Error(format!("{e}")), }; let _ = conn.reply(&reply); } served } /// Resuelve una petición de control: la acción pasa por el mismo /// `apply` que el teclado; la consulta lee el `Desktop`. fn serve_ctl(&mut self, req: CtlRequest) -> CtlReply { match req { CtlRequest::Do(action) => { self.act(action); CtlReply::Ok } CtlRequest::ListWindows => CtlReply::Windows(self.desktop.window_lines()), } } /// Reparte los comandos del Cerebro: actualiza lo pintado y, o bien /// los manda al Cuerpo, o bien —en simulación— cierra las ventanas /// por su cuenta (no hay nadie que devuelva el `WindowClosed`). fn dispatch(&mut self, cmds: Vec) { for cmd in &cmds { if let BrainCommand::Place(p) = cmd { self.placements = p.clone(); } } match self.link.as_mut() { Some(link) => { for cmd in &cmds { let _ = link.send(cmd); } } None => { for cmd in cmds { match cmd { BrainCommand::Close(id) | BrainCommand::Kill(id) => { self.feed(BodyEvent::WindowClosed { id }); } _ => {} } } } } } /// Traduce una tecla de la ventana a una acción de escritorio. fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context) { let ks = &event.keystroke; let ctrl = ks.modifiers.control; let shift = ks.modifiers.shift; let connected = self.link.is_some(); match ks.key.as_str() { "n" if shift && !connected => { // Simulación: añade un monitor más, en fila a la derecha. let id = self.desktop.outputs().len() as u32; self.feed(BodyEvent::OutputAdded { id, width: SCREEN_W, height: SCREEN_H }); } "n" if !connected => self.open_window(), "w" => self.act(DesktopAction::CloseFocused), "f" if shift => self.act(DesktopAction::ToggleFullscreen), "f" => self.act(DesktopAction::ToggleFloat), "j" if shift => self.act(DesktopAction::MoveForward), "k" if shift => self.act(DesktopAction::MoveBackward), "j" => self.act(DesktopAction::FocusNext), "k" => self.act(DesktopAction::FocusPrev), "tab" | "space" => self.act(DesktopAction::CycleLayout), "t" => self.act(DesktopAction::SetLayout(LayoutMode::MasterStack)), "m" => self.act(DesktopAction::SetLayout(LayoutMode::Monocle)), "g" => self.act(DesktopAction::SetLayout(LayoutMode::Grid)), "c" => self.act(DesktopAction::SetLayout(LayoutMode::Columns)), "r" => self.act(DesktopAction::SetLayout(LayoutMode::Rows)), "d" => self.act(DesktopAction::SetLayout(LayoutMode::CenteredMaster)), "s" => self.act(DesktopAction::SetLayout(LayoutMode::Spiral)), "h" => self.act(DesktopAction::ShrinkMaster), "l" => self.act(DesktopAction::GrowMaster), "o" => self.act(DesktopAction::FocusOutputNext), "`" if shift => self.act(DesktopAction::SendToScratchpad), "`" => self.act(DesktopAction::ToggleScratchpad), "enter" => self.act(DesktopAction::PromoteToMaster), "," => self.act(DesktopAction::IncMaster), "." => self.act(DesktopAction::DecMaster), d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0" => { let n = (d.as_bytes()[0] - b'1') as usize; if ctrl { self.act(DesktopAction::SendToWorkspace(n)); } else { self.act(DesktopAction::SwitchWorkspace(n)); } } _ => return, } cx.notify(); } } /// Conecta con el Cuerpo si `MIRADA_SOCKET` apunta a un socket vivo. fn connect_body() -> Option { let path = std::env::var("MIRADA_SOCKET").ok()?; BrainLink::connect(&path).ok() } /// Carga las reglas de ventana del usuario, o ninguna si no hay archivo. fn load_user_rules() -> Rules { match Rules::default_path() { Some(p) => Rules::load_or_default(&p), None => Rules::default(), } } /// Nombre legible de un modo de teselado. fn mode_name(m: LayoutMode) -> &'static str { match m { LayoutMode::MasterStack => "maestro + pila", LayoutMode::Monocle => "monóculo", LayoutMode::Grid => "rejilla", LayoutMode::Columns => "columnas", LayoutMode::Rows => "filas", LayoutMode::CenteredMaster => "maestro centrado", LayoutMode::Spiral => "espiral", } } impl Render for Mirada { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // El lienzo necesita el foco del teclado desde el primer frame. if !self.focused_once { window.focus(&self.focus); self.focused_once = true; } let theme = Theme::global(cx).clone(); let win_bg = hsla(220.0 / 360.0, 0.16, 0.13, 1.0); let bar_bg = hsla(220.0 / 360.0, 0.20, 0.09, 1.0); let canvas_bg = hsla(220.0 / 360.0, 0.24, 0.05, 1.0); // Texto legible sobre un fondo de acento. let on_accent = hsla(220.0 / 360.0, 0.24, 0.06, 1.0); let active = self.desktop.active_index(); let mode = self.desktop.active_workspace().params().mode; let loads = self.desktop.workspace_loads(); let focused = self.desktop.focused_window(); // --- Barra superior: identidad + escritorios + modo ---------- let pips = loads.iter().enumerate().map(|(i, &load)| { let is_active = i == active; let fg = if is_active { on_accent } else if load > 0 { theme.fg_text } else { theme.fg_disabled }; div() .w(px(24.)) .h(px(22.)) .flex() .items_center() .justify_center() .rounded(px(4.)) .cursor_pointer() .when(is_active, |d| d.bg(theme.accent)) .when(!is_active && load > 0, |d| d.bg(theme.bg_row_hover)) .text_color(fg) .on_mouse_down( MouseButton::Left, cx.listener(move |app, _, _, cx| { app.act(DesktopAction::SwitchWorkspace(i)); cx.notify(); }), ) .child(SharedString::from(format!("{}", i + 1))) }); let focus_label = match focused.and_then(|id| self.desktop.window_info(id)) { Some(info) => info.title.clone(), None => "—".to_string(), }; let bar = div() .h(px(44.)) .flex() .flex_row() .items_center() .gap(px(12.)) .px(px(14.)) .bg(bar_bg) .text_color(theme.fg_text) .child(div().text_color(theme.accent).child("mirada")) .child(div().text_color(theme.fg_disabled).child("·")) .child(div().flex().flex_row().gap(px(4.)).children(pips)) .child(div().text_color(theme.fg_disabled).child("·")) .child( div() .text_color(theme.fg_muted) .child(SharedString::from(format!("layout: {}", mode_name(mode)))), ) .child(div().flex_1()) .child( div() .text_color(theme.fg_muted) .child(SharedString::from(format!("foco: {focus_label}"))), ); // --- Lienzo: el escritorio teselado, a escala ---------------- // El lienzo es de tamaño fijo; el contenido vive en el espacio // global de las salidas. `scale` encaja ese espacio en el lienzo // — con una sola salida, escala 1:1. let outs = self.desktop.outputs(); let (bb_w, bb_h) = if outs.is_empty() { (SCREEN_W as f32, SCREEN_H as f32) } else { let w = outs.iter().map(|o| o.rect.x + o.rect.w).max().unwrap_or(SCREEN_W); let h = outs.iter().map(|o| o.rect.y + o.rect.h).max().unwrap_or(SCREEN_H); (w as f32, h as f32) }; let scale = (SCREEN_W as f32 / bb_w) .min(SCREEN_H as f32 / bb_h) .min(1.0); let mut canvas = div() .relative() .w(px(SCREEN_W as f32)) .h(px(SCREEN_H as f32)) .bg(canvas_bg) .overflow_hidden(); // Un marco por salida, con su número y el escritorio que muestra. for (i, o) in outs.iter().enumerate() { let is_focused_out = i == self.desktop.focused_output(); canvas = canvas.child( div() .absolute() .left(px(o.rect.x as f32 * scale)) .top(px(o.rect.y as f32 * scale)) .w(px(o.rect.w as f32 * scale)) .h(px(o.rect.h as f32 * scale)) .border_1() .border_color(if is_focused_out { theme.accent } else { theme.border }) .child( div() .absolute() .top(px(2.)) .left(px(4.)) .text_color(theme.fg_disabled) .child(SharedString::from(format!( "salida {} · escritorio {}", i + 1, o.workspace + 1 ))), ), ); } let visible = self.placements.iter().filter(|p| p.visible).count(); if visible == 0 { canvas = canvas.child( div() .absolute() .size_full() .flex() .items_center() .justify_center() .text_color(theme.fg_disabled) .child("escritorio vacío — pulsa n para abrir una ventana"), ); } for p in self.placements.iter().filter(|p| p.visible) { let info = self.desktop.window_info(p.id); let title = info .map(|i| i.title.clone()) .unwrap_or_else(|| format!("ventana {}", p.id)); let app_id = info.map(|i| i.app_id.clone()).unwrap_or_default(); let border = if p.focused { theme.accent } else { theme.border }; let tb_bg = if p.focused { theme.accent } else { theme.bg_row_hover }; let tb_fg = if p.focused { on_accent } else { theme.fg_muted }; let pid = p.id; let kind_label = if p.fullscreen { "· pantalla completa ·" } else if p.floating { "· ventana flotante ·" } else { "· superficie del Cuerpo ·" }; canvas = canvas.child( div() .absolute() .left(px(p.rect.x as f32 * scale)) .top(px(p.rect.y as f32 * scale)) .w(px(p.rect.w as f32 * scale)) .h(px(p.rect.h as f32 * scale)) .border_2() .border_color(border) .bg(win_bg) .rounded(px(5.)) .overflow_hidden() .cursor_pointer() .on_mouse_down( MouseButton::Left, cx.listener(move |app, _, _, cx| { app.act(DesktopAction::FocusWindow(pid)); cx.notify(); }), ) .flex() .flex_col() .child( // Barra de título de la ventana. div() .h(px(22.)) .flex() .items_center() .px(px(8.)) .bg(tb_bg) .text_color(tb_fg) .child(SharedString::from(title)), ) .child( // Interior: en el compositor real lo compone el // Cuerpo (zero-copy); aquí es un marcador. div() .flex_1() .flex() .flex_col() .items_center() .justify_center() .gap(px(4.)) .text_color(theme.fg_disabled) .child(SharedString::from(app_id)) .child(kind_label), ), ); } // --- Composición --------------------------------------------- div() .track_focus(&self.focus) .key_context("Mirada") .on_key_down(cx.listener(Self::handle_key)) .size_full() .flex() .flex_col() .bg(theme.bg_app) .text_color(theme.fg_text) .child(bar) .child( div() .flex_1() .flex() .items_center() .justify_center() .bg(theme.bg_app) .child(canvas), ) .child( // Pie: el estado. div() .h(px(26.)) .flex() .items_center() .px(px(14.)) .bg(bar_bg) .text_color(theme.fg_disabled) .child(self.note.clone()), ) } } fn main() { launch_app("brahman · mirada", (SCREEN_W as f32, (SCREEN_H + 70) as f32), Mirada::new); }