atagen

monolith

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