atagen

monolith

public
Star0
Watch0
eacaaa02eca575a1a8316c7db2d14b3b7ffd5a08
1
ssh://brewery@pls.lobotomise.me:33/atagen/monolith.git
crates/config/src/lib.rsRust1458 lines45.4 KB
1
//! The TOML config. Parsing and validation only, the compositor applies it.
2
3
use monolith_ipc::Command;
4
use regex::Regex;
5
use serde::Deserialize;
6
use serde::de::DeserializeOwned;
7
use std::fmt;
8
use std::path::{Path, PathBuf};
9
10
pub const DEFAULT: &str = include_str!("../default.toml");
11
12
#[derive(Debug, Clone)]
13
pub struct Config {
14
    pub general: General,
15
    pub output: Outputs,
16
    pub decoration: Decoration,
17
    pub animation: Animation,
18
    pub keyboard: Keyboard,
19
    pub input: Input,
20
    pub idle: Idle,
21
    pub lid: Lid,
22
    pub xwayland: Xwayland,
23
    pub binds: Vec<Bind>,
24
    pub rules: Vec<Rule>,
25
    pub startup: Vec<Vec<String>>,
26
}
27
28
#[derive(Deserialize, Debug, Clone, PartialEq)]
29
#[serde(default, deny_unknown_fields)]
30
pub struct General {
31
    /// What `Mod` means in binds.
32
    #[serde(rename = "mod")]
33
    pub mod_key: Modifier,
34
    pub gaps: Gaps,
35
    /// A new window splits the one it opens beside along that window's
36
    /// longer side, instead of joining its container.
37
    pub autotile: bool,
38
    /// What a window move does when it meets a neighbour.
39
    #[serde(rename = "move")]
40
    pub move_mode: MoveMode,
41
    pub focus_follows_mouse: bool,
42
    pub mouse_follows_focus: bool,
43
    /// The first window to land on or move to an empty tag nobody is
44
    /// viewing brings that tag into view on its output.
45
    pub view_tag_on_first_window: bool,
46
}
47
48
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
49
#[serde(rename_all = "lowercase")]
50
pub enum MoveMode {
51
    /// Trade places with it.
52
    #[default]
53
    Swap,
54
    /// Join its lane, stacking with it if it is alone.
55
    Merge,
56
}
57
58
impl Default for General {
59
    fn default() -> Self {
60
        Self {
61
            mod_key: Modifier::Super,
62
            gaps: Gaps::default(),
63
            autotile: true,
64
            move_mode: MoveMode::Swap,
65
            focus_follows_mouse: false,
66
            mouse_follows_focus: false,
67
            view_tag_on_first_window: true,
68
        }
69
    }
70
}
71
72
#[derive(Deserialize, Debug, Clone, PartialEq)]
73
#[serde(default, deny_unknown_fields)]
74
pub struct Xwayland {
75
    /// Which X11 clients may read the Wayland selections and set them.
76
    pub selection_access: SelectionAccess,
77
}
78
79
impl Default for Xwayland {
80
    fn default() -> Self {
81
        Self {
82
            selection_access: SelectionAccess::Focused,
83
        }
84
    }
85
}
86
87
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
88
#[serde(rename_all = "lowercase")]
89
pub enum SelectionAccess {
90
    /// Only the X11 window holding keyboard focus.
91
    Focused,
92
    /// Every X11 client, which clipboard managers and headless tools need.
93
    Any,
94
}
95
96
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
97
#[serde(default, deny_unknown_fields)]
98
pub struct Gaps {
99
    pub inner: f64,
100
    pub outer: f64,
101
}
102
103
impl Default for Gaps {
104
    fn default() -> Self {
105
        Self {
106
            inner: 8.0,
107
            outer: 8.0,
108
        }
109
    }
110
}
111
112
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
113
#[serde(default)]
114
pub struct Outputs {
115
    pub vrr: Vrr,
116
    /// Settings for one connector, by name, like `DP-1`, or `nested` for the
117
    /// nested backend's window.
118
    #[serde(flatten)]
119
    pub named: std::collections::BTreeMap<String, OutputConfig>,
120
}
121
122
/// Transform, position and scale follow a reload. A new mode waits for a
123
/// restart or a replug, since it means a modeset.
124
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
125
#[serde(default, deny_unknown_fields)]
126
pub struct OutputConfig {
127
    pub transform: OutputTransform,
128
    /// Top left corner in the global layout. Outputs without one are placed
129
    /// to the right of the others.
130
    pub position: Option<[i32; 2]>,
131
    /// `WIDTHxHEIGHT` or `WIDTHxHEIGHT@HZ`. Without a rate the highest one
132
    /// for that size wins.
133
    pub mode: Option<String>,
134
    /// How many physical pixels make a logical one, `0.5` to `4.0` in steps of
135
    /// 1/120. Positions are in logical pixels.
136
    pub scale: Option<f64>,
137
}
138
139
impl OutputConfig {
140
    pub fn scale(&self) -> f64 {
141
        self.scale.unwrap_or(1.0)
142
    }
143
144
    fn validate(&mut self, name: &str, w: &mut Vec<String>) {
145
        let Some(s) = self.scale else {
146
            return;
147
        };
148
        if !(MIN_SCALE..=MAX_SCALE).contains(&s) {
149
            w.push(format!(
150
                "{name}.scale: {s} is outside {MIN_SCALE} to {MAX_SCALE}, ignored"
151
            ));
152
            self.scale = None;
153
        } else {
154
            self.scale = Some((s * 120.0).round() / 120.0);
155
        }
156
    }
157
}
158
159
const MIN_SCALE: f64 = 0.5;
160
const MAX_SCALE: f64 = 4.0;
161
162
/// Counterclockwise rotation, optionally after flipping horizontally.
163
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
164
#[serde(rename_all = "kebab-case")]
165
pub enum OutputTransform {
166
    #[default]
167
    Normal,
168
    #[serde(rename = "90")]
169
    Rotate90,
170
    #[serde(rename = "180")]
171
    Rotate180,
172
    #[serde(rename = "270")]
173
    Rotate270,
174
    Flipped,
175
    #[serde(rename = "flipped-90")]
176
    Flipped90,
177
    #[serde(rename = "flipped-180")]
178
    Flipped180,
179
    #[serde(rename = "flipped-270")]
180
    Flipped270,
181
}
182
183
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
184
#[serde(rename_all = "lowercase")]
185
pub enum Vrr {
186
    Off,
187
    On,
188
    /// Only while a fullscreen window is shown, where a game sets the pace.
189
    #[default]
190
    Fullscreen,
191
}
192
193
#[derive(Deserialize, Debug, Clone, PartialEq)]
194
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
195
pub struct Decoration {
196
    /// Border width in logical pixels, drawn in the gap around each window.
197
    pub border: u32,
198
    pub radius: Corners,
199
    pub focused: Color,
200
    pub unfocused: Color,
201
    pub urgent: Color,
202
    pub extended: Color,
203
    pub extended_focused: Color,
204
}
205
206
/// Corner radii in logical pixels, top left, top right, bottom right and
207
/// bottom left. One number rounds every corner alike.
208
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
209
#[serde(from = "CornersToml")]
210
pub struct Corners(pub [u32; 4]);
211
212
#[derive(Deserialize)]
213
#[serde(
214
    untagged,
