Commit f4c93ddeb08f
tarball3 files changed+416-31
@@ -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. Rules185
+# are matched when a window maps and again when its title or app_id changes186
+# before it has drawn, since many apps only set the real ones after their first187
+# 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 class191
+# title the current title192
+# initial_title the title the window had when it mapped, which never changes193
+# class the X11 WM_CLASS class, never hits a Wayland window194
+# instance the X11 WM_CLASS instance, never hits a Wayland window195
+# xwayland true for X11 windows, false for Wayland ones196
+# dialog true for a window with a parent or transient, or an X11197
+# dialog, false for any other198
+# parent_app_id the app_id of the window it is a dialog of, never hits a199
+# window without a parent200
+#201
+# Placement, fixed when the window maps or when its rules change before it has202
+# drawn.203
+# tag tag name to open on204
+# output connector name to open on205
+# floating open floating or tiled, whatever the window would be206
+# sticky show on every tag207
+# scratchpad name of the scratchpad to join208
+# fullscreen open fullscreen or not, over what the client asks209
+# focus false keeps the focus where it is when the window maps210
+# size [width, height] of a floating window211
+# position [x, y] of a floating window from its output top left212
+# center true centres a floating window, over position213
+# min_size [width, height] lower bound for floating sizes, 0 for none214
+# max_size [width, height] upper bound for floating sizes, 0 for none215
+# size, position, center and the bounds only touch windows that float, so216
+# 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 drawn220
+# idle_inhibit visible keeps the screen on while the window is shown,221
+# fullscreen only while it is also fullscreen222
+# vrr true or false forces adaptive sync on or off for the output223
+# while this window is fullscreen on it224
+# tearing reserved, tearing presentation is not implemented yet225
+#185226
# [[rules]]186227
# app_id = "^pavucontrol$"187228
# floating = true229
+# size = [900, 600]230
+# center = true231
+#232
+# [[rules]]233
+# app_id = "^mpv$"234
+# idle_inhibit = "fullscreen"235
+# vrr = true236
+#237
+# [[rules]]238
+# app_id = "^firefox$"239
+# title = "^Picture-in-Picture$"240
+# floating = true241
+# opacity = 0.9242
+# focus = false@@ -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 for530
+/// 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 the687
+ /// 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.opacity739
+ && !(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 = e652825
.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
p694859
}@@ -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.51213
+ idle_inhibit = "visible"1214
+ [[rules]]1215
+ title = "Picture"1216
+ opacity = 0.81217
+ focus = false1218
+ vrr = true1219
+ tearing = true1220
+ [[rules]]1221
+ class = "^Steam$"1222
+ instance = "steam"1223
+ xwayland = true1224
+ fullscreen = true1225
+ [[rules]]1226
+ dialog = true1227
+ parent_app_id = "^firefox$"1228
+ center = true1229
+ 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
+ ..dialog1270
+ };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 = true1281
+ "#,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
+ ..later1293
+ };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.51304
+ size = [0, 100]1305
+ min_size = [500, 500]1306
+ max_size = [400, 0]1307
+ position = [10, 10]1308
+ center = true1309
+ floating = true1310
+ [[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 = false1326
+ "#,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
}@@ -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