atagen

monolith

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