atagen

monolith

public
Star0
Watch0

Commit f4c93ddeb08f

tarball

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

3 files changed+416-31
config: parse the full set of window rule matchers and effects

Rule matched only app_id and title and carried five placement fields, so apps could not be sized, left unfocused, dimmed or kept from idling by config. Rule now splits into a Matcher over a Subject and a Placement, and a pattern that fails to compile still drops the whole rule since it would otherwise hit every window.
Modified file: crates/config/default.toml+56 additions−1 deletions
@@ -181,7 +181,62 @@ selection_access = "focused"
181181 "Mod+Shift+8" = "tag 8"
182182 "Mod+Shift+9" = "tag 9"
183183
184-# Rules match app_id and title by regex, later rules overriding earlier ones.
184+# Every rule matcher given must hit, later rules overriding earlier ones. Rules
185+# are matched when a window maps and again when its title or app_id changes
186+# before it has drawn, since many apps only set the real ones after their first
187+# commit. A pattern that does not compile leaves the rule out.
188+#
189+# Matchers, all optional. Patterns are regexes searched anywhere in the text.
190+# app_id the Wayland app_id, or the X11 class
191+# title the current title
192+# initial_title the title the window had when it mapped, which never changes
193+# class the X11 WM_CLASS class, never hits a Wayland window
194+# instance the X11 WM_CLASS instance, never hits a Wayland window
195+# xwayland true for X11 windows, false for Wayland ones
196+# dialog true for a window with a parent or transient, or an X11
197+# dialog, false for any other
198+# parent_app_id the app_id of the window it is a dialog of, never hits a
199+# window without a parent
200+#
201+# Placement, fixed when the window maps or when its rules change before it has
202+# drawn.
203+# tag tag name to open on
204+# output connector name to open on
205+# floating open floating or tiled, whatever the window would be
206+# sticky show on every tag
207+# scratchpad name of the scratchpad to join
208+# fullscreen open fullscreen or not, over what the client asks
209+# focus false keeps the focus where it is when the window maps
210+# size [width, height] of a floating window
211+# position [x, y] of a floating window from its output top left
212+# center true centres a floating window, over position
213+# min_size [width, height] lower bound for floating sizes, 0 for none
214+# max_size [width, height] upper bound for floating sizes, 0 for none
215+# size, position, center and the bounds only touch windows that float, so
216+# pair them with floating = true unless the window floats anyway.
217+#
218+# Effects, following the latest rules that hit for the window's lifetime.
219+# opacity 0.0 to 1.0, multiplies how opaque the window is drawn
220+# idle_inhibit visible keeps the screen on while the window is shown,
221+# fullscreen only while it is also fullscreen
222+# vrr true or false forces adaptive sync on or off for the output
223+# while this window is fullscreen on it
224+# tearing reserved, tearing presentation is not implemented yet
225+#
185226 # [[rules]]
186227 # app_id = "^pavucontrol$"
187228 # floating = true
229+# size = [900, 600]
230+# center = true
231+#
232+# [[rules]]
233+# app_id = "^mpv$"
234+# idle_inhibit = "fullscreen"
235+# vrr = true
236+#
237+# [[rules]]
238+# app_id = "^firefox$"
239+# title = "^Picture-in-Picture$"
240+# floating = true
241+# opacity = 0.9
242+# focus = false
Modified file: crates/config/src/lib.rs+352 additions−29 deletions
@@ -509,13 +509,68 @@ pub struct Bind {
509509
510510 #[derive(Debug, Clone)]
511511 pub struct Rule {
512+ pub when: Matcher,
513+ pub set: Placement,
514+}
515+
516+/// What a rule asks of a window. Every matcher given must hit.
517+#[derive(Debug, Clone, Default)]
518+pub struct Matcher {
512519 pub app_id: Option<Regex>,
513520 pub title: Option<Regex>,
514- pub tag: Option<String>,
515- pub output: Option<String>,
516- pub floating: Option<bool>,
517- pub sticky: Option<bool>,
518- pub scratchpad: Option<String>,
521+ pub initial_title: Option<Regex>,
522+ pub class: Option<Regex>,
523+ pub instance: Option<Regex>,
524+ pub xwayland: Option<bool>,
525+ pub dialog: Option<bool>,
526+ pub parent_app_id: Option<Regex>,
527+}
528+
529+/// What the rules can see of a window. `class` and `instance` are None for
530+/// windows that are not X11, and `parent_app_id` for those without a parent.
531+#[derive(Debug, Clone, Copy, Default)]
532+pub struct Subject<'a> {
533+ pub app_id: &'a str,
534+ pub title: &'a str,
535+ pub initial_title: &'a str,
536+ pub class: Option<&'a str>,
537+ pub instance: Option<&'a str>,
538+ pub xwayland: bool,
539+ pub dialog: bool,
540+ pub parent_app_id: Option<&'a str>,
541+}
542+
543+impl Matcher {
544+ fn hits(&self, s: &Subject) -> bool {
545+ fn re(re: &Option<Regex>, s: Option<&str>) -> bool {
546+ re.as_ref()
547+ .is_none_or(|re| s.is_some_and(|s| re.is_match(s)))
548+ }
549+ re(&self.app_id, Some(s.app_id))
550+ && re(&self.title, Some(s.title))
551+ && re(&self.initial_title, Some(s.initial_title))
552+ && re(&self.class, s.class)
553+ && re(&self.instance, s.instance)
554+ && re(&self.parent_app_id, s.parent_app_id)
555+ && self.xwayland.is_none_or(|x| x == s.xwayland)
556+ && self.dialog.is_none_or(|d| d == s.dialog)
557+ }
558+}
559+
560+/// Where a rule puts a floating window.
561+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562+pub enum At {
563+ /// From the top left of the output's area.
564+ Position(i32, i32),
565+ Center,
566+}
567+
568+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
569+#[serde(rename_all = "lowercase")]
570+pub enum IdleInhibit {
571+ /// While the window is on screen.
572+ Visible,
573+ Fullscreen,
519574 }
520575
521576 /// Everything the matching rules say about a window, later rules winning.
@@ -526,6 +581,52 @@ pub struct Placement {
526581 pub floating: Option<bool>,
527582 pub sticky: Option<bool>,
528583 pub scratchpad: Option<String>,
584+ pub size: Option<(u32, u32)>,
585+ pub at: Option<At>,
586+ /// A zero axis puts no lower bound on it.
587+ pub min_size: Option<(u32, u32)>,
588+ /// A zero axis puts no upper bound on it.
589+ pub max_size: Option<(u32, u32)>,
590+ pub fullscreen: Option<bool>,
591+ pub focus: Option<bool>,
592+ pub opacity: Option<f32>,
593+ pub idle_inhibit: Option<IdleInhibit>,
594+ pub vrr: Option<bool>,
595+ /// Inert until tearing presentation exists.
596+ pub tearing: Option<bool>,
597+}
598+
599+impl Placement {
600+ fn over(&mut self, r: &Placement) {
601+ self.tag = r.tag.clone().or(self.tag.take());
602+ self.output = r.output.clone().or(self.output.take());
603+ self.floating = r.floating.or(self.floating);
604+ self.sticky = r.sticky.or(self.sticky);
605+ self.scratchpad = r.scratchpad.clone().or(self.scratchpad.take());
606+ self.size = r.size.or(self.size);
607+ self.at = r.at.or(self.at);
608+ self.min_size = r.min_size.or(self.min_size);
609+ self.max_size = r.max_size.or(self.max_size);
610+ self.fullscreen = r.fullscreen.or(self.fullscreen);
611+ self.focus = r.focus.or(self.focus);
612+ self.opacity = r.opacity.or(self.opacity);
613+ self.idle_inhibit = r.idle_inhibit.or(self.idle_inhibit);
614+ self.vrr = r.vrr.or(self.vrr);
615+ self.tearing = r.tearing.or(self.tearing);
616+ }
617+
618+ /// Bounds `(w, h)` by `min_size` and `max_size`.
619+ pub fn clamp_size(&self, w: f64, h: f64) -> (f64, f64) {
620+ let axis = |v: f64, min: Option<u32>, max: Option<u32>| {
621+ let v = min.map_or(v, |m| v.max(f64::from(m)));
622+ max.filter(|&m| m > 0).map_or(v, |m| v.min(f64::from(m)))
623+ };
624+ let (min, max) = (self.min_size, self.max_size);
625+ (
626+ axis(w, min.map(|s| s.0), max.map(|s| s.0)),
627+ axis(h, min.map(|s| s.1), max.map(|s| s.1)),
628+ )
629+ }
529630 }
530631
531632 #[derive(Debug)]
@@ -557,11 +658,91 @@ enum Words {
557658 struct RawRule {
558659 app_id: Option<String>,
559660 title: Option<String>,
661+ initial_title: Option<String>,
662+ class: Option<String>,
663+ instance: Option<String>,
664+ xwayland: Option<bool>,
665+ dialog: Option<bool>,
666+ parent_app_id: Option<String>,
560667 tag: Option<String>,
561668 output: Option<String>,
562669 floating: Option<bool>,
563670 sticky: Option<bool>,
564671 scratchpad: Option<String>,
672+ size: Option<[u32; 2]>,
673+ position: Option<[i32; 2]>,
674+ center: Option<bool>,
675+ min_size: Option<[u32; 2]>,
676+ max_size: Option<[u32; 2]>,
677+ fullscreen: Option<bool>,
678+ focus: Option<bool>,
679+ opacity: Option<f32>,
680+ idle_inhibit: Option<IdleInhibit>,
681+ vrr: Option<bool>,
682+ tearing: Option<bool>,
683+}
684+
685+impl RawRule {
686+ /// A pattern that does not compile drops the rule, since without it the
687+ /// rule would hit every window. A bad effect only drops itself.
688+ fn build(self, name: &str, warn: &mut Vec<String>) -> Result<Rule, regex::Error> {
689+ let regex = |p: Option<String>| p.map(|p| Regex::new(&p)).transpose();
690+ let when = Matcher {
691+ app_id: regex(self.app_id)?,
692+ title: regex(self.title)?,
693+ initial_title: regex(self.initial_title)?,
694+ class: regex(self.class)?,
695+ instance: regex(self.instance)?,
696+ xwayland: self.xwayland,
697+ dialog: self.dialog,
698+ parent_app_id: regex(self.parent_app_id)?,
699+ };
700+ let mut set = Placement {
701+ tag: self.tag,
702+ output: self.output,
703+ floating: self.floating,
704+ sticky: self.sticky,
705+ scratchpad: self.scratchpad,
706+ size: self.size.map(|[a, b]| (a, b)),
707+ at: self.position.map(|[x, y]| At::Position(x, y)),
708+ min_size: self.min_size.map(|[a, b]| (a, b)),
709+ max_size: self.max_size.map(|[a, b]| (a, b)),
710+ fullscreen: self.fullscreen,
711+ focus: self.focus,
712+ opacity: self.opacity,
713+ idle_inhibit: self.idle_inhibit,
714+ vrr: self.vrr,
715+ tearing: self.tearing,
716+ };
717+ if self.center == Some(true) {
718+ if set.at.is_some() {
719+ warn.push(format!("{name}: position and center both set, center wins"));
720+ }
721+ set.at = Some(At::Center);
722+ }
723+ if set.size.is_some_and(|(w, h)| w == 0 || h == 0) {
724+ warn.push(format!("{name}.size: must be above zero, ignored"));
725+ set.size = None;
726+ }
727+ if let (Some(min), Some(max)) = (set.min_size, set.max_size)
728+ && [(min.0, max.0), (min.1, max.1)]
729+ .iter()
730+ .any(|&(lo, hi)| hi > 0 && lo > hi)
731+ {
732+ warn.push(format!(
733+ "{name}: min_size is larger than max_size, both ignored"
734+ ));
735+ set.min_size = None;
736+ set.max_size = None;
737+ }
738+ if let Some(o) = set.opacity
739+ && !(0.0..=1.0).contains(&o)
740+ {
741+ warn.push(format!("{name}.opacity: {o} is outside 0 to 1, ignored"));
742+ set.opacity = None;
743+ }
744+ Ok(Rule { when, set })
745+ }
565746 }
566747
567748 impl Config {
@@ -631,29 +812,21 @@ impl Config {
631812 }
632813 }
633814 }
634- let regex = |p: Option<String>| p.map(|p| Regex::new(&p)).transpose();
635815 let mut rules = Vec::new();
636816 for (i, raw) in items::<RawRule>("rules", table.remove("rules"), w)
637817 .into_iter()
638818 .enumerate()
639819 {
640- match (regex(raw.app_id), regex(raw.title)) {
641- (Ok(app_id), Ok(title)) => rules.push(Rule {
642- app_id,
643- title,
644- tag: raw.tag,
645- output: raw.output,
646- floating: raw.floating,
647- sticky: raw.sticky,
648- scratchpad: raw.scratchpad,
649- }),
650- (Err(e), _) | (_, Err(e)) => {
820+ let name = format!("rules[{i}]");
821+ match raw.build(&name, w) {
822+ Ok(rule) => rules.push(rule),
823+ Err(e) => {
651824 let e = e
652825 .to_string()
653826 .split_whitespace()
654827 .collect::<Vec<_>>()
655828 .join(" ");
656- w.push(format!("rules[{i}]: {e}, left out"));
829+ w.push(format!("{name}: {e}, left out"));
657830 }
658831 }
659832 }
@@ -677,18 +850,10 @@ impl Config {
677850 Ok(Loaded { config, warnings })
678851 }
679852
680- pub fn placement(&self, app_id: &str, title: &str) -> Placement {
853+ pub fn placement(&self, window: &Subject) -> Placement {
681854 let mut p = Placement::default();
682- let hit = |re: &Option<Regex>, s: &str| re.as_ref().is_none_or(|re| re.is_match(s));
683- for r in &self.rules {
684- if !hit(&r.app_id, app_id) || !hit(&r.title, title) {
685- continue;
686- }
687- p.tag = r.tag.clone().or(p.tag);
688- p.output = r.output.clone().or(p.output);
689- p.floating = r.floating.or(p.floating);
690- p.sticky = r.sticky.or(p.sticky);
691- p.scratchpad = r.scratchpad.clone().or(p.scratchpad);
855+ for r in self.rules.iter().filter(|r| r.when.hits(window)) {
856+ p.over(&r.set);
692857 }
693858 p
694859 }
@@ -1027,4 +1192,162 @@ mod tests {
10271192 assert_eq!(input.device[2].settings.accel_points, None);
10281193 assert_eq!(loaded.warnings.len(), 3, "{:#?}", loaded.warnings);
10291194 }
1195+
1196+ fn subject<'a>(app_id: &'a str, title: &'a str) -> Subject<'a> {
1197+ Subject {
1198+ app_id,
1199+ title,
1200+ initial_title: title,
1201+ ..Subject::default()
1202+ }
1203+ }
1204+
1205+ #[test]
1206+ fn rules_match_every_given_matcher_and_later_ones_win() {
1207+ let loaded = Config::parse(
1208+ r#"
1209+ [[rules]]
1210+ app_id = "^mpv$"
1211+ size = [800, 600]
1212+ opacity = 0.5
1213+ idle_inhibit = "visible"
1214+ [[rules]]
1215+ title = "Picture"
1216+ opacity = 0.8
1217+ focus = false
1218+ vrr = true
1219+ tearing = true
1220+ [[rules]]
1221+ class = "^Steam$"
1222+ instance = "steam"
1223+ xwayland = true
1224+ fullscreen = true
1225+ [[rules]]
1226+ dialog = true
1227+ parent_app_id = "^firefox$"
1228+ center = true
1229+ min_size = [300, 0]
1230+ max_size = [0, 500]
1231+ "#,
1232+ )
1233+ .unwrap();
1234+ assert_eq!(loaded.warnings, Vec::<String>::new());
1235+ let c = loaded.config;
1236+ let p = c.placement(&subject("mpv", "Picture in picture"));
1237+ assert_eq!(p.size, Some((800, 600)));
1238+ assert_eq!(p.opacity, Some(0.8));
1239+ assert_eq!(p.idle_inhibit, Some(IdleInhibit::Visible));
1240+ assert_eq!(
1241+ (p.focus, p.vrr, p.tearing),
1242+ (Some(false), Some(true), Some(true))
1243+ );
1244+ assert_eq!(c.placement(&subject("foot", "sh")), Placement::default());
1245+
1246+ let x11 = Subject {
1247+ class: Some("Steam"),
1248+ instance: Some("steam"),
1249+ xwayland: true,
1250+ ..subject("Steam", "Steam")
1251+ };
1252+ assert_eq!(c.placement(&x11).fullscreen, Some(true));
1253+ let wayland = Subject {
1254+ xwayland: false,
1255+ ..subject("Steam", "Steam")
1256+ };
1257+ assert_eq!(c.placement(&wayland).fullscreen, None);
1258+
1259+ let dialog = Subject {
1260+ dialog: true,
1261+ parent_app_id: Some("firefox"),
1262+ ..subject("firefox", "Save")
1263+ };
1264+ let p = c.placement(&dialog);
1265+ assert_eq!(p.at, Some(At::Center));
1266+ assert_eq!(p.clamp_size(100.0, 900.0), (300.0, 500.0));
1267+ let orphan = Subject {
1268+ parent_app_id: None,
1269+ ..dialog
1270+ };
1271+ assert_eq!(c.placement(&orphan), Placement::default());
1272+ }
1273+
1274+ #[test]
1275+ fn initial_title_holds_when_the_title_changes() {
1276+ let loaded = Config::parse(
1277+ r#"
1278+ [[rules]]
1279+ initial_title = "^Untitled$"
1280+ floating = true
1281+ "#,
1282+ )
1283+ .unwrap();
1284+ let later = Subject {
1285+ title: "notes.txt",
1286+ initial_title: "Untitled",
1287+ ..subject("gedit", "")
1288+ };
1289+ assert_eq!(loaded.config.placement(&later).floating, Some(true));
1290+ let other = Subject {
1291+ initial_title: "notes.txt",
1292+ ..later
1293+ };
1294+ assert_eq!(loaded.config.placement(&other).floating, None);
1295+ }
1296+
1297+ #[test]
1298+ fn bad_rule_values_are_warned_about_and_cost_little() {
1299+ let loaded = Config::parse(
1300+ r#"
1301+ [[rules]]
1302+ app_id = "a"
1303+ opacity = 1.5
1304+ size = [0, 100]
1305+ min_size = [500, 500]
1306+ max_size = [400, 0]
1307+ position = [10, 10]
1308+ center = true
1309+ floating = true
1310+ [[rules]]
1311+ app_id = "b"
1312+ idle_inhibit = "always"
1313+ [[rules]]
1314+ class = "("
1315+ [[rules]]
1316+ parent_app_id = "["
1317+ [[rules]]
1318+ app_id = "c"
1319+ size = [-1, 10]
1320+ [[rules]]
1321+ app_id = "d"
1322+ focus = "no"
1323+ [[rules]]
1324+ app_id = "e"
1325+ vrr = false
1326+ "#,
1327+ )
1328+ .unwrap();
1329+ let c = loaded.config;
1330+ assert_eq!(c.rules.len(), 2, "{:#?}", loaded.warnings);
1331+ let a = c.placement(&subject("a", ""));
1332+ assert_eq!(a.floating, Some(true));
1333+ assert_eq!(
1334+ (a.opacity, a.size, a.min_size, a.max_size),
1335+ (None, None, None, None)
1336+ );
1337+ assert_eq!(a.at, Some(At::Center));
1338+ assert_eq!(c.placement(&subject("e", "")).vrr, Some(false));
1339+ assert_eq!(loaded.warnings.len(), 9, "{:#?}", loaded.warnings);
1340+ }
1341+
1342+ #[test]
1343+ fn size_bounds_treat_zero_as_open() {
1344+ let p = Placement {
1345+ min_size: Some((200, 0)),
1346+ max_size: Some((0, 300)),
1347+ ..Placement::default()
1348+ };
1349+ assert_eq!(p.clamp_size(50.0, 900.0), (200.0, 300.0));
1350+ assert_eq!(p.clamp_size(4000.0, 10.0), (4000.0, 10.0));
1351+ assert_eq!(Placement::default().clamp_size(7.0, 9.0), (7.0, 9.0));
1352+ }
10301353 }
Modified file: crates/monolith/src/windows.rs+8 additions−1 deletions
@@ -1,6 +1,7 @@
11 use crate::anim::Ghost;
22 use crate::state::{State, now_ns};
33 use crate::wm::{Kind, LastFrame, identity, root};
4+use monolith_config::Subject;
45 use monolith_layout::{OutputId, Rect, WindowId};
56 use monolith_render_vk::VulkanTexture;
67 use smithay::backend::renderer::Renderer;
@@ -20,7 +21,13 @@ impl State {
2021 /// and title are known and the initial configure can carry its size.
2122 pub fn map_window(&mut self, window: Window, float: Option<Rectangle<i32, Logical>>) {
2223 let (app_id, title) = identity(&window);
23- let rule = self.settings.config.placement(&app_id, &title);
24+ let subject = Subject {
25+ app_id: &app_id,
26+ title: &title,
27+ initial_title: &title,
28+ ..Subject::default()
29+ };
30+ let rule = self.settings.config.placement(&subject);
2431 let launch = self.launch_of(self.window_pid(&window));
2532 let (fullscreen, fullscreen_output) = requested_fullscreen(&window);
2633 let output = rule