215
    expecting = "expected a number or [top left, top right, bottom right, bottom left]"
216
)]
217
enum CornersToml {
218
    All(u32),
219
    Each([u32; 4]),
220
}
221
222
impl From<CornersToml> for Corners {
223
    fn from(c: CornersToml) -> Self {
224
        match c {
225
            CornersToml::All(r) => Self([r; 4]),
226
            CornersToml::Each(r) => Self(r),
227
        }
228
    }
229
}
230
231
impl Default for Decoration {
232
    fn default() -> Self {
233
        Self {
234
            border: 2,
235
            radius: Corners([0; 4]),
236
            focused: Color::rgb(0x88, 0xbf, 0xd1),
237
            unfocused: Color::rgb(0x3b, 0x42, 0x52),
238
            urgent: Color::rgb(0xeb, 0xcb, 0x8b),
239
            extended: Color::rgb(0x5e, 0x4b, 0x6e),
240
            extended_focused: Color::rgb(0xb4, 0x8e, 0xad),
241
        }
242
    }
243
}
244
245
/// Straight RGBA from `#rrggbb` or `#rrggbbaa`.
246
#[derive(Debug, Clone, Copy, PartialEq)]
247
pub struct Color(pub [f32; 4]);
248
249
impl Color {
250
    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
251
        Self([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
252
    }
253
}
254
255
impl<'de> Deserialize<'de> for Color {
256
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
257
        let s = String::deserialize(d)?;
258
        let hex = s.strip_prefix('#').unwrap_or(&s);
259
        let bad =
260
            || serde::de::Error::custom(format!("bad colour {s:?}, use #rrggbb or #rrggbbaa"));
261
        if !matches!(hex.len(), 6 | 8) || !hex.is_ascii() {
262
            return Err(bad());
263
        }
264
        let mut c = [1.0f32; 4];
265
        for (i, v) in c.iter_mut().enumerate().take(hex.len() / 2) {
266
            let byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| bad())?;
267
            *v = f32::from(byte) / 255.0;
268
        }
269
        Ok(Self(c))
270
    }
