atagen

monolith

public
Star0
Watch0
2454f8b9122d101a822ca97b17c55ca9df53a24e
1
ssh://brewery@pls.lobotomise.me:33/atagen/monolith.git
crates/config/src/lib.rsRust836 lines25.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 binds: Vec<Bind>,
21
    pub rules: Vec<Rule>,
22
    pub startup: Vec<Vec<String>>,
23
}
24
25
#[derive(Deserialize, Debug, Clone, PartialEq)]
26
#[serde(default, deny_unknown_fields)]
27
pub struct General {
28
    /// What `Mod` means in binds.
29
    #[serde(rename = "mod")]
30
    pub mod_key: Modifier,
31
    pub gaps: Gaps,
32
    /// What a window move does when it meets a neighbour.
33
    #[serde(rename = "move")]
34
    pub move_mode: MoveMode,
35
    pub focus_follows_mouse: bool,
36
    pub mouse_follows_focus: bool,
37
    /// The first window to land on or move to an empty tag nobody is
38
    /// viewing brings that tag into view on its output.
39
    pub view_tag_on_first_window: bool,
40
}
41
42
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
43
#[serde(rename_all = "lowercase")]
44
pub enum MoveMode {
45
    /// Trade places with it.
46
    #[default]
47
    Swap,
48
    /// Join its lane, stacking with it if it is alone.
49
    Merge,
50
}
51
52
impl Default for General {
53
    fn default() -> Self {
54
        Self {
55
            mod_key: Modifier::Super,
56
            gaps: Gaps::default(),
57
            move_mode: MoveMode::Swap,
58
            focus_follows_mouse: false,
59
            mouse_follows_focus: false,
60
            view_tag_on_first_window: true,
61
        }
62
    }
63
}
64
65
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
66
#[serde(default, deny_unknown_fields)]
67
pub struct Gaps {
68
    pub inner: f64,
69
    pub outer: f64,
70
}
71
72
impl Default for Gaps {
73
    fn default() -> Self {
74
        Self {
75
            inner: 8.0,
76
            outer: 8.0,
77
        }
78
    }
79
}
80
81
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
82
#[serde(default)]
83
pub struct Outputs {
84
    pub vrr: Vrr,
85
    /// Settings for one connector, by name, like `DP-1`.
86
    #[serde(flatten)]
87
    pub named: std::collections::BTreeMap<String, OutputConfig>,
88
}
89
90
/// Transform and position follow a reload. A new mode waits for a restart
91
/// or a replug, since it means a modeset.
92
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
93
#[serde(default, deny_unknown_fields)]
94
pub struct OutputConfig {
95
    pub transform: OutputTransform,
96
    /// Top left corner in the global layout. Outputs without one are placed
97
    /// to the right of the others.
98
    pub position: Option<[i32; 2]>,
99
    /// `WIDTHxHEIGHT` or `WIDTHxHEIGHT@HZ`. Without a rate the highest one
100
    /// for that size wins.
101
    pub mode: Option<String>,
102
}
103
104
/// Counterclockwise rotation, optionally after flipping horizontally.
105
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
106
#[serde(rename_all = "kebab-case")]
107
pub enum OutputTransform {
108
    #[default]
109
    Normal,
110
    #[serde(rename = "90")]
111
    Rotate90,
112
    #[serde(rename = "180")]
113
    Rotate180,
114
    #[serde(rename = "270")]
115
    Rotate270,
116
    Flipped,
117
    #[serde(rename = "flipped-90")]
118
    Flipped90,
119
    #[serde(rename = "flipped-180")]
120
    Flipped180,
121
    #[serde(rename = "flipped-270")]
122
    Flipped270,
123
}
124
125
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
126
#[serde(rename_all = "lowercase")]
127
pub enum Vrr {
128
    Off,
129
    On,
130
    /// Only while a fullscreen window is shown, where a game sets the pace.
131
    #[default]
132
    Fullscreen,
133
}
134
135
#[derive(Deserialize, Debug, Clone, PartialEq)]
136
#[serde(default, deny_unknown_fields)]
137
pub struct Decoration {
138
    /// Border width in logical pixels, drawn in the gap around each window.
139
    pub border: u32,
140
    pub radius: Corners,
141
    pub focused: Color,
142
    pub unfocused: Color,
143
}
144
145
/// Corner radii in logical pixels, top left, top right, bottom right and
146
/// bottom left. One number rounds every corner alike.
147
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
148
#[serde(from = "CornersToml")]
149
pub struct Corners(pub [u32; 4]);
150
151
#[derive(Deserialize)]
152
#[serde(
153
    untagged,
