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>
This commit is contained in:
sergio
2026-05-21 01:01:14 +00:00
parent 4719f7c9f9
commit 6dfd9e62ac
7 changed files with 311 additions and 8 deletions
@@ -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();