atagen

monolith

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