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> { let mut display: Display = Display::new()?; let dh = display.handle(); @@ -494,7 +504,9 @@ fn run() -> Result<(), Box> { 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::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`; - `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` 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, /// 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, + /// 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, +} + +impl Rules { + /// Construye un conjunto de reglas a partir de una lista. + pub fn new(rules: Vec) -> 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 { + 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 { + 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 { + 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. + + +