atagen

monolith

public
Star0
Watch0
188ed042565870ea81fbcde94e876cec79e72734
1
ssh://brewery@pls.lobotomise.me:33/atagen/monolith.git
crates/config/src/lib.rsRust1030 lines31.7 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 a Wayland-owned selection.
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)]
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
}
203
204
/// Corner radii in logical pixels, top left, top right, bottom right and
205
/// bottom left. One number rounds every corner alike.
206
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
207
#[serde(from = "CornersToml")]
208
pub struct Corners(pub [u32; 4]);
209
210
#[derive(Deserialize)]
211
#[serde(
212
    untagged,
213
    expecting = "expected a number or [top left, top right, bottom right, bottom left]"
214
)]
215
enum CornersToml {
216
    All(u32),
217
    Each([u32; 4]),
218
}
219
220
impl From<CornersToml> for Corners {
221
    fn from(c: CornersToml) -> Self {
222
        match c {
223
            CornersToml::All(r) => Self([r; 4]),
224
            CornersToml::Each(r) => Self(r),
225
        }
226
    }
227
}
228
229
impl Default for Decoration {
230
    fn default() -> Self {
231
        Self {
232
            border: 2,
233
            radius: Corners([0; 4]),
234
            focused: Color::rgb(0x88, 0xbf, 0xd1),
235
            unfocused: Color::rgb(0x3b, 0x42, 0x52),
236
            urgent: Color::rgb(0xeb, 0xcb, 0x8b),
237
        }
238
    }
239
}
240
241
/// Straight RGBA from `#rrggbb` or `#rrggbbaa`.
242
#[derive(Debug, Clone, Copy, PartialEq)]
243
pub struct Color(pub [f32; 4]);
244
245
impl Color {
246
    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
247
        Self([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
248
    }
249
}
250
251
impl<'de> Deserialize<'de> for Color {
252
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
253
        let s = String::deserialize(d)?;
254
        let hex = s.strip_prefix('#').unwrap_or(&s);
255
        let bad =
256
            || serde::de::Error::custom(format!("bad colour {s:?}, use #rrggbb or #rrggbbaa"));
257
        if !matches!(hex.len(), 6 | 8) || !hex.is_ascii() {
258
            return Err(bad());
259
        }
260
        let mut c = [1.0f32; 4];
261
        for (i, v) in c.iter_mut().enumerate().take(hex.len() / 2) {
262
            let byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| bad())?;
263
            *v = f32::from(byte) / 255.0;
264
        }
265
        Ok(Self(c))
266
    }