271
}
272
273
#[derive(Deserialize, Debug, Clone, PartialEq)]
274
#[serde(default, deny_unknown_fields)]
275
pub struct Animation {
276
    pub enabled: bool,
277
    pub style: AnimationStyle,
278
    /// Every phase of a planned transition lasts this long.
279
    pub phase_ms: u64,
280
    /// A cubic bezier, as in CSS.
281
    pub curve: [f64; 4],
282
}
283
284
impl Default for Animation {
285
    fn default() -> Self {
286
        Self {
287
            enabled: true,
288
            style: AnimationStyle::Orthogonal,
289
            phase_ms: 160,
290
            curve: [0.45, 0.0, 0.2, 1.0],
291
        }
292
    }
293
}
294
295
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
296
#[serde(rename_all = "lowercase")]
297
pub enum AnimationStyle {
298
    Orthogonal,
299
    Direct,
300
}
301
302
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
303
#[serde(default, deny_unknown_fields)]
304
pub struct Idle {
305
    /// Seconds without input before every output is switched off, 0 for
306
    /// never.
307
    pub blank_after: u64,
308
}
309
310
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
311
#[serde(default, deny_unknown_fields)]
312
pub struct Lid {
313
    /// What closing the lid does while no other output is connected.
314
    pub close: LidAction,
315
}
316
317
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
318
#[serde(rename_all = "lowercase")]
319
pub enum LidAction {
320
    #[default]
321
    Suspend,
322
    Poweroff,
323
    /// Leave it to logind or whatever else watches the switch.
324
    Ignore,
325
}
326
327
#[derive(Deserialize, Debug, Clone, PartialEq)]
328
#[serde(default, deny_unknown_fields)]
329
pub struct Keyboard {
330
    pub rules: String,
331
    pub model: String,
332
    pub layout: String,
333
    pub variant: String,
334
    pub options: Option<String>,
335
    pub repeat_delay: i32,
336
    pub repeat_rate: i32,
337
}
338
339
impl Default for Keyboard {
340
    fn default() -> Self {
341
        Self {
342
            rules: String::new(),
343
            model: String::new(),
344
            layout: String::new(),
345
            variant: String::new(),
346
            options: None,
347
            repeat_delay: 200,
348
            repeat_rate: 25,
349
        }
350
    }
351
}
352
353
/// libinput settings for pointing devices. Unset options keep libinput's
354
/// default for the device.
355
#[derive(Debug, Clone, PartialEq, Default)]
356
pub struct Input {
357
    pub pointer: DeviceSettings,
358
    /// Devices that can tap, which libinput only offers on touchpads.
359
    pub touchpad: DeviceSettings,
360
    /// Overrides for devices by name, later entries winning.
361
    pub device: Vec<NamedDevice>,
362
}
363
364
#[derive(Debug, Clone, PartialEq)]
365
pub struct NamedDevice {
366
    pub name: String,
367
    pub settings: DeviceSettings,
368
}
369
370
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
371
#[serde(default, deny_unknown_fields)]
372
pub struct DeviceSettings {
373
    pub accel_profile: Option<AccelProfile>,
374
    pub accel_speed: Option<f64>,
375
    /// The custom profile's curve, pointer speed sampled every `accel_step`
376
    /// device units per millisecond.
377
    pub accel_points: Option<Vec<f64>>,
378
    pub accel_step: Option<f64>,
379
    pub natural_scroll: Option<bool>,
380
    pub left_handed: Option<bool>,
381
    pub tap: Option<bool>,
382
    pub tap_drag: Option<bool>,
383
    pub tap_drag_lock: Option<bool>,
384
    pub dwt: Option<bool>,
385
    pub middle_emulation: Option<bool>,
386
    pub scroll_method: Option<ScrollMethod>,
387
    pub scroll_button: Option<u32>,
388
    pub click_method: Option<ClickMethod>,
389
    /// Applied by the compositor, libinput has no such setting.
390
    pub scroll_factor: Option<f64>,
391
}
392
393
impl DeviceSettings {
394
    /// `self` with every option `over` sets replaced.
395
    pub fn overlay(&self, over: &Self) -> Self {
396
        Self {
397
            accel_profile: over.accel_profile.or(self.accel_profile),
398
            accel_speed: over.accel_speed.or(self.accel_speed),
399
            accel_points: over
400
                .accel_points
401
                .clone()
402
                .or_else(|| self.accel_points.clone()),
403
            accel_step: over.accel_step.or(self.accel_step),
404
            natural_scroll: over.natural_scroll.or(self.natural_scroll),
405
            left_handed: over.left_handed.or(self.left_handed),
406
            tap: over.tap.or(self.tap),
407
            tap_drag: over.tap_drag.or(self.tap_drag),
408
            tap_drag_lock: over.tap_drag_lock.or(self.tap_drag_lock),
409
            dwt: over.dwt.or(self.dwt),
410
            middle_emulation: over.middle_emulation.or(self.middle_emulation),
411
            scroll_method: over.scroll_method.or(self.scroll_method),
412
            scroll_button: over.scroll_button.or(self.scroll_button),
413
            click_method: over.click_method.or(self.click_method),
414
            scroll_factor: over.scroll_factor.or(self.scroll_factor),
415
        }
416
    }
417
418
    fn validate(&mut self, name: &str, w: &mut Vec<String>) {
419
        if let Some(s) = self.accel_speed
420
            && !(-1.0..=1.0).contains(&s)
421
        {
422
            w.push(format!(
423
                "{name}.accel_speed: {s} is outside -1 to 1, ignored"
424
            ));
425
            self.accel_speed = None;
426
        }
427
        if let Some(f) = self.scroll_factor
428
            && !(f.is_finite() && f >= 0.0)
429
        {
430
            w.push(format!(
431
                "{name}.scroll_factor: {f} must be zero or more, ignored"
432
            ));
433
            self.scroll_factor = None;
434
        }
435
        if self.accel_points.is_some() != self.accel_step.is_some() {
436
            w.push(format!(
437
                "{name}: accel_points needs accel_step and the other way round, both ignored"
438
            ));
439
            self.accel_points = None;
440
            self.accel_step = None;
441
        }
442
    }
443
}
444
445
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
446
#[serde(rename_all = "lowercase")]
447
pub enum AccelProfile {
448
    Flat,
449
    Adaptive,
450
    Custom,
451
}
452
453
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
454
#[serde(rename_all = "kebab-case")]
455
pub enum ScrollMethod {
456
    None,
457
    TwoFinger,
458
    Edge,
459
    OnButtonDown,
460
}
461
462
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
463
#[serde(rename_all = "kebab-case")]
464
pub enum ClickMethod {
465
    ButtonAreas,
466
    Clickfinger,
467
}
468
469
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
470
#[serde(rename_all = "lowercase")]
471
pub enum Modifier {
472
    Super,
473
    Alt,
474
    Ctrl,
475
    Shift,
476
}
477
478
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
479
pub struct Mods {
480
    pub logo: bool,
481
    pub alt: bool,
482
    pub ctrl: bool,
483
    pub shift: bool,
484
}
485
486
impl Mods {
487
    pub fn has(&self, m: Modifier) -> bool {
488
        match m {
489
            Modifier::Super => self.logo,
490
            Modifier::Alt => self.alt,
491
            Modifier::Ctrl => self.ctrl,
492
            Modifier::Shift => self.shift,
493
        }
494
    }
495
496
    fn set(&mut self, m: Modifier) {
497
        match m {
498
            Modifier::Super => self.logo = true,
499
            Modifier::Alt => self.alt = true,
500
            Modifier::Ctrl => self.ctrl = true,
501
            Modifier::Shift => self.shift = true,
502
        }
503
    }
504
}
505
506
#[derive(Debug, Clone, PartialEq)]
507
pub struct Bind {
508
    pub mods: Mods,
509
    /// An xkb keysym name, resolved by the compositor.
510
    pub key: String,
511
    pub command: Command,
512
}
513
514
#[derive(Debug, Clone)]
515
pub struct Rule {
516
    pub when: Matcher,
517
    pub set: Placement,
518
}
519
520
/// What a rule asks of a window. Every matcher given must hit.
521
#[derive(Debug, Clone, Default)]
522
pub struct Matcher {
523
    pub app_id: Option<Regex>,
524
    pub title: Option<Regex>,
525
    pub initial_title: Option<Regex>,
526
    pub class: Option<Regex>,
527
    pub instance: Option<Regex>,
528
    pub xwayland: Option<bool>,
529
    pub dialog: Option<bool>,
530
    pub parent_app_id: Option<Regex>,
531
}
532
533
/// What the rules can see of a window. `class` and `instance` are None for
534
/// windows that are not X11, and `parent_app_id` for those without a parent.
535
#[derive(Debug, Clone, Copy, Default)]
536
pub struct Subject<'a> {
537
    pub app_id: &'a str,
538
    pub title: &'a str,
539
    pub initial_title: &'a str,
540
    pub class: Option<&'a str>,
541
    pub instance: Option<&'a str>,
542
    pub xwayland: bool,
543
    pub dialog: bool,
544
    pub parent_app_id: Option<&'a str>,
