From ee27108f6c9abe63ac3abdca743da82b467fc2ca Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 21 May 2026 05:38:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(mirada):=20acople=20del=20shell=20?= =?UTF-8?q?=E2=80=94=20ventana-dock=20al=20pie=20de=20la=20pantalla?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../apps/mirada-compositor/src/drm_backend.rs | 63 ++++++++----- crates/apps/mirada-compositor/src/main.rs | 90 ++++++++++++++++--- crates/modules/mirada/SDD.md | 17 ++-- crates/modules/mirada/mirada-body/src/lib.rs | 11 +++ .../mirada/mirada-brain/src/desktop.rs | 29 ++++++ .../modules/mirada/mirada-protocol/src/lib.rs | 5 ++ vamos.txt | 1 + 7 files changed, 178 insertions(+), 38 deletions(-) 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 { let mut idx: Vec = (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> { // 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, + /// 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, @@ -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> { 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> { { 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> { 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> { { 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> = 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: 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.