154
    expecting = "expected a number or [top left, top right, bottom right, bottom left]"
155
)]
156
enum CornersToml {
157
    All(u32),
158
    Each([u32; 4]),
159
}
160
161
impl From<CornersToml> for Corners {
162
    fn from(c: CornersToml) -> Self {
163
        match c {
164
            CornersToml::All(r) => Self([r; 4]),
165
            CornersToml::Each(r) => Self(r),
166
        }
167
    }
168
}
169
170
impl Default for Decoration {
171
    fn default() -> Self {
172
        Self {
173
            border: 2,
174
            radius: Corners([0; 4]),
175
            focused: Color::rgb(0x88, 0xbf, 0xd1),
176
            unfocused: Color::rgb(0x3b, 0x42, 0x52),
177
        }
178
    }
179
}
180
181
/// Straight RGBA from `#rrggbb` or `#rrggbbaa`.
182
#[derive(Debug, Clone, Copy, PartialEq)]
183
pub struct Color(pub [f32; 4]);
184
185
impl Color {
186
    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
187
        Self([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
188
    }
189
}
190
191
impl<'de> Deserialize<'de> for Color {
192
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
193
        let s = String::deserialize(d)?;
194
        let hex = s.strip_prefix('#').unwrap_or(&s);
195
        let bad =
196
            || serde::de::Error::custom(format!("bad colour {s:?}, use #rrggbb or #rrggbbaa"));
197
        if !matches!(hex.len(), 6 | 8) || !hex.is_ascii() {
198
            return Err(bad());
199
        }
200
        let mut c = [1.0f32; 4];
201
        for (i, v) in c.iter_mut().enumerate().take(hex.len() / 2) {
202
            let byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| bad())?;
203
            *v = f32::from(byte) / 255.0;
204
        }
205
        Ok(Self(c))
206
    }