545
}
546
547
impl Matcher {
548
    fn hits(&self, s: &Subject) -> bool {
549
        fn re(re: &Option<Regex>, s: Option<&str>) -> bool {
550
            re.as_ref()
551
                .is_none_or(|re| s.is_some_and(|s| re.is_match(s)))
552
        }
553
        re(&self.app_id, Some(s.app_id))
554
            && re(&self.title, Some(s.title))
555
            && re(&self.initial_title, Some(s.initial_title))
556
            && re(&self.class, s.class)
557
            && re(&self.instance, s.instance)
558
            && re(&self.parent_app_id, s.parent_app_id)
559
            && self.xwayland.is_none_or(|x| x == s.xwayland)
560
            && self.dialog.is_none_or(|d| d == s.dialog)
561
    }
562
}
563
564
/// Where a rule puts a floating window.
565
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566
pub enum At {
567
    /// From the top left of the output's area.
568
    Position(i32, i32),
569
    Center,
570
}
571
572
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
573
#[serde(rename_all = "lowercase")]
574
pub enum IdleInhibit {
575
    /// While the window is on screen.
576
    Visible,
577
    Fullscreen,
578
}
579
580
/// Everything the matching rules say about a window, later rules winning.
581
#[derive(Debug, Clone, Default, PartialEq)]
582
pub struct Placement {
583
    pub tag: Option<String>,
584
    pub output: Option<String>,
585
    pub floating: Option<bool>,
586
    pub sticky: Option<bool>,
587
    pub scratchpad: Option<String>,
588
    pub size: Option<(u32, u32)>,
589
    pub at: Option<At>,
590
    /// A zero axis puts no lower bound on it.
591
    pub min_size: Option<(u32, u32)>,
592
    /// A zero axis puts no upper bound on it.
593
    pub max_size: Option<(u32, u32)>,
594
    pub fullscreen: Option<bool>,
595
    pub focus: Option<bool>,
596
    pub opacity: Option<f32>,
597
    pub idle_inhibit: Option<IdleInhibit>,
598
    pub vrr: Option<bool>,
599
    /// Inert until tearing presentation exists.
600
    pub tearing: Option<bool>,
601
}
602
603
impl Placement {
604
    fn over(&mut self, r: &Placement) {
605
        self.tag = r.tag.clone().or(self.tag.take());
606
        self.output = r.output.clone().or(self.output.take());
607
        self.floating = r.floating.or(self.floating);
608
        self.sticky = r.sticky.or(self.sticky);
609
        self.scratchpad = r.scratchpad.clone().or(self.scratchpad.take());
610
        self.size = r.size.or(self.size);
611
        self.at = r.at.or(self.at);
612
        self.min_size = r.min_size.or(self.min_size);
613
        self.max_size = r.max_size.or(self.max_size);
614
        self.fullscreen = r.fullscreen.or(self.fullscreen);
615
        self.focus = r.focus.or(self.focus);
616
        self.opacity = r.opacity.or(self.opacity);
617
        self.idle_inhibit = r.idle_inhibit.or(self.idle_inhibit);
618
        self.vrr = r.vrr.or(self.vrr);
619
        self.tearing = r.tearing.or(self.tearing);
620
    }
621
622
    /// Bounds `(w, h)` by `min_size` and `max_size`.
623
    pub fn clamp_size(&self, w: f64, h: f64) -> (f64, f64) {
624
        let axis = |v: f64, min: Option<u32>, max: Option<u32>| {
625
            let v = min.map_or(v, |m| v.max(f64::from(m)));
626
            max.filter(|&m| m > 0).map_or(v, |m| v.min(f64::from(m)))
627
        };
628
        let (min, max) = (self.min_size, self.max_size);
629
        (
630
            axis(w, min.map(|s| s.0), max.map(|s| s.0)),
631
            axis(h, min.map(|s| s.1), max.map(|s| s.1)),
632
        )
633
    }
634
}
635
636
#[derive(Debug)]
637
pub enum Error {
638
    Io(PathBuf, std::io::Error),
639
    Toml(toml::de::Error),
640
}
641
642
impl fmt::Display for Error {
643
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
644
        match self {
645
            Error::Io(p, e) => write!(f, "{}: {e}", p.display()),
646
            Error::Toml(e) => write!(f, "{e}"),
647
        }
648
    }