267
}
268
269
#[derive(Deserialize, Debug, Clone, PartialEq)]
270
#[serde(default, deny_unknown_fields)]
271
pub struct Animation {
272
    pub enabled: bool,
273
    pub style: AnimationStyle,
274
    /// Every phase of a planned transition lasts this long.
275
    pub phase_ms: u64,
276
    /// A cubic bezier, as in CSS.
277
    pub curve: [f64; 4],
278
}
279
280
impl Default for Animation {
281
    fn default() -> Self {
282
        Self {
283
            enabled: true,
284
            style: AnimationStyle::Orthogonal,
285
            phase_ms: 160,
286
            curve: [0.45, 0.0, 0.2, 1.0],
287
        }
288
    }
289
}
290
291
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
292
#[serde(rename_all = "lowercase")]
293
pub enum AnimationStyle {
294
    Orthogonal,
295
    Direct,
296
}
297
298
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
299
#[serde(default, deny_unknown_fields)]
300
pub struct Idle {
301
    /// Seconds without input before every output is switched off, 0 for
302
    /// never.
303
    pub blank_after: u64,
304
}
305
306
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
307
#[serde(default, deny_unknown_fields)]
308
pub struct Lid {
309
    /// What closing the lid does while no other output is connected.
310
    pub close: LidAction,
311
}
312
313
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
314
#[serde(rename_all = "lowercase")]
315
pub enum LidAction {
316
    #[default]
317
    Suspend,
318
    Poweroff,
319
    /// Leave it to logind or whatever else watches the switch.
320
    Ignore,
321
}
322
323
#[derive(Deserialize, Debug, Clone, PartialEq)]
324
#[serde(default, deny_unknown_fields)]
325
pub struct Keyboard {
326
    pub rules: String,
327
    pub model: String,
328
    pub layout: String,
329
    pub variant: String,
330
    pub options: Option<String>,
331
    pub repeat_delay: i32,
332
    pub repeat_rate: i32,
333
}
334
335
impl Default for Keyboard {
336
    fn default() -> Self {
337
        Self {
338
            rules: String::new(),
339
            model: String::new(),
340
            layout: String::new(),
341
            variant: String::new(),
342
            options: None,
343
            repeat_delay: 200,
344
            repeat_rate: 25,
345
        }
346
    }
347
}
348
349
/// libinput settings for pointing devices. Unset options keep libinput's
350
/// default for the device.
351
#[derive(Debug, Clone, PartialEq, Default)]
352
pub struct Input {
353
    pub pointer: DeviceSettings,
354
    /// Devices that can tap, which libinput only offers on touchpads.
355
    pub touchpad: DeviceSettings,
356
    /// Overrides for devices by name, later entries winning.
357
    pub device: Vec<NamedDevice>,
358
}
359
360
#[derive(Debug, Clone, PartialEq)]
361
pub struct NamedDevice {
362
    pub name: String,
363
    pub settings: DeviceSettings,
364
}
365
366
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
367
#[serde(default, deny_unknown_fields)]
368
pub struct DeviceSettings {
369
    pub accel_profile: Option<AccelProfile>,
370
    pub accel_speed: Option<f64>,
371
    /// The custom profile's curve, pointer speed sampled every `accel_step`
372
    /// device units per millisecond.
373
    pub accel_points: Option<Vec<f64>>,
374
    pub accel_step: Option<f64>,
375
    pub natural_scroll: Option<bool>,
376
    pub left_handed: Option<bool>,
377
    pub tap: Option<bool>,
378
    pub tap_drag: Option<bool>,
379
    pub tap_drag_lock: Option<bool>,
380
    pub dwt: Option<bool>,
381
    pub middle_emulation: Option<bool>,
382
    pub scroll_method: Option<ScrollMethod>,
383
    pub scroll_button: Option<u32>,
384
    pub click_method: Option<ClickMethod>,
385
    /// Applied by the compositor, libinput has no such setting.
386
    pub scroll_factor: Option<f64>,
387
}
388
389
impl DeviceSettings {
390
    /// `self` with every option `over` sets replaced.
391
    pub fn overlay(&self, over: &Self) -> Self {
392
        Self {
393
            accel_profile: over.accel_profile.or(self.accel_profile),
394
            accel_speed: over.accel_speed.or(self.accel_speed),
395
            accel_points: over
396
                .accel_points
397
                .clone()
398
                .or_else(|| self.accel_points.clone()),
399
            accel_step: over.accel_step.or(self.accel_step),
400
            natural_scroll: over.natural_scroll.or(self.natural_scroll),
401
            left_handed: over.left_handed.or(self.left_handed),
402
            tap: over.tap.or(self.tap),
403
            tap_drag: over.tap_drag.or(self.tap_drag),
404
            tap_drag_lock: over.tap_drag_lock.or(self.tap_drag_lock),
405
            dwt: over.dwt.or(self.dwt),
406
            middle_emulation: over.middle_emulation.or(self.middle_emulation),
407
            scroll_method: over.scroll_method.or(self.scroll_method),
408
            scroll_button: over.scroll_button.or(self.scroll_button),
409
            click_method: over.click_method.or(self.click_method),
410
            scroll_factor: over.scroll_factor.or(self.scroll_factor),
411
        }
412
    }
413
414
    fn validate(&mut self, name: &str, w: &mut Vec<String>) {
415
        if let Some(s) = self.accel_speed
416
            && !(-1.0..=1.0).contains(&s)
417
        {
418
            w.push(format!(
419
                "{name}.accel_speed: {s} is outside -1 to 1, ignored"
420
            ));
421
            self.accel_speed = None;
422
        }
423
        if let Some(f) = self.scroll_factor
424
            && !(f.is_finite() && f >= 0.0)
425
        {
426
            w.push(format!(
427
                "{name}.scroll_factor: {f} must be zero or more, ignored"
428
            ));
429
            self.scroll_factor = None;
430
        }
431
        if self.accel_points.is_some() != self.accel_step.is_some() {
432
            w.push(format!(
433
                "{name}: accel_points needs accel_step and the other way round, both ignored"
434
            ));
435
            self.accel_points = None;
436
            self.accel_step = None;
437
        }
438
    }
439
}
440
441
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
442
#[serde(rename_all = "lowercase")]
443
pub enum AccelProfile {
444
    Flat,
445
    Adaptive,
446
    Custom,
447
}
448
449
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
450
#[serde(rename_all = "kebab-case")]
451
pub enum ScrollMethod {
452
    None,
453
    TwoFinger,
454
    Edge,
455
    OnButtonDown,
456
}
457
458
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
459
#[serde(rename_all = "kebab-case")]
460
pub enum ClickMethod {
461
    ButtonAreas,
462
    Clickfinger,
463
}
464
465
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
466
#[serde(rename_all = "lowercase")]
467
pub enum Modifier {
468
    Super,
469
    Alt,
470
    Ctrl,
471
    Shift,
472
}
473
474
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
475
pub struct Mods {
476
    pub logo: bool,
477
    pub alt: bool,
478
    pub ctrl: bool,
479
    pub shift: bool,
480
}
481
482
impl Mods {
483
    pub fn has(&self, m: Modifier) -> bool {
484
        match m {
485
            Modifier::Super => self.logo,
486
            Modifier::Alt => self.alt,
487
            Modifier::Ctrl => self.ctrl,
488
            Modifier::Shift => self.shift,
489
        }
490
    }
491
492
    fn set(&mut self, m: Modifier) {
493
        match m {
494
            Modifier::Super => self.logo = true,
495
            Modifier::Alt => self.alt = true,
496
            Modifier::Ctrl => self.ctrl = true,
497
            Modifier::Shift => self.shift = true,
498
        }
499
    }
500
}
501
502
#[derive(Debug, Clone, PartialEq)]
503
pub struct Bind {
504
    pub mods: Mods,
505
    /// An xkb keysym name, resolved by the compositor.
506
    pub key: String,
507
    pub command: Command,
508
}
509
510
#[derive(Debug, Clone)]
511
pub struct Rule {
512
    pub app_id: Option<Regex>,
513
    pub title: Option<Regex>,
514
    pub tag: Option<String>,
515
    pub output: Option<String>,
516
    pub floating: Option<bool>,
517
    pub sticky: Option<bool>,
518
    pub scratchpad: Option<String>,
519
}
520
521
/// Everything the matching rules say about a window, later rules winning.
522
#[derive(Debug, Clone, Default, PartialEq)]
523
pub struct Placement {
524
    pub tag: Option<String>,
525
    pub output: Option<String>,
526
    pub floating: Option<bool>,
527
    pub sticky: Option<bool>,
528
    pub scratchpad: Option<String>,
529
}
530
531
#[derive(Debug)]
532
pub enum Error {
533
    Io(PathBuf, std::io::Error),
534
    Toml(toml::de::Error),
535
}
536
537
impl fmt::Display for Error {
538
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539
        match self {
540
            Error::Io(p, e) => write!(f, "{}: {e}", p.display()),
541
            Error::Toml(e) => write!(f, "{e}"),
542
        }
543
    }
