atagen

monolith

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