atagen

monolith

public
Star0
Watch0

Commit 075d167f7479

tarball

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

5 files changed+187-21
rules: match again when a title or app_id changes before first draw

Rules were matched once at map_window, but clients such as GTK and Electron only set their real title after the first commit, so title rules missed them. replace_placement now moves a window still awaiting its buffer to what the new match asks, and later changes only update opacity, idle_inhibit, vrr and tearing. smithay's title_changed and app_id_changed replace the identity poll on every commit.
Modified file: crates/config/src/lib.rs+29 additions
@@ -1340,6 +1340,35 @@ mod tests {
13401340 }
13411341
13421342 #[test]
1343+ fn a_title_set_after_mapping_brings_its_rules_in() {
1344+ let loaded = Config::parse(
1345+ r#"
1346+ [[rules]]
1347+ app_id = "^gimp$"
1348+ tag = "art"
1349+ [[rules]]
1350+ app_id = "^gimp$"
1351+ title = "^Tool Options$"
1352+ floating = true
1353+ opacity = 0.9
1354+ "#,
1355+ )
1356+ .unwrap();
1357+ let before = loaded
1358+ .config
1359+ .placement(&subject("gimp", "GNU Image Manipulation Program"));
1360+ let after = Subject {
1361+ title: "Tool Options",
1362+ initial_title: "GNU Image Manipulation Program",
1363+ ..subject("gimp", "")
1364+ };
1365+ let after = loaded.config.placement(&after);
1366+ assert_eq!((before.floating, before.opacity), (None, None));
1367+ assert_eq!((after.floating, after.opacity), (Some(true), Some(0.9)));
1368+ assert_eq!(before.tag, after.tag);
1369+ }
1370+
1371+ #[test]
13431372 fn size_bounds_treat_zero_as_open() {
13441373 let p = Placement {
13451374 min_size: Some((200, 0)),
Modified file: crates/monolith/src/commit.rs−1 deletions
@@ -76,7 +76,6 @@ impl CompositorHandler for State {
7676 }
7777 self.count_commit(&root);
7878 if let Some(id) = self.window_id(&root) {
79- self.refresh_identity(id);
8079 self.realign(id);
8180 if self.services.captures.wants_damage() {
8281 self.services.captures.window_damaged(id);
Modified file: crates/monolith/src/rules.rs+124 additions−1 deletions
@@ -3,7 +3,8 @@
33
44 use crate::state::State;
55 use crate::wm::identity;
6-use monolith_config::Subject;
6+use monolith_config::{Placement, Subject};
7+use monolith_layout::{OutputId, WindowId};
78 use smithay::desktop::Window;
89 use smithay::xwayland::xwm::WmWindowType;
910
@@ -60,3 +61,125 @@ impl State {
6061 }
6162 }
6263 }
64+
65+/// Takes the rules that follow a window for its whole life from `latest`,
66+/// leaving what was only meant for mapping as it was.
67+fn follow_effects(rule: &mut Placement, latest: &Placement) {
68+ rule.opacity = latest.opacity;
69+ rule.idle_inhibit = latest.idle_inhibit;
70+ rule.vrr = latest.vrr;
71+ rule.tearing = latest.tearing;
72+}
73+
74+impl State {
75+ pub fn layout_output_named(&self, name: &str) -> Option<OutputId> {
76+ let mut outputs = self.wm.layout.outputs();
77+ outputs.find(|(_, o)| o.name == name).map(|(id, _)| id)
78+ }
79+
80+ /// Matches a window's rules again after its title or app_id changed.
81+ /// Many clients only set the real ones after their first commit, so until
82+ /// the window has drawn everything is applied again. After that only the
83+ /// effects follow.
84+ pub fn rematch(&mut self, id: WindowId) {
85+ let Some(s) = self.wm.get(id) else {
86+ return;
87+ };
88+ let facts = self.facts(&s.window, Some(&s.initial_title));
89+ let new = self.settings.config.placement(&facts.subject());
90+ if new == s.rule {
91+ return;
92+ }
93+ let awaiting = s.awaiting_buffer;
94+ let Some(s) = self.wm.get_mut(id) else {
95+ return;
96+ };
97+ if awaiting {
98+ let old = std::mem::replace(&mut s.rule, new.clone());
99+ s.placed_by_rule = new.tag.is_some() || new.output.is_some();
100+ self.replace_placement(id, &old, &new);
101+ } else {
102+ follow_effects(&mut s.rule, &new);
103+ }
104+ self.update_inhibit();
105+ self.dirty.redraw = true;
106+ }
107+
108+ /// Moves a window that has not drawn yet from what `old` put it in to
109+ /// what `new` asks. A field `new` leaves out keeps what it has.
110+ fn replace_placement(&mut self, id: WindowId, old: &Placement, new: &Placement) {
111+ let output = new
112+ .output
113+ .as_deref()
114+ .and_then(|n| self.layout_output_named(n));
115+ let layout = &mut self.wm.layout;
116+ let Some(win) = layout.window(id) else {
117+ return;
118+ };
119+ let (floating, sticky, fullscreen) = (win.floating, win.sticky, win.fullscreen);
120+ if new.output != old.output
121+ && let Some(o) = output
122+ {
123+ layout.send_to_output(id, o);
124+ }
125+ if new.tag != old.tag
126+ && let Some(name) = &new.tag
127+ {
128+ let tag = layout.tag(name);
129+ layout.relocate(id, None, tag);
130+ }
131+ let flip = |was: Option<bool>, now: Option<bool>, current: bool| {
132+ was != now && now.is_some_and(|n| n != current)
133+ };
134+ if flip(old.floating, new.floating, floating) {
135+ layout.toggle_floating(id);
136+ }
137+ if flip(old.fullscreen, new.fullscreen, fullscreen) {
138+ layout.toggle_fullscreen(id);
139+ }
140+ if flip(old.sticky, new.sticky, sticky) {
141+ layout.toggle_sticky(id);
142+ }
143+ if new.scratchpad != old.scratchpad
144+ && let Some(name) = &new.scratchpad
145+ {
146+ layout.scratchpad_add(id, name);
147+ }
148+ self.view_if_first(id);
149+ if let Some(s) = self.wm.get_mut(id)
150+ && new.floating.is_some()
151+ {
152+ s.reclassify = false;
153+ }
154+ self.float_by_rule(id);
155+ self.apply_layout();
156+ }
157+}
158+
159+#[cfg(test)]
160+mod tests {
161+ use super::*;
162+ use monolith_config::IdleInhibit;
163+
164+ #[test]
165+ fn after_placement_only_effects_follow_the_latest_rules() {
166+ let mut rule = Placement {
167+ size: Some((10, 20)),
168+ focus: Some(false),
169+ opacity: Some(0.5),
170+ vrr: Some(true),
171+ ..Placement::default()
172+ };
173+ let latest = Placement {
174+ size: Some((30, 40)),
175+ idle_inhibit: Some(IdleInhibit::Visible),
176+ ..Placement::default()
177+ };
178+ follow_effects(&mut rule, &latest);
179+ assert_eq!(rule.size, Some((10, 20)));
180+ assert_eq!(rule.focus, Some(false));
181+ assert_eq!(rule.opacity, None);
182+ assert_eq!(rule.vrr, None);
183+ assert_eq!(rule.idle_inhibit, Some(IdleInhibit::Visible));
184+ }
185+}
Modified file: crates/monolith/src/windows.rs+22 additions−19 deletions
@@ -29,13 +29,7 @@ impl State {
2929 let output = rule
3030 .output
3131 .as_deref()
32- .and_then(|name| {
33- self.wm
34- .layout
35- .outputs()
36- .find(|(_, o)| o.name == name)
37- .map(|(id, _)| id)
38- })
32+ .and_then(|name| self.layout_output_named(name))
3933 .or(fullscreen_output.and_then(|o| self.layout_output(&o)))
4034 .or(launch.0);
4135 let tag = match rule.tag.as_deref() {
@@ -72,20 +66,15 @@ impl State {
7266 }
7367 self.view_if_first(id);
7468 let by_rule = rule.tag.is_some() || rule.output.is_some();
75- let natural_size =
76- floating && !fullscreen && window.toplevel().is_some() && rule.size.is_none();
69+ let natural_size = floating && !fullscreen && window.toplevel().is_some();
7770 let reclassify = !floating && rule.floating.is_none() && window.toplevel().is_some();
7871 let focus = rule.focus != Some(false);
79- let shaped = rule.at.is_some() || rule.min_size.is_some() || rule.max_size.is_some();
80- let positioned = rule.size.is_some() || (shaped && !natural_size && !fullscreen);
8172 self.wm.insert(id, window, by_rule, app_id, title, rule);
8273 if let Some(s) = self.wm.get_mut(id) {
8374 s.natural_size = natural_size;
8475 s.reclassify = reclassify;
8576 }
86- if positioned {
87- self.float_by_rule(id);
88- }
77+ self.float_by_rule(id);
8978 self.await_first_buffer(id);
9079 if focus && self.wm.layout.visible(id) && !self.wm.layout.covered(id) {
9180 self.wm.layout.focus(id);
@@ -330,14 +319,27 @@ impl State {
330319 }
331320
332321 /// Gives a floating window the size and spot its rules ask for, keeping
333- /// whichever of the two they leave out.
334- fn float_by_rule(&mut self, id: WindowId) {
322+ /// whichever of the two they leave out. One still waiting for its client
323+ /// to pick a size gets its spot when the size arrives, unless the rule
324+ /// has a size to give it right away.
325+ pub(crate) fn float_by_rule(&mut self, id: WindowId) {
335326 let Some(win) = self.wm.layout.window(id).filter(|win| win.floating) else {
336327 return;
337328 };
338- let held = win.float_rect.map(|r| (r.w, r.h));
339- let size = self.wm.get(id).and_then(|s| s.rule.size);
340- let Some((w, h)) = size.map(|(w, h)| (f64::from(w), f64::from(h))).or(held) else {
329+ let (held, fullscreen) = (win.float_rect.map(|r| (r.w, r.h)), win.fullscreen);
330+ let Some(s) = self.wm.get_mut(id) else {
331+ return;
332+ };
333+ let rule = &s.rule;
334+ let size = rule.size.map(|(w, h)| (f64::from(w), f64::from(h)));
335+ let shaped = rule.at.is_some() || rule.min_size.is_some() || rule.max_size.is_some();
336+ if size.is_some() {
337+ s.natural_size = false;
338+ s.natural_after = None;
339+ } else if !shaped || s.natural_size || fullscreen {
340+ return;
341+ }
342+ let Some((w, h)) = size.or(held) else {
341343 return;
342344 };
343345 if let Some(rect) = self.float_rect_for(id, w, h) {
@@ -500,6 +502,7 @@ impl State {
500502 w.app_id = app_id;
501503 w.title = title;
502504 self.dirty.snapshot = true;
505+ self.rematch(id);
503506 }
504507 }
505508
Modified file: crates/monolith/src/xdg.rs+12 additions
@@ -66,6 +66,18 @@ impl XdgShellHandler for State {
6666 }
6767 }
6868
69+ fn title_changed(&mut self, surface: ToplevelSurface) {
70+ if let Some(id) = self.window_id(surface.wl_surface()) {
71+ self.refresh_identity(id);
72+ }
73+ }
74+
75+ fn app_id_changed(&mut self, surface: ToplevelSurface) {
76+ if let Some(id) = self.window_id(surface.wl_surface()) {
77+ self.refresh_identity(id);
78+ }
79+ }
80+
6981 fn fullscreen_request(&mut self, surface: ToplevelSurface, output: Option<WlOutput>) {
7082 self.set_fullscreen(&surface, true, output);
7183 }