544
}
545
546
impl std::error::Error for Error {}
547
548
#[derive(Deserialize)]
549
#[serde(untagged)]
550
enum Words {
551
    Line(String),
552
    List(Vec<String>),
553
}
554
555
#[derive(Deserialize)]
556
#[serde(deny_unknown_fields)]
557
struct RawRule {
558
    app_id: Option<String>,
559
    title: Option<String>,
560
    tag: Option<String>,
561
    output: Option<String>,
562
    floating: Option<bool>,
563
    sticky: Option<bool>,
564
    scratchpad: Option<String>,
565
}
566
567
impl Config {
568
    /// `$MONOLITH_CONFIG`, else `$XDG_CONFIG_HOME/monolith/config.toml`.
569
    pub fn path() -> Option<PathBuf> {
570
        if let Some(p) = std::env::var_os("MONOLITH_CONFIG") {
571
            return Some(p.into());
572
        }
573
        let base = std::env::var_os("XDG_CONFIG_HOME")
574
            .map(PathBuf::from)
575
            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
576
        Some(base.join("monolith/config.toml"))
577
    }
578
579
    /// Loads `path`, or the built-in default when it does not exist.
580
    pub fn load(path: Option<&Path>) -> Result<Loaded, Error> {
581
        match path {
582
            Some(p) => match std::fs::read_to_string(p) {
583
                Ok(text) => Self::parse(&text),
584
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::parse(DEFAULT),
585
                Err(e) => Err(Error::Io(p.to_owned(), e)),
586
            },
587
            None => Self::parse(DEFAULT),
588
        }
589
    }
590
591
    /// Only TOML too broken to repair line by line fails. A bad option, bind
592
    /// or rule is left out with a warning, so one typo does not throw away the
593
    /// rest of the file.
594
    pub fn parse(text: &str) -> Result<Loaded, Error> {
595
        let mut warnings = Vec::new();
596
        let mut table = lenient_table(text, &mut warnings)?;
597
        let w = &mut warnings;
598
        let general: General = section("general", table.remove("general"), w);
599
        let keyboard = section("keyboard", table.remove("keyboard"), w);
600
        let input = input(table.remove("input"), w);
601
        let idle = section("idle", table.remove("idle"), w);
602
        let lid = section("lid", table.remove("lid"), w);
603
        let animation = section("animation", table.remove("animation"), w);
604
        let xwayland = section("xwayland", table.remove("xwayland"), w);
605
        let decoration = section("decoration", table.remove("decoration"), w);
606
        let mut output: Outputs = section("output", table.remove("output"), w);
607
        for name in output
608
            .named
609
            .keys()
610
            .filter(|n| *n != "nested" && !connector_like(n))
611
        {
612
            w.push(format!(
613
                "output.{name}: not a connector name like DP-1 or HDMI-A-1, matches nothing"
614
            ));
615
        }
616
        for (name, config) in &mut output.named {
617
            config.validate(&format!("output.{name}"), w);
618
        }
619
        let startup = items("startup", table.remove("startup"), w);
620
        let mut binds = Vec::new();
621
        if let Some(value) = table.remove("binds") {
622
            let entries: toml::Table = entry("binds", value, w).unwrap_or_default();
623
            for (combo, value) in entries {
624
                let name = format!("binds.{combo:?}");
625
                let bind = entry::<Words>(&name, value, w)
626
                    .map(|words| parse_bind(&combo, words, general.mod_key));
627
                match bind {
628
                    Some(Ok(b)) => binds.push(b),
629
                    Some(Err(e)) => w.push(format!("{name}: {e}, left out")),
630
                    None => {}
631
                }
632
            }
633
        }
634
        let regex = |p: Option<String>| p.map(|p| Regex::new(&p)).transpose();
635
        let mut rules = Vec::new();
636
        for (i, raw) in items::<RawRule>("rules", table.remove("rules"), w)
637
            .into_iter()
638
            .enumerate()
639
        {
640
            match (regex(raw.app_id), regex(raw.title)) {
641
                (Ok(app_id), Ok(title)) => rules.push(Rule {
642
                    app_id,
643
                    title,
644
                    tag: raw.tag,
645
                    output: raw.output,
646
                    floating: raw.floating,
647
                    sticky: raw.sticky,
648
                    scratchpad: raw.scratchpad,
649
                }),
650
                (Err(e), _) | (_, Err(e)) => {
651
                    let e = e
652
                        .to_string()
653
                        .split_whitespace()
654
                        .collect::<Vec<_>>()
655
                        .join(" ");
656
                    w.push(format!("rules[{i}]: {e}, left out"));
657
                }
658
            }
659
        }
660
        for key in table.keys() {
661
            w.push(format!("unknown section [{key}], ignored"));
662
        }
663
        let config = Self {
664
            general,
665
            animation,
666
            decoration,
667
            output,
668
            keyboard,
669
            input,
670
            idle,
671
            lid,
672
            xwayland,
673
            binds,
674
            rules,
675
            startup,
676
        };
677
        Ok(Loaded { config, warnings })
678
    }
679
680
    pub fn placement(&self, app_id: &str, title: &str) -> Placement {
681
        let mut p = Placement::default();
682
        let hit = |re: &Option<Regex>, s: &str| re.as_ref().is_none_or(|re| re.is_match(s));
683
        for r in &self.rules {
684
            if !hit(&r.app_id, app_id) || !hit(&r.title, title) {
685
                continue;
686
            }
687
            p.tag = r.tag.clone().or(p.tag);
688
            p.output = r.output.clone().or(p.output);
689
            p.floating = r.floating.or(p.floating);
690
            p.sticky = r.sticky.or(p.sticky);
691
            p.scratchpad = r.scratchpad.clone().or(p.scratchpad);
692
        }
693
        p
694
    }
695
}
696
697
fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, String> {
698
    let parts: Vec<&str> = combo.split('+').map(str::trim).collect();
699
    let Some((&key, mods)) = parts.split_last().filter(|(k, _)| !k.is_empty()) else {
700
        return Err("no key".into());
701
    };
702
    let mut set = Mods::default();
703
    for m in mods {
704
        let m = match m.to_ascii_lowercase().as_str() {
705
            "mod" => mod_key,
706
            "super" | "logo" | "win" => Modifier::Super,
707
            "alt" => Modifier::Alt,
708
            "ctrl" | "control" => Modifier::Ctrl,
709
            "shift" => Modifier::Shift,
710
            other => return Err(format!("unknown modifier {other}")),
711
        };
712
        set.set(m);
713
    }
714
    let words: Vec<String> = match words {
715
        Words::Line(s) => s.split_whitespace().map(str::to_owned).collect(),
716
        Words::List(v) => v,
717
    };
718
    let command = Command::parse(&words).map_err(|e| e.0)?;
719
    Ok(Bind {
720
        mods: set,
721
        key: key.to_owned(),
722
        command,
723
    })
724
}
725
726
/// A config and what had to be left out of it.
727
pub struct Loaded {
728
    pub config: Config,
729
    pub warnings: Vec<String>,
730
}
731
732
/// Parses TOML, dropping each line the parser rejects and trying again, so a
733
/// syntax error costs its own line rather than the whole file. An unclosed
734
/// table or array is reported lines after the mistake, so the culprit is
735
/// the nearby line whose removal clears that error, or else gets furthest.
736
fn lenient_table(text: &str, w: &mut Vec<String>) -> Result<toml::Table, Error> {
737
    const MAX_BAD_LINES: usize = 32;
738
    const LOOK_BACK: usize = 4;
739
    let mut lines: Vec<(usize, &str)> = text.lines().enumerate().map(|(i, l)| (i + 1, l)).collect();
740
    let join =
741
        |lines: &[(usize, &str)]| lines.iter().map(|(_, l)| *l).collect::<Vec<_>>().join("\n");
742
    let failing_line = |lines: &[(usize, &str)], e: &toml::de::Error| -> Option<usize> {
743
        let at = e.span()?.start;
744
        let text = join(lines);
745
        let index = text[..at.min(text.len())].matches('\n').count();
746
        Some(index.min(lines.len().saturating_sub(1)))
747
    };
748
    for _ in 0..MAX_BAD_LINES {
749
        let e = match toml::from_str(&join(&lines)) {
750
            Ok(table) => return Ok(table),
751
            Err(e) => e,
752
        };
753
        let Some(at) = failing_line(&lines, &e) else {
754
            return Err(Error::Toml(e));
755
        };
756
        let progress = |drop: usize| {
757
            let mut rest = lines.clone();
758
            rest.remove(drop);
759
            match toml::from_str::<toml::Table>(&join(&rest)) {
760
                Ok(_) => (true, usize::MAX),
761
                Err(next) => (
762
                    next.message() != e.message(),
763
                    failing_line(&rest, &next).map_or(0, |i| rest[i].0),
764
                ),
765
            }
766
        };
767
        let culprit = (at.saturating_sub(LOOK_BACK)..=at)
768
            .filter(|&i| !lines[i].1.trim().is_empty())
769
            .max_by_key(|&i| progress(i))
770
            .unwrap_or(at);
771
        let (line, _) = lines.remove(culprit);
772
        w.push(format!("line {line}: {}, left out", e.message().trim()));
773
    }
774
    toml::from_str(&join(&lines)).map_err(Error::Toml)
775
}
776
777
fn entry<T: DeserializeOwned>(name: &str, value: toml::Value, w: &mut Vec<String>) -> Option<T> {
778
    value
779
        .try_into()
780
        .map_err(|e| w.push(format!("{name}: {}, left out", one_line(&e))))
781
        .ok()
782
}
783
784
fn input(value: Option<toml::Value>, w: &mut Vec<String>) -> Input {
785
    let mut table = match value {
786
        None => toml::Table::new(),
787
        Some(toml::Value::Table(t)) => t,
788
        Some(_) => {
789
            w.push("[input] is not a table, using the defaults".into());
790
            toml::Table::new()
791
        }
792
    };
793
    let mut input = Input {
794
        pointer: section("input.pointer", table.remove("pointer"), w),
795
        touchpad: section("input.touchpad", table.remove("touchpad"), w),
796
        device: Vec::new(),
797
    };
798
    for (i, mut entry) in items::<toml::Table>("input.device", table.remove("device"), w)
799
        .into_iter()
800
        .enumerate()
801
    {
802
        let name = format!("input.device[{i}]");
803
        let Some(toml::Value::String(device)) = entry.remove("name") else {
804
            w.push(format!("{name}: no name, left out"));
805
            continue;
806
        };
807
        input.device.push(NamedDevice {
808
            name: device,
809
            settings: section(&name, Some(toml::Value::Table(entry)), w),
810
        });
811
    }
812
    for key in table.keys() {
813
        w.push(format!("input.{key}: unknown option, ignored"));
814
    }
815
    input.pointer.validate("input.pointer", w);
816
    input.touchpad.validate("input.touchpad", w);
817
    for (i, d) in input.device.iter_mut().enumerate() {
818
        d.settings.validate(&format!("input.device[{i}]"), w);
819
    }
820
    input
821
}
822
823
fn connector_like(name: &str) -> bool {
824
    name.rsplit_once('-').is_some_and(|(kind, n)| {
825
        !kind.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())
826
    })