207
}
208
209
#[derive(Deserialize, Debug, Clone, PartialEq)]
210
#[serde(default, deny_unknown_fields)]
211
pub struct Animation {
212
    pub enabled: bool,
213
    pub style: AnimationStyle,
214
    /// Every phase of a planned transition lasts this long.
215
    pub phase_ms: u64,
216
    /// A cubic bezier, as in CSS.
217
    pub curve: [f64; 4],
218
}
219
220
impl Default for Animation {
221
    fn default() -> Self {
222
        Self {
223
            enabled: true,
224
            style: AnimationStyle::Orthogonal,
225
            phase_ms: 160,
226
            curve: [0.45, 0.0, 0.2, 1.0],
227
        }
228
    }
229
}
230
231
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
232
#[serde(rename_all = "lowercase")]
233
pub enum AnimationStyle {
234
    Orthogonal,
235
    Direct,
236
}
237
238
#[derive(Deserialize, Debug, Clone, PartialEq)]
239
#[serde(default, deny_unknown_fields)]
240
pub struct Keyboard {
241
    pub rules: String,
242
    pub model: String,
243
    pub layout: String,
244
    pub variant: String,
245
    pub options: Option<String>,
246
    pub repeat_delay: i32,
247
    pub repeat_rate: i32,
248
}
249
250
impl Default for Keyboard {
251
    fn default() -> Self {
252
        Self {
253
            rules: String::new(),
254
            model: String::new(),
255
            layout: String::new(),
256
            variant: String::new(),
257
            options: None,
258
            repeat_delay: 200,
259
            repeat_rate: 25,
260
        }
261
    }
262
}
263
264
/// libinput settings for pointing devices. Unset options keep libinput's
265
/// default for the device.
266
#[derive(Debug, Clone, PartialEq, Default)]
267
pub struct Input {
268
    pub pointer: DeviceSettings,
269
    /// Devices that can tap, which libinput only offers on touchpads.
270
    pub touchpad: DeviceSettings,
271
    /// Overrides for devices by name, later entries winning.
272
    pub device: Vec<NamedDevice>,
273
}
274
275
#[derive(Debug, Clone, PartialEq)]
276
pub struct NamedDevice {
277
    pub name: String,
278
    pub settings: DeviceSettings,
279
}
280
281
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
282
#[serde(default, deny_unknown_fields)]
283
pub struct DeviceSettings {
284
    pub accel_profile: Option<AccelProfile>,
285
    pub accel_speed: Option<f64>,
286
    /// The custom profile's curve, pointer speed sampled every `accel_step`
287
    /// device units per millisecond.
288
    pub accel_points: Option<Vec<f64>>,
289
    pub accel_step: Option<f64>,
290
    pub natural_scroll: Option<bool>,
291
    pub left_handed: Option<bool>,
292
    pub tap: Option<bool>,
293
    pub tap_drag: Option<bool>,
294
    pub tap_drag_lock: Option<bool>,
295
    pub dwt: Option<bool>,
296
    pub middle_emulation: Option<bool>,
297
    pub scroll_method: Option<ScrollMethod>,
298
    pub scroll_button: Option<u32>,
299
    pub click_method: Option<ClickMethod>,
300
    /// Applied by the compositor, libinput has no such setting.
301
    pub scroll_factor: Option<f64>,
302
}
303
304
impl DeviceSettings {
305
    /// `self` with every option `over` sets replaced.
306
    pub fn overlay(&self, over: &Self) -> Self {
307
        Self {
308
            accel_profile: over.accel_profile.or(self.accel_profile),
309
            accel_speed: over.accel_speed.or(self.accel_speed),
310
            accel_points: over
311
                .accel_points
312
                .clone()
313
                .or_else(|| self.accel_points.clone()),
314
            accel_step: over.accel_step.or(self.accel_step),
315
            natural_scroll: over.natural_scroll.or(self.natural_scroll),
316
            left_handed: over.left_handed.or(self.left_handed),
317
            tap: over.tap.or(self.tap),
318
            tap_drag: over.tap_drag.or(self.tap_drag),
319
            tap_drag_lock: over.tap_drag_lock.or(self.tap_drag_lock),
320
            dwt: over.dwt.or(self.dwt),
321
            middle_emulation: over.middle_emulation.or(self.middle_emulation),
322
            scroll_method: over.scroll_method.or(self.scroll_method),
323
            scroll_button: over.scroll_button.or(self.scroll_button),
324
            click_method: over.click_method.or(self.click_method),
325
            scroll_factor: over.scroll_factor.or(self.scroll_factor),
326
        }
327
    }
328
329
    fn validate(&mut self, name: &str, w: &mut Vec<String>) {
330
        if let Some(s) = self.accel_speed
331
            && !(-1.0..=1.0).contains(&s)
332
        {
333
            w.push(format!(
334
                "{name}.accel_speed: {s} is outside -1 to 1, ignored"
335
            ));
336
            self.accel_speed = None;
337
        }
338
        if let Some(f) = self.scroll_factor
339
            && !(f.is_finite() && f >= 0.0)
340
        {
341
            w.push(format!(
342
                "{name}.scroll_factor: {f} must be zero or more, ignored"
343
            ));
344
            self.scroll_factor = None;
345
        }
346
        if self.accel_points.is_some() != self.accel_step.is_some() {
347
            w.push(format!(
348
                "{name}: accel_points needs accel_step and the other way round, both ignored"
349
            ));
350
            self.accel_points = None;
351
            self.accel_step = None;
352
        }
353
    }
354
}
355
356
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
357
#[serde(rename_all = "lowercase")]
358
pub enum AccelProfile {
359
    Flat,
360
    Adaptive,
361
    Custom,
362
}
363
364
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
365
#[serde(rename_all = "kebab-case")]
366
pub enum ScrollMethod {
367
    None,
368
    TwoFinger,
369
    Edge,
370
    OnButtonDown,
371
}
372
373
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
374
#[serde(rename_all = "kebab-case")]
375
pub enum ClickMethod {
376
    ButtonAreas,
377
    Clickfinger,
378
}
379
380
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
381
#[serde(rename_all = "lowercase")]
382
pub enum Modifier {
383
    Super,
384
    Alt,
385
    Ctrl,
386
    Shift,
387
}
388
389
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
390
pub struct Mods {
391
    pub logo: bool,
392
    pub alt: bool,
393
    pub ctrl: bool,
394
    pub shift: bool,
395
}
396
397
impl Mods {
398
    pub fn has(&self, m: Modifier) -> bool {
399
        match m {
400
            Modifier::Super => self.logo,
401
            Modifier::Alt => self.alt,
402
            Modifier::Ctrl => self.ctrl,
403
            Modifier::Shift => self.shift,
404
        }
405
    }
406
407
    fn set(&mut self, m: Modifier) {
408
        match m {
409
            Modifier::Super => self.logo = true,
410
            Modifier::Alt => self.alt = true,
411
            Modifier::Ctrl => self.ctrl = true,
412
            Modifier::Shift => self.shift = true,
413
        }
414
    }
415
}
416
417
#[derive(Debug, Clone, PartialEq)]
418
pub struct Bind {
419
    pub mods: Mods,
420
    /// An xkb keysym name, resolved by the compositor.
421
    pub key: String,
422
    pub command: Command,
423
}
424
425
#[derive(Debug, Clone)]
426
pub struct Rule {
427
    pub app_id: Option<Regex>,
428
    pub title: Option<Regex>,
429
    pub tag: Option<String>,
430
    pub output: Option<String>,
431
    pub floating: Option<bool>,
432
    pub sticky: Option<bool>,
433
    pub scratchpad: Option<String>,
434
}
435
436
/// Everything the matching rules say about a window, later rules winning.
437
#[derive(Debug, Clone, Default, PartialEq)]
438
pub struct Placement {
439
    pub tag: Option<String>,
440
    pub output: Option<String>,
441
    pub floating: Option<bool>,
442
    pub sticky: Option<bool>,
443
    pub scratchpad: Option<String>,
444
}
445
446
#[derive(Debug)]
447
pub enum Error {
448
    Io(PathBuf, std::io::Error),
449
    Toml(toml::de::Error),
450
}
451
452
impl fmt::Display for Error {
453
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454
        match self {
455
            Error::Io(p, e) => write!(f, "{}: {e}", p.display()),
456
            Error::Toml(e) => write!(f, "{e}"),
457
        }
458
    }
