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>
diff --git a/crates/apps/mirada-compositor/src/drm_backend.rs b/crates/apps/mirada-compositor/src/drm_backend.rs
index 0cac095..34f0933 100644
--- a/crates/apps/mirada-compositor/src/drm_backend.rs
+++ b/crates/apps/mirada-compositor/src/drm_backend.rs
@@ -157,8 +157,8 @@ impl DrmState {
// tamaño (sigue al contenido) y su color (según el foco). Cada
// `SolidColorBuffer` sube su contador de daño sólo si algo cambió.
for w in &mut self.app.windows {
- if !w.visible {
- continue;
+ if !w.visible || w.is_shell {
+ continue; // el shell no lleva marco
}
let (x, y) = crate::render_loc(w);
let (sw, sh) = crate::surface_px_size(w).unwrap_or(w.size);
@@ -210,21 +210,27 @@ impl DrmState {
}
}
+ // El shell va sobre todo; luego las flotantes; luego las
+ // teseladas. `sort_by_key` es estable: respeta el orden de
+ // apertura dentro de cada grupo.
let mut shown: Vec<_> = self.app.windows.iter().filter(|w| w.visible).collect();
- shown.sort_by_key(|w| !w.floating);
+ shown.sort_by_key(|w| (!w.is_shell, !w.floating));
for w in &shown {
let (x, y) = crate::render_loc(w);
let (sw, sh) = crate::surface_px_size(w).unwrap_or(w.size);
- let rects = border_rects(x, y, sw, sh);
- // El marco, encima de la propia superficie de la ventana.
- for (buf, (bx, by, _, _)) in w.borders.iter().zip(rects) {
- out.push(Frame::Solid(SolidColorRenderElement::from_buffer(
- buf,
- (bx, by),
- 1.0,
- 1.0,
- Kind::Unspecified,
- )));
+ // El marco, encima de la propia superficie de la ventana
+ // — el shell no lleva.
+ if !w.is_shell {
+ let rects = border_rects(x, y, sw, sh);
+ for (buf, (bx, by, _, _)) in w.borders.iter().zip(rects) {
+ out.push(Frame::Solid(SolidColorRenderElement::from_buffer(
+ buf,
+ (bx, by),
+ 1.0,
+ 1.0,
+ Kind::Unspecified,
+ )));
+ }
}
for el in render_elements_from_surface_tree(
&mut self.renderer,
@@ -525,13 +531,25 @@ impl DrmState {
self.app.cursor_status = CursorImageStatus::default_named();
}
- // Foco-sigue-ratón: al pasar a otra ventana, que el Cerebro la enfoque.
+ // Foco-sigue-ratón: al pasar a otra ventana, que la enfoque quien
+ // corresponda — el Cerebro para las teseladas, carmen mismo para
+ // el shell (que no vive en el Cerebro).
let hovered = hit.map(|i| self.app.windows[i].id);
if hovered != self.last_pointer_window {
self.last_pointer_window = hovered;
- if let Some(id) = hovered {
- let ev = self.app.body.pointer_enter(id);
- self.app.brain_feed(ev);
+ match hit {
+ Some(i) if self.app.windows[i].is_shell => {
+ let surf = self.app.windows[i].surface.clone();
+ if let Some(kb) = self.app.keyboard.clone() {
+ kb.set_focus(&mut self.app, Some(surf), SERIAL_COUNTER.next_serial());
+ }
+ }
+ Some(i) => {
+ let id = self.app.windows[i].id;
+ let ev = self.app.body.pointer_enter(id);
+ self.app.brain_feed(ev);
+ }
+ None => {}
}
}
}
@@ -564,13 +582,17 @@ impl DrmState {
true
}
- /// El índice de la ventana visible bajo el punto `(x, y)`, si la hay —
- /// en orden front-to-back (las flotantes ganan a las teseladas).
+ /// El índice de la ventana visible bajo el punto `(x, y)`, si la hay
+ /// — en orden front-to-back (el shell gana a las flotantes, y éstas a
+ /// las teseladas).
fn window_at(&self, x: f64, y: f64) -> Option<usize> {
let mut idx: Vec<usize> = (0..self.app.windows.len())
.filter(|&i| self.app.windows[i].visible)
.collect();
- idx.sort_by_key(|&i| !self.app.windows[i].floating);
+ idx.sort_by_key(|&i| {
+ let w = &self.app.windows[i];
+ (!w.is_shell, !w.floating)
+ });
idx.into_iter().find(|&i| {
let w = &self.app.windows[i];
let (lx, ly) = crate::render_loc(w);
@@ -716,6 +738,7 @@ pub fn run() -> Result<(), Box<dyn Error>> {
// La salida del Cerebro = el modo del monitor.
let ev = app.body.add_output(0, mode_w as i32, mode_h as i32);
app.brain_feed(ev);
+ app.output_size = (mode_w as i32, mode_h as i32);
// El puntero arranca en el centro de la pantalla.
app.pointer_loc = (mode_w as f64 / 2.0, mode_h as f64 / 2.0);
// Anuncia el monitor en el protocolo Wayland — los clientes lo exigen.
diff --git a/crates/apps/mirada-compositor/src/main.rs b/crates/apps/mirada-compositor/src/main.rs
index 6c3804d..04032dc 100644
--- a/crates/apps/mirada-compositor/src/main.rs
+++ b/crates/apps/mirada-compositor/src/main.rs
@@ -89,6 +89,13 @@ enum Brain {
Linked(BodyLink),
}
+/// `app_id` que distingue a la ventana del shell del escritorio. carmen
+/// no la tesela: la acopla a una franja al pie de la pantalla.
+const SHELL_APP_ID: &str = "carmen.shell";
+
+/// Alto en píxeles de la franja del shell, al pie de la salida.
+const SHELL_DOCK_HEIGHT: i32 = 40;
+
/// Una ventana de cliente que el compositor gestiona.
struct ManagedWindow {
id: u64,
@@ -104,6 +111,8 @@ struct ManagedWindow {
floating: bool,
/// `true` si tiene el foco del teclado — pinta el marco resaltado.
focused: bool,
+ /// `true` si es la ventana del shell — acoplada al pie, sin teselar.
+ is_shell: bool,
/// Búferes de los 4 lados del marco (arriba, abajo, izq., der.) —
/// cada uno con su `Id` estable para el seguimiento de daño.
borders: [SolidColorBuffer; 4],
@@ -150,6 +159,9 @@ struct App {
cursor_status: CursorImageStatus,
/// Arrastre de ventana en curso (mover o redimensionar con el ratón).
drag: Option<DragGrab>,
+ /// Tamaño real de la salida (con la franja del shell incluida) — lo
+ /// fija el backend; sirve para acoplar la ventana del shell.
+ output_size: (i32, i32),
/// Ventanas gestionadas, en orden de aparición.
windows: Vec<ManagedWindow>,
@@ -304,8 +316,8 @@ impl App {
})
.unwrap_or_default()
});
- let app_id = if app_id.is_empty() { "cliente".into() } else { app_id };
- let title = if title.is_empty() { format!("ventana {id}") } else { title };
+ // La ventana del shell no se tesela: carmen la acopla al pie.
+ let is_shell = app_id == SHELL_APP_ID;
self.windows.push(ManagedWindow {
id,
@@ -316,10 +328,55 @@ impl App {
visible: false,
floating: false,
focused: false,
+ is_shell,
borders: std::array::from_fn(|_| SolidColorBuffer::default()),
});
- let ev = self.body.open_surface(id, app_id, title);
+
+ if is_shell {
+ self.dock_shell();
+ } else {
+ let app_id = if app_id.is_empty() { "cliente".into() } else { app_id };
+ let title = if title.is_empty() { format!("ventana {id}") } else { title };
+ let ev = self.body.open_surface(id, app_id, title);
+ self.brain_feed(ev);
+ }
+ }
+
+ /// Acopla la ventana del shell: le reserva una franja al pie de la
+ /// salida —el Cerebro tesela el área que queda— y la dimensiona y
+ /// coloca ahí. Se llama al registrarla y al cambiar el tamaño de la
+ /// salida.
+ fn dock_shell(&mut self) {
+ let (ow, oh) = self.output_size;
+ if ow == 0 || oh == 0 {
+ return; // la salida todavía no está lista
+ }
+ // Reserva la franja: el Cerebro tesela en el alto que queda.
+ let ev = self.body.resize_output(0, ow, oh - SHELL_DOCK_HEIGHT);
self.brain_feed(ev);
+ // Dimensiona la ventana del shell y la fija en la franja.
+ if let Some(w) = self.windows.iter_mut().find(|w| w.is_shell) {
+ w.loc = (0, oh - SHELL_DOCK_HEIGHT);
+ w.size = (ow, SHELL_DOCK_HEIGHT);
+ w.visible = true;
+ w.toplevel.with_pending_state(|s| {
+ s.size = Some((ow.max(1), SHELL_DOCK_HEIGHT.max(1)).into());
+ });
+ w.toplevel.send_pending_configure();
+ }
+ }
+
+ /// El backend informa de un tamaño de salida nuevo (arranque o
+ /// redimensión). Si hay shell acoplado, recoloca su franja; si no,
+ /// le pasa el área entera al Cerebro.
+ fn output_changed(&mut self, width: i32, height: i32) {
+ self.output_size = (width, height);
+ if self.windows.iter().any(|w| w.is_shell) {
+ self.dock_shell();
+ } else {
+ let ev = self.body.resize_output(0, width, height);
+ self.brain_feed(ev);
+ }
}
}
@@ -388,8 +445,16 @@ impl XdgShellHandler for App {
.iter()
.position(|w| w.surface == *surface.wl_surface());
if let Some(pos) = pos {
- let id = self.windows.remove(pos).id;
- if let Some(ev) = self.body.close_surface(id) {
+ let w = self.windows.remove(pos);
+ if w.is_shell {
+ // El shell se cerró: libera su franja, el Cerebro vuelve
+ // a teselar en la salida entera.
+ let (ow, oh) = self.output_size;
+ if ow != 0 && oh != 0 {
+ let ev = self.body.resize_output(0, ow, oh);
+ self.brain_feed(ev);
+ }
+ } else if let Some(ev) = self.body.close_surface(w.id) {
self.brain_feed(ev);
}
}
@@ -813,6 +878,7 @@ fn build_app() -> Result<Setup, Box<dyn std::error::Error>> {
pointer_loc: (0.0, 0.0),
cursor_status: CursorImageStatus::default_named(),
drag: None,
+ output_size: (0, 0),
windows: Vec::new(),
body: BodyState::new(),
brain,
@@ -925,6 +991,7 @@ fn run_winit() -> Result<(), Box<dyn std::error::Error>> {
{
let ev = state.body.add_output(0, win_size.w, win_size.h);
state.brain_feed(ev);
+ state.output_size = (win_size.w, win_size.h);
}
while state.running {
@@ -932,10 +999,7 @@ fn run_winit() -> Result<(), Box<dyn std::error::Error>> {
let status = winit.dispatch_new_events(|event| match event {
WinitEvent::CloseRequested => state.running = false,
WinitEvent::Resized { size, .. } => {
- let ev = state.body.remove_output(0);
- state.brain_feed(ev);
- let ev = state.body.add_output(0, size.w, size.h);
- state.brain_feed(ev);
+ state.output_changed(size.w, size.h);
}
WinitEvent::Input(InputEvent::Keyboard { event }) => {
let code = event.key_code();
@@ -1015,12 +1079,12 @@ fn run_winit() -> Result<(), Box<dyn std::error::Error>> {
{
let (renderer, mut framebuffer) = backend.bind().unwrap();
// Orden de pintado: la lista de elementos va front-to-back
- // (índice 0 = encima), así que las flotantes —que deben
- // quedar sobre las teseladas— se ordenan primero. `sort_by_key`
- // es estable: dentro de cada grupo se respeta el orden de apertura.
+ // (índice 0 = encima): el shell primero —va sobre todo—, luego
+ // las flotantes, luego las teseladas. `sort_by_key` es estable:
+ // dentro de cada grupo se respeta el orden de apertura.
let mut shown: Vec<&ManagedWindow> =
state.windows.iter().filter(|w| w.visible).collect();
- shown.sort_by_key(|w| !w.floating);
+ shown.sort_by_key(|w| (!w.is_shell, !w.floating));
let elements: Vec<WaylandSurfaceRenderElement<GlesRenderer>> = shown
.iter()
.flat_map(|w| {
diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md
index 5619e8a..0b642e7 100644
--- a/crates/modules/mirada/SDD.md
+++ b/crates/modules/mirada/SDD.md
@@ -228,14 +228,21 @@ Cerebro: **autónomo** (`Desktop` embebido) o **enlazado** (`MIRADA_SOCKET`
- **Sesión** — `~/.config/mirada/autostart` (un comando por línea) se
lanza al arrancar el backend DRM; el script `session/mirada-session` y
`session/carmen.desktop` integran carmen con un gestor de login.
+- **Acople del shell** — una ventana con `app_id = "carmen.shell"` no se
+ tesela: carmen le reserva una franja de 40 px al pie de la salida y la
+ pinta sobre todo. La reserva viaja como `BodyEvent::OutputResized`, que
+ encoge el área útil del Cerebro **sin** perder el escritorio que
+ muestra (a diferencia de quitar y volver a añadir la salida). Es el
+ anclaje de la futura `shuma-shell` en modo launcher.
**Pendiente** — refinamientos del Cuerpo:
-| capa pendiente | rol |
-| ---------------- | ------------------------------------------------------------ |
+| capa pendiente | rol |
+| ------------------ | ---------------------------------------------------------- |
| puntero en `winit` | ratón en el backend anidado (hoy sólo el backend DRM) |
-| `mirada-input` | repetición de teclas, gestos; hotplug de monitores |
-| barra de estado | `wlr-layer-shell` + un cliente que dibuje la barra |
-| `mirada-sandbox` | aislamiento de clientes sobre `arje-incarnate` |
+| `mirada-input` | repetición de teclas, gestos; hotplug de monitores |
+| `shuma-shell` | modo launcher: barra + input + cajón sobre el acople shell |
+| `wlr-layer-shell` | barras externas tipo waybar, fondos, notificaciones |
+| `mirada-sandbox` | aislamiento de clientes sobre `arje-incarnate` |
CRIU (congelar/restaurar ventanas) queda anotado como futuro.
diff --git a/crates/modules/mirada/mirada-body/src/lib.rs b/crates/modules/mirada/mirada-body/src/lib.rs
index 001d559..729bcfe 100644
--- a/crates/modules/mirada/mirada-body/src/lib.rs
+++ b/crates/modules/mirada/mirada-body/src/lib.rs
@@ -184,6 +184,17 @@ impl BodyState {
BodyEvent::OutputRemoved { id }
}
+ /// Cambia el área útil de una salida sin desconectarla — al
+ /// redimensionar la ventana anfitriona o al reservar/liberar la
+ /// franja del shell. Conserva el escritorio que muestra.
+ pub fn resize_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent {
+ if let Some((_, rect)) = self.outputs.iter_mut().find(|(o, _)| *o == id) {
+ rect.w = width;
+ rect.h = height;
+ }
+ BodyEvent::OutputResized { id, width, height }
+ }
+
/// Registra una superficie recién creada por un cliente.
pub fn open_surface(
&mut self,
diff --git a/crates/modules/mirada/mirada-brain/src/desktop.rs b/crates/modules/mirada/mirada-brain/src/desktop.rs
index cdeb3ec..a6094f9 100644
--- a/crates/modules/mirada/mirada-brain/src/desktop.rs
+++ b/crates/modules/mirada/mirada-brain/src/desktop.rs
@@ -141,6 +141,18 @@ impl Desktop {
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);
@@ -877,6 +889,23 @@ mod tests {
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();
diff --git a/crates/modules/mirada/mirada-protocol/src/lib.rs b/crates/modules/mirada/mirada-protocol/src/lib.rs
index aa87cde..d7257a0 100644
--- a/crates/modules/mirada/mirada-protocol/src/lib.rs
+++ b/crates/modules/mirada/mirada-protocol/src/lib.rs
@@ -82,6 +82,11 @@ pub enum BodyEvent {
OutputAdded { id: OutputId, width: i32, height: i32 },
/// Desapareció un monitor.
OutputRemoved { id: OutputId },
+ /// Cambió el área útil de un monitor — porque se redimensionó la
+ /// ventana anfitriona, o porque el shell reservó o liberó su franja.
+ /// El escritorio que muestra **no** cambia (a diferencia de quitar y
+ /// volver a añadir la salida).
+ OutputResized { id: OutputId, width: i32, height: i32 },
/// Un cliente creó una ventana de nivel superior.
WindowOpened { id: WindowId, app_id: String, title: String },
/// Una ventana se cerró (por el cliente o tras un [`BrainCommand::Close`]).
diff --git a/vamos.txt b/vamos.txt
index 7b42fd7..50125c1 100644
--- a/vamos.txt
+++ b/vamos.txt
@@ -1009,6 +1009,7 @@
Lanzar programas: acción spawn:<comando> del keymap (Super+Shift+Return → spawn:foot por defecto).
Lanzador de apps: mirada-launcher (escanea los .desktop, lista filtrable de terminal); atado a Super+p.
Conmutación de VT: Ctrl+Alt+Fn salta a otra TTY y vuelve sin romper la sesión (pausa DRM + libinput).
+ Acople del shell: una ventana con app_id "carmen.shell" se ancla en una franja al pie; el resto tesela arriba.
Sesión: ~/.config/mirada/autostart (un comando por línea) + script session/mirada-session + carmen.desktop.
Ver crates/apps/mirada-compositor/README.md.