Files
brahman/crates/modules/mirada/mirada-brain/src/desktop.rs
T
sergio ee27108f6c feat(mirada): acople del shell — ventana-dock al pie de la pantalla
Fase 2 del plan «shell»: carmen reconoce la ventana del shell y le
reserva su sitio, en vez de teselarla como una más.

Una ventana cuyo `app_id` es `carmen.shell` no entra en el teselado:
carmen le reserva una franja de 40 px al pie de la salida, la dimensiona
y la fija ahí, y la compone sobre todas las demás. El Cerebro tesela el
resto de ventanas en el área que queda.

- `mirada-protocol`: nuevo `BodyEvent::OutputResized { id, w, h }` — el
  Cerebro cambia el área útil de una salida **sin** perder el escritorio
  que muestra (a diferencia de quitar y volver a añadir la salida — que,
  de paso, era un bug latente al redimensionar la ventana winit).
- `mirada-brain`: `Desktop` atiende `OutputResized` (test nuevo).
- `mirada-body`: `BodyState::resize_output`.
- `mirada-compositor`: `ManagedWindow.is_shell`, `App.output_size`,
  `dock_shell`/`output_changed`; `register_toplevel` no registra el
  shell en el Cerebro; al cerrarse libera la franja. El shell se compone
  y se enfoca con el ratón aunque no viva en el Cerebro; no lleva marco.
  El backend winit usa ahora `resize_output` al redimensionar.

GPUI no habla `wlr-layer-shell`, así que el acople es por `app_id`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 05:38:12 +00:00

