gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

feat(mirada): reglas de ventana — escritorio y flotante por app_id

mirada-brain::rules — config declarativa que decide qué hacer con una
ventana al abrirse, mismo patrón que el keymap.

- Rule casa por subcadena de app_id y/o title (sin distinguir
  mayúsculas; vacío = cualquiera) y aplica un destino: workspace (1..9)
  y/o floating. Gana la primera regla que case.
- Rules en RON (~/.config/mirada/rules.ron); la primera vez se escribe
  una plantilla con ejemplos comentados, si está corrupta se ignora.
- Desktop consulta Rules::resolve en cada WindowOpened — el evento ya
  trae app_id/title — y abre la ventana en su escritorio, flotando si
  toca. set_rules en Desktop; las apps cargan rules.ron al arrancar.

mirada-brain 42->51 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

6dfd9e62ac1bd728d4fba35857498e649d39c1c8 · por sergio · (hace 5 meses) · padre 4719f7c9f · ver el árbol en este commit

Delta semántico · 33

crates/apps/mirada-compositor/src/main.rs

crates/apps/mirada/src/main.rs

crates/modules/mirada/mirada-brain/src/desktop.rs

crates/modules/mirada/mirada-brain/src/lib.rs

crates/modules/mirada/mirada-brain/src/rules.rs

7 archivos · +311 −8

diff --git a/crates/apps/mirada-compositor/src/main.rs b/crates/apps/mirada-compositor/src/main.rs
index f49cd6a..435b928 100644
--- a/crates/apps/mirada-compositor/src/main.rs
+++ b/crates/apps/mirada-compositor/src/main.rs
@@ -59,7 +59,9 @@ use smithay::{
 };
 
 use mirada_body::{BodyOp, BodyState};
-use mirada_brain::{BodyEvent, BrainCommand, CtlReply, CtlRequest, CtlServer, Desktop, Keymap};
+use mirada_brain::{
+    BodyEvent, BrainCommand, CtlReply, CtlRequest, CtlServer, Desktop, Keymap, Rules,
+};
 use mirada_link::BodyLink;
 
 // ---------------------------------------------------------------------
@@ -469,6 +471,14 @@ fn send_frames_surface_tree(surface: &WlSurface, time: u32) {
 // Bucle principal
 // ---------------------------------------------------------------------
 
+/// 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(),
+    }
+}
+
 fn run() -> Result<(), Box<dyn std::error::Error>> {
     let mut display: Display<App> = Display::new()?;
     let dh = display.handle();
@@ -494,7 +504,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
                 Some(p) => Keymap::load_or_init(p),
                 None => Keymap::default(),
             };
-            Brain::Embedded(Desktop::with_keymap(keymap))
+            let mut desktop = Desktop::with_keymap(keymap);
+            desktop.set_rules(load_user_rules());
+            Brain::Embedded(desktop)
         }
     };
 
diff --git a/crates/apps/mirada/src/main.rs b/crates/apps/mirada/src/main.rs
index 3e117fb..6f3cfe8 100644
--- a/crates/apps/mirada/src/main.rs
+++ b/crates/apps/mirada/src/main.rs
@@ -40,7 +40,7 @@ use gpui::{
 };
 use mirada_brain::{
     BodyEvent, BrainCommand, CtlConn, CtlReply, CtlRequest, CtlServer, Desktop, DesktopAction,
-    Keymap, KeymapWatch, LayoutMode, WindowId, WindowPlacement,
+    Keymap, KeymapWatch, LayoutMode, Rules, WindowId, WindowPlacement,
 };
 use mirada_link::BrainLink;
 use nahual_launcher::launch_app;
@@ -105,8 +105,13 @@ impl Mirada {
             }
         };
 
+        // 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: Desktop::with_keymap(keymap),
+            desktop,
             placements: Vec::new(),
             next_id: 1,
             link,