649
}
650
651
impl std::error::Error for Error {}
652
653
#[derive(Deserialize)]
654
#[serde(untagged)]
655
enum Words {
656
    Line(String),
657
    List(Vec<String>),
658
}
659
660
#[derive(Deserialize)]
661
#[serde(deny_unknown_fields)]
662
struct RawRule {
663
    app_id: Option<String>,
664
    title: Option<String>,
665
    initial_title: Option<String>,
666
    class: Option<String>,
667
    instance: Option<String>,
668
    xwayland: Option<bool>,
669
    dialog: Option<bool>,
670
    parent_app_id: Option<String>,
671
    tag: Option<String>,
672
    output: Option<String>,
673
    floating: Option<bool>,
674
    sticky: Option<bool>,
675
    scratchpad: Option<String>,
676
    size: Option<[u32; 2]>,
677
    position: Option<[i32; 2]>,
678
    center: Option<bool>,
679
    min_size: Option<[u32; 2]>,
680
    max_size: Option<[u32; 2]>,
681
    fullscreen: Option<bool>,
682
    focus: Option<bool>,
683
    opacity: Option<f32>,
684
    idle_inhibit: Option<IdleInhibit>,
685
    vrr: Option<bool>,
686
    tearing: Option<bool>,
687
}
688
689
impl RawRule {
690
    /// A pattern that does not compile drops the rule, since without it the
691
    /// rule would hit every window. A bad effect only drops itself.
692
    fn build(self, name: &str, warn: &mut Vec<String>) -> Result<Rule, regex::Error> {
693
        let regex = |p: Option<String>| p.map(|p| Regex::new(&p)).transpose();
694
        let when = Matcher {
695
            app_id: regex(self.app_id)?,
696
            title: regex(self.title)?,
697
            initial_title: regex(self.initial_title)?,
698
            class: regex(self.class)?,
699
            instance: regex(self.instance)?,
700
            xwayland: self.xwayland,
701
            dialog: self.dialog,
702
            parent_app_id: regex(self.parent_app_id)?,
703
        };
704
        let mut set = Placement {
705
            tag: self.tag,
706
            output: self.output,
707
            floating: self.floating,
708
            sticky: self.sticky,
709
            scratchpad: self.scratchpad,
710
            size: self.size.map(|[a, b]| (a, b)),
711
            at: self.position.map(|[x, y]| At::Position(x, y)),
712
            min_size: self.min_size.map(|[a, b]| (a, b)),
713
            max_size: self.max_size.map(|[a, b]| (a, b)),
714
            fullscreen: self.fullscreen,
715
            focus: self.focus,
716
            opacity: self.opacity,
717
            idle_inhibit: self.idle_inhibit,
718
            vrr: self.vrr,
719
            tearing: self.tearing,
720
        };
721
        if self.center == Some(true) {
722
            if set.at.is_some() {
723
                warn.push(format!("{name}: position and center both set, center wins"));
724
            }
725
            set.at = Some(At::Center);
726
        }
727
        if set.size.is_some_and(|(w, h)| w == 0 || h == 0) {
728
            warn.push(format!("{name}.size: must be above zero, ignored"));
729
            set.size = None;
730
        }
731
        if let (Some(min), Some(max)) = (set.min_size, set.max_size)
732
            && [(min.0, max.0), (min.1, max.1)]
733
                .iter()
734
                .any(|&(lo, hi)| hi > 0 && lo > hi)
735
        {
736
            warn.push(format!(
737
                "{name}: min_size is larger than max_size, both ignored"
738
            ));
739
            set.min_size = None;
740
            set.max_size = None;
741
        }
742
        if let Some(o) = set.opacity
743
            && !(0.0..=1.0).contains(&o)
744
        {
745
            warn.push(format!("{name}.opacity: {o} is outside 0 to 1, ignored"));
746
            set.opacity = None;
747
        }
748
        Ok(Rule { when, set })
749
    }
750
}
751
752
impl Config {
753
    /// `$MONOLITH_CONFIG`, else `$XDG_CONFIG_HOME/monolith/config.toml`.
754
    pub fn path() -> Option<PathBuf> {
755
        if let Some(p) = std::env::var_os("MONOLITH_CONFIG") {
756
            return Some(p.into());
757
        }
758
        let base = std::env::var_os("XDG_CONFIG_HOME")
759
            .map(PathBuf::from)
760
            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
761
        Some(base.join("monolith/config.toml"))
762
    }
763
764
    /// Loads `path`, or the built-in default when it does not exist.
765
    pub fn load(path: Option<&Path>) -> Result<Loaded, Error> {
766
        match path {
767
            Some(p) => match std::fs::read_to_string(p) {
768
                Ok(text) => Self::parse(&text),
769
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::parse(DEFAULT),
770
                Err(e) => Err(Error::Io(p.to_owned(), e)),
771
            },
772
            None => Self::parse(DEFAULT),
773
        }
774
    }
775
776
    /// Only TOML too broken to repair line by line fails. A bad option, bind
777
    /// or rule is left out with a warning, so one typo does not throw away the
778
    /// rest of the file.
779
    pub fn parse(text: &str) -> Result<Loaded, Error> {
780
        let mut warnings = Vec::new();
781
        let mut table = lenient_table(text, &mut warnings)?;
782
        let w = &mut warnings;
783
        let general: General = section("general", table.remove("general"), w);
784
        let keyboard = section("keyboard", table.remove("keyboard"), w);
785
        let input = input(table.remove("input"), w);
786
        let idle = section("idle", table.remove("idle"), w);
787
        let lid = section("lid", table.remove("lid"), w);
788
        let animation = section("animation", table.remove("animation"), w);
789
        let xwayland = section("xwayland", table.remove("xwayland"), w);
790
        let decoration = section("decoration", table.remove("decoration"), w);
791
        let mut output: Outputs = section("output", table.remove("output"), w);
792
        for name in output
793
            .named
794
            .keys()
795
            .filter(|n| *n != "nested" && !connector_like(n))
796
        {
797
            w.push(format!(
798
                "output.{name}: not a connector name like DP-1 or HDMI-A-1, matches nothing"
799
            ));
800
        }
801
        for (name, config) in &mut output.named {
802
            config.validate(&format!("output.{name}"), w);
803
        }
804
        let startup = items("startup", table.remove("startup"), w);
805
        let mut binds = Vec::new();
806
        if let Some(value) = table.remove("binds") {
807
            let entries: toml::Table = entry("binds", value, w).unwrap_or_default();
808
            for (combo, value) in entries {
809
                let name = format!("binds.{combo:?}");
810
                let bind = entry::<Words>(&name, value, w)
811
                    .map(|words| parse_bind(&combo, words, general.mod_key));
812
                match bind {
813
                    Some(Ok(b)) => binds.push(b),
814
                    Some(Err(e)) => w.push(format!("{name}: {e}, left out")),
815
                    None => {}
816
                }
817
            }
818
        }
819
        let mut rules = Vec::new();
820
        for (i, raw) in items::<RawRule>("rules", table.remove("rules"), w)
821
            .into_iter()
822
            .enumerate()
823
        {
824
            let name = format!("rules[{i}]");
825
            match raw.build(&name, w) {
826
                Ok(rule) => rules.push(rule),
827
                Err(e) => {
828
                    let e = e
829
                        .to_string()
830
                        .split_whitespace()
831
                        .collect::<Vec<_>>()
832
                        .join(" ");
833
                    w.push(format!("{name}: {e}, left out"));
834
                }
835
            }
836
        }
837
        for key in table.keys() {
838
            w.push(format!("unknown section [{key}], ignored"));
839
        }
840
        let config = Self {
841
            general,
842
            animation,
843
            decoration,
844
            output,
845
            keyboard,
846
            input,
847
            idle,
848
            lid,
849
            xwayland,
850
            binds,
851
            rules,
852
            startup,
853
        };
854
        Ok(Loaded { config, warnings })
855
    }
856
857
    pub fn placement(&self, window: &Subject) -> Placement {
858
        let mut p = Placement::default();
859
        for r in self.rules.iter().filter(|r| r.when.hits(window)) {
860
            p.over(&r.set);
861
        }
862
        p
863
    }
864
}
865
866
fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, String> {
867
    let parts: Vec<&str> = combo.split('+').map(str::trim).collect();
868
    let Some((&key, mods)) = parts.split_last().filter(|(k, _)| !k.is_empty()) else {
869
        return Err("no key".into());
870
    };
871
    let mut set = Mods::default();
872
    for m in mods {
873
        let m = match m.to_ascii_lowercase().as_str() {
874
            "mod" => mod_key,
875
            "super" | "logo" | "win" => Modifier::Super,
876
            "alt" => Modifier::Alt,
877
            "ctrl" | "control" => Modifier::Ctrl,
878
            "shift" => Modifier::Shift,
879
            other => return Err(format!("unknown modifier {other}")),
880
        };
881
        set.set(m);
882
    }
883
    let words: Vec<String> = match words {
884
        Words::Line(s) => s.split_whitespace().map(str::to_owned).collect(),
885
        Words::List(v) => v,
886
    };
887
    let command = Command::parse(&words).map_err(|e| e.0)?;
888
    Ok(Bind {
889
        mods: set,
890
        key: key.to_owned(),
891
        command,
892
    })
893
}
894
895
/// A config and what had to be left out of it.
896
pub struct Loaded {
897
    pub config: Config,
898
    pub warnings: Vec<String>,
899
}
900
901
/// Parses TOML, dropping each line the parser rejects and trying again, so a
902
/// syntax error costs its own line rather than the whole file. An unclosed
903
/// table or array is reported lines after the mistake, so the culprit is
904
/// the nearby line whose removal clears that error, or else gets furthest.
905
/// A table header is never dropped alone, since its keys would then join the
906
/// table above and change what they mean. It takes its keys with it.
907
fn lenient_table(text: &str, w: &mut Vec<String>) -> Result<toml::Table, Error> {
908
    const MAX_BAD_LINES: usize = 32;
909
    const LOOK_BACK: usize = 4;
910
    let mut lines: Vec<(usize, &str)> = text.lines().enumerate().map(|(i, l)| (i + 1, l)).collect();
911
    let join =
912
        |lines: &[(usize, &str)]| lines.iter().map(|(_, l)| *l).collect::<Vec<_>>().join("\n");
913
    let failing_line = |lines: &[(usize, &str)], e: &toml::de::Error| -> Option<usize> {
914
        let at = e.span()?.start;
915
        let text = join(lines);
916
        let index = text[..at.min(text.len())].matches('\n').count();
917
        Some(index.min(lines.len().saturating_sub(1)))
918
    };
919
    for _ in 0..MAX_BAD_LINES {
920
        let e = match toml::from_str(&join(&lines)) {
921
            Ok(table) => return Ok(table),
922
            Err(e) => e,
923
        };
924
        let Some(at) = failing_line(&lines, &e) else {
925
            return Err(Error::Toml(e));
926
        };
927
        let progress = |drop: usize| {
928
            let mut rest = lines.clone();
929
            rest.remove(drop);
930
            match toml::from_str::<toml::Table>(&join(&rest)) {
931
                Ok(_) => (true, usize::MAX),
932
                Err(next) => (
933
                    next.message() != e.message(),
934
                    failing_line(&rest, &next).map_or(0, |i| rest[i].0),
935
                ),
936
            }
937
        };
938
        let culprit = (at.saturating_sub(LOOK_BACK)..=at)
939
            .filter(|&i| !lines[i].1.trim().is_empty())
940
            .max_by_key(|&i| (progress(i), !is_header(lines[i].1)))
941
            .unwrap_or(at);
942
        let (line, text) = lines[culprit];
943
        if is_header(text) {
944
            let end = (culprit + 1..lines.len())
945
                .find(|&i| is_header(lines[i].1))
946
                .unwrap_or(lines.len());
947
            lines.drain(culprit..end);
948
            let name = text.split('#').next().unwrap_or(text).trim();
949
            w.push(format!(
950
                "line {line}: {}, section {name} left out",
951
                e.message().trim()
952
            ));
953
        } else {
954
            lines.remove(culprit);
955
            w.push(format!("line {line}: {}, left out", e.message().trim()));
956
        }
957
    }
958
    toml::from_str(&join(&lines)).map_err(Error::Toml)
959
}
960
961
fn is_header(line: &str) -> bool {
962
    let line = line.split('#').next().unwrap_or_default().trim_start();
963
    line.starts_with('[') && !line.contains(['=', ',', '{'])
964
}
965
966
fn entry<T: DeserializeOwned>(name: &str, value: toml::Value, w: &mut Vec<String>) -> Option<T> {
967
    value
968
        .try_into()
969
        .map_err(|e| w.push(format!("{name}: {}, left out", one_line(&e))))
970
        .ok()
971
}
972
973
fn input(value: Option<toml::Value>, w: &mut Vec<String>) -> Input {
974
    let mut table = match value {
975
        None => toml::Table::new(),
976
        Some(toml::Value::Table(t)) => t,
977
        Some(_) => {
978
            w.push("[input] is not a table, using the defaults".into());
979
            toml::Table::new()
980
        }
981
    };
982
    let mut input = Input {
983
        pointer: section("input.pointer", table.remove("pointer"), w),
984
        touchpad: section("input.touchpad", table.remove("touchpad"), w),
985
        device: Vec::new(),
986
    };
987
    for (i, mut entry) in items::<toml::Table>("input.device", table.remove("device"), w)
988
        .into_iter()
989
        .enumerate()
990
    {
991
        let name = format!("input.device[{i}]");
992
        let Some(toml::Value::String(device)) = entry.remove("name") else {
993
            w.push(format!("{name}: no name, left out"));
994
            continue;
995
        };
996
        input.device.push(NamedDevice {
997
            name: device,
998
            settings: section(&name, Some(toml::Value::Table(entry)), w),
999
        });
1000
    }
1001
    for key in table.keys() {
1002
        w.push(format!("input.{key}: unknown option, ignored"));
1003
    }
1004
    input.pointer.validate("input.pointer", w);
1005
    input.touchpad.validate("input.touchpad", w);
1006
    for (i, d) in input.device.iter_mut().enumerate() {
1007
        d.settings.validate(&format!("input.device[{i}]"), w);
1008
    }
1009
    input
1010
}
1011
1012
fn connector_like(name: &str) -> bool {
1013
    name.rsplit_once('-').is_some_and(|(kind, n)| {
1014
        !kind.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())
1015
    })
