atagen

monolith

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