827
}
828
829
/// A section with every option that does not fit left at its default.
830
fn section<T: DeserializeOwned + Default>(
831
    name: &str,
832
    value: Option<toml::Value>,
833
    w: &mut Vec<String>,
834
) -> T {
835
    let Some(value) = value else {
836
        return T::default();
837
    };
838
    if let Ok(t) = value.clone().try_into() {
839
        return t;
840
    }
841
    let toml::Value::Table(options) = value else {
842
        w.push(format!("[{name}] is not a table, using the defaults"));
843
        return T::default();
844
    };
845
    let mut kept = toml::Table::new();
846
    for (key, v) in options {
847
        let alone = toml::Table::from_iter([(key.clone(), v.clone())]);
848
        match toml::Value::Table(alone).try_into::<T>() {
849
            Ok(_) => {
850
                kept.insert(key, v);
851
            }
852
            Err(e) => {
853
                let e = one_line(&e);
854
                let fallback = if e.starts_with("unknown field") {
855
                    "ignored"
856
                } else {
857
                    "using the default"
858
                };
859
                w.push(format!("{name}.{key}: {e}, {fallback}"));
860
            }
861
        }
862
    }
863
    toml::Value::Table(kept).try_into().unwrap_or_default()
864
}
865
866
/// An array with the elements that do not fit left out.
867
fn items<T: DeserializeOwned>(
868
    name: &str,
869
    value: Option<toml::Value>,
870
    w: &mut Vec<String>,
871
) -> Vec<T> {
872
    match value {
873
        None => Vec::new(),
874
        Some(toml::Value::Array(a)) => a
875
            .into_iter()
876
            .enumerate()
877
            .filter_map(|(i, v)| entry(&format!("{name}[{i}]"), v, w))
878
            .collect(),
879
        Some(_) => {
880
            w.push(format!("{name} is not an array, left out"));
881
            Vec::new()
882
        }
883
    }
884
}
885
886
fn one_line(e: &toml::de::Error) -> String {
887
    e.message().trim().replace('\n', " ")
888
}
889
#[cfg(test)]
890
mod tests {
891
    use super::*;
892
893
    /// default.toml documents the defaults for users, and the Default impls
894
    /// apply them to sections a user config leaves out. They must agree.
895
    #[test]
896
    fn default_toml_states_the_defaults() {
897
        let loaded = Config::parse(DEFAULT).unwrap();
898
        assert_eq!(loaded.warnings, Vec::<String>::new());
899
        let c = loaded.config;
900
        assert_eq!(c.general, General::default());
901
        assert_eq!(c.output, Outputs::default());
902
        assert_eq!(c.decoration, Decoration::default());
903
        assert_eq!(c.animation, Animation::default());
904
        assert_eq!(c.keyboard, Keyboard::default());
905
        assert_eq!(c.input, Input::default());
906
        assert_eq!(c.idle, Idle::default());
907
        assert_eq!(c.lid, Lid::default());
908
        assert_eq!(c.xwayland, Xwayland::default());
909
        assert!(c.binds.len() > 40);
910
    }
911
912
    #[test]
913
    fn output_scale_is_snapped_and_range_checked() {
914
        let loaded = Config::parse(
915
            r#"
916
            [output.DP-1]
917
            scale = 1.5
918
            [output.DP-2]
919
            scale = 1.3333
920
            [output.DP-3]
921
            scale = 9.0
922
            [output.nested]
923
            scale = 2.0
924
            "#,
925
        )
926
        .unwrap();
927
        let named = &loaded.config.output.named;
928
        assert_eq!(named["DP-1"].scale(), 1.5);
929
        assert_eq!(named["DP-2"].scale(), 160.0 / 120.0);
930
        assert_eq!(named["DP-3"].scale(), 1.0);
931
        assert_eq!(loaded.warnings.len(), 1, "{:#?}", loaded.warnings);
932
    }
933
934
    #[test]
935
    fn a_bad_option_costs_only_itself() {
936
        let loaded = Config::parse(
937
            r##"
938
            [general]
939
            gaps = { inner = 3, outer = 3 }
940
            focus_follows_mouse = "yes"
941
            typo = 1
942
            [decoration]
943
            radius = [1, 2, 3, 4]
944
            focused = "#zzzzzz"
945
            [binds]
946
            "Mod+Return" = "spawn beer"
947
            "Hyper+x" = "close"
948
            [output.DP-1]
949
            transform = "90"
950
            position = [0, 0]
951
            [output.DP-2]
952
            transform = "sideways"
953
            [output.DP1]
954
            [[rules]]
955
            app_id = "("
956
            [[rules]]
957
            app_id = "^mpv$"
958
            floating = true
959
            "##,
960
        )
961
        .unwrap();
962
        let c = loaded.config;
963
        assert_eq!(c.general.gaps.inner, 3.0);
964
        assert!(!c.general.focus_follows_mouse);
965
        assert_eq!(c.decoration.radius, Corners([1, 2, 3, 4]));
966
        assert_eq!(c.decoration.focused, Decoration::default().focused);
967
        assert_eq!((c.binds.len(), c.rules.len()), (1, 1));
968
        assert_eq!(c.output.named["DP-1"].transform, OutputTransform::Rotate90);
969
        assert!(!c.output.named.contains_key("DP-2"));
970
        assert_eq!(loaded.warnings.len(), 7, "{:#?}", loaded.warnings);
971
    }
972
973
    #[test]
974
    fn broken_toml_costs_only_its_line() {
975
        let loaded = Config::parse(
976
            r##"
977
            [general]
978
            gaps = { inner = 3, outer = 3
979
            focus_follows_mouse = true
980
            focus_follows_mouse = false
981
            [binds]
982
            "Mod+Return" = "spawn beer
983
            "Mod+q" = "close"
984
            "##,
985
        )
986
        .unwrap();
987
        let c = loaded.config;
988
        assert_eq!(c.general.gaps, General::default().gaps);
989
        assert_eq!(c.binds.len(), 1);
990
        assert_eq!(loaded.warnings.len(), 3, "{:#?}", loaded.warnings);
991
        assert!(
992
            loaded.warnings[0].starts_with("line 3:"),
993
            "{:#?}",
994
            loaded.warnings
995
        );
996
    }
997
998
    #[test]
999
    fn a_bad_device_option_costs_only_itself() {
1000
        let loaded = Config::parse(
1001
            r#"
1002
            [input.pointer]
1003
            accel_speed = 2.0
1004
            scroll_method = "two-finger"
1005
            [[input.device]]
1006
            name = "Mouse"
1007
            accel_profile = "flat"
1008
            [[input.device]]
1009
            name = "Typo"
1010
            tapp = true
1011
            [[input.device]]
1012
            name = "Half"
1013
            accel_points = [0.0, 1.0]
1014
            "#,
1015
        )
1016
        .unwrap();
1017
        let input = loaded.config.input;
1018
        assert_eq!(input.pointer.accel_speed, None);
1019
        assert_eq!(input.pointer.scroll_method, Some(ScrollMethod::TwoFinger));
1020
        let names: Vec<_> = input.device.iter().map(|d| d.name.as_str()).collect();
1021
        assert_eq!(names, ["Mouse", "Typo", "Half"]);
1022
        assert_eq!(
1023
            input.device[0].settings.accel_profile,
1024
            Some(AccelProfile::Flat)
1025
        );
1026
        assert_eq!(input.device[1].settings, DeviceSettings::default());
1027
        assert_eq!(input.device[2].settings.accel_points, None);
1028
        assert_eq!(loaded.warnings.len(), 3, "{:#?}", loaded.warnings);
1029
    }
1030
}