feat(mirada): pantalla completa real — toggle-fullscreen

ToggleFullscreen (Super+Shift+f) lleva la ventana enfocada a pantalla
completa: cubre toda la salida sin gap, oculta al resto y se lleva el
foco. Distinto del modo Monocle (un modo de teselado): es un estado
por ventana que ignora el layout.

- Workspace.fullscreen: Option<WindowId>; set_fullscreen / fullscreen();
  remove() lo limpia si se cierra esa ventana.
- placements() da a la fullscreen el rect completo y marca al resto
  visible: false. WindowPlacement y BodyOp::Configure llevan
  fullscreen: bool.
- mirada-compositor fija el estado xdg_toplevel::Fullscreen en la
  superficie, para que el cliente lo sepa.
- Cableado en keymap, HUD de mirada y mirada-ctl.

Verificado end-to-end con headless-ctl. mirada-protocol 10->11,
mirada-brain 51->52.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-21 01:07:01 +00:00
parent 6dfd9e62ac
commit be61ddb6eb
12 changed files with 133 additions and 11 deletions
+5 -1
View File
@@ -118,6 +118,10 @@ que un front-end (`Keybind` → `lookup` → `apply`); hay otros tres:
flotantes aparte, `layout()` las pone al final y `WindowPlacement`
/`BodyOp::Configure` llevan `floating: bool` para que el Cuerpo las
componga por encima.
- **Pantalla completa** — `ToggleFullscreen` (`Super+Shift+f`): la ventana
cubre toda la salida (sin gap), oculta al resto y se lleva el foco;
`Workspace.fullscreen: Option<WindowId>`, y el Cuerpo le fija el estado
`xdg_toplevel Fullscreen`.
- **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`);
@@ -160,7 +164,7 @@ a las ya abiertas.
## Estado
Implementado y verde: `mirada-layout` (32 tests), `mirada-protocol`
(10), `mirada-brain` (51), `mirada-link` (7), `mirada-body` (14), las
(11), `mirada-brain` (52), `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`).
+19 -3
View File
@@ -31,6 +31,8 @@ pub struct Surface {
pub focused: bool,
/// `true` si flota: el backend la pinta por encima de las teseladas.
pub floating: bool,
/// `true` si está en pantalla completa.
pub fullscreen: bool,
}
impl Surface {
@@ -42,6 +44,7 @@ impl Surface {
visible: false,
focused: false,
floating: false,
fullscreen: false,
}
}
}
@@ -49,9 +52,16 @@ impl Surface {
/// Una orden concreta para el backend (smithay, headless, …).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BodyOp {
/// 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 },
/// Recoloca una superficie, la muestra u oculta y dice si flota o
/// está en pantalla completa (el backend ajusta el orden de pintado
/// y el estado `xdg_toplevel` en consecuencia).
Configure {
id: WindowId,
rect: Rect,
visible: bool,
floating: bool,
fullscreen: bool,
},
/// Da el foco del teclado a una superficie.
Focus(WindowId),
/// Quita el foco a todas las superficies.
@@ -104,15 +114,18 @@ impl BodyState {
if s.geometry != Some(p.rect)
|| s.visible != p.visible
|| s.floating != p.floating
|| s.fullscreen != p.fullscreen
{
s.geometry = Some(p.rect);
s.visible = p.visible;
s.floating = p.floating;
s.fullscreen = p.fullscreen;
ops.push(BodyOp::Configure {
id: p.id,
rect: p.rect,
visible: p.visible,
floating: p.floating,
fullscreen: p.fullscreen,
});
}
}
@@ -128,6 +141,7 @@ impl BodyState {
rect,
visible: false,
floating: s.floating,
fullscreen: s.fullscreen,
});
}
}
@@ -248,6 +262,7 @@ mod tests {
visible,
focused,
floating: false,
fullscreen: false,
}
}
@@ -310,6 +325,7 @@ mod tests {
rect: Rect::new(0, 0, 800, 600),
visible: false,
floating: false,
fullscreen: false,
}));
assert!(!b.surface(2).unwrap().visible);
}
@@ -60,7 +60,13 @@ fn main() {
p.rect.h,
p.rect.x,
p.rect.y,
if p.floating { " ~flotante" } else { "" },
if p.fullscreen {
" ~pantalla"
} else if p.floating {
" ~flotante"
} else {
""
},
if p.focused { " *" } else { "" },
);
}
@@ -40,6 +40,8 @@ pub enum DesktopAction {
CloseFocused,
/// Alterna entre flotante y teselada la ventana enfocada.
ToggleFloat,
/// Alterna pantalla completa en la ventana enfocada.
ToggleFullscreen,
/// Pasa al siguiente modo de teselado.
CycleLayout,
/// Fija un modo de teselado concreto.
@@ -101,6 +103,7 @@ impl fmt::Display for DesktopAction {
DesktopAction::MoveBackward => f.write_str("move-backward"),
DesktopAction::CloseFocused => f.write_str("close-focused"),
DesktopAction::ToggleFloat => f.write_str("toggle-float"),
DesktopAction::ToggleFullscreen => f.write_str("toggle-fullscreen"),
DesktopAction::CycleLayout => f.write_str("cycle-layout"),
DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)),
DesktopAction::GrowMaster => f.write_str("grow-master"),
@@ -129,6 +132,7 @@ impl FromStr for DesktopAction {
"move-backward" => Self::MoveBackward,
"close-focused" => Self::CloseFocused,
"toggle-float" => Self::ToggleFloat,
"toggle-fullscreen" => Self::ToggleFullscreen,
"cycle-layout" => Self::CycleLayout,
"grow-master" => Self::GrowMaster,
"shrink-master" => Self::ShrinkMaster,
@@ -188,6 +192,7 @@ pub fn default_keymap() -> Vec<(String, DesktopAction)> {
("Super+Shift+k".into(), DesktopAction::MoveBackward),
("Super+q".into(), DesktopAction::CloseFocused),
("Super+f".into(), DesktopAction::ToggleFloat),
("Super+Shift+f".into(), DesktopAction::ToggleFullscreen),
("Super+space".into(), DesktopAction::CycleLayout),
("Super+t".into(), DesktopAction::SetLayout(LayoutMode::MasterStack)),
("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)),
@@ -218,6 +218,18 @@ impl Desktop {
}
self.relayout()
}
DesktopAction::ToggleFullscreen => {
let Some(id) = self.workspaces[self.active].focused() else {
return Vec::new();
};
let ws = &mut self.workspaces[self.active];
if ws.fullscreen() == Some(id) {
ws.set_fullscreen(None);
} else {
ws.set_fullscreen(Some(id));
}
self.relayout()
}
DesktopAction::CycleLayout => {
let next = self.workspaces[self.active].params().mode.next();
self.workspaces[self.active].set_mode(next);
@@ -475,6 +487,23 @@ mod tests {
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();
@@ -238,6 +238,7 @@ const KEYMAP_HEADER: &str = "\
// move-forward / move-backward reordena la ventana enfocada
// close-focused cierra la enfocada
// toggle-float alterna flotante / teselada
// toggle-fullscreen alterna pantalla completa
// cycle-layout siguiente modo de teselado
// layout:<modo> master-stack | centered-master | spiral
// grid | columns | rows | monocle
@@ -21,6 +21,9 @@ pub struct Workspace {
/// Ventanas flotantes y su rectángulo: salen del teselado y se pintan
/// encima. Las que no están aquí se teselan normalmente.
floating: BTreeMap<WindowId, Rect>,
/// La ventana en pantalla completa, si hay alguna: cubre toda la
/// salida y oculta al resto.
fullscreen: Option<WindowId>,
}
impl Workspace {
@@ -31,6 +34,7 @@ impl Workspace {
focus: 0,
params,
floating: BTreeMap::new(),
fullscreen: None,
}
}
@@ -84,6 +88,9 @@ impl Workspace {
};
self.windows.remove(i);
self.floating.remove(&window);
if self.fullscreen == Some(window) {
self.fullscreen = None;
}
if i < self.focus {
self.focus -= 1;
}
@@ -111,6 +118,16 @@ impl Workspace {
self.floating.contains_key(&window)
}
/// La ventana en pantalla completa de este escritorio, si hay alguna.
pub fn fullscreen(&self) -> Option<WindowId> {
self.fullscreen
}
/// Pone (o quita, con `None`) la ventana en pantalla completa.
pub fn set_fullscreen(&mut self, window: Option<WindowId>) {
self.fullscreen = window;
}
/// Ventana enfocada, o `None` si el escritorio está vacío.
pub fn focused(&self) -> Option<WindowId> {
self.windows.get(self.focus).copied()
@@ -48,6 +48,8 @@ pub struct WindowPlacement {
pub focused: bool,
/// `true` si flota (fuera del teselado): el Cuerpo la pinta encima.
pub floating: bool,
/// `true` si está en pantalla completa: cubre toda la salida.
pub fullscreen: bool,
}
/// Una orden del Cerebro al Cuerpo.
@@ -140,20 +142,31 @@ pub fn read_frame<R: Read, T: DeserializeOwned>(r: &mut R) -> io::Result<Option<
/// En modo [`LayoutMode::Monocle`] sólo la ventana enfocada queda
/// `visible`; en el resto de modos todas lo están.
pub fn placements(ws: &Workspace, screen: Rect) -> Vec<WindowPlacement> {
let fullscreen = ws.fullscreen();
let monocle = ws.params().mode == LayoutMode::Monocle;
let focused = ws.focused();
ws.layout(screen)
.into_iter()
.map(|(id, rect)| {
let is_focused = focused == Some(id);
let floating = ws.is_floating(id);
let is_fs = fullscreen == Some(id);
// Con una ventana en pantalla completa manda ella: ocupa toda
// la salida, es la única visible y se lleva el foco.
let (rect, visible, is_focused) = match fullscreen {
Some(_) => (if is_fs { screen } else { rect }, is_fs, is_fs),
None => {
let f = focused == Some(id);
// Una flotante siempre se ve; en `Monocle`, sólo la enfocada.
(rect, floating || !monocle || f, f)
}
};
WindowPlacement {
id,
rect,
// Una flotante siempre se ve; en `Monocle`, sólo la enfocada.
visible: floating || !monocle || is_focused,
visible,
focused: is_focused,
floating,
fullscreen: is_fs,
}
})
.collect()
@@ -183,6 +196,7 @@ mod tests {
visible: true,
focused: true,
floating: false,
fullscreen: false,
}]);
let mut buf = Vec::new();
write_frame(&mut buf, &cmd).unwrap();
@@ -277,6 +291,19 @@ mod tests {
assert_eq!(f.rect, Rect::new(0, 0, 200, 200));
}
#[test]
fn a_fullscreen_window_covers_the_screen_and_hides_the_rest() {
let mut w = ws(LayoutMode::Columns);
w.set_fullscreen(Some(20));
let p = placements(&w, SCREEN);
let fs = p.iter().find(|x| x.id == 20).unwrap();
assert!(fs.fullscreen);
assert!(fs.focused, "la ventana en pantalla completa se lleva el foco");
assert_eq!(fs.rect, SCREEN);
// El resto queda oculto.
assert!(p.iter().filter(|x| x.id != 20).all(|x| !x.visible));
}
#[test]
fn placements_fill_a_place_command_round_trip() {
let cmd = BrainCommand::Place(placements(&ws(LayoutMode::Grid), SCREEN));