1016
}
1017
1018
/// A section with every option that does not fit left at its default.
1019
fn section<T: DeserializeOwned + Default>(
1020
    name: &str,
1021
    value: Option<toml::Value>,
1022
    w: &mut Vec<String>,
1023
) -> T {
1024
    let Some(value) = value else {
1025
        return T::default();
1026
    };
1027
    if let Ok(t) = value.clone().try_into() {
1028
        return t;
1029
    }
1030
    let toml::Value::Table(options) = value else {
1031
        w.push(format!("[{name}] is not a table, using the defaults"));
1032
        return T::default();
1033
    };
1034
    let mut kept = toml::Table::new();
1035
    for (key, v) in options {
1036
        let alone = toml::Table::from_iter([(key.clone(), v.clone())]);
1037
        match toml::Value::Table(alone).try_into::<T>() {
1038
            Ok(_) => {
1039
                kept.insert(key, v);
1040
            }
1041
            Err(e) => {
1042
                let e = one_line(&e);
1043
                let fallback = if e.starts_with("unknown field") {
1044
                    "ignored"
1045
                } else {
1046
                    "using the default"
1047
                };
1048
                w.push(format!("{name}.{key}: {e}, {fallback}"));
1049
            }
1050
        }
1051
    }
1052
    toml::Value::Table(kept).try_into().unwrap_or_default()
1053
}
1054
1055
/// An array with the elements that do not fit left out.
1056
fn items<T: DeserializeOwned>(
1057
    name: &str,
1058
    value: Option<toml::Value>,
1059
    w: &mut Vec<String>,
1060
) -> Vec<T> {
1061
    match value {
1062
        None => Vec::new(),
1063
        Some(toml::Value::Array(a)) => a
1064
            .into_iter()
1065
            .enumerate()
1066
            .filter_map(|(i, v)| entry(&format!("{name}[{i}]"), v, w))
1067
            .collect(),
1068
        Some(_) => {
1069
            w.push(format!("{name} is not an array, left out"));
1070
            Vec::new()
1071
        }
1072
    }
1073
}
1074
1075
fn one_line(e: &toml::de::Error) -> String {
1076
    e.message().trim().replace('\n', " ")
1077
}
1078
#[cfg(test)]
1079
mod tests {
1080
    use super::*;
1081
1082
    /// default.toml documents the defaults for users, and the Default impls
1083
    /// apply them to sections a user config leaves out. They must agree.
1084
    #[test]
1085
    fn default_toml_states_the_defaults() {
1086
        let loaded = Config::parse(DEFAULT).unwrap();
1087
        assert_eq!(loaded.warnings, Vec::<String>::new());
1088
        let c = loaded.config;
1089
        assert_eq!(c.general, General::default());
1090
        assert_eq!(c.output, Outputs::default());
1091
        assert_eq!(c.decoration, Decoration::default());
1092
        assert_eq!(c.animation, Animation::default());
1093
        assert_eq!(c.keyboard, Keyboard::default());
1094
        assert_eq!(c.input, Input::default());
1095
        assert_eq!(c.idle, Idle::default());
1096
        assert_eq!(c.lid, Lid::default());
1097
        assert_eq!(c.xwayland, Xwayland::default());
1098
        assert!(c.binds.len() > 40);
1099
    }
1100
1101
    #[test]
1102
    fn output_scale_is_snapped_and_range_checked() {
1103
        let loaded = Config::parse(
1104
            r#"
1105
            [output.DP-1]
1106
            scale = 1.5
1107
            [output.DP-2]
1108
            scale = 1.3333
1109
            [output.DP-3]
1110
            scale = 9.0
1111
            [output.nested]
1112
            scale = 2.0
1113
            "#,
1114
        )
1115
        .unwrap();
1116
        let named = &loaded.config.output.named;
1117
        assert_eq!(named["DP-1"].scale(), 1.5);
1118
        assert_eq!(named["DP-2"].scale(), 160.0 / 120.0);
1119
        assert_eq!(named["DP-3"].scale(), 1.0);
1120
        assert_eq!(loaded.warnings.len(), 1, "{:#?}", loaded.warnings);
1121
    }
1122
1123
    #[test]
1124
    fn a_bad_option_costs_only_itself() {
1125
        let loaded = Config::parse(
1126
            r##"
1127
            [general]
1128
            gaps = { inner = 3, outer = 3 }
1129
            focus_follows_mouse = "yes"
1130
            typo = 1
1131
            [decoration]
1132
            radius = [1, 2, 3, 4]
1133
            focused = "#zzzzzz"
1134
            [binds]
1135
            "Mod+Return" = "spawn beer"
1136
            "Hyper+x" = "close"
1137
            [output.DP-1]
1138
            transform = "90"
1139
            position = [0, 0]
1140
            [output.DP-2]
1141
            transform = "sideways"
1142
            [output.DP1]
1143
            [[rules]]
1144
            app_id = "("
1145
            [[rules]]
1146
            app_id = "^mpv$"
1147
            floating = true
1148
            "##,
1149
        )
1150
        .unwrap();
1151
        let c = loaded.config;
1152
        assert_eq!(c.general.gaps.inner, 3.0);
1153
        assert!(!c.general.focus_follows_mouse);
1154
        assert_eq!(c.decoration.radius, Corners([1, 2, 3, 4]));
1155
        assert_eq!(c.decoration.focused, Decoration::default().focused);
1156
        assert_eq!((c.binds.len(), c.rules.len()), (1, 1));
1157
        assert_eq!(c.output.named["DP-1"].transform, OutputTransform::Rotate90);
1158
        assert!(!c.output.named.contains_key("DP-2"));
1159
        assert_eq!(loaded.warnings.len(), 7, "{:#?}", loaded.warnings);
1160
    }
1161
1162
    #[test]
1163
    fn broken_toml_costs_only_its_line() {
1164
        let loaded = Config::parse(
1165
            r##"
1166
            [general]
1167
            gaps = { inner = 3, outer = 3
1168
            focus_follows_mouse = true
1169
            focus_follows_mouse = false
1170
            [binds]
1171
            "Mod+Return" = "spawn beer
1172
            "Mod+q" = "close"
1173
            "##,
1174
        )
1175
        .unwrap();
1176
        let c = loaded.config;
1177
        assert_eq!(c.general.gaps, General::default().gaps);
1178
        assert_eq!(c.binds.len(), 1);
1179
        assert_eq!(loaded.warnings.len(), 3, "{:#?}", loaded.warnings);
1180
        assert!(
1181
            loaded.warnings[0].starts_with("line 3:"),
1182
            "{:#?}",
1183
            loaded.warnings
1184
        );
1185
    }
1186
1187
    #[test]
1188
    fn a_broken_table_header_takes_its_section_with_it() {
1189
        let loaded = Config::parse(
1190
            r##"
1191
            [general]
1192
            focus_follows_mouse = false
1193
            [binds
1194
            "Mod+q" = "close"
1195
            [decoration]
1196
            radius = [1, 2, 3, 4]
1197
            "##,
1198
        )
1199
        .unwrap();
1200
        let c = loaded.config;
1201
        assert!(!c.general.focus_follows_mouse);
1202
        assert_eq!(c.binds.len(), 0);
1203
        assert_eq!(c.decoration.radius, Corners([1, 2, 3, 4]));
1204
        assert_eq!(loaded.warnings.len(), 1, "{:#?}", loaded.warnings);
1205
        assert!(
1206
            loaded.warnings[0].contains("section [binds left out"),
1207
            "{:#?}",
1208
            loaded.warnings
1209
        );
1210
    }
1211
1212
    #[test]
1213
    fn a_broken_rule_header_does_not_feed_the_rule_above() {
1214
        let loaded = Config::parse(
1215
            r##"
1216
            [[rules]]
1217
            app_id = "^a$"
1218
            floating = true
1219
            [[rules]
1220
            app_id = "^b$"
1221
            fullscreen = true
1222
            [[rules]]
1223
            app_id = "^c$"
1224
            "##,
1225
        )
1226
        .unwrap();
1227
        let apps: Vec<_> = loaded
1228
            .config
1229
            .rules
1230
            .iter()
1231
            .map(|r| format!("{r:?}"))
1232
            .collect();
1233
        assert_eq!(apps.len(), 2, "{:#?} {:#?}", apps, loaded.warnings);
1234
        assert!(apps[0].contains("floating: Some(true)") && !apps[0].contains("fullscreen: Some"));
1235
        assert_eq!(loaded.warnings.len(), 1, "{:#?}", loaded.warnings);
1236
        assert!(loaded.warnings[0].contains("section [[rules] left out"));
1237
    }
1238
1239
    #[test]
1240
    fn a_bad_device_option_costs_only_itself() {
1241
        let loaded = Config::parse(
1242
            r#"
1243
            [input.pointer]
1244
            accel_speed = 2.0
1245
            scroll_method = "two-finger"
1246
            [[input.device]]
1247
            name = "Mouse"
1248
            accel_profile = "flat"
1249
            [[input.device]]
1250
            name = "Typo"
1251
            tapp = true
1252
            [[input.device]]
1253
            name = "Half"
1254
            accel_points = [0.0, 1.0]
1255
            "#,
1256
        )
1257
        .unwrap();
1258
        let input = loaded.config.input;
1259
        assert_eq!(input.pointer.accel_speed, None);
1260
        assert_eq!(input.pointer.scroll_method, Some(ScrollMethod::TwoFinger));
1261
        let names: Vec<_> = input.device.iter().map(|d| d.name.as_str()).collect();
1262
        assert_eq!(names, ["Mouse", "Typo", "Half"]);
1263
        assert_eq!(
1264
            input.device[0].settings.accel_profile,
1265
            Some(AccelProfile::Flat)
1266
        );
1267
        assert_eq!(input.device[1].settings, DeviceSettings::default());
1268
        assert_eq!(input.device[2].settings.accel_points, None);
1269
        assert_eq!(loaded.warnings.len(), 3, "{:#?}", loaded.warnings);
1270
    }
1271
1272
    fn subject<'a>(app_id: &'a str, title: &'a str) -> Subject<'a> {
1273
        Subject {
1274
            app_id,
1275
            title,
1276
            initial_title: title,
1277
            ..Subject::default()
1278
        }
1279
    }
1280
1281
    #[test]
1282
    fn rules_match_every_given_matcher_and_later_ones_win() {
1283
        let loaded = Config::parse(
1284
            r#"
1285
            [[rules]]
1286
            app_id = "^mpv$"
1287
            size = [800, 600]
1288
            opacity = 0.5
1289
            idle_inhibit = "visible"
1290
            [[rules]]
1291
            title = "Picture"
1292
            opacity = 0.8
1293
            focus = false
1294
            vrr = true
1295
            tearing = true
1296
            [[rules]]
1297
            class = "^Steam$"
1298
            instance = "steam"
1299
            xwayland = true
1300
            fullscreen = true
1301
            [[rules]]
1302
            dialog = true
1303
            parent_app_id = "^firefox$"
1304
            center = true
1305
            min_size = [300, 0]
1306
            max_size = [0, 500]
1307
            "#,
1308
        )
1309
        .unwrap();
1310
        assert_eq!(loaded.warnings, Vec::<String>::new());
1311
        let c = loaded.config;
1312
        let p = c.placement(&subject("mpv", "Picture in picture"));
1313
        assert_eq!(p.size, Some((800, 600)));
1314
        assert_eq!(p.opacity, Some(0.8));
1315
        assert_eq!(p.idle_inhibit, Some(IdleInhibit::Visible));
1316
        assert_eq!(
1317
            (p.focus, p.vrr, p.tearing),
1318
            (Some(false), Some(true), Some(true))
1319
        );
1320
        assert_eq!(c.placement(&subject("foot", "sh")), Placement::default());
1321
1322
        let x11 = Subject {
1323
            class: Some("Steam"),
1324
            instance: Some("steam"),
1325
            xwayland: true,
1326
            ..subject("Steam", "Steam")
1327
        };
1328
        assert_eq!(c.placement(&x11).fullscreen, Some(true));
1329
        let wayland = Subject {
1330
            xwayland: false,
1331
            ..subject("Steam", "Steam")
1332
        };
1333
        assert_eq!(c.placement(&wayland).fullscreen, None);
1334
1335
        let dialog = Subject {
1336
            dialog: true,
1337
            parent_app_id: Some("firefox"),
1338
            ..subject("firefox", "Save")
1339
        };
1340
        let p = c.placement(&dialog);
1341
        assert_eq!(p.at, Some(At::Center));
1342
        assert_eq!(p.clamp_size(100.0, 900.0), (300.0, 500.0));
1343
        let orphan = Subject {
1344
            parent_app_id: None,
1345
            ..dialog
1346
        };
1347
        assert_eq!(c.placement(&orphan), Placement::default());
1348
    }
1349
1350
    #[test]
1351
    fn initial_title_holds_when_the_title_changes() {
1352
        let loaded = Config::parse(
1353
            r#"
1354
            [[rules]]
1355
            initial_title = "^Untitled$"
1356
            floating = true
1357
            "#,
1358
        )
1359
        .unwrap();
1360
        let later = Subject {
1361
            title: "notes.txt",
1362
            initial_title: "Untitled",
1363
            ..subject("gedit", "")
1364
        };
1365
        assert_eq!(loaded.config.placement(&later).floating, Some(true));
1366
        let other = Subject {
1367
            initial_title: "notes.txt",
1368
            ..later
1369
        };
1370
        assert_eq!(loaded.config.placement(&other).floating, None);
1371
    }
1372
1373
    #[test]
1374
    fn bad_rule_values_are_warned_about_and_cost_little() {
1375
        let loaded = Config::parse(
1376
            r#"
1377
            [[rules]]
1378
            app_id = "a"
1379
            opacity = 1.5
1380
            size = [0, 100]
1381
            min_size = [500, 500]
1382
            max_size = [400, 0]
1383
            position = [10, 10]
1384
            center = true
1385
            floating = true
1386
            [[rules]]
1387
            app_id = "b"
1388
            idle_inhibit = "always"
1389
            [[rules]]
1390
            class = "("
1391
            [[rules]]
1392
            parent_app_id = "["
1393
            [[rules]]
1394
            app_id = "c"
1395
            size = [-1, 10]
1396
            [[rules]]
1397
            app_id = "d"
1398
            focus = "no"
1399
            [[rules]]
1400
            app_id = "e"
1401
            vrr = false
1402
            "#,
1403
        )
1404
        .unwrap();
1405
        let c = loaded.config;
1406
        assert_eq!(c.rules.len(), 2, "{:#?}", loaded.warnings);
1407
        let a = c.placement(&subject("a", ""));
1408
        assert_eq!(a.floating, Some(true));
1409
        assert_eq!(
1410
            (a.opacity, a.size, a.min_size, a.max_size),
1411
            (None, None, None, None)
1412
        );
1413
        assert_eq!(a.at, Some(At::Center));
1414
        assert_eq!(c.placement(&subject("e", "")).vrr, Some(false));
1415
        assert_eq!(loaded.warnings.len(), 9, "{:#?}", loaded.warnings);
1416
    }
1417
1418
    #[test]
1419
    fn a_title_set_after_mapping_brings_its_rules_in() {
1420
        let loaded = Config::parse(
1421
            r#"
1422
            [[rules]]
1423
            app_id = "^gimp$"
1424
            tag = "art"
1425
            [[rules]]
1426
            app_id = "^gimp$"
1427
            title = "^Tool Options$"
1428
            floating = true
1429
            opacity = 0.9
1430
            "#,
1431
        )
1432
        .unwrap();
1433
        let before = loaded
1434
            .config
1435
            .placement(&subject("gimp", "GNU Image Manipulation Program"));
1436
        let after = Subject {
1437
            title: "Tool Options",
1438
            initial_title: "GNU Image Manipulation Program",
1439
            ..subject("gimp", "")
1440
        };
1441
        let after = loaded.config.placement(&after);
1442
        assert_eq!((before.floating, before.opacity), (None, None));
1443
        assert_eq!((after.floating, after.opacity), (Some(true), Some(0.9)));
1444
        assert_eq!(before.tag, after.tag);
1445
    }
1446
1447
    #[test]
1448
    fn size_bounds_treat_zero_as_open() {
1449
        let p = Placement {
1450
            min_size: Some((200, 0)),
1451
            max_size: Some((0, 300)),
1452
            ..Placement::default()
1453
        };
1454
        assert_eq!(p.clamp_size(50.0, 900.0), (200.0, 300.0));
1455
        assert_eq!(p.clamp_size(4000.0, 10.0), (4000.0, 10.0));
1456
        assert_eq!(Placement::default().clamp_size(7.0, 9.0), (7.0, 9.0));
1457
    }
1458
}