459
}
460
461
impl std::error::Error for Error {}
462
463
#[derive(Deserialize)]
464
#[serde(untagged)]
465
enum Words {
466
    Line(String),
467
    List(Vec<String>),
468
}
469
470
#[derive(Deserialize)]
471
#[serde(deny_unknown_fields)]
472
struct RawRule {
473
    app_id: Option<String>,
474
    title: Option<String>,
475
    tag: Option<String>,
476
    output: Option<String>,
477
    floating: Option<bool>,
478
    sticky: Option<bool>,
479
    scratchpad: Option<String>,
480
}
481
482
impl Config {
483
    /// `$MONOLITH_CONFIG`, else `$XDG_CONFIG_HOME/monolith/config.toml`.
484
    pub fn path() -> Option<PathBuf> {
485
        if let Some(p) = std::env::var_os("MONOLITH_CONFIG") {
486
            return Some(p.into());
487
        }
488
        let base = std::env::var_os("XDG_CONFIG_HOME")
489
            .map(PathBuf::from)
490
            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
491
        Some(base.join("monolith/config.toml"))
492
    }
493
494
    /// Loads `path`, or the built-in default when it does not exist.
495
    pub fn load(path: Option<&Path>) -> Result<Loaded, Error> {
496
        match path {
497
            Some(p) => match std::fs::read_to_string(p) {
498
                Ok(text) => Self::parse(&text),
499
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::parse(DEFAULT),
500
                Err(e) => Err(Error::Io(p.to_owned(), e)),
501
            },
502
            None => Self::parse(DEFAULT),
503
        }
504
    }
505
506
    /// Only broken TOML fails. A bad option, bind or rule is left out with a
507
    /// warning, so one typo does not throw away the rest of the file.
508
    pub fn parse(text: &str) -> Result<Loaded, Error> {
509
        let mut table: toml::Table = toml::from_str(text).map_err(Error::Toml)?;
510
        let mut warnings = Vec::new();
511
        let w = &mut warnings;
512
        let general: General = section("general", table.remove("general"), w);
513
        let keyboard = section("keyboard", table.remove("keyboard"), w);
514
        let input = input(table.remove("input"), w);
515
        let animation = section("animation", table.remove("animation"), w);
516
        let decoration = section("decoration", table.remove("decoration"), w);
517
        let output: Outputs = section("output", table.remove("output"), w);
518
        for name in output.named.keys().filter(|n| !connector_like(n)) {
519
            w.push(format!(
520
                "output.{name}: not a connector name like DP-1 or HDMI-A-1, matches nothing"
521
            ));
522
        }
523
        let startup = items("startup", table.remove("startup"), w);
524
        let mut binds = Vec::new();
525
        if let Some(value) = table.remove("binds") {
526
            let entries: toml::Table = entry("binds", value, w).unwrap_or_default();
527
            for (combo, value) in entries {
528
                let name = format!("binds.{combo:?}");
529
                let bind = entry::<Words>(&name, value, w)
530
                    .map(|words| parse_bind(&combo, words, general.mod_key));
531
                match bind {
532
                    Some(Ok(b)) => binds.push(b),
533
                    Some(Err(e)) => w.push(format!("{name}: {e}, left out")),
534
                    None => {}
535
                }
536
            }
537
        }
538
        let regex = |p: Option<String>| p.map(|p| Regex::new(&p)).transpose();
539
        let mut rules = Vec::new();
540
        for (i, raw) in items::<RawRule>("rules", table.remove("rules"), w)
541
            .into_iter()
542
            .enumerate()
543
        {
544
            match (regex(raw.app_id), regex(raw.title)) {
545
                (Ok(app_id), Ok(title)) => rules.push(Rule {
546
                    app_id,
547
                    title,
548
                    tag: raw.tag,
549
                    output: raw.output,
550
                    floating: raw.floating,
551
                    sticky: raw.sticky,
552
                    scratchpad: raw.scratchpad,
553
                }),
554
                (Err(e), _) | (_, Err(e)) => {
555
                    let e = e
556
                        .to_string()
557
                        .split_whitespace()
558
                        .collect::<Vec<_>>()
559
                        .join(" ");
560
                    w.push(format!("rules[{i}]: {e}, left out"));
561
                }
562
            }
563
        }
564
        for key in table.keys() {
565
            w.push(format!("unknown section [{key}], ignored"));
566
        }
567
        let config = Self {
568
            general,
569
            animation,
570
            decoration,
571
            output,
572
            keyboard,
573
            input,
574
            binds,
575
            rules,
576
            startup,
577
        };
578
        Ok(Loaded { config, warnings })
579
    }
580
581
    pub fn placement(&self, app_id: &str, title: &str) -> Placement {
582
        let mut p = Placement::default();
583
        let hit = |re: &Option<Regex>, s: &str| re.as_ref().is_none_or(|re| re.is_match(s));
584
        for r in &self.rules {
585
            if !hit(&r.app_id, app_id) || !hit(&r.title, title) {
586
                continue;
587
            }
588
            p.tag = r.tag.clone().or(p.tag);
589
            p.output = r.output.clone().or(p.output);
590
            p.floating = r.floating.or(p.floating);
591
            p.sticky = r.sticky.or(p.sticky);
592
            p.scratchpad = r.scratchpad.clone().or(p.scratchpad);
593
        }
594
        p
595
    }
596
}
597
598
fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, String> {
599
    let parts: Vec<&str> = combo.split('+').map(str::trim).collect();
600
    let Some((&key, mods)) = parts.split_last().filter(|(k, _)| !k.is_empty()) else {
601
        return Err("no key".into());
602
    };
603
    let mut set = Mods::default();
604
    for m in mods {
605
        let m = match m.to_ascii_lowercase().as_str() {
606
            "mod" => mod_key,
607
            "super" | "logo" | "win" => Modifier::Super,
608
            "alt" => Modifier::Alt,
609
            "ctrl" | "control" => Modifier::Ctrl,
610
            "shift" => Modifier::Shift,
611
            other => return Err(format!("unknown modifier {other}")),
612
        };
613
        set.set(m);
614
    }
615
    let words: Vec<String> = match words {
616
        Words::Line(s) => s.split_whitespace().map(str::to_owned).collect(),
617
        Words::List(v) => v,
618
    };
619
    let command = Command::parse(&words).map_err(|e| e.0)?;
620
    Ok(Bind {
621
        mods: set,
622
        key: key.to_owned(),
623
        command,
624
    })
625
}
626
627
/// A config and what had to be left out of it.
628
pub struct Loaded {
629
    pub config: Config,
630
    pub warnings: Vec<String>,
631
}
632
633
fn entry<T: DeserializeOwned>(name: &str, value: toml::Value, w: &mut Vec<String>) -> Option<T> {
634
    value
635
        .try_into()
636
        .map_err(|e| w.push(format!("{name}: {}, left out", one_line(&e))))
637
        .ok()
638
}
639
640
fn input(value: Option<toml::Value>, w: &mut Vec<String>) -> Input {
641
    let mut table = match value {
642
        None => toml::Table::new(),
643
        Some(toml::Value::Table(t)) => t,
644
        Some(_) => {
645
            w.push("[input] is not a table, using the defaults".into());
646
            toml::Table::new()
647
        }
648
    };
649
    let mut input = Input {
650
        pointer: section("input.pointer", table.remove("pointer"), w),
651
        touchpad: section("input.touchpad", table.remove("touchpad"), w),
652
        device: Vec::new(),
653
    };
654
    for (i, mut entry) in items::<toml::Table>("input.device", table.remove("device"), w)
655
        .into_iter()
656
        .enumerate()
657
    {
658
        let name = format!("input.device[{i}]");
659
        let Some(toml::Value::String(device)) = entry.remove("name") else {
660
            w.push(format!("{name}: no name, left out"));
661
            continue;
662
        };
663
        input.device.push(NamedDevice {
664
            name: device,
665
            settings: section(&name, Some(toml::Value::Table(entry)), w),
666
        });
667
    }
668
    for key in table.keys() {
669
        w.push(format!("input.{key}: unknown option, ignored"));
670
    }
671
    input.pointer.validate("input.pointer", w);
672
    input.touchpad.validate("input.touchpad", w);
673
    for (i, d) in input.device.iter_mut().enumerate() {
674
        d.settings.validate(&format!("input.device[{i}]"), w);
675
    }
676
    input
677
}
678
679
fn connector_like(name: &str) -> bool {
680
    name.rsplit_once('-').is_some_and(|(kind, n)| {
681
        !kind.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())
682
    })