@@ -316,6 +321,14 @@ fn connect_body() -> Option<BrainLink> {
     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 {
diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md
index 129948f..3056470 100644
--- a/crates/modules/mirada/SDD.md
+++ b/crates/modules/mirada/SDD.md
@@ -61,8 +61,8 @@ ejecuta operaciones de geometría".
   `placements(&Workspace, Rect)`.
 - **`mirada-brain`** — `Desktop`: salidas, 9 escritorios virtuales,
   registro de ventanas. `on_event(BodyEvent) -> Vec<BrainCommand>`;
-  `DesktopAction` + `Keymap` configurable. `set_keymap` lo cambia en
-  caliente y devuelve el `GrabKeys` a reenviar.
+  `DesktopAction` + `Keymap` configurable (`set_keymap` en caliente) +
+  `Rules` de ventana.
 - **`mirada-link`** — `Link<Out,In>` sobre socket Unix; hilo lector de
   fondo + canal `mpsc` para sondeo no bloqueante. `BrainLink`/`BodyLink`,
   `connected_pair` (socketpair), `connect`/`listen` por ruta.
@@ -137,6 +137,20 @@ que un front-end (`Keybind` → `lookup` → `apply`); hay otros tres:
 `cargo run -p mirada-brain --example headless-ctl` levanta un Cerebro sin
 gráficos para ejercitar `mirada-ctl` en modo desatendido.
 
+## Reglas de ventana
+
+`mirada-brain::rules` decide qué hacer con una ventana **al abrirse**:
+config declarativa en RON (`~/.config/mirada/rules.ron`), mismo patrón
+que el keymap. Cada `Rule` casa por subcadena de `app_id` y/o `title`
+(sin distinguir mayúsculas; vacío = cualquiera) y aplica un destino:
+`workspace` (1..9) y/o `floating`. Gana la primera regla que case.
+
+El `Desktop` consulta `Rules::resolve` en cada `WindowOpened` — el evento
+ya trae `app_id`/`title` — y manda la ventana a su escritorio, flotando
+si toca. Se carga al arrancar (la primera vez se escribe una plantilla
+con ejemplos comentados); las reglas afectan a las ventanas futuras, no
+a las ya abiertas.
+
 ## Dependencias
 
 - Todos los `lib` con `#![forbid(unsafe_code)]`. Cero Wayland, cero
@@ -146,7 +160,7 @@ gráficos para ejercitar `mirada-ctl` en modo desatendido.
 ## Estado
 
 Implementado y verde: `mirada-layout` (32 tests), `mirada-protocol`
-(10), `mirada-brain` (42), `mirada-link` (7), `mirada-body` (14), las
+(10), `mirada-brain` (51), `mirada-link` (7), `mirada-body` (14), las
 apps `mirada` y `mirada-compositor` (compilan; verificación visual
 manual) y `mirada-ctl` (CLI, probado vía el ejemplo `headless-ctl`).
 
diff --git a/crates/modules/mirada/mirada-brain/src/desktop.rs b/crates/modules/mirada/mirada-brain/src/desktop.rs
index 2500803..f8e4a63 100644
--- a/crates/modules/mirada/mirada-brain/src/desktop.rs
+++ b/crates/modules/mirada/mirada-brain/src/desktop.rs
@@ -7,6 +7,7 @@ use mirada_protocol::{placements, BodyEvent, BrainCommand, OutputId};
 
 use crate::action::{DesktopAction, WORKSPACE_COUNT};
 use crate::keymap::Keymap;
+use crate::rules::Rules;
 
 /// Lo que el Cerebro sabe de una ventana: su identidad de aplicación.
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -35,6 +36,8 @@ pub struct Desktop {
     windows: HashMap<WindowId, WindowInfo>,
     /// Atajos globales → acción. Configurable, recargable en caliente.
     keymap: Keymap,
+    /// Reglas de ventana — escritorio/flotante por `app_id`/título.
+    rules: Rules,
 }
 
 impl Default for Desktop {
@@ -62,9 +65,16 @@ impl Desktop {
             active: 0,
             windows: HashMap::new(),
             keymap,
+            rules: Rules::default(),
         }
     }
 
+    /// Reemplaza las reglas de ventana. Se aplican a las ventanas que se
+    /// abran a partir de ahora; las ya abiertas no se tocan.
+    pub fn set_rules(&mut self, rules: Rules) {
+        self.rules = rules;
+    }
+
     /// El comando que registra los atajos globales en el Cuerpo. La app
     /// lo envía al conectar, y de nuevo tras cada recarga del keymap.
     pub fn grab_keys(&self) -> BrainCommand {
@@ -103,8 +113,21 @@ impl Desktop {
                 self.relayout()
             }
             BodyEvent::WindowOpened { id, app_id, title } => {
+                // Las reglas pueden mandarla a otro escritorio o hacerla flotar.
+                let outcome = self.rules.resolve(&app_id, &title);
                 self.windows.insert(id, WindowInfo { app_id, title });
-                self.workspaces[self.active].add(id);
+                let ws = outcome
+                    .workspace
+                    .filter(|&n| n < self.workspaces.len())
+                    .unwrap_or(self.active);
+                self.workspaces[ws].add(id);
+                if outcome.floating {
+                    let rect = self
+                        .screen()
+                        .map(centered_float_rect)
+                        .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+                    self.workspaces[ws].set_floating(id, Some(rect));
+                }
                 self.relayout()
             }
             BodyEvent::WindowClosed { id } => {
@@ -452,6 +475,24 @@ mod tests {
         assert!(!places(&cmds).iter().find(|x| x.id == 2).unwrap().floating);
     }
 
+    #[test]
+    fn a_rule_sends_a_new_window_to_its_workspace() {
+        let mut d = desktop_with_screen();
+        d.set_rules(Rules::from_ron(r#"( rules: [ (app_id: "app2", workspace: 3) ] )"#).unwrap());
+        open(&mut d, 1); // app1 → sin regla → escritorio activo (1)
+        open(&mut d, 2); // app2 → regla → escritorio 3
+        assert_eq!(d.workspace_loads()[0], 1);
+        assert_eq!(d.workspace_loads()[2], 1);
+    }
+
+    #[test]
+    fn a_rule_can_open_a_window_floating() {
+        let mut d = desktop_with_screen();
+        d.set_rules(Rules::from_ron(r#"( rules: [ (app_id: "app1", floating: true) ] )"#).unwrap());
+        let cmds = open(&mut d, 1);
+        assert!(places(&cmds).iter().find(|p| p.id == 1).unwrap().floating);
+    }
+
     #[test]
     fn without_a_screen_nothing_is_placed() {
         let mut d = Desktop::new();
diff --git a/crates/modules/mirada/mirada-brain/src/lib.rs b/crates/modules/mirada/mirada-brain/src/lib.rs
index 3a6c409..f467ea8 100644
--- a/crates/modules/mirada/mirada-brain/src/lib.rs
+++ b/crates/modules/mirada/mirada-brain/src/lib.rs
@@ -13,6 +13,7 @@
 //! - [`action`] — las acciones de escritorio y el mapa de teclas.
 //! - [`desktop`] — el [`Desktop`]: el estado y el bucle `evento → comandos`.
 //! - [`keymap`] — el [`Keymap`] configurable en RON, recargable en caliente.
+//! - [`rules`] — las [`Rules`] de ventana (escritorio/flotante por `app_id`).
 //! - [`ctl`] — el API de control externo (`mirada-ctl`, taskbars, scripts).
 
 #![forbid(unsafe_code)]
@@ -21,11 +22,13 @@ pub mod action;
 pub mod ctl;
 pub mod desktop;
 pub mod keymap;
+pub mod rules;
 
 pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
 pub use ctl::{CtlConn, CtlReply, CtlRequest, CtlServer, WindowLine};
 pub use desktop::{Desktop, WindowInfo};
 pub use keymap::{Keymap, KeymapError, KeymapWatch};
+pub use rules::{Rule, RuleOutcome, Rules};
 
 pub use mirada_layout::{LayoutMode, LayoutParams, Rect, WindowId, Workspace};
 pub use mirada_protocol::{BodyEvent, BrainCommand, OutputId, WindowPlacement};
diff --git a/crates/modules/mirada/mirada-brain/src/rules.rs b/crates/modules/mirada/mirada-brain/src/rules.rs
new file mode 100644
index 0000000..28a3750
--- /dev/null
+++ b/crates/modules/mirada/mirada-brain/src/rules.rs
@@ -0,0 +1,211 @@
+//! Reglas de ventana — config declarativa que decide, al abrirse una
+//! ventana, a qué escritorio va y si flota.
+//!
+//! Mismo patrón que [`crate::keymap`]: RON de texto en
+//! `~/.config/mirada/rules.ron`, que el [`Desktop`](crate::Desktop)
+//! consulta en cada `WindowOpened` — el evento ya trae `app_id` y
+//! `title`. Una regla casa por subcadena (sin distinguir mayúsculas);
+//! gana la primera que case.
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+/// Una regla: criterio de coincidencia + qué aplicar.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct Rule {
+    /// Subcadena que debe contener el `app_id`; vacía = casa con cualquiera.
+    #[serde(default)]
+    pub app_id: String,
+    /// Subcadena que debe contener el título; vacía = cualquiera.
+    #[serde(default)]
+    pub title: String,
+    /// Escritorio de destino (1-based); `0` = no moverla.
+    #[serde(default)]
+    pub workspace: usize,
+    /// Abrir la ventana flotando.
+    #[serde(default)]
+    pub floating: bool,
+}
+
+impl Rule {
+    /// `true` si la regla casa con una ventana de este `app_id`/`title`.
+    fn matches(&self, app_id: &str, title: &str) -> bool {
+        let app_ok = self.app_id.is_empty() || contains_ci(app_id, &self.app_id);
+        let title_ok = self.title.is_empty() || contains_ci(title, &self.title);
+        app_ok && title_ok
+    }
+}
+
+/// `true` si `haystack` contiene `needle`, sin distinguir mayúsculas.
+fn contains_ci(haystack: &str, needle: &str) -> bool {
+    haystack.to_lowercase().contains(&needle.to_lowercase())
+}
+
+/// Qué hacer con una ventana recién abierta, según las reglas.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct RuleOutcome {
+    /// Escritorio de destino, ya como índice 0-based. `None` = el activo.
+    pub workspace: Option<usize>,
+    /// Abrir flotando.
+    pub floating: bool,
+}
+
+/// El conjunto de reglas de ventana del usuario.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Rules {
+    #[serde(default)]
+    rules: Vec<Rule>,
+}
+
+impl Rules {
+    /// Construye un conjunto de reglas a partir de una lista.
+    pub fn new(rules: Vec<Rule>) -> Self {
+        Self { rules }
+    }
+
+    /// Resuelve qué hacer con una ventana — gana la primera regla que case.
+    pub fn resolve(&self, app_id: &str, title: &str) -> RuleOutcome {
+        for r in &self.rules {
+            if r.matches(app_id, title) {
+                return RuleOutcome {
+                    workspace: (r.workspace >= 1).then(|| r.workspace - 1),
+                    floating: r.floating,
+                };
+            }
+        }
+        RuleOutcome::default()
+    }
+
+    /// Cuántas reglas hay.
+    pub fn len(&self) -> usize {
+        self.rules.len()
+    }
+
+    /// `true` si no hay ninguna regla.
+    pub fn is_empty(&self) -> bool {
+        self.rules.is_empty()
+    }
+
+    /// Parsea las reglas desde el texto RON de un archivo de config.
+    pub fn from_ron(text: &str) -> Result<Rules, String> {
+        ron::from_str(text).map_err(|e| format!("RON inválido: {e}"))
+    }
+
+    /// La ruta canónica de las reglas: `~/.config/mirada/rules.ron`.
+    pub fn default_path() -> Option<PathBuf> {
+        directories::ProjectDirs::from("", "", "mirada")
+            .map(|d| d.config_dir().join("rules.ron"))
+    }
+
+    /// Carga las reglas de un archivo RON.
+    pub fn load(path: &Path) -> Result<Rules, String> {
+        let text = std::fs::read_to_string(path).map_err(|e| format!("E/S: {e}"))?;
+        Rules::from_ron(&text)
+    }
+
+    /// Carga las reglas del usuario con un fallback amable: si el archivo
+    /// no existe, escribe una plantilla documentada y devuelve un
+    /// conjunto vacío; si está corrupto, avisa y devuelve vacío.
+    pub fn load_or_default(path: &Path) -> Rules {
+        if path.exists() {
+            match Rules::load(path) {
+                Ok(r) => r,
+                Err(e) => {
+                    eprintln!(
+                        "mirada · reglas «{}» inválidas ({e}); las ignoro.",
+                        path.display()
+                    );
+                    Rules::default()
+                }
+            }
+        } else {
+            if let Some(dir) = path.parent() {
+                let _ = std::fs::create_dir_all(dir);
+            }
+            match std::fs::write(path, RULES_TEMPLATE) {
+                Ok(()) => eprintln!("mirada · plantilla de reglas escrita en {}", path.display()),
+                Err(e) => eprintln!("mirada · no pude escribir la plantilla de reglas: {e}"),
+            }
+            Rules::default()
+        }
+    }
+}
+
+/// La plantilla que se escribe la primera vez — sin reglas, con ejemplos
+/// comentados para que el usuario los descubra.
+const RULES_TEMPLATE: &str = "\
+// Reglas de ventana de mirada — qué hacer con una ventana al abrirse.
+//
+// Cada regla casa por subcadena de `app_id` y/o `title` (sin distinguir
+// mayúsculas; cadena vacía = cualquiera) y aplica un destino: `workspace`
+// (1..9; 0 = no mover) y/o `floating`. Gana la primera regla que case.
+//
+// Descomenta y edita los ejemplos:
+(
+    rules: [
+        // (app_id: \"pavucontrol\", floating: true),
+        // (app_id: \"firefox\", workspace: 2),
+        // (title: \"Picture-in-Picture\", floating: true),
+    ],
+)
+";
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn the_template_parses_to_an_empty_rule_set() {
+        assert!(Rules::from_ron(RULES_TEMPLATE).unwrap().is_empty());
+    }
+
+    #[test]
+    fn rules_parse_from_ron_with_omitted_fields() {
+        let ron = r#"(
+            rules: [
+                (app_id: "pavucontrol", floating: true),
+                (app_id: "firefox", workspace: 2),
+            ],
+        )"#;
+        assert_eq!(Rules::from_ron(ron).unwrap().len(), 2);
+    }
+
+    #[test]
+    fn resolve_sends_a_match_to_its_workspace() {
+        let r = Rules::from_ron(r#"( rules: [ (app_id: "firefox", workspace: 3) ] )"#).unwrap();
+        let out = r.resolve("org.mozilla.firefox", "");
+        assert_eq!(out.workspace, Some(2)); // 3 (1-based) -> índice 2
+        assert!(!out.floating);
+    }
+
+    #[test]
+    fn resolve_matches_app_id_case_insensitively_by_substring() {
+        let r = Rules::from_ron(r#"( rules: [ (app_id: "FIREFOX", floating: true) ] )"#).unwrap();
+        assert!(r.resolve("org.mozilla.firefox", "").floating);
+    }
+
+    #[test]
+    fn resolve_matches_by_title() {
+        let r =
+            Rules::from_ron(r#"( rules: [ (title: "Picture-in-Picture", floating: true) ] )"#)
+                .unwrap();
+        assert!(r.resolve("cualquiera", "YouTube — Picture-in-Picture").floating);
+        assert!(!r.resolve("cualquiera", "ventana normal").floating);
+    }
+
+    #[test]
+    fn the_first_matching_rule_wins() {
+        let r = Rules::from_ron(
+            r#"( rules: [ (app_id: "term", workspace: 1), (app_id: "term", workspace: 5) ] )"#,
+        )
+        .unwrap();
+        assert_eq!(r.resolve("term", "").workspace, Some(0));
+    }
+
+    #[test]
+    fn no_match_yields_the_default_outcome() {
+        let r = Rules::from_ron(r#"( rules: [ (app_id: "firefox", workspace: 2) ] )"#).unwrap();
+        assert_eq!(r.resolve("xterm", ""), RuleOutcome::default());
+    }
+}
diff --git a/vamos.txt b/vamos.txt
index 9ffe4d6..3ec5838 100644
--- a/vamos.txt
+++ b/vamos.txt
@@ -1056,5 +1056,14 @@
 
 
 
+  Reglas de ventana — mirada-brain::rules:
+  Config declarativa en RON (~/.config/mirada/rules.ron); el Desktop la consulta en cada
+  WindowOpened y manda la ventana a su escritorio / la hace flotar.
+  Cada regla casa por subcadena de app_id y/o title (sin mayúsculas); gana la primera.
+  Ejemplo:  (app_id: "firefox", workspace: 2)   ·   (title: "Picture-in-Picture", floating: true)
+  La primera vez se escribe una plantilla con ejemplos comentados.
+
+
+