diff --git a/crates/apps/mirada-compositor/src/main.rs b/crates/apps/mirada-compositor/src/main.rs index de69296..f49cd6a 100644 --- a/crates/apps/mirada-compositor/src/main.rs +++ b/crates/apps/mirada-compositor/src/main.rs @@ -82,6 +82,8 @@ struct ManagedWindow { /// Esquina superior-izquierda en píxeles, según el Cerebro. loc: (i32, i32), visible: bool, + /// `true` si flota: se compone por encima de las teseladas. + floating: bool, } /// El estado global del compositor. @@ -170,10 +172,11 @@ impl App { /// Ejecuta una operación concreta sobre las superficies reales. fn exec_op(&mut self, op: BodyOp) { match op { - BodyOp::Configure { id, rect, visible } => { + BodyOp::Configure { id, rect, visible, floating } => { if let Some(w) = self.windows.iter_mut().find(|w| w.id == id) { w.loc = (rect.x, rect.y); w.visible = visible; + w.floating = floating; w.toplevel.with_pending_state(|s| { s.size = Some((rect.w.max(1), rect.h.max(1)).into()); }); @@ -244,6 +247,7 @@ impl App { surface, loc: (0, 0), visible: false, + floating: false, }); let ev = self.body.open_surface(id, app_id, title); self.brain_feed(ev); @@ -684,10 +688,15 @@ fn run() -> Result<(), Box> { let damage: Rectangle = Rectangle::from_size(size); { let (renderer, mut framebuffer) = backend.bind().unwrap(); - let elements: Vec> = state - .windows + // 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. + let mut shown: Vec<&ManagedWindow> = + state.windows.iter().filter(|w| w.visible).collect(); + shown.sort_by_key(|w| !w.floating); + let elements: Vec> = shown .iter() - .filter(|w| w.visible) .flat_map(|w| { render_elements_from_surface_tree( renderer, diff --git a/crates/apps/mirada-ctl/src/main.rs b/crates/apps/mirada-ctl/src/main.rs index 83c68b0..0f596ea 100644 --- a/crates/apps/mirada-ctl/src/main.rs +++ b/crates/apps/mirada-ctl/src/main.rs @@ -120,6 +120,7 @@ Acciones de mirada-ctl: move-forward adelanta la ventana enfocada en el teselado move-backward la atrasa close-focused cierra la ventana enfocada + toggle-float alterna flotante / teselada la enfocada cycle-layout pasa al siguiente modo de teselado layout master-stack · centered-master · spiral grid · columns · rows · monocle diff --git a/crates/apps/mirada/src/main.rs b/crates/apps/mirada/src/main.rs index 7ac08c0..3e117fb 100644 --- a/crates/apps/mirada/src/main.rs +++ b/crates/apps/mirada/src/main.rs @@ -21,10 +21,10 @@ //! ```text //! n abre una ventana tab / espacio cicla layout //! w cierra la enfocada t m g c r d s layout directo -//! j / k foco siguiente/anterior h / l área maestra −/+ -//! Shift+j / k mueve la enfocada , / . nmaster −/+ -//! Enter promueve a maestra 1..9 ir a escritorio -//! Ctrl+1..9 enviar a escritorio +//! f flota / tesela h / l área maestra −/+ +//! j / k foco siguiente/anterior , / . nmaster −/+ +//! Shift+j / k mueve la enfocada 1..9 ir a escritorio +//! Enter promueve a maestra Ctrl+1..9 enviar a escritorio //! ``` //! //! Los pips de escritorio y las ventanas del lienzo son **clicables**, y @@ -278,6 +278,7 @@ impl Mirada { match ks.key.as_str() { "n" if !connected => self.open_window(), "w" => self.act(DesktopAction::CloseFocused), + "f" => self.act(DesktopAction::ToggleFloat), "j" if shift => self.act(DesktopAction::MoveForward), "k" if shift => self.act(DesktopAction::MoveBackward), "j" => self.act(DesktopAction::FocusNext), @@ -440,6 +441,11 @@ impl Render for Mirada { let tb_bg = if p.focused { theme.accent } else { theme.bg_row_hover }; let tb_fg = if p.focused { on_accent } else { theme.fg_muted }; let pid = p.id; + let kind_label = if p.floating { + "· ventana flotante ·" + } else { + "· superficie del Cuerpo ·" + }; canvas = canvas.child( div() @@ -486,7 +492,7 @@ impl Render for Mirada { .gap(px(4.)) .text_color(theme.fg_disabled) .child(SharedString::from(app_id)) - .child("· superficie del Cuerpo ·"), + .child(kind_label), ), ); } diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md index ccec167..129948f 100644 --- a/crates/modules/mirada/SDD.md +++ b/crates/modules/mirada/SDD.md @@ -113,6 +113,11 @@ que un front-end (`Keybind` → `lookup` → `apply`); hay otros tres: - **`DesktopAction::FocusWindow(WindowId)`** — direccionamiento directo de una ventana (no sólo ciclar con `FocusNext`/`Prev`); si está en otro escritorio, salta a él. Lo usan la taskbar y `mirada-ctl`. +- **Ventanas flotantes** — `ToggleFloat` (`Super+f`) saca la enfocada del + teselado a un rectángulo libre (centrado, 60 %); `Workspace` guarda las + flotantes aparte, `layout()` las pone al final y `WindowPlacement` + /`BodyOp::Configure` llevan `floating: bool` para que el Cuerpo las + componga por encima. - **Layout y área maestra por el API** — los 7 modos se intercambian (`SetLayout`/`CycleLayout`, `mirada-ctl layout spiral`); el área maestra se redimensiona (`grow`/`shrink-master`, `Super+l`/`Super+h`); @@ -140,8 +145,8 @@ gráficos para ejercitar `mirada-ctl` en modo desatendido. ## Estado -Implementado y verde: `mirada-layout` (30 tests), `mirada-protocol` -(9), `mirada-brain` (41), `mirada-link` (7), `mirada-body` (13), las +Implementado y verde: `mirada-layout` (32 tests), `mirada-protocol` +(10), `mirada-brain` (42), `mirada-link` (7), `mirada-body` (14), las apps `mirada` y `mirada-compositor` (compilan; verificación visual manual) y `mirada-ctl` (CLI, probado vía el ejemplo `headless-ctl`). diff --git a/crates/modules/mirada/mirada-body/src/lib.rs b/crates/modules/mirada/mirada-body/src/lib.rs index 385806d..f5060c4 100644 --- a/crates/modules/mirada/mirada-body/src/lib.rs +++ b/crates/modules/mirada/mirada-body/src/lib.rs @@ -29,19 +29,29 @@ pub struct Surface { pub geometry: Option, pub visible: bool, pub focused: bool, + /// `true` si flota: el backend la pinta por encima de las teseladas. + pub floating: bool, } impl Surface { fn new(app_id: String, title: String) -> Self { - Self { app_id, title, geometry: None, visible: false, focused: false } + Self { + app_id, + title, + geometry: None, + visible: false, + focused: false, + floating: false, + } } } /// Una orden concreta para el backend (smithay, headless, …). #[derive(Debug, Clone, PartialEq, Eq)] pub enum BodyOp { - /// Recoloca una superficie y la muestra u oculta. - Configure { id: WindowId, rect: Rect, visible: bool }, + /// Recoloca una superficie, la muestra u oculta y dice si flota + /// (las flotantes se componen por encima de las teseladas). + Configure { id: WindowId, rect: Rect, visible: bool, floating: bool }, /// Da el foco del teclado a una superficie. Focus(WindowId), /// Quita el foco a todas las superficies. @@ -91,13 +101,18 @@ impl BodyState { new_focus = Some(p.id); } if let Some(s) = self.surfaces.get_mut(&p.id) { - if s.geometry != Some(p.rect) || s.visible != p.visible { + if s.geometry != Some(p.rect) + || s.visible != p.visible + || s.floating != p.floating + { s.geometry = Some(p.rect); s.visible = p.visible; + s.floating = p.floating; ops.push(BodyOp::Configure { id: p.id, rect: p.rect, visible: p.visible, + floating: p.floating, }); } } @@ -108,7 +123,12 @@ impl BodyState { if !listed.contains(id) && s.visible { s.visible = false; let rect = s.geometry.unwrap_or(Rect::new(0, 0, 0, 0)); - ops.push(BodyOp::Configure { id: *id, rect, visible: false }); + ops.push(BodyOp::Configure { + id: *id, + rect, + visible: false, + floating: s.floating, + }); } } @@ -227,6 +247,7 @@ mod tests { rect: Rect::new(0, 0, 800, 600), visible, focused, + floating: false, } } @@ -288,6 +309,7 @@ mod tests { id: 2, rect: Rect::new(0, 0, 800, 600), visible: false, + floating: false, })); assert!(!b.surface(2).unwrap().visible); } @@ -369,6 +391,20 @@ mod tests { assert_eq!(ops, vec![BodyOp::Focus(2)]); } + #[test] + fn a_floating_change_alone_triggers_a_configure() { + let mut b = body_with_two(); + let mut p1 = placement(1, true, true); + b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)])); + // Sólo cambia `floating` — misma geometría y visibilidad. + p1.floating = true; + let ops = b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)])); + assert!(ops + .iter() + .any(|o| matches!(o, BodyOp::Configure { id: 1, floating: true, .. }))); + assert!(b.surface(1).unwrap().floating); + } + #[test] fn visible_iterates_only_shown_surfaces() { let mut b = body_with_two(); diff --git a/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs b/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs index 77240d9..19702d6 100644 --- a/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs +++ b/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs @@ -54,12 +54,13 @@ fn main() { BrainCommand::Place(places) => { for p in places { eprintln!( - " win {} → {:>5}×{:<4} @ ({:>5},{:>4}){}", + " win {} → {:>5}×{:<4} @ ({:>5},{:>4}){}{}", p.id, p.rect.w, p.rect.h, p.rect.x, p.rect.y, + if p.floating { " ~flotante" } else { "" }, if p.focused { " *" } else { "" }, ); } diff --git a/crates/modules/mirada/mirada-brain/src/action.rs b/crates/modules/mirada/mirada-brain/src/action.rs index 39df510..438cc54 100644 --- a/crates/modules/mirada/mirada-brain/src/action.rs +++ b/crates/modules/mirada/mirada-brain/src/action.rs @@ -38,6 +38,8 @@ pub enum DesktopAction { MoveBackward, /// Cierra la ventana enfocada (cierre ordenado). CloseFocused, + /// Alterna entre flotante y teselada la ventana enfocada. + ToggleFloat, /// Pasa al siguiente modo de teselado. CycleLayout, /// Fija un modo de teselado concreto. @@ -98,6 +100,7 @@ impl fmt::Display for DesktopAction { DesktopAction::MoveForward => f.write_str("move-forward"), DesktopAction::MoveBackward => f.write_str("move-backward"), DesktopAction::CloseFocused => f.write_str("close-focused"), + DesktopAction::ToggleFloat => f.write_str("toggle-float"), DesktopAction::CycleLayout => f.write_str("cycle-layout"), DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)), DesktopAction::GrowMaster => f.write_str("grow-master"), @@ -125,6 +128,7 @@ impl FromStr for DesktopAction { "move-forward" => Self::MoveForward, "move-backward" => Self::MoveBackward, "close-focused" => Self::CloseFocused, + "toggle-float" => Self::ToggleFloat, "cycle-layout" => Self::CycleLayout, "grow-master" => Self::GrowMaster, "shrink-master" => Self::ShrinkMaster, @@ -183,6 +187,7 @@ pub fn default_keymap() -> Vec<(String, DesktopAction)> { ("Super+Shift+j".into(), DesktopAction::MoveForward), ("Super+Shift+k".into(), DesktopAction::MoveBackward), ("Super+q".into(), DesktopAction::CloseFocused), + ("Super+f".into(), DesktopAction::ToggleFloat), ("Super+space".into(), DesktopAction::CycleLayout), ("Super+t".into(), DesktopAction::SetLayout(LayoutMode::MasterStack)), ("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)), diff --git a/crates/modules/mirada/mirada-brain/src/desktop.rs b/crates/modules/mirada/mirada-brain/src/desktop.rs index 36629a0..2500803 100644 --- a/crates/modules/mirada/mirada-brain/src/desktop.rs +++ b/crates/modules/mirada/mirada-brain/src/desktop.rs @@ -179,6 +179,22 @@ impl Desktop { None => Vec::new(), } } + DesktopAction::ToggleFloat => { + let Some(id) = self.workspaces[self.active].focused() else { + return Vec::new(); + }; + let screen = self.screen(); + let ws = &mut self.workspaces[self.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::CycleLayout => { let next = self.workspaces[self.active].params().mode.next(); self.workspaces[self.active].set_mode(next); @@ -306,6 +322,18 @@ impl Desktop { } } +/// 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::*; @@ -409,6 +437,21 @@ mod tests { 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 without_a_screen_nothing_is_placed() { let mut d = Desktop::new(); diff --git a/crates/modules/mirada/mirada-brain/src/keymap.rs b/crates/modules/mirada/mirada-brain/src/keymap.rs index 2de0ea4..918e7ae 100644 --- a/crates/modules/mirada/mirada-brain/src/keymap.rs +++ b/crates/modules/mirada/mirada-brain/src/keymap.rs @@ -237,6 +237,7 @@ const KEYMAP_HEADER: &str = "\ // focus-next / focus-prev mueve el foco // move-forward / move-backward reordena la ventana enfocada // close-focused cierra la enfocada +// toggle-float alterna flotante / teselada // cycle-layout siguiente modo de teselado // layout: master-stack | centered-master | spiral // grid | columns | rows | monocle diff --git a/crates/modules/mirada/mirada-layout/src/workspace.rs b/crates/modules/mirada/mirada-layout/src/workspace.rs index 4d68d84..938a362 100644 --- a/crates/modules/mirada/mirada-layout/src/workspace.rs +++ b/crates/modules/mirada/mirada-layout/src/workspace.rs @@ -1,5 +1,7 @@ //! `Workspace` — un conjunto de ventanas, su foco y su modo de teselado. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; use crate::geometry::Rect; @@ -16,12 +18,20 @@ pub struct Workspace { /// Índice de la ventana enfocada en `windows`. focus: usize, params: LayoutParams, + /// Ventanas flotantes y su rectángulo: salen del teselado y se pintan + /// encima. Las que no están aquí se teselan normalmente. + floating: BTreeMap, } impl Workspace { /// Escritorio vacío con los parámetros dados. pub fn new(params: LayoutParams) -> Self { - Self { windows: Vec::new(), focus: 0, params } + Self { + windows: Vec::new(), + focus: 0, + params, + floating: BTreeMap::new(), + } } pub fn len(&self) -> usize { @@ -73,6 +83,7 @@ impl Workspace { return false; }; self.windows.remove(i); + self.floating.remove(&window); if i < self.focus { self.focus -= 1; } @@ -82,6 +93,24 @@ impl Workspace { true } + /// Marca una ventana como flotante en `rect`, o la devuelve al + /// teselado con `None`. La ventana sigue en el orden de foco. + pub fn set_floating(&mut self, window: WindowId, rect: Option) { + match rect { + Some(r) => { + self.floating.insert(window, r); + } + None => { + self.floating.remove(&window); + } + } + } + + /// `true` si la ventana está flotando. + pub fn is_floating(&self, window: WindowId) -> bool { + self.floating.contains_key(&window) + } + /// Ventana enfocada, o `None` si el escritorio está vacío. pub fn focused(&self) -> Option { self.windows.get(self.focus).copied() @@ -142,10 +171,24 @@ impl Workspace { } /// Resuelve la geometría: el rectángulo de cada ventana dentro de - /// `screen`, en orden de teselado. + /// `screen`. Primero las teseladas en orden de teselado, luego las + /// flotantes con su propio rectángulo — éstas van al final para que + /// el Cuerpo las pinte encima. pub fn layout(&self, screen: Rect) -> Vec<(WindowId, Rect)> { - let rects = tile(screen, self.windows.len(), &self.params); - self.windows.iter().copied().zip(rects).collect() + let tiled: Vec = self + .windows + .iter() + .copied() + .filter(|id| !self.floating.contains_key(id)) + .collect(); + let rects = tile(screen, tiled.len(), &self.params); + let mut out: Vec<(WindowId, Rect)> = tiled.into_iter().zip(rects).collect(); + for &id in &self.windows { + if let Some(&rect) = self.floating.get(&id) { + out.push((id, rect)); + } + } + out } } @@ -268,4 +311,35 @@ mod tests { fn empty_workspace_lays_out_nothing() { assert!(ws().layout(Rect::new(0, 0, 800, 600)).is_empty()); } + + #[test] + fn a_floating_window_keeps_its_rect_and_goes_last() { + let mut w = ws(); + for id in [1, 2, 3] { + w.add(id); + } + let float_rect = Rect::new(50, 50, 400, 300); + w.set_floating(2, Some(float_rect)); + assert!(w.is_floating(2)); + let placed = w.layout(Rect::new(0, 0, 1920, 1080)); + assert_eq!(placed.len(), 3); + // La flotante va al final, con su rectángulo intacto. + assert_eq!(placed[2], (2, float_rect)); + let ids: Vec<_> = placed.iter().map(|(id, _)| *id).collect(); + assert_eq!(ids, vec![1, 3, 2]); + // Devolverla al teselado. + w.set_floating(2, None); + assert!(!w.is_floating(2)); + assert_eq!(w.layout(Rect::new(0, 0, 1920, 1080)).len(), 3); + } + + #[test] + fn removing_a_window_clears_its_floating_state() { + let mut w = ws(); + w.add(1); + w.set_floating(1, Some(Rect::new(0, 0, 100, 100))); + w.remove(1); + w.add(1); // mismo id, ventana nueva: ya no flota + assert!(!w.is_floating(1)); + } } diff --git a/crates/modules/mirada/mirada-protocol/src/lib.rs b/crates/modules/mirada/mirada-protocol/src/lib.rs index 7e0a9c2..58701e4 100644 --- a/crates/modules/mirada/mirada-protocol/src/lib.rs +++ b/crates/modules/mirada/mirada-protocol/src/lib.rs @@ -46,6 +46,8 @@ pub struct WindowPlacement { pub visible: bool, /// `true` si esta ventana tiene el foco del teclado. pub focused: bool, + /// `true` si flota (fuera del teselado): el Cuerpo la pinta encima. + pub floating: bool, } /// Una orden del Cerebro al Cuerpo. @@ -144,11 +146,14 @@ pub fn placements(ws: &Workspace, screen: Rect) -> Vec { .into_iter() .map(|(id, rect)| { let is_focused = focused == Some(id); + let floating = ws.is_floating(id); WindowPlacement { id, rect, - visible: !monocle || is_focused, + // Una flotante siempre se ve; en `Monocle`, sólo la enfocada. + visible: floating || !monocle || is_focused, focused: is_focused, + floating, } }) .collect() @@ -177,6 +182,7 @@ mod tests { rect: Rect::new(0, 0, 800, 600), visible: true, focused: true, + floating: false, }]); let mut buf = Vec::new(); write_frame(&mut buf, &cmd).unwrap(); @@ -259,6 +265,18 @@ mod tests { assert!(placements(&empty, SCREEN).is_empty()); } + #[test] + fn a_floating_window_is_marked_and_stays_visible_in_monocle() { + let mut w = ws(LayoutMode::Monocle); // Monocle oculta las no enfocadas + w.set_floating(10, Some(Rect::new(0, 0, 200, 200))); + let p = placements(&w, SCREEN); + let f = p.iter().find(|x| x.id == 10).unwrap(); + assert!(f.floating); + assert!(f.visible, "una flotante se ve aunque el modo sea Monocle"); + // Y conserva su rectángulo flotante. + assert_eq!(f.rect, Rect::new(0, 0, 200, 200)); + } + #[test] fn placements_fill_a_place_command_round_trip() { let cmd = BrainCommand::Place(placements(&ws(LayoutMode::Grid), SCREEN)); diff --git a/vamos.txt b/vamos.txt index 531d115..9ffe4d6 100644 --- a/vamos.txt +++ b/vamos.txt @@ -1047,5 +1047,14 @@ + Ventanas flotantes — toggle-float (Super+f): + La ventana enfocada sale del teselado a un rectángulo libre (centrado, 60% de pantalla); + las demás reteselan sin ella. Workspace guarda las flotantes aparte; layout() las pone al final. + WindowPlacement y BodyOp::Configure llevan floating: bool — el Cuerpo las compone por encima + (mirada-compositor ordena las flotantes al frente de la lista front-to-back). + mirada-ctl toggle-float # alterna flotante / teselada + + +