683
}
684
685
/// A section with every option that does not fit left at its default.
686
fn section<T: DeserializeOwned + Default>(
687
    name: &str,
688
    value: Option<toml::Value>,
689
    w: &mut Vec<String>,
690
) -> T {
691
    let Some(value) = value else {
692
        return T::default();
693
    };
694
    if let Ok(t) = value.clone().try_into() {
695
        return t;
696
    }
697
    let toml::Value::Table(options) = value else {
698
        w.push(format!("[{name}] is not a table, using the defaults"));
699
        return T::default();
700
    };
701
    let mut kept = toml::Table::new();
702
    for (key, v) in options {
703
        let alone = toml::Table::from_iter([(key.clone(), v.clone())]);
704
        match toml::Value::Table(alone).try_into::<T>() {
705
            Ok(_) => {
706
                kept.insert(key, v);
707
            }
708
            Err(e) => {
709
                let e = one_line(&e);
710
                let fallback = if e.starts_with("unknown field") {
711
                    "ignored"
712
                } else {
713
                    "using the default"
714
                };
715
                w.push(format!("{name}.{key}: {e}, {fallback}"));
716
            }
717
        }
718
    }
719
    toml::Value::Table(kept).try_into().unwrap_or_default()
720
}
721
722
/// An array with the elements that do not fit left out.
723
fn items<T: DeserializeOwned>(
724
    name: &str,
725
    value: Option<toml::Value>,
726
    w: &mut Vec<String>,
727
) -> Vec<T> {
728
    match value {
729
        None => Vec::new(),
730
        Some(toml::Value::Array(a)) => a
731
            .into_iter()
732
            .enumerate()
733
            .filter_map(|(i, v)| entry(&format!("{name}[{i}]"), v, w))
734
            .collect(),
735
        Some(_) => {
736
            w.push(format!("{name} is not an array, left out"));
737
            Vec::new()
738
        }
739
    }
740
}
741
742
fn one_line(e: &toml::de::Error) -> String {
743
    e.message().trim().replace('\n', " ")
744
}
745
#[cfg(test)]
746
mod tests {
747
    use super::*;
748
749
    /// default.toml documents the defaults for users, and the Default impls
750
    /// apply them to sections a user config leaves out. They must agree.
751
    #[test]
752
    fn default_toml_states_the_defaults() {
753
        let loaded = Config::parse(DEFAULT).unwrap();
754
        assert_eq!(loaded.warnings, Vec::<String>::new());
755
        let c = loaded.config;
756
        assert_eq!(c.general, General::default());
757
        assert_eq!(c.output, Outputs::default());
758
        assert_eq!(c.decoration, Decoration::default());
759
        assert_eq!(c.animation, Animation::default());
760
        assert_eq!(c.keyboard, Keyboard::default());
761
        assert_eq!(c.input, Input::default());
762
        assert!(c.binds.len() > 40);
763
    }
764
765
    #[test]
766
    fn a_bad_option_costs_only_itself() {
767
        let loaded = Config::parse(
768
            r##"
769
            [general]
770
            gaps = { inner = 3, outer = 3 }
771
            focus_follows_mouse = "yes"
772
            typo = 1
773
            [decoration]
774
            radius = [1, 2, 3, 4]
775
            focused = "#zzzzzz"
776
            [binds]
777
            "Mod+Return" = "spawn beer"
778
            "Hyper+x" = "close"
779
            [output.DP-1]
780
            transform = "90"
781
            position = [0, 0]
782
            [output.DP-2]
783
            transform = "sideways"
784
            [output.DP1]
785
            [[rules]]
786
            app_id = "("
787
            [[rules]]
788
            app_id = "^mpv$"
789
            floating = true
790
            "##,
791
        )
792
        .unwrap();
793
        let c = loaded.config;
794
        assert_eq!(c.general.gaps.inner, 3.0);
795
        assert!(!c.general.focus_follows_mouse);
796
        assert_eq!(c.decoration.radius, Corners([1, 2, 3, 4]));
797
        assert_eq!(c.decoration.focused, Decoration::default().focused);
798
        assert_eq!((c.binds.len(), c.rules.len()), (1, 1));
799
        assert_eq!(c.output.named["DP-1"].transform, OutputTransform::Rotate90);
800
        assert!(!c.output.named.contains_key("DP-2"));
801
        assert_eq!(loaded.warnings.len(), 7, "{:#?}", loaded.warnings);
802
    }
803
804
    #[test]
805
    fn a_bad_device_option_costs_only_itself() {
806
        let loaded = Config::parse(
807
            r#"
808
            [input.pointer]
809
            accel_speed = 2.0
810
            scroll_method = "two-finger"
811
            [[input.device]]
812
            name = "Mouse"
813
            accel_profile = "flat"
814
            [[input.device]]
815
            name = "Typo"
816
            tapp = true
817
            [[input.device]]
818
            name = "Half"
819
            accel_points = [0.0, 1.0]
820
            "#,
821
        )
822
        .unwrap();
823
        let input = loaded.config.input;
824
        assert_eq!(input.pointer.accel_speed, None);
825
        assert_eq!(input.pointer.scroll_method, Some(ScrollMethod::TwoFinger));
826
        let names: Vec<_> = input.device.iter().map(|d| d.name.as_str()).collect();
827
        assert_eq!(names, ["Mouse", "Typo", "Half"]);
828
        assert_eq!(
829
            input.device[0].settings.accel_profile,
830
            Some(AccelProfile::Flat)
831
        );
832
        assert_eq!(input.device[1].settings, DeviceSettings::default());
833
        assert_eq!(input.device[2].settings.accel_points, None);
834
        assert_eq!(loaded.warnings.len(), 3, "{:#?}", loaded.warnings);
835
    }
836
}