1119 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! El [`Desktop`] — el estado del escritorio y el bucle `evento → comandos`.
use std::collections::HashMap;
use mirada_layout::{LayoutParams, Rect, WindowId, Workspace};
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)]
pub struct WindowInfo {
pub app_id: String,
pub title: String,
}
/// Una salida física y el escritorio virtual que muestra ahora mismo.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Output {
pub id: OutputId,
/// Rectángulo en el espacio global — las salidas van en fila horizontal.
pub rect: Rect,
/// Índice del escritorio que esta salida muestra.
pub workspace: usize,
}
/// El estado completo del escritorio.
///
/// Mantiene las salidas físicas, [`WORKSPACE_COUNT`] escritorios
/// virtuales, el registro de ventanas, el keymap y las reglas. El único
/// punto de entrada es [`Desktop::on_event`]: traga un [`BodyEvent`],
/// muta el estado y devuelve los [`BrainCommand`]s a enviar al Cuerpo.
///
/// **Multi-monitor**: cada salida muestra un escritorio distinto; el
/// teselado se calcula para todas y el `Place` resultante las cubre. Un
/// escritorio se ve en una salida como mucho — pedir uno que ya muestra
/// otra salida las intercambia.
pub struct Desktop {
/// Salidas físicas, en fila horizontal y en orden de aparición.
outputs: Vec<Output>,
/// Escritorios virtuales — `WORKSPACE_COUNT` fijos.
workspaces: Vec<Workspace>,
/// Índice (en `outputs`) de la salida con el foco.
focused_output: usize,
/// Identidad de cada ventana conocida.
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,
/// Ventanas del scratchpad: se invocan flotando y se ocultan a
/// voluntad; mientras están guardadas no viven en ningún escritorio.
scratchpad: Vec<WindowId>,
}
impl Default for Desktop {
fn default() -> Self {
Self::new()
}
}
impl Desktop {
/// Escritorio recién arrancado: sin salidas ni ventanas, con los
/// escritorios virtuales vacíos y el mapa de teclas por defecto.
pub fn new() -> Self {
Self::with_keymap(Keymap::default())
}
/// Como [`Desktop::new`], pero con un keymap dado — el que la app
/// cargó del archivo de configuración del usuario.
pub fn with_keymap(keymap: Keymap) -> Self {
let workspaces = (0..WORKSPACE_COUNT)
.map(|_| Workspace::new(LayoutParams::default()))
.collect();
Self {
outputs: Vec::new(),
workspaces,
focused_output: 0,
windows: HashMap::new(),
keymap,
rules: Rules::default(),
scratchpad: Vec::new(),
}
}
/// 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 {
BrainCommand::GrabKeys(self.keymap.grab_list())
}
/// Reemplaza el keymap en caliente. Devuelve el [`BrainCommand`] que
/// el dueño debe enviar al Cuerpo para reajustar qué teclas intercepta.
pub fn set_keymap(&mut self, keymap: Keymap) -> BrainCommand {
self.keymap = keymap;
self.grab_keys()
}
/// El keymap vigente — para un HUD o un editor visual de atajos.
pub fn keymap(&self) -> &Keymap {
&self.keymap
}
/// Geometría de la salida enfocada, si hay alguna conectada.
pub fn screen(&self) -> Option<Rect> {
self.outputs.get(self.focused_output).map(|o| o.rect)
}
/// Procesa un evento del Cuerpo: muta el estado y devuelve los
/// comandos a enviar de vuelta.
pub fn on_event(&mut self, event: BodyEvent) -> Vec<BrainCommand> {
match event {
BodyEvent::OutputAdded { id, width, height } => {
// La salida nueva muestra el primer escritorio que no
// muestre ya otra salida.
let taken: Vec<usize> = self.outputs.iter().map(|o| o.workspace).collect();
let workspace = (0..self.workspaces.len())
.find(|n| !taken.contains(n))
.unwrap_or(0);
self.outputs.push(Output {
id,
rect: Rect::new(0, 0, width, height),
workspace,
});
self.reflow_outputs();
self.relayout()
}
BodyEvent::OutputRemoved { id } => {
self.outputs.retain(|o| o.id != id);
if self.focused_output >= self.outputs.len() {
self.focused_output = self.outputs.len().saturating_sub(1);
}
self.reflow_outputs();
self.relayout()
}
BodyEvent::OutputResized { id, width, height } => {
// Sólo cambia el área útil; el escritorio que muestra la
// salida se conserva.
if let Some(o) = self.outputs.iter_mut().find(|o| o.id == id) {
o.rect.w = width;
o.rect.h = height;
self.reflow_outputs();
self.relayout()
} else {
Vec::new()
}
}
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 });
let ws = outcome
.workspace
.filter(|&n| n < self.workspaces.len())
.unwrap_or(self.active_index());
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 } => {
self.windows.remove(&id);
self.scratchpad.retain(|&w| w != id);
for ws in &mut self.workspaces {
ws.remove(id);
}
self.relayout()
}
BodyEvent::WindowRetitled { id, title } => {
if let Some(info) = self.windows.get_mut(&id) {
info.title = title;
}
// Un cambio de título no altera la geometría.
Vec::new()
}
BodyEvent::PointerEntered { id } => {
// Foco al pasar el puntero, sólo si la ventana está en el
// escritorio activo.
let active = self.active_index();
if self.workspaces[active].focus_window(id) {
self.relayout()
} else {
Vec::new()
}
}
BodyEvent::Keybind(key) => match self.keymap.lookup(&key) {
Some(action) => self.apply(action),
None => Vec::new(),
},
BodyEvent::FullscreenRequest { id, fullscreen } => {
// El cliente (un reproductor, un juego) pidió pantalla
// completa: la fijamos en el escritorio que tiene la ventana.
let mut changed = false;
for ws in &mut self.workspaces {
if ws.windows().contains(&id) {
if fullscreen {
ws.set_fullscreen(Some(id));
} else if ws.fullscreen() == Some(id) {
ws.set_fullscreen(None);
}
changed = true;
break;
}
}
if changed {
self.relayout()
} else {
Vec::new()
}
}
BodyEvent::WindowFloatTo { id, rect } => {
// Arrastre interactivo: la ventana pasa a flotar en el
// rectángulo dado, en el escritorio donde viva.
let mut changed = false;
for ws in &mut self.workspaces {
if ws.windows().contains(&id) {
ws.set_floating(id, Some(rect));
changed = true;
break;
}
}
if changed {
self.relayout()
} else {
Vec::new()
}
}
}
}
/// Aplica una acción de escritorio directamente (sin pasar por una
/// tecla). Útil para disparar acciones desde un HUD.
pub fn apply(&mut self, action: DesktopAction) -> Vec<BrainCommand> {
let active = self.active_index();
match action {
DesktopAction::FocusNext => {
self.workspaces[active].focus_next();
self.relayout()
}
DesktopAction::FocusPrev => {
self.workspaces[active].focus_prev();
self.relayout()
}
DesktopAction::FocusWindow(id) => {
// En el escritorio activo basta enfocar; si la ventana
// está en otro, lo traemos a la salida enfocada.
if self.workspaces[active].focus_window(id) {
return self.relayout();
}
for n in 0..self.workspaces.len() {
if n != active && self.workspaces[n].focus_window(id) {
self.show_workspace(n);
return self.relayout();
}
}
Vec::new()
}
DesktopAction::MoveForward => {
self.workspaces[active].move_focused_forward();
self.relayout()
}
DesktopAction::MoveBackward => {
self.workspaces[active].move_focused_backward();
self.relayout()
}
DesktopAction::CloseFocused => {
// Pedimos el cierre; el estado se actualiza al recibir el
// `WindowClosed` de vuelta, no antes.
match self.workspaces[active].focused() {
Some(id) => vec![BrainCommand::Close(id)],
None => Vec::new(),
}
}
DesktopAction::ToggleFloat => {
let Some(id) = self.workspaces[active].focused() else {
return Vec::new();
};
let screen = self.screen();
let ws = &mut self.workspaces[active];
if ws.is_floating(id) {
ws.set_floating(id, None);
} else {
let rect = screen
.map(centered_float_rect)
.unwrap_or_else(|| Rect::new(100, 100, 800, 600));
ws.set_floating(id, Some(rect));
}
self.relayout()
}
DesktopAction::ToggleFullscreen => {
let Some(id) = self.workspaces[active].focused() else {
return Vec::new();
};
let ws = &mut self.workspaces[active];
if ws.fullscreen() == Some(id) {
ws.set_fullscreen(None);
} else {
ws.set_fullscreen(Some(id));
}
self.relayout()
}
DesktopAction::SendToScratchpad => {
let Some(id) = self.workspaces[active].focused() else {
return Vec::new();
};
for ws in &mut self.workspaces {
ws.remove(id);
}
if !self.scratchpad.contains(&id) {
self.scratchpad.push(id);
}
self.relayout()
}
DesktopAction::ToggleScratchpad => {
// ¿Hay alguna ventana del scratchpad en el escritorio activo?
let shown: Vec<WindowId> = self.workspaces[active]
.windows()
.iter()
.copied()
.filter(|id| self.scratchpad.contains(id))
.collect();
if !shown.is_empty() {
for id in shown {
self.workspaces[active].remove(id);
}
self.relayout()
} else if let Some(&id) = self.scratchpad.first() {
// La traemos de donde esté y la mostramos flotando.
for ws in &mut self.workspaces {
ws.remove(id);
}
let rect = self
.screen()
.map(centered_float_rect)
.unwrap_or_else(|| Rect::new(100, 100, 800, 600));
self.workspaces[active].add(id);
self.workspaces[active].set_floating(id, Some(rect));
self.relayout()
} else {
Vec::new()
}
}
DesktopAction::CycleLayout => {
let next = self.workspaces[active].params().mode.next();
self.workspaces[active].set_mode(next);
self.relayout()
}
DesktopAction::SetLayout(mode) => {
self.workspaces[active].set_mode(mode);
self.relayout()
}
DesktopAction::GrowMaster => self.nudge_master(0.05),
DesktopAction::ShrinkMaster => self.nudge_master(-0.05),
DesktopAction::IncMaster => self.nudge_master_count(1),
DesktopAction::DecMaster => self.nudge_master_count(-1),
DesktopAction::PromoteToMaster => {
self.workspaces[active].promote_focused();
self.relayout()
}
DesktopAction::SwitchWorkspace(n) => {
if n < self.workspaces.len() && n != active {
self.show_workspace(n);
self.relayout()
} else {
Vec::new()
}
}
DesktopAction::SendToWorkspace(n) => {
if n >= self.workspaces.len() || n == active {
return Vec::new();
}
match self.workspaces[active].focused() {
Some(id) => {
self.workspaces[active].remove(id);
self.workspaces[n].add(id);
self.relayout()
}
None => Vec::new(),
}
}
DesktopAction::FocusOutputNext => {
if self.outputs.len() > 1 {
self.focused_output = (self.focused_output + 1) % self.outputs.len();
self.relayout()
} else {
Vec::new()
}
}
DesktopAction::Spawn(cmd) => vec![BrainCommand::Spawn(cmd)],
DesktopAction::Quit => vec![BrainCommand::Shutdown],
}
}
/// El índice del escritorio activo — el que muestra la salida
/// enfocada. `0` si todavía no hay ninguna salida.
pub fn active_index(&self) -> usize {
self.outputs
.get(self.focused_output)
.map(|o| o.workspace)
.unwrap_or(0)
}
/// Hace que la salida enfocada muestre el escritorio `n`. Si otra
/// salida ya lo mostraba, intercambian — así ningún escritorio se
/// ve en dos sitios a la vez.
fn show_workspace(&mut self, n: usize) {
if n >= self.workspaces.len() || self.focused_output >= self.outputs.len() {
return;
}
let current = self.outputs[self.focused_output].workspace;
if current == n {
return;
}
if let Some(other) = self.outputs.iter().position(|o| o.workspace == n) {
self.outputs[other].workspace = current;
}
self.outputs[self.focused_output].workspace = n;
}
/// Recoloca las salidas en fila horizontal, en su orden de aparición.
fn reflow_outputs(&mut self) {
let mut x = 0;
for o in &mut self.outputs {
o.rect.x = x;
o.rect.y = 0;
x += o.rect.w;
}
}
/// Ajusta la fracción del área maestra del escritorio activo (la usan
/// `MasterStack` y `CenteredMaster`), acotada a `0.05..=0.95`.
fn nudge_master(&mut self, delta: f32) -> Vec<BrainCommand> {
let active = self.active_index();
let ws = &mut self.workspaces[active];
let ratio = (ws.params().master_ratio + delta).clamp(0.05, 0.95);
ws.set_master_ratio(ratio);
self.relayout()
}
/// Ajusta `nmaster` del escritorio activo, acotado a `1..=9`.
fn nudge_master_count(&mut self, delta: i32) -> Vec<BrainCommand> {
let active = self.active_index();
let ws = &mut self.workspaces[active];
let n = (ws.params().master_count as i32 + delta).clamp(1, 9) as usize;
ws.set_master_count(n);
self.relayout()
}
/// Recalcula la geometría de **todas** las salidas y la empaqueta en
/// un único [`BrainCommand::Place`]. Sin salidas, no hay nada que
/// colocar.
fn relayout(&self) -> Vec<BrainCommand> {
if self.outputs.is_empty() {
return Vec::new();
}
let mut all = Vec::new();
for o in &self.outputs {
all.extend(placements(&self.workspaces[o.workspace], o.rect));
}
// El foco del teclado es único: sólo la ventana enfocada de la
// salida enfocada. `placements` marca el foco por escritorio (lo
// necesita para la visibilidad en `Monocle`); aquí lo unificamos.
let global_focus = self.focused_window();
for p in &mut all {
p.focused = Some(p.id) == global_focus;
}
vec![BrainCommand::Place(all)]
}
// --- Accesores de sólo lectura, para el HUD de la app GPUI ---------
/// El escritorio activo — el de la salida enfocada.
pub fn active_workspace(&self) -> &Workspace {
&self.workspaces[self.active_index()]
}
/// Las salidas conectadas, en orden, con el escritorio que muestran.
pub fn outputs(&self) -> &[Output] {
&self.outputs
}
/// Índice (en [`outputs`](Desktop::outputs)) de la salida enfocada.
pub fn focused_output(&self) -> usize {
self.focused_output
}
/// Identidad de una ventana conocida.
pub fn window_info(&self, id: WindowId) -> Option<&WindowInfo> {
self.windows.get(&id)
}
/// La ventana con el foco del teclado: la enfocada del escritorio
/// activo — o su ventana en pantalla completa, si la hay.
pub fn focused_window(&self) -> Option<WindowId> {
let ws = &self.workspaces[self.active_index()];
ws.fullscreen().or_else(|| ws.focused())
}
/// Cuántas ventanas hay en cada escritorio virtual.
pub fn workspace_loads(&self) -> Vec<usize> {
self.workspaces.iter().map(Workspace::len).collect()
}
/// Una vista de todas las ventanas conocidas, en todos los
/// escritorios — la base de `mirada-ctl windows` y de una taskbar.
pub fn window_lines(&self) -> Vec<crate::ctl::WindowLine> {
let active = self.active_index();
let mut lines = Vec::new();
for (n, ws) in self.workspaces.iter().enumerate() {
let ws_focus = ws.focused();
for &id in ws.windows() {
let info = self.windows.get(&id);
lines.push(crate::ctl::WindowLine {
id,
app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
title: info.map(|i| i.title.clone()).unwrap_or_default(),
workspace: n + 1,
focused: n == active && ws_focus == Some(id),
});
}
}
// Ventanas guardadas en el scratchpad — en ningún escritorio.
for &id in &self.scratchpad {
let stashed = !self.workspaces.iter().any(|ws| ws.windows().contains(&id));
if stashed {
let info = self.windows.get(&id);
lines.push(crate::ctl::WindowLine {
id,
app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
title: info.map(|i| i.title.clone()).unwrap_or_default(),
workspace: 0, // 0 = guardada en el scratchpad
focused: false,
});
}
}
lines
}
}
/// El rectángulo flotante por defecto: 60 % de la pantalla, centrado.
fn centered_float_rect(screen: Rect) -> Rect {
let w = screen.w * 3 / 5;
let h = screen.h * 3 / 5;
Rect::new(
screen.x + (screen.w - w) / 2,
screen.y + (screen.h - h) / 2,
w,
h,
)
}
#[cfg(test)]
mod tests {
use super::*;
use mirada_layout::LayoutMode;
/// Un escritorio con una salida 1920×1080 ya conectada.
fn desktop_with_screen() -> Desktop {
let mut d = Desktop::new();
d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
d
}
fn open(d: &mut Desktop, id: WindowId) -> Vec<BrainCommand> {
d.on_event(BodyEvent::WindowOpened {
id,
app_id: format!("app{id}"),
title: format!("win {id}"),
})
}
/// Extrae las colocaciones de un único `Place`.
fn places(cmds: &[BrainCommand]) -> &[mirada_protocol::WindowPlacement] {
match cmds {
[BrainCommand::Place(p)] => p,
other => panic!("se esperaba un solo Place, no {other:?}"),
}
}
#[test]
fn grab_keys_lists_the_whole_keymap() {
let d = Desktop::new();
match d.grab_keys() {
BrainCommand::GrabKeys(keys) => {
assert!(keys.contains(&"Super+j".to_string()));
assert!(keys.contains(&"Super+Shift+e".to_string()));
}
other => panic!("se esperaba GrabKeys, no {other:?}"),
}
}
#[test]
fn set_keymap_swaps_the_bindings_and_regrabs() {
let mut d = desktop_with_screen();
for id in [1, 2, 3] {
open(&mut d, id);
}
// El keymap por defecto no usa Alt.
assert!(d.on_event(BodyEvent::Keybind("Alt+x".into())).is_empty());
// Cargamos un keymap a medida; el comando devuelto re-registra grabs.
let custom = crate::Keymap::from_ron(r#"( bindings: { "Alt+x": "focus-prev" } )"#).unwrap();
match d.set_keymap(custom) {
BrainCommand::GrabKeys(keys) => assert_eq!(keys, vec!["Alt+x".to_string()]),
other => panic!("se esperaba GrabKeys, no {other:?}"),
}
// Ahora «Alt+x» sí mueve el foco, y «Super+j» ya no.
assert_eq!(d.focused_window(), Some(3));
d.on_event(BodyEvent::Keybind("Alt+x".into()));
assert_eq!(d.focused_window(), Some(2));
assert!(d.on_event(BodyEvent::Keybind("Super+j".into())).is_empty());
}
#[test]
fn focus_window_addresses_a_specific_window() {
let mut d = desktop_with_screen();
for id in [1, 2, 3] {
open(&mut d, id);
}
assert_eq!(d.focused_window(), Some(3));
d.apply(DesktopAction::FocusWindow(1));
assert_eq!(d.focused_window(), Some(1));
}
#[test]
fn focus_window_jumps_to_the_workspace_that_holds_it() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2); // enfocada
// Manda la 2 al escritorio 3; seguimos en el 1.
d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
assert_eq!(d.active_index(), 0);
// Enfocar la 2 nos lleva a su escritorio.
d.apply(DesktopAction::FocusWindow(2));
assert_eq!(d.active_index(), 2);
assert_eq!(d.focused_window(), Some(2));
}
#[test]
fn window_lines_cover_every_window_with_its_workspace() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2);
d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); // la 2 al esc. 3
let lines = d.window_lines();
assert_eq!(lines.len(), 2);
let w1 = lines.iter().find(|l| l.id == 1).unwrap();
let w2 = lines.iter().find(|l| l.id == 2).unwrap();
assert_eq!(w1.workspace, 1);
assert_eq!(w2.workspace, 3);
// La 1 quedó enfocada en el escritorio activo (el 1).
assert!(w1.focused);
assert!(!w2.focused);
}
#[test]
fn toggle_float_marks_the_focused_window_and_floats_it_last() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2); // enfocada
let cmds = d.apply(DesktopAction::ToggleFloat);
let p = places(&cmds);
assert!(p.iter().find(|x| x.id == 2).unwrap().floating);
// La flotante va al final de la lista — orden de pintado.
assert_eq!(p.last().unwrap().id, 2);
// Alternar de nuevo la devuelve al teselado.
let cmds = d.apply(DesktopAction::ToggleFloat);
assert!(!places(&cmds).iter().find(|x| x.id == 2).unwrap().floating);
}
#[test]
fn toggle_fullscreen_covers_the_screen_and_hides_the_rest() {
let mut d = desktop_with_screen();
for id in [1, 2, 3] {
open(&mut d, id);
}
let cmds = d.apply(DesktopAction::ToggleFullscreen); // sobre la 3
let p = places(&cmds);
let fs = p.iter().find(|x| x.id == 3).unwrap();
assert!(fs.fullscreen && fs.visible);
assert_eq!(fs.rect, d.screen().unwrap());
assert!(p.iter().filter(|x| x.id != 3).all(|x| !x.visible));
// Alternar de nuevo restaura el teselado: las tres visibles.
let cmds = d.apply(DesktopAction::ToggleFullscreen);
assert_eq!(places(&cmds).iter().filter(|x| x.visible).count(), 3);
}
#[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();
assert!(open(&mut d, 1).is_empty());
}
#[test]
fn opening_a_window_places_it() {
let mut d = desktop_with_screen();
let cmds = open(&mut d, 1);
assert_eq!(places(&cmds).len(), 1);
assert_eq!(d.focused_window(), Some(1));
}
#[test]
fn closing_a_window_removes_it_everywhere() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2);
let cmds = d.on_event(BodyEvent::WindowClosed { id: 1 });
assert_eq!(places(&cmds).len(), 1);
assert!(d.window_info(1).is_none());
assert_eq!(d.focused_window(), Some(2));
}
#[test]
fn focus_keybind_cycles_within_the_active_workspace() {
let mut d = desktop_with_screen();
for id in [1, 2, 3] {
open(&mut d, id);
}
assert_eq!(d.focused_window(), Some(3));
d.on_event(BodyEvent::Keybind("Super+j".into())); // next, da la vuelta
assert_eq!(d.focused_window(), Some(1));
d.on_event(BodyEvent::Keybind("Super+k".into())); // prev
assert_eq!(d.focused_window(), Some(3));
}
#[test]
fn close_focused_keybind_asks_to_close_the_focused_window() {
let mut d = desktop_with_screen();
open(&mut d, 7);
let cmds = d.on_event(BodyEvent::Keybind("Super+q".into()));
assert_eq!(cmds, vec![BrainCommand::Close(7)]);
// No se elimina hasta que el Cuerpo confirme con WindowClosed.
assert!(d.window_info(7).is_some());
}
#[test]
fn cycle_layout_walks_every_mode_and_returns() {
let mut d = desktop_with_screen();
open(&mut d, 1);
let start = d.active_workspace().params().mode;
for _ in 0..LayoutMode::ALL.len() {
let before = d.active_workspace().params().mode;
d.on_event(BodyEvent::Keybind("Super+space".into()));
assert_eq!(d.active_workspace().params().mode, before.next());
}
// Una vuelta completa devuelve al modo inicial.
assert_eq!(d.active_workspace().params().mode, start);
}
#[test]
fn grow_and_shrink_master_adjust_the_ratio() {
let mut d = desktop_with_screen();
open(&mut d, 1);
let r0 = d.active_workspace().params().master_ratio;
d.apply(DesktopAction::GrowMaster);
assert!(d.active_workspace().params().master_ratio > r0);
d.apply(DesktopAction::ShrinkMaster);
assert!((d.active_workspace().params().master_ratio - r0).abs() < 1e-6);
}
#[test]
fn inc_and_dec_master_adjust_nmaster() {
let mut d = desktop_with_screen();
for id in [1, 2, 3] {
open(&mut d, id);
}
assert_eq!(d.active_workspace().params().master_count, 1);
d.apply(DesktopAction::IncMaster);
assert_eq!(d.active_workspace().params().master_count, 2);
d.apply(DesktopAction::DecMaster);
d.apply(DesktopAction::DecMaster); // no baja de 1
assert_eq!(d.active_workspace().params().master_count, 1);
}
#[test]
fn promote_to_master_brings_the_focused_window_to_the_front() {
let mut d = desktop_with_screen();
for id in [1, 2, 3] {
open(&mut d, id);
}
d.apply(DesktopAction::FocusWindow(3));
d.apply(DesktopAction::PromoteToMaster);
assert_eq!(d.active_workspace().windows()[0], 3);
assert_eq!(d.focused_window(), Some(3));
}
#[test]
fn master_ratio_stays_within_bounds() {
let mut d = desktop_with_screen();
open(&mut d, 1);
for _ in 0..50 {
d.apply(DesktopAction::GrowMaster);
}
assert!(d.active_workspace().params().master_ratio <= 0.95);
for _ in 0..50 {
d.apply(DesktopAction::ShrinkMaster);
}
assert!(d.active_workspace().params().master_ratio >= 0.05);
}
#[test]
fn monocle_keybind_hides_all_but_the_focused_window() {
let mut d = desktop_with_screen();
for id in [1, 2, 3] {
open(&mut d, id);
}
let cmds = d.on_event(BodyEvent::Keybind("Super+m".into()));
let visible = places(&cmds).iter().filter(|p| p.visible).count();
assert_eq!(visible, 1);
}
#[test]
fn switching_workspace_changes_what_is_placed() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2);
// Escritorio 2 (índice 1) está vacío.
let cmds = d.on_event(BodyEvent::Keybind("Super+2".into()));
assert!(places(&cmds).is_empty());
assert_eq!(d.active_index(), 1);
// Volver al 1 reaparece las dos ventanas.
let cmds = d.on_event(BodyEvent::Keybind("Super+1".into()));
assert_eq!(places(&cmds).len(), 2);
}
#[test]
fn send_to_workspace_moves_the_focused_window() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2); // enfocada
d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
assert_eq!(d.workspace_loads()[2], 1); // la 2 viajó al escritorio 3
// La ventana 2 sigue registrada — sólo cambió de escritorio.
assert!(d.window_info(2).is_some());
}
#[test]
fn pointer_focuses_a_window_in_the_active_workspace() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2); // enfocada
d.on_event(BodyEvent::PointerEntered { id: 1 });
assert_eq!(d.focused_window(), Some(1));
}
#[test]
fn dragging_floats_a_window_at_the_given_rect() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2);
assert!(!d.active_workspace().is_floating(2));
let target = Rect::new(300, 200, 640, 480);
let cmds = d.on_event(BodyEvent::WindowFloatTo { id: 2, rect: target });
// La 2 ahora flota exactamente en el rectángulo pedido.
assert!(d.active_workspace().is_floating(2));
let p = places(&cmds).iter().find(|p| p.id == 2).unwrap();
assert!(p.floating);
assert_eq!(p.rect, target);
}
#[test]
fn resizing_an_output_retiles_without_losing_the_workspace() {
let mut d = desktop_with_screen();
open(&mut d, 1);
d.on_event(BodyEvent::Keybind("Super+2".into())); // escritorio activo → 2
assert_eq!(d.active_index(), 1);
let cmds = d.on_event(BodyEvent::OutputResized {
id: 0,
width: 1920,
height: 1040,
});
// A diferencia de quitar y volver a añadir la salida, el
// escritorio activo se conserva.
assert_eq!(d.active_index(), 1);
assert!(matches!(cmds.as_slice(), [BrainCommand::Place(_)]));
}
#[test]
fn a_spawn_keybind_becomes_a_spawn_command() {
let mut d = desktop_with_screen();
let cmds = d.on_event(BodyEvent::Keybind("Super+Shift+Return".into()));
assert_eq!(cmds, vec![BrainCommand::Spawn("foot".into())]);
}
#[test]
fn dragging_an_unknown_window_does_nothing() {
let mut d = desktop_with_screen();
open(&mut d, 1);
let cmds = d.on_event(BodyEvent::WindowFloatTo {
id: 99,
rect: Rect::new(0, 0, 100, 100),
});
assert!(cmds.is_empty());
}
#[test]
fn retitling_updates_the_registry_without_relayout() {
let mut d = desktop_with_screen();
open(&mut d, 1);
let cmds = d.on_event(BodyEvent::WindowRetitled {
id: 1,
title: "nuevo".into(),
});
assert!(cmds.is_empty());
assert_eq!(d.window_info(1).unwrap().title, "nuevo");
}
#[test]
fn an_unknown_keybind_does_nothing() {
let mut d = desktop_with_screen();
open(&mut d, 1);
assert!(d.on_event(BodyEvent::Keybind("Super+F12".into())).is_empty());
}
#[test]
fn quit_emits_a_shutdown() {
let mut d = desktop_with_screen();
assert_eq!(
d.on_event(BodyEvent::Keybind("Super+Shift+e".into())),
vec![BrainCommand::Shutdown]
);
}
// --- Multi-monitor -------------------------------------------------
/// Un escritorio con dos salidas 1920×1080.
fn desktop_with_two_outputs() -> Desktop {
let mut d = Desktop::new();
d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
d.on_event(BodyEvent::OutputAdded { id: 1, width: 1920, height: 1080 });
d
}
#[test]
fn outputs_lay_side_by_side() {
let mut d = Desktop::new();
d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
d.on_event(BodyEvent::OutputAdded { id: 1, width: 2560, height: 1440 });
assert_eq!(d.outputs().len(), 2);
// La segunda salida arranca donde acaba la primera.
assert_eq!(d.outputs()[1].rect.x, 1920);
}
#[test]
fn each_output_shows_a_distinct_workspace() {
let d = desktop_with_two_outputs();
assert_eq!(d.outputs()[0].workspace, 0);
assert_eq!(d.outputs()[1].workspace, 1);
}
#[test]
fn switching_to_a_workspace_shown_on_another_output_swaps_them() {
let mut d = desktop_with_two_outputs();
// La salida enfocada (0, ws 0) pide el ws 1, que muestra la 1 → swap.
d.apply(DesktopAction::SwitchWorkspace(1));
assert_eq!(d.outputs()[0].workspace, 1);
assert_eq!(d.outputs()[1].workspace, 0);
}
#[test]
fn focus_output_next_moves_the_focus_between_outputs() {
let mut d = desktop_with_two_outputs();
assert_eq!(d.active_index(), 0); // salida 0 → ws 0
d.apply(DesktopAction::FocusOutputNext);
assert_eq!(d.active_index(), 1); // salida 1 → ws 1
d.apply(DesktopAction::FocusOutputNext); // envuelve
assert_eq!(d.active_index(), 0);
}
#[test]
fn relayout_places_windows_on_every_output() {
let mut d = Desktop::new();
d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
d.on_event(BodyEvent::OutputAdded { id: 1, width: 1280, height: 720 });
open(&mut d, 1); // en la salida 0 (ws 0)
d.apply(DesktopAction::FocusOutputNext);
let cmds = open(&mut d, 2); // en la salida 1 (ws 1)
let p = places(&cmds);
assert_eq!(p.len(), 2);
// Cada ventana cae en el rectángulo de su salida.
assert_eq!(p.iter().find(|x| x.id == 1).unwrap().rect.x, 0);
assert_eq!(p.iter().find(|x| x.id == 2).unwrap().rect.x, 1920);
}
#[test]
fn keyboard_focus_is_unique_across_outputs() {
let mut d = desktop_with_two_outputs();
open(&mut d, 1);
d.apply(DesktopAction::FocusOutputNext);
let cmds = open(&mut d, 2);
// Sólo una ventana con foco de teclado en todo el Place.
assert_eq!(places(&cmds).iter().filter(|p| p.focused).count(), 1);
}
#[test]
fn removing_an_output_keeps_its_windows_in_their_workspace() {
let mut d = desktop_with_two_outputs();
d.apply(DesktopAction::FocusOutputNext); // foco en la salida 1 (ws 1)
open(&mut d, 1); // en ws 1
d.on_event(BodyEvent::OutputRemoved { id: 1 });
// La ventana sigue registrada, en el ws 1.
assert!(d.window_info(1).is_some());
assert_eq!(d.workspace_loads()[1], 1);
assert_eq!(d.outputs().len(), 1);
}
// --- Scratchpad ----------------------------------------------------
#[test]
fn send_to_scratchpad_hides_the_focused_window() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2); // enfocada
d.apply(DesktopAction::SendToScratchpad);
assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
assert!(d.window_info(2).is_some()); // sigue registrada
}
#[test]
fn toggle_scratchpad_shows_then_hides_the_stashed_window() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2);
d.apply(DesktopAction::SendToScratchpad); // guarda la 2
assert_eq!(d.workspace_loads()[0], 1);
// Toggle la invoca, flotando.
let cmds = d.apply(DesktopAction::ToggleScratchpad);
assert!(places(&cmds).iter().find(|x| x.id == 2).unwrap().floating);
assert_eq!(d.workspace_loads()[0], 2);
// Toggle de nuevo la oculta.
d.apply(DesktopAction::ToggleScratchpad);
assert_eq!(d.workspace_loads()[0], 1);
}
#[test]
fn a_scratchpad_window_follows_you_across_workspaces() {
let mut d = desktop_with_screen();
open(&mut d, 1);
d.apply(DesktopAction::SendToScratchpad);
d.apply(DesktopAction::ToggleScratchpad); // mostrada en el escritorio 1
assert_eq!(d.workspace_loads()[0], 1);
d.apply(DesktopAction::SwitchWorkspace(1)); // al escritorio 2
d.apply(DesktopAction::ToggleScratchpad); // estaba en el 1 → la trae al 2
assert_eq!(d.workspace_loads()[1], 1);
assert_eq!(d.workspace_loads()[0], 0);
}
#[test]
fn closing_a_stashed_window_drops_it_from_the_scratchpad() {
let mut d = desktop_with_screen();
open(&mut d, 1);
d.apply(DesktopAction::SendToScratchpad);
d.on_event(BodyEvent::WindowClosed { id: 1 });
// Ya no hay nada que invocar.
assert!(d.apply(DesktopAction::ToggleScratchpad).is_empty());
}
#[test]
fn a_client_fullscreen_request_is_honoured() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2);
let cmds = d.on_event(BodyEvent::FullscreenRequest { id: 1, fullscreen: true });
assert!(places(&cmds).iter().find(|x| x.id == 1).unwrap().fullscreen);
// El cliente la suelta.
let cmds = d.on_event(BodyEvent::FullscreenRequest { id: 1, fullscreen: false });
assert!(!places(&cmds).iter().find(|x| x.id == 1).unwrap().fullscreen);
}
#[test]
fn a_fullscreen_request_for_an_unknown_window_does_nothing() {
let mut d = desktop_with_screen();
open(&mut d, 1);
assert!(d
.on_event(BodyEvent::FullscreenRequest { id: 99, fullscreen: true })
.is_empty());
}
#[test]
fn window_lines_show_a_stashed_window_as_workspace_zero() {
let mut d = desktop_with_screen();
open(&mut d, 1);
d.apply(DesktopAction::SendToScratchpad);
let line = d.window_lines().into_iter().find(|l| l.id == 1).unwrap();
assert_eq!(line.workspace, 0);
}
}