atagen

monolith

public
Star0
Watch0

Commit b44dde08d7ac

tarball

atagen <boss@atagen.co>2026-10-19 07:51 UTC

6 files changed+130-13
layout: keep a fullscreen window's transients above it

Fullscreen was raised last and alone received frame callbacks, while a new window still took focus, so a save dialog from a fullscreen game was focused but invisible. Layout now records transient parents and exposes the floating descendants of the fullscreen window as Arrangement::over, which scene, hit-testing and frame callbacks keep live above it. focus_dir no longer walks into covered windows, and a new non-transient window mapping under a fullscreen one does not take focus.
Modified file: crates/layout/src/lib.rs+60 additions−1 deletions
@@ -65,6 +65,8 @@ pub struct Window {
6565 /// user chose.
6666 pub float_default: bool,
6767 pub fullscreen: bool,
68+ /// The window this one is a dialog or other transient of.
69+ pub parent: Option<WindowId>,
6870 pub sticky: bool,
6971 pub scratchpad: Option<usize>,
7072 evicted_from: Option<String>,
@@ -103,6 +105,8 @@ pub struct Arrangement {
103105 /// Visible floating windows and shown scratchpads, bottom to top.
104106 pub floating: Vec<(WindowId, Rect)>,
105107 pub fullscreen: Option<WindowId>,
108+ /// Floating transients of the fullscreen window, which stay above it.
109+ pub over: Vec<WindowId>,
106110 }
107111
108112 #[derive(Clone, Copy, Debug)]
@@ -330,6 +334,7 @@ impl Layout {
330334 float_rect: floating,
331335 float_default: false,
332336 fullscreen: false,
337+ parent: None,
333338 sticky: false,
334339 scratchpad: None,
335340 evicted_from: None,
@@ -352,6 +357,11 @@ impl Layout {
352357 if let Some(s) = self.windows[w].scratchpad {
353358 self.leave_scratchpad(s, w);
354359 }
360+ for win in self.windows.values_mut() {
361+ if win.parent == Some(w) {
362+ win.parent = None;
363+ }
364+ }
355365 self.windows.remove(w);
356366 }
357367
@@ -440,13 +450,17 @@ impl Layout {
440450 let from = self.focused_on(o).and_then(|f| self.rect_of(f));
441451 let target = from.and_then(|from| {
442452 let arr = self.arrange(o);
453+ let reachable = |w| {
454+ arr.fullscreen
455+ .is_none_or(|f| w == f || arr.over.contains(&w))
456+ };
443457 let candidates = arr
444458 .tiled
445459 .iter()
446460 .filter(|p| p.visible)
447461 .map(|p| (p.window, p.rect))
448462 .chain(arr.floating.iter().copied())
449- .filter(|&(w, _)| Some(w) != self.focused_on(o));
463+ .filter(|&(w, _)| Some(w) != self.focused_on(o) && reachable(w));
450464 nearest(from, dir, candidates)
451465 });
452466 if let Some(w) = target {
@@ -703,11 +717,56 @@ impl Layout {
703717 .rev()
704718 .copied()
705719 .find(|&w| self.windows[w].fullscreen && vis(w));
720+ let over = fullscreen
721+ .map(|f| {
722+ floating
723+ .iter()
724+ .map(|&(w, _)| w)
725+ .filter(|&w| w != f && self.descends(w, f))
726+ .collect()
727+ })
728+ .unwrap_or_default();
706729 Arrangement {
707730 tiled,
708731 floating,
709732 fullscreen,
733+ over,
734+ }
735+ }
736+
737+ /// Records that `w` is a transient of `parent`.
738+ pub fn set_parent(&mut self, w: WindowId, parent: Option<WindowId>) {
739+ if parent.is_some_and(|p| p == w || self.descends(p, w)) {
740+ return;
741+ }
742+ if let Some(win) = self.windows.get_mut(w) {
743+ win.parent = parent;
744+ }
745+ }
746+
747+ fn descends(&self, mut w: WindowId, of: WindowId) -> bool {
748+ for _ in 0..self.windows.len() {
749+ match self.windows.get(w).and_then(|x| x.parent) {
750+ Some(p) if p == of => return true,
751+ Some(p) => w = p,
752+ None => return false,
753+ }
710754 }
755+ false
756+ }
757+
758+ /// Whether a fullscreen window on `w`'s output hides it. The fullscreen
759+ /// window's transients are not hidden, and a window that is itself
760+ /// fullscreen takes over.
761+ pub fn covered(&self, w: WindowId) -> bool {
762+ let Some(win) = self.windows.get(w) else {
763+ return false;
764+ };
765+ let Some(o) = win.output else {
766+ return false;
767+ };
768+ let arr = self.arrange(o);
769+ !win.fullscreen && arr.fullscreen.is_some_and(|f| f != w) && !arr.over.contains(&w)
711770 }
712771
713772 #[cfg(test)]
Modified file: crates/layout/src/tests.rs+32 additions
@@ -470,3 +470,35 @@ fn moves_are_seen_or_undone() {
470470 }
471471 }
472472 }
473+
474+/// A fullscreen window hides everything but its own transients, which stay
475+/// reachable by focus and are not hidden when they map.
476+#[test]
477+fn fullscreen_leaves_its_transients_reachable() {
478+ let mut l = Layout::default();
479+ let o = l.add_output("a", AREA);
480+ let beside = l.add_window(None, None, None);
481+ l.focus(beside);
482+ let game = l.add_window(None, None, None);
483+ l.focus(game);
484+ l.toggle_fullscreen(game);
485+ let stranger = l.add_window(None, None, None);
486+ assert!(l.covered(stranger));
487+ let dialog = l.add_window(None, None, Some(Rect::new(300.0, 200.0, 200.0, 100.0)));
488+ l.set_parent(dialog, Some(game));
489+ assert!(!l.covered(dialog));
490+ l.focus(dialog);
491+
492+ let arr = l.arrange(o);
493+ assert_eq!(arr.fullscreen, Some(game));
494+ assert_eq!(arr.over, vec![dialog]);
495+ for dir in [Dir::Left, Dir::Right, Dir::Up, Dir::Down] {
496+ if let Some(w) = l.focus_dir(dir) {
497+ assert!(w == game || w == dialog, "focus walked to a covered window");
498+ }
499+ }
500+
501+ l.remove_window(game);
502+ assert!(!l.covered(stranger));
503+ assert_eq!(l.window(dialog).and_then(|w| w.parent), None);
504+}
Modified file: crates/monolith/src/frame.rs+4 additions−4 deletions
@@ -77,14 +77,14 @@ pub fn send_frame_callbacks(
7777 crate::lock::send_frames(lock, output, elapsed);
7878 return;
7979 }
80- let covering = wm
81- .layout
82- .arrange(o)
80+ let arr = wm.layout.arrange(o);
81+ let covering = arr
8382 .fullscreen
8483 .filter(|&w| wm.anim.gliding(w, now).is_none())
8584 .and_then(|w| wm.window(w));
85+ let over: Vec<_> = arr.over.iter().filter_map(|&w| wm.window(w)).collect();
8686 for window in wm.space.elements_for_output(output) {
87- if covering.is_some_and(|c| c != window) {
87+ if covering.is_some_and(|c| c != window && !over.contains(&window)) {
8888 continue;
8989 }
9090 window.send_frame(output, elapsed, Some(Duration::ZERO), |_, _| {
Modified file: crates/monolith/src/scene.rs+8 additions−4 deletions
@@ -3,7 +3,7 @@
33
44 use crate::wm::{LastFrame, Wm};
55 use monolith_config::Decoration;
6-use monolith_layout::{OutputId, Rect};
6+use monolith_layout::{OutputId, Rect, WindowId};
77 use monolith_render_vk::{Clip, Effect, VulkanFrame, VulkanRenderer, VulkanTexture};
88 use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement;
99 use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement};
@@ -89,14 +89,18 @@ pub fn build(
8989 }
9090 }
9191 }
92- for &(w, r) in &arr.floating {
92+ let float = |&(w, r): &(WindowId, Rect)| {
9393 let glide = anim.gliding(w, now);
94- above.push((w, local(glide.unwrap_or(r)), true, glide.is_some()));
95- }
94+ (w, local(glide.unwrap_or(r)), true, glide.is_some())
95+ };
96+ let (over, under): (Vec<_>, Vec<_>) =
97+ arr.floating.iter().partition(|f| arr.over.contains(&f.0));
98+ above.extend(under.into_iter().map(float));
9699 if let Some(w) = arr.fullscreen {
97100 let glide = anim.gliding(w, now);
98101 above.push((w, local(glide.unwrap_or(area)), false, glide.is_some()));
99102 }
103+ above.extend(over.into_iter().map(float));
100104 let focused = layout.focused();
101105 let mut out = Vec::new();
102106 for x in unmanaged.iter().rev() {
Modified file: crates/monolith/src/state.rs+3 additions−1 deletions
@@ -251,6 +251,7 @@ impl State {
251251 let now = now_ns();
252252 let mut shown = Vec::new();
253253 let mut tracks = Vec::new();
254+ let mut over = Vec::new();
254255 let outputs: Vec<OutputId> = self.wm.layout.outputs().map(|(o, _)| o).collect();
255256 for o in outputs {
256257 let arr = self.wm.layout.arrange(o);
@@ -268,6 +269,7 @@ impl State {
268269 if let Some(w) = arr.fullscreen {
269270 shown.push((w, area, Kind::Fullscreen, o));
270271 }
272+ over.extend(arr.over.iter().copied());
271273 let held: Vec<WindowId> = arr
272274 .tiled
273275 .iter()
@@ -384,7 +386,7 @@ impl State {
384386 for window in hidden {
385387 self.wm.space.unmap_elem(&window);
386388 }
387- for id in mapped {
389+ for id in mapped.into_iter().chain(over) {
388390 if let Some(window) = self.wm.window(id).cloned() {
389391 self.wm.space.raise_element(&window, false);
390392 }
Modified file: crates/monolith/src/windows.rs+23 additions−3 deletions
@@ -62,6 +62,8 @@ impl State {
6262 .wm
6363 .layout
6464 .add_window(output, tag, float.filter(|_| floating));
65+ let parent = self.parent_of(&window);
66+ self.wm.layout.set_parent(id, parent);
6567 if floating != self.wm.layout.window(id).is_some_and(|w| w.floating) {
6668 self.wm.layout.toggle_floating(id);
6769 }
@@ -84,12 +86,30 @@ impl State {
8486 s.reclassify = reclassify;
8587 }
8688 self.await_first_buffer(id);
87- if self.wm.layout.visible(id) {
89+ if self.wm.layout.visible(id) && !self.wm.layout.covered(id) {
8890 self.wm.layout.focus(id);
8991 }
9092 self.apply_layout();
9193 }
9294
95+ /// The mapped window `window` is a dialog or other transient of.
96+ fn parent_of(&self, window: &Window) -> Option<WindowId> {
97+ if let Some(x) = window.x11_surface() {
98+ let parent = x.is_transient_for()?;
99+ return self
100+ .wm
101+ .iter()
102+ .find(|(_, w)| {
103+ w.window
104+ .x11_surface()
105+ .is_some_and(|p| p.window_id() == parent)
106+ })
107+ .map(|(id, _)| id);
108+ }
109+ let parent = window.toplevel()?.parent()?;
110+ self.window_id(&parent)
111+ }
112+
93113 /// Holds a new window on its seed until its first buffer lands, so the
94114 /// spawn animation never plays over an empty slot. Clients that are slow
95115 /// to draw get it anyway after a while.
@@ -264,9 +284,9 @@ impl State {
264284 fn float_at_natural_size(&mut self, id: WindowId, w: f64, h: f64) {
265285 let parent = self
266286 .wm
287+ .layout
267288 .window(id)
268- .and_then(|win| win.toplevel()?.parent())
269- .and_then(|p| self.window_id(&p))
289+ .and_then(|win| win.parent)
270290 .and_then(|p| self.wm.get(p)?.placed)
271291 .map(|(_, r, _)| r);
272292 let output = self