atagen

monolith

public
Star0
Watch0

Commit ecb0c8c1f31d

tarball

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

6 files changed+160-26
focus: keep focus on a window's modal dialog over the window

Focus aimed at a window with a mapped modal child used to land on the blocked parent, whichever path asked for it. Layout::focus and focused_on now resolve to the deepest modal dialog, fed from xdg_dialog_v1 and _NET_WM_STATE_MODAL, and the dialog sits above its parent in the focus history so closing it returns focus there.
Modified file: crates/layout/src/lib.rs+75 additions−17 deletions
@@ -67,6 +67,8 @@ pub struct Window {
6767 pub fullscreen: bool,
6868 /// The window this one is a dialog or other transient of.
6969 pub parent: Option<WindowId>,
70+ /// Focus aimed at the parent goes to this window while it is mapped.
71+ pub modal: bool,
7072 pub sticky: bool,
7173 pub scratchpad: Option<usize>,
7274 pub urgent: bool,
@@ -140,6 +142,8 @@ pub struct Layout {
140142 active: Option<OutputId>,
141143 /// Focus history, most recent last.
142144 history: Vec<WindowId>,
145+ /// How many windows are modal, so focus lookups skip the scan for one.
146+ modals: usize,
143147 scratchpads: Vec<Scratchpad>,
144148 last_scratchpad: Option<usize>,
145149 parked: HashMap<String, Parked>,
@@ -162,6 +166,7 @@ impl Default for Layout {
162166 outputs: SlotMap::with_key(),
163167 active: None,
164168 history: Vec::new(),
169+ modals: 0,
165170 scratchpads: Vec::new(),
166171 last_scratchpad: None,
167172 parked: HashMap::new(),
@@ -351,6 +356,7 @@ impl Layout {
351356 float_default: false,
352357 fullscreen: false,
353358 parent: None,
359+ modal: false,
354360 sticky: false,
355361 scratchpad: None,
356362 urgent: false,
@@ -371,6 +377,7 @@ impl Layout {
371377 }
372378 self.unplace(w);
373379 self.history.retain(|&h| h != w);
380+ self.modals -= usize::from(self.windows[w].modal);
374381 if let Some(s) = self.windows[w].scratchpad {
375382 self.leave_scratchpad(s, w);
376383 }
@@ -449,21 +456,71 @@ impl Layout {
449456 out.floating.retain(|&f| f != w);
450457 }
451458
452- pub fn focus(&mut self, w: WindowId) {
453- let Some(win) = self.windows.get(w) else {
454- return;
455- };
456- self.active = win.output.or(self.active);
457- if let Some(o) = win.output {
458- let stack = &mut self.outputs[o].floating;
459- if let Some(i) = stack.iter().position(|&f| f == w) {
460- stack.remove(i);
461- stack.push(w);
459+ /// 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
461+ /// dialog hands focus back to its parent.
462+ pub fn focus(&mut self, mut w: WindowId) -> Option<WindowId> {
463+ loop {
464+ let win = self.windows.get(w)?;
465+ self.active = win.output.or(self.active);
466+ if let Some(o) = win.output {
467+ let stack = &mut self.outputs[o].floating;
468+ if let Some(i) = stack.iter().position(|&f| f == w) {
469+ stack.remove(i);
470+ stack.push(w);
471+ }
472+ }
473+ self.history.retain(|&h| h != w);
474+ self.history.push(w);
475+ self.windows[w].urgent = false;
476+ match self.modal_child(w) {
477+ Some(child) => w = child,
478+ None => return Some(w),
462479 }
463480 }
464- self.history.retain(|&h| h != w);
465- self.history.push(w);
466- self.windows[w].urgent = false;
481+ }
482+
483+ /// Marks `w` as a modal dialog of its parent, or not.
484+ pub fn set_modal(&mut self, w: WindowId, modal: bool) {
485+ if let Some(win) = self.windows.get_mut(w)
486+ && std::mem::replace(&mut win.modal, modal) != modal
487+ {
488+ self.modals = if modal {
489+ self.modals + 1
490+ } else {
491+ self.modals - 1
492+ };
493+ }
494+ }
495+
496+ /// The visible modal dialog of `w` on its output, the most recently
497+ /// focused if it has several.
498+ pub fn modal_child(&self, w: WindowId) -> Option<WindowId> {
499+ if self.modals == 0 {
500+ return None;
501+ }
502+ let output = self.windows.get(w)?.output;
503+ self.windows
504+ .iter()
505+ .filter(|&(c, win)| {
506+ win.modal && win.parent == Some(w) && win.output == output && self.visible(c)
507+ })
508+ .max_by_key(|&(c, _)| self.history.iter().rposition(|&h| h == c))
509+ .map(|(c, _)| c)
510+ }
511+
512+ /// The window that takes focus aimed at `w`, the deepest modal dialog
513+ /// over it or `w` itself.
514+ pub fn modal_leaf(&self, mut w: WindowId) -> WindowId {
515+ while let Some(child) = self.modal_child(w) {
516+ w = child;
517+ }
518+ w
519+ }
520+
521+ /// Whether focus aimed at `w` would change nothing.
522+ pub fn is_focused(&self, w: WindowId) -> bool {
523+ self.focused() == Some(self.modal_leaf(w))
467524 }
468525
469526 /// The most recently focused visible window on the active output. Focus
@@ -480,13 +537,15 @@ impl Layout {
480537 .rev()
481538 .copied()
482539 .find(|&w| self.windows[w].output == Some(o) && self.visible(w))
540+ .map(|w| self.modal_leaf(w))
483541 }
484542
485543 /// Focuses the nearest visible window in `dir`, crossing to the next
486544 /// output when there is none on this one.
487545 pub fn focus_dir(&mut self, dir: Dir) -> Option<WindowId> {
488546 let o = self.active?;
489- let from = self.focused_on(o).and_then(|f| self.rect_of(f));
547+ let current = self.focused_on(o);
548+ let from = current.and_then(|f| self.rect_of(f));
490549 let target = from.and_then(|from| {
491550 let arr = self.arrange(o);
492551 let reachable = |w| {
@@ -499,12 +558,11 @@ impl Layout {
499558 .filter(|p| p.visible)
500559 .map(|p| (p.window, p.rect))
501560 .chain(arr.floating.iter().copied())
502- .filter(|&(w, _)| Some(w) != self.focused_on(o) && reachable(w));
561+ .filter(|&(w, _)| Some(self.modal_leaf(w)) != current && reachable(w));
503562 nearest(from, dir, candidates)
504563 });
505564 if let Some(w) = target {
506- self.focus(w);
507- return Some(w);
565+ return self.focus(w);
508566 }
509567 let next = self.output_towards(o, dir)?;
510568 self.active = Some(next);
Modified file: crates/layout/src/tests.rs+40 additions
@@ -729,3 +729,43 @@ fn scratchpads_stay_centred_whatever_their_rect() {
729729 assert_eq!(r.x + r.w / 2.0, area.x + area.w / 2.0);
730730 assert_eq!(r.y, area.y);
731731 }
732+
733+/// Focus aimed at a window with a modal dialog lands on the deepest dialog
734+/// from every path, and falls back to the parent once the dialog is gone.
735+#[test]
736+fn modal_dialogs_keep_focus_over_their_parent() {
737+ let mut l = Layout::default();
738+ l.add_output("a", AREA);
739+ let parent = l.add_window(None, None, None);
740+ let other = l.add_window(None, None, None);
741+ l.focus(parent);
742+ let dialog = l.add_window(None, None, Some(Rect::new(300.0, 200.0, 200.0, 100.0)));
743+ let nested = l.add_window(None, None, Some(Rect::new(350.0, 250.0, 100.0, 50.0)));
744+ l.set_parent(dialog, Some(parent));
745+ l.set_parent(nested, Some(dialog));
746+ l.set_modal(dialog, true);
747+ assert_eq!(l.focused(), Some(dialog));
748+ l.set_modal(nested, true);
749+ assert_eq!(l.focused(), Some(nested));
750+
751+ l.focus(other);
752+ assert_eq!(l.focused(), Some(other));
753+ assert_eq!(l.focus(parent), Some(nested));
754+ assert_eq!(l.focused(), Some(nested));
755+ assert!(l.is_focused(parent));
756+
757+ l.focus(other);
758+ for dir in [Dir::Left, Dir::Right, Dir::Up, Dir::Down] {
759+ l.focus(other);
760+ if let Some(w) = l.focus_dir(dir) {
761+ assert_ne!(w, parent, "directional focus stopped on a blocked parent");
762+ }
763+ }
764+
765+ l.focus(parent);
766+ l.remove_window(nested);
767+ assert_eq!(l.focused(), Some(dialog));
768+ l.remove_window(dialog);
769+ assert_eq!(l.focused(), Some(parent));
770+ l.check();
771+}
Modified file: crates/monolith/src/input.rs+6 additions−9 deletions
@@ -261,10 +261,9 @@ fn focus_under_pointer(state: &mut State) {
261261 }
262262 let id = window_at(state, pos);
263263 if let Some(id) = id
264- && state.wm.layout.focused() != Some(id)
264+ && !state.wm.layout.is_focused(id)
265265 {
266- state.input.followed = Some(id);
267- state.wm.layout.focus(id);
266+ state.input.followed = state.wm.layout.focus(id);
268267 state.apply_layout();
269268 }
270269 }
@@ -297,12 +296,11 @@ fn focus_on_hover(state: &mut State, pos: Point<f64, Logical>, warped: bool) {
297296 let Some(id) = under else {
298297 return;
299298 };
300- if state.wm.layout.focused() == Some(id) {
299+ if state.wm.layout.is_focused(id) {
301300 return;
302301 }
303302 state.wm.layer_focus = None;
304- state.input.followed = Some(id);
305- state.wm.layout.focus(id);
303+ state.input.followed = state.wm.layout.focus(id);
306304 state.apply_layout();
307305 }
308306
@@ -561,10 +559,9 @@ fn start_drag(state: &mut State, button: u32) -> bool {
561559 let Some((id, edges)) = state.edge_at(pos, over).filter(|_| button == BTN_LEFT) else {
562560 return false;
563561 };
564- if state.wm.layout.focused() != Some(id) {
562+ if !state.wm.layout.is_focused(id) {
565563 state.wm.layer_focus = None;
566- state.input.followed = Some(id);
567- state.wm.layout.focus(id);
564+ state.input.followed = state.wm.layout.focus(id);
568565 state.apply_layout();
569566 }
570567 return begin_drag(state, id, Some(edges), None);
Modified file: crates/monolith/src/windows.rs+15 additions
@@ -52,6 +52,7 @@ impl State {
5252 .add_window(output, tag, float.filter(|_| floating));
5353 let parent = self.parent_of(&window);
5454 self.wm.layout.set_parent(id, parent);
55+ self.wm.layout.set_modal(id, is_modal(&window));
5556 if floating != self.wm.layout.window(id).is_some_and(|w| w.floating) {
5657 self.wm.layout.toggle_floating(id);
5758 }
@@ -100,6 +101,13 @@ impl State {
100101 self.window_id(&parent)
101102 }
102103
104+ /// Feeds the window's modal state to the layout and lets focus follow.
105+ pub(crate) fn sync_modal(&mut self, id: WindowId) {
106+ let modal = self.wm.window(id).is_some_and(is_modal);
107+ self.wm.layout.set_modal(id, modal);
108+ self.apply_layout();
109+ }
110+
103111 /// Holds a new window on its seed until its first buffer lands, so the
104112 /// spawn animation never plays over an empty slot. Clients that are slow
105113 /// to draw get it anyway after a while.
@@ -547,6 +555,13 @@ impl State {
547555
548556 /// Whether the client has acknowledged the configure with `serial` or a later
549557 /// one.
558+fn is_modal(window: &Window) -> bool {
559+ match window.x11_surface() {
560+ Some(x) => x.is_modal(),
561+ None => window.toplevel().is_some_and(crate::xdg::is_modal),
562+ }
563+}
564+
550565 fn acked(window: &Window, serial: Serial) -> bool {
551566 let Some(t) = window.toplevel() else {
552567 return true;
Modified file: crates/monolith/src/xdg.rs+9 additions
@@ -35,6 +35,14 @@ pub fn is_dialog(toplevel: &ToplevelSurface) -> bool {
3535 toplevel.parent().is_some() || marked
3636 }
3737
38+/// A toplevel that asked to be modal through xdg_dialog_v1.
39+pub fn is_modal(toplevel: &ToplevelSurface) -> bool {
40+ with_states(toplevel.wl_surface(), |s| {
41+ let role = s.data_map.get::<XdgToplevelSurfaceData>();
42+ role.is_some_and(|r| r.lock().unwrap().dialog_hint == ToplevelDialogHint::Modal)
43+ })
44+}
45+
3846 /// The size of a window that asks for exactly one.
3947 pub fn fixed_size(toplevel: &ToplevelSurface) -> Option<Size<i32, Logical>> {
4048 let (min, max) = size_hints(toplevel);
@@ -142,6 +150,7 @@ impl XdgDialogHandler for State {
142150 fn dialog_hint_changed(&mut self, surface: ToplevelSurface, _hint: ToplevelDialogHint) {
143151 if let Some(id) = self.window_id(surface.wl_surface()) {
144152 self.rematch(id);
153+ self.sync_modal(id);
145154 }
146155 }
147156 }
Modified file: crates/monolith/src/xwayland.rs+15 additions
@@ -399,6 +399,14 @@ impl XwmHandler for State {
399399 }
400400 }
401401
402+ fn modal_request(&mut self, _xwm: XwmId, window: X11Surface) {
403+ self.x11_modal(&window, true);
404+ }
405+
406+ fn unmodal_request(&mut self, _xwm: XwmId, window: X11Surface) {
407+ self.x11_modal(&window, false);
408+ }
409+
402410 fn allow_selection_access(&mut self, xwm: XwmId, _selection: SelectionTarget) -> bool {
403411 self.settings.config.xwayland.selection_access == SelectionAccess::Any
404412 || matches!(
@@ -547,6 +555,13 @@ impl State {
547555 self.dirty.redraw = true;
548556 }
549557
558+ fn x11_modal(&mut self, window: &X11Surface, on: bool) {
559+ logged("modal", window.set_modal(on));
560+ if let Some(id) = self.x11_window_id(window) {
561+ self.sync_modal(id);
562+ }
563+ }
564+
550565 fn x11_fullscreen(&mut self, window: &X11Surface, on: bool) {
551566 if let Some(id) = self.x11_window_id(window)
552567 && self