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>
This commit is contained in:
sergio
2026-05-21 05:38:12 +00:00
parent 7b5c583a98
commit ee27108f6c
7 changed files with 178 additions and 38 deletions
@@ -157,8 +157,8 @@ impl DrmState {
// tamaño (sigue al contenido) y su color (según el foco). Cada // 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ó. // `SolidColorBuffer` sube su contador de daño sólo si algo cambió.
for w in &mut self.app.windows { for w in &mut self.app.windows {
if !w.visible { if !w.visible || w.is_shell {
continue; continue; // el shell no lleva marco
} }
let (x, y) = crate::render_loc(w); let (x, y) = crate::render_loc(w);
let (sw, sh) = crate::surface_px_size(w).unwrap_or(w.size); let (sw, sh) = crate::surface_px_size(w).unwrap_or(w.size);
@@ -210,13 +210,18 @@ 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(); 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 { for w in &shown {
let (x, y) = crate::render_loc(w); let (x, y) = crate::render_loc(w);
let (sw, sh) = crate::surface_px_size(w).unwrap_or(w.size); let (sw, sh) = crate::surface_px_size(w).unwrap_or(w.size);
// 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); 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) { for (buf, (bx, by, _, _)) in w.borders.iter().zip(rects) {
out.push(Frame::Solid(SolidColorRenderElement::from_buffer( out.push(Frame::Solid(SolidColorRenderElement::from_buffer(
buf, buf,
@@ -226,6 +231,7 @@ impl DrmState {
Kind::Unspecified, Kind::Unspecified,
))); )));
} }
}
for el in render_elements_from_surface_tree( for el in render_elements_from_surface_tree(
&mut self.renderer, &mut self.renderer,
&w.surface, &w.surface,
@@ -525,14 +531,26 @@ impl DrmState {
self.app.cursor_status = CursorImageStatus::default_named(); 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); let hovered = hit.map(|i| self.app.windows[i].id);
if hovered != self.last_pointer_window { if hovered != self.last_pointer_window {
self.last_pointer_window = hovered; self.last_pointer_window = hovered;
if let Some(id) = hovered { 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); let ev = self.app.body.pointer_enter(id);
self.app.brain_feed(ev); self.app.brain_feed(ev);
} }
None => {}
}
} }
} }
@@ -564,13 +582,17 @@ impl DrmState {
true true
} }
/// El índice de la ventana visible bajo el punto `(x, y)`, si la hay /// 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). /// 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> { fn window_at(&self, x: f64, y: f64) -> Option<usize> {
let mut idx: Vec<usize> = (0..self.app.windows.len()) let mut idx: Vec<usize> = (0..self.app.windows.len())
.filter(|&i| self.app.windows[i].visible) .filter(|&i| self.app.windows[i].visible)
.collect(); .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| { idx.into_iter().find(|&i| {
let w = &self.app.windows[i]; let w = &self.app.windows[i];
let (lx, ly) = crate::render_loc(w); 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. // La salida del Cerebro = el modo del monitor.
let ev = app.body.add_output(0, mode_w as i32, mode_h as i32); let ev = app.body.add_output(0, mode_w as i32, mode_h as i32);
app.brain_feed(ev); app.brain_feed(ev);
app.output_size = (mode_w as i32, mode_h as i32);
// El puntero arranca en el centro de la pantalla. // El puntero arranca en el centro de la pantalla.
app.pointer_loc = (mode_w as f64 / 2.0, mode_h as f64 / 2.0); 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. // Anuncia el monitor en el protocolo Wayland — los clientes lo exigen.
+76 -12
View File
@@ -89,6 +89,13 @@ enum Brain {
Linked(BodyLink), 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. /// Una ventana de cliente que el compositor gestiona.
struct ManagedWindow { struct ManagedWindow {
id: u64, id: u64,
@@ -104,6 +111,8 @@ struct ManagedWindow {
floating: bool, floating: bool,
/// `true` si tiene el foco del teclado — pinta el marco resaltado. /// `true` si tiene el foco del teclado — pinta el marco resaltado.
focused: bool, 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.) — /// Búferes de los 4 lados del marco (arriba, abajo, izq., der.) —
/// cada uno con su `Id` estable para el seguimiento de daño. /// cada uno con su `Id` estable para el seguimiento de daño.
borders: [SolidColorBuffer; 4], borders: [SolidColorBuffer; 4],
@@ -150,6 +159,9 @@ struct App {
cursor_status: CursorImageStatus, cursor_status: CursorImageStatus,
/// Arrastre de ventana en curso (mover o redimensionar con el ratón). /// Arrastre de ventana en curso (mover o redimensionar con el ratón).
drag: Option<DragGrab>, 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. /// Ventanas gestionadas, en orden de aparición.
windows: Vec<ManagedWindow>, windows: Vec<ManagedWindow>,
@@ -304,8 +316,8 @@ impl App {
}) })
.unwrap_or_default() .unwrap_or_default()
}); });
let app_id = if app_id.is_empty() { "cliente".into() } else { app_id }; // La ventana del shell no se tesela: carmen la acopla al pie.
let title = if title.is_empty() { format!("ventana {id}") } else { title }; let is_shell = app_id == SHELL_APP_ID;
self.windows.push(ManagedWindow { self.windows.push(ManagedWindow {
id, id,
@@ -316,11 +328,56 @@ impl App {
visible: false, visible: false,
floating: false, floating: false,
focused: false, focused: false,
is_shell,
borders: std::array::from_fn(|_| SolidColorBuffer::default()), borders: std::array::from_fn(|_| SolidColorBuffer::default()),
}); });
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); let ev = self.body.open_surface(id, app_id, title);
self.brain_feed(ev); 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() .iter()
.position(|w| w.surface == *surface.wl_surface()); .position(|w| w.surface == *surface.wl_surface());
if let Some(pos) = pos { if let Some(pos) = pos {
let id = self.windows.remove(pos).id; let w = self.windows.remove(pos);
if let Some(ev) = self.body.close_surface(id) { 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); self.brain_feed(ev);
} }
} }
@@ -813,6 +878,7 @@ fn build_app() -> Result<Setup, Box<dyn std::error::Error>> {
pointer_loc: (0.0, 0.0), pointer_loc: (0.0, 0.0),
cursor_status: CursorImageStatus::default_named(), cursor_status: CursorImageStatus::default_named(),
drag: None, drag: None,
output_size: (0, 0),
windows: Vec::new(), windows: Vec::new(),
body: BodyState::new(), body: BodyState::new(),
brain, 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); let ev = state.body.add_output(0, win_size.w, win_size.h);
state.brain_feed(ev); state.brain_feed(ev);
state.output_size = (win_size.w, win_size.h);
} }
while state.running { 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 { let status = winit.dispatch_new_events(|event| match event {
WinitEvent::CloseRequested => state.running = false, WinitEvent::CloseRequested => state.running = false,
WinitEvent::Resized { size, .. } => { WinitEvent::Resized { size, .. } => {
let ev = state.body.remove_output(0); state.output_changed(size.w, size.h);
state.brain_feed(ev);
let ev = state.body.add_output(0, size.w, size.h);
state.brain_feed(ev);
} }
WinitEvent::Input(InputEvent::Keyboard { event }) => { WinitEvent::Input(InputEvent::Keyboard { event }) => {
let code = event.key_code(); 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(); let (renderer, mut framebuffer) = backend.bind().unwrap();
// Orden de pintado: la lista de elementos va front-to-back // Orden de pintado: la lista de elementos va front-to-back
// (índice 0 = encima), así que las flotantes —que deben // (índice 0 = encima): el shell primero —va sobre todo—, luego
// quedar sobre las teseladas— se ordenan primero. `sort_by_key` // las flotantes, luego las teseladas. `sort_by_key` es estable:
// es estable: dentro de cada grupo se respeta el orden de apertura. // dentro de cada grupo se respeta el orden de apertura.
let mut shown: Vec<&ManagedWindow> = let mut shown: Vec<&ManagedWindow> =
state.windows.iter().filter(|w| w.visible).collect(); 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 let elements: Vec<WaylandSurfaceRenderElement<GlesRenderer>> = shown
.iter() .iter()
.flat_map(|w| { .flat_map(|w| {
+9 -2
View File
@@ -228,14 +228,21 @@ Cerebro: **autónomo** (`Desktop` embebido) o **enlazado** (`MIRADA_SOCKET`
- **Sesión** — `~/.config/mirada/autostart` (un comando por línea) se - **Sesión** — `~/.config/mirada/autostart` (un comando por línea) se
lanza al arrancar el backend DRM; el script `session/mirada-session` y lanza al arrancar el backend DRM; el script `session/mirada-session` y
`session/carmen.desktop` integran carmen con un gestor de login. `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: **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) | | 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 | | `mirada-input` | repetición de teclas, gestos; hotplug de monitores |
| barra de estado | `wlr-layer-shell` + un cliente que dibuje la barra | | `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` | | `mirada-sandbox` | aislamiento de clientes sobre `arje-incarnate` |
CRIU (congelar/restaurar ventanas) queda anotado como futuro. CRIU (congelar/restaurar ventanas) queda anotado como futuro.
@@ -184,6 +184,17 @@ impl BodyState {
BodyEvent::OutputRemoved { id } 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. /// Registra una superficie recién creada por un cliente.
pub fn open_surface( pub fn open_surface(
&mut self, &mut self,
@@ -141,6 +141,18 @@ impl Desktop {
self.reflow_outputs(); self.reflow_outputs();
self.relayout() 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 } => { BodyEvent::WindowOpened { id, app_id, title } => {
// Las reglas pueden mandarla a otro escritorio o hacerla flotar. // Las reglas pueden mandarla a otro escritorio o hacerla flotar.
let outcome = self.rules.resolve(&app_id, &title); let outcome = self.rules.resolve(&app_id, &title);
@@ -877,6 +889,23 @@ mod tests {
assert_eq!(p.rect, target); 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] #[test]
fn a_spawn_keybind_becomes_a_spawn_command() { fn a_spawn_keybind_becomes_a_spawn_command() {
let mut d = desktop_with_screen(); let mut d = desktop_with_screen();
@@ -82,6 +82,11 @@ pub enum BodyEvent {
OutputAdded { id: OutputId, width: i32, height: i32 }, OutputAdded { id: OutputId, width: i32, height: i32 },
/// Desapareció un monitor. /// Desapareció un monitor.
OutputRemoved { id: OutputId }, 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. /// Un cliente creó una ventana de nivel superior.
WindowOpened { id: WindowId, app_id: String, title: String }, WindowOpened { id: WindowId, app_id: String, title: String },
/// Una ventana se cerró (por el cliente o tras un [`BrainCommand::Close`]). /// Una ventana se cerró (por el cliente o tras un [`BrainCommand::Close`]).
+1
View File
@@ -1009,6 +1009,7 @@
Lanzar programas: acción spawn:<comando> del keymap (Super+Shift+Return → spawn:foot por defecto). 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. 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). 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. Sesión: ~/.config/mirada/autostart (un comando por línea) + script session/mirada-session + carmen.desktop.
Ver crates/apps/mirada-compositor/README.md. Ver crates/apps/mirada-compositor/README.md.