atagen

monolith

public
Star0
Watch0

Commit 1c7e3075e57f

tarball

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

5 files changed+166-16
layout: focus requests give way to a covering fullscreen window

Activation tokens, taskbar activation and scratchpad summons could focus a window hidden behind a fullscreen one, leaving the keyboard on something invisible. They now share focus_unless_covered, which flags the window urgent instead. A shown scratchpad counts like a fullscreen window's transient and stays above it, since the user asked for it. arrange also finds a fullscreen window that was never focused, as a focus=false rule makes, and a modal dialog now brings its parent up just beneath it so closing the dialog does not focus an unrelated window.
Modified file: crates/layout/src/lib.rs+43 additions−8 deletions
@@ -72,6 +72,9 @@ pub struct Window {
7272 pub sticky: bool,
7373 pub scratchpad: Option<usize>,
7474 pub urgent: bool,
75+ /// Its client took the buffer away, as when hiding to a tray. The entry
76+ /// stays for when the client maps it again, but nothing shows or focuses it.
77+ pub hidden: bool,
7578 evicted_from: Option<String>,
7679 }
7780
@@ -121,7 +124,8 @@ pub struct Arrangement {
121124 /// Visible floating windows and shown scratchpads, bottom to top.
122125 pub floating: Vec<(WindowId, Rect)>,
123126 pub fullscreen: Option<WindowId>,
124- /// Floating transients of the fullscreen window, which stay above it.
127+ /// Floating transients of the fullscreen window and shown scratchpads,
128+ /// which stay above it.
125129 pub over: Vec<WindowId>,
126130 }
127131
@@ -321,7 +325,7 @@ impl Layout {
321325 let Some(win) = self.windows.get(w) else {
322326 return false;
323327 };
324- let Some(o) = win.output else {
328+ let Some(o) = win.output.filter(|_| !win.hidden) else {
325329 return false;
326330 };
327331 match win.scratchpad {
@@ -360,6 +364,7 @@ impl Layout {
360364 sticky: false,
361365 scratchpad: None,
362366 urgent: false,
367+ hidden: false,
363368 evicted_from: None,
364369 });
365370 if let Some(o) = output {
@@ -457,7 +462,8 @@ impl Layout {
457462 }
458463
459464 /// Focuses `w`, or the deepest modal dialog over it, which is returned.
460- /// The windows between stay below it in the history, so closing the
465+ /// The windows between stay below it in the history, and a modal dialog
466+ /// focused on its own brings its parent up beneath it, so closing the
461467 /// dialog hands focus back to its parent.
462468 pub fn focus(&mut self, mut w: WindowId) -> Option<WindowId> {
463469 loop {
@@ -470,8 +476,13 @@ impl Layout {
470476 stack.push(w);
471477 }
472478 }
479+ let parent = win.parent.filter(|_| win.modal);
473480 self.history.retain(|&h| h != w);
474481 self.history.push(w);
482+ if let Some(p) = parent {
483+ self.history.retain(|&h| h != p);
484+ self.history.insert(self.history.len() - 1, p);
485+ }
475486 self.windows[w].urgent = false;
476487 match self.modal_child(w) {
477488 Some(child) => w = child,
@@ -716,7 +727,7 @@ impl Layout {
716727 /// Flags a window that wants attention unless it already has the focus,
717728 /// and its tag too when the window is out of sight.
718729 pub fn mark_urgent(&mut self, w: WindowId) {
719- if !self.windows.contains_key(w) {
730+ if self.windows.get(w).is_none_or(|win| win.hidden) {
720731 return;
721732 }
722733 if self.focused() != Some(w) {
@@ -805,6 +816,14 @@ impl Layout {
805816 }
806817 }
807818
819+ /// Hides `w` or shows it again, keeping everything else about it.
820+ pub fn set_hidden(&mut self, w: WindowId, hidden: bool) {
821+ if let Some(win) = self.windows.get_mut(w) {
822+ win.hidden = hidden;
823+ win.urgent &= !hidden;
824+ }
825+ }
826+
808827 /// Target geometry for everything on `o`, in output-local logical
809828 /// coordinates offset by the output's area.
810829 pub fn arrange(&self, o: OutputId) -> Arrangement {
@@ -840,23 +859,28 @@ impl Layout {
840859 for sp in &self.scratchpads {
841860 if let Some(i) = sp.shown
842861 && sp.output == Some(o)
862+ && !self.windows[sp.windows[i]].hidden
843863 && let Some(r) = self.windows[sp.windows[i]].float_rect
844864 {
845865 floating.push((sp.windows[i], centre_in(out.usable(), r.w, r.h)));
846866 }
847867 }
868+ let is_fullscreen = |w: WindowId| self.windows[w].fullscreen && vis(w);
848869 let fullscreen = self
849870 .history
850871 .iter()
851872 .rev()
852873 .copied()
853- .find(|&w| self.windows[w].fullscreen && vis(w));
874+ .find(|&w| is_fullscreen(w))
875+ .or_else(|| self.windows.keys().find(|&w| is_fullscreen(w)));
854876 let over = fullscreen
855877 .map(|f| {
856878 floating
857879 .iter()
858880 .map(|&(w, _)| w)
859- .filter(|&w| w != f && self.descends(w, f))
881+ .filter(|&w| {
882+ w != f && (self.descends(w, f) || self.windows[w].scratchpad.is_some())
883+ })
860884 .collect()
861885 })
862886 .unwrap_or_default();
@@ -903,9 +927,20 @@ impl Layout {
903927 }
904928 }
905929
930+ /// Focuses `w` unless it is out of sight or a fullscreen window hides it,
931+ /// flagging it urgent then. Returns whether it took the focus.
932+ pub fn focus_unless_covered(&mut self, w: WindowId) -> bool {
933+ if self.visible(w) && !self.covered(w) {
934+ self.focus(w);
935+ return true;
936+ }
937+ self.mark_urgent(w);
938+ false
939+ }
940+
906941 /// Whether a fullscreen window on `w`'s output hides it. The fullscreen
907- /// window's transients are not hidden, and a window that is itself
908- /// fullscreen takes over.
942+ /// window's transients and shown scratchpads are not hidden, and a window
943+ /// that is itself fullscreen takes over.
909944 pub fn covered(&self, w: WindowId) -> bool {
910945 let Some(win) = self.windows.get(w) else {
911946 return false;
Modified file: crates/layout/src/scratchpad.rs+5 additions−5 deletions
@@ -39,10 +39,10 @@ impl Layout {
3939 let s = self.scratchpads.iter().position(|sp| sp.name == name)?;
4040 let o = self.active?;
4141 let sp = &mut self.scratchpads[s];
42- let next = match sp.shown {
43- None => 0,
44- Some(i) => i + 1,
45- };
42+ let mut next = sp.shown.map_or(0, |i| i + 1);
43+ while sp.windows.get(next).is_some_and(|&w| self.windows[w].hidden) {
44+ next += 1;
45+ }
4646 if next >= sp.windows.len() {
4747 sp.shown = None;
4848 return None;
@@ -63,7 +63,7 @@ impl Layout {
6363 win.output = Some(o);
6464 }
6565 win.float_rect = win.float_rect.map(|r| centre_in(usable, r.w, r.h));
66- self.focus(w);
66+ self.focus_unless_covered(w);
6767 Some(w)
6868 }
6969
Modified file: crates/layout/src/tests.rs+115 additions
@@ -769,3 +769,118 @@ fn modal_dialogs_keep_focus_over_their_parent() {
769769 assert_eq!(l.focused(), Some(parent));
770770 l.check();
771771 }
772+
773+/// A fullscreen window nobody ever focused, as a rule with `focus = false`
774+/// makes, still takes the output once its tag is in view.
775+#[test]
776+fn fullscreen_does_not_need_focus_history() {
777+ let mut l = Layout::default();
778+ let o = l.add_output("a", AREA);
779+ let (main, other) = (l.tag("main"), l.tag("other"));
780+ l.view(o, main);
781+ let shown = l.add_window(None, Some(main), None);
782+ let player = l.add_window(None, Some(main), None);
783+ l.toggle_fullscreen(player);
784+ assert_eq!(l.arrange(o).fullscreen, Some(player));
785+ assert!(l.covered(shown));
786+
787+ let hidden = l.add_window(None, Some(other), None);
788+ l.toggle_fullscreen(hidden);
789+ assert_eq!(l.arrange(o).fullscreen, Some(player));
790+ l.focus(shown);
791+ l.view(o, other);
792+ assert_eq!(l.arrange(o).fullscreen, Some(hidden));
793+}
794+
795+/// A request to focus a covered window flags it instead, but its own
796+/// fullscreen window's transients and a summoned scratchpad take the focus.
797+#[test]
798+fn covered_windows_are_flagged_not_focused() {
799+ let mut l = Layout::default();
800+ let o = l.add_output("a", AREA);
801+ let game = l.add_window(None, None, None);
802+ l.focus(game);
803+ l.toggle_fullscreen(game);
804+ let stranger = l.add_window(None, None, None);
805+ assert!(!l.focus_unless_covered(stranger));
806+ assert_eq!(l.focused(), Some(game));
807+ assert!(l.window(stranger).unwrap().urgent);
808+
809+ let dialog = l.add_window(None, None, Some(Rect::new(300.0, 200.0, 200.0, 100.0)));
810+ l.set_parent(dialog, Some(game));
811+ assert!(l.focus_unless_covered(dialog));
812+ assert_eq!(l.focused(), Some(dialog));
813+
814+ let pad = l.add_window(None, None, None);
815+ l.scratchpad_add(pad, "s");
816+ assert_eq!(l.summon("s"), Some(pad));
817+ assert_eq!(l.focused(), Some(pad));
818+ assert!(l.arrange(o).over.contains(&pad));
819+ l.check();
820+}
821+
822+/// A window its client hid keeps its place, floating state, parent links and
823+/// scratchpad, and comes back as it was when shown again.
824+#[test]
825+fn hidden_windows_come_back_as_they_were() {
826+ let mut l = Layout::default();
827+ let o = l.add_output("a", AREA);
828+ let tag = l.tag("work");
829+ l.view(o, tag);
830+ let tray = l.add_window(None, Some(tag), Some(Rect::new(40.0, 50.0, 300.0, 200.0)));
831+ let child = l.add_window(None, Some(tag), Some(Rect::new(60.0, 70.0, 100.0, 80.0)));
832+ let tiled = l.add_window(None, Some(tag), None);
833+ l.set_parent(child, Some(tray));
834+ l.toggle_sticky(tray);
835+ l.focus(tray);
836+ l.focus(tiled);
837+
838+ l.set_hidden(tray, true);
839+ assert!(!l.visible(tray));
840+ assert!(l.arrange(o).floating.iter().all(|f| f.0 != tray));
841+ assert!(!l.focus_unless_covered(tray));
842+ assert!(!l.window(tray).unwrap().urgent);
843+ assert_eq!(l.focused(), Some(tiled));
844+ assert_eq!(l.window(child).and_then(|w| w.parent), Some(tray));
845+
846+ l.set_hidden(tray, false);
847+ let w = l.window(tray).unwrap();
848+ assert!(l.visible(tray));
849+ assert!(w.floating && w.sticky && w.tag == tag);
850+ assert_eq!(w.float_rect, Some(Rect::new(40.0, 50.0, 300.0, 200.0)));
851+ assert!(l.arrange(o).floating.iter().any(|f| f.0 == tray));
852+ l.check();
853+}
854+
855+/// Summoning skips a scratchpad window that is hidden to a tray.
856+#[test]
857+fn summoning_skips_hidden_scratchpad_windows() {
858+ let mut l = Layout::default();
859+ let o = l.add_output("a", AREA);
860+ let a = l.add_window(None, None, None);
861+ let b = l.add_window(None, None, None);
862+ l.scratchpad_add(a, "s");
863+ l.scratchpad_add(b, "s");
864+ l.set_hidden(a, true);
865+ assert_eq!(l.summon("s"), Some(b));
866+ assert!(l.arrange(o).floating.iter().all(|f| f.0 != a));
867+ assert_eq!(l.summon("s"), None);
868+}
869+
870+/// A modal dialog that takes focus on mapping puts its parent just beneath
871+/// it, so closing it does not land on whatever was focused before the parent.
872+#[test]
873+fn a_modal_brings_its_parent_up_beneath_it() {
874+ let mut l = Layout::default();
875+ l.add_output("a", AREA);
876+ let parent = l.add_window(None, None, None);
877+ let other = l.add_window(None, None, None);
878+ l.focus(parent);
879+ l.focus(other);
880+ let dialog = l.add_window(None, None, Some(Rect::new(300.0, 200.0, 200.0, 100.0)));
881+ l.set_parent(dialog, Some(parent));
882+ l.set_modal(dialog, true);
883+ l.focus(dialog);
884+ l.remove_window(dialog);
885+ assert_eq!(l.focused(), Some(parent));
886+}
Modified file: crates/monolith/src/activation.rs+2 additions−2 deletions
@@ -63,8 +63,8 @@ impl XdgActivationHandler for State {
6363 let refused = self.wm.get(id).is_some_and(|w| w.rule.focus == Some(false));
6464 let may_focus =
6565 ctx.is_some_and(|c| c.may_focus) && data.timestamp.elapsed() < FRESH && !refused;
66- if may_focus && self.wm.layout.visible(id) {
67- self.wm.layout.focus(id);
66+ if may_focus {
67+ self.wm.layout.focus_unless_covered(id);
6868 } else {
6969 self.wm.layout.mark_urgent(id);
7070 }
Modified file: crates/monolith/src/toplevels.rs+1 additions−1 deletions
@@ -33,7 +33,7 @@ impl ForeignToplevelHandler for State {
3333 let tag = w.tag;
3434 self.wm.layout.toggle_view(o, tag);
3535 }
36- self.wm.layout.focus(id);
36+ self.wm.layout.focus_unless_covered(id);
3737 }
3838 Action::Close(k) => {
3939 if let Some(window) = id_of(k).and_then(|id| self.wm.window(id)) {