Skip to main content

drmkit_csd/
load.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Reading a theme from TOML.
5
6use crate::{Button, Buttons, Color, ColorError, Colors, Theme};
7
8/// Why a theme could not be read.
9#[derive(Debug, thiserror::Error)]
10#[non_exhaustive]
11pub enum ThemeError {
12    /// The file could not be read.
13    #[error("reading {path}: {source}")]
14    Io {
15        /// What was asked for.
16        path: String,
17        /// Why not.
18        source: std::io::Error,
19    },
20
21    /// The text is not TOML.
22    #[error("parsing the theme: {0}")]
23    Syntax(String),
24
25    /// A field held the wrong kind of value.
26    ///
27    /// Named, because a theme file is hand-written and "invalid argument" over
28    /// forty fields is not something a person can act on.
29    #[error("{field}: expected {expected}")]
30    WrongType {
31        /// Which field.
32        field: String,
33        /// What it should have been.
34        expected: &'static str,
35    },
36
37    /// A colour was not readable.
38    #[error("{field}: {source}")]
39    BadColor {
40        /// Which field.
41        field: String,
42        /// Why not.
43        source: ColorError,
44    },
45}
46
47/// Load a theme from TOML text, filling anything absent from `base`.
48///
49/// Inheritance is what makes a theme file worth hand-writing: a user who wants
50/// a different accent colour writes three lines, not forty. `base` is
51/// typically [`glass_default`](crate::glass_default).
52///
53/// # Errors
54///
55/// [`ThemeError::Syntax`] if the text is not TOML, [`ThemeError::WrongType`]
56/// if a field holds the wrong kind of value, and [`ThemeError::BadColor`] if a
57/// colour is not `#RRGGBB` or `#RRGGBBAA` — each naming the field, since a
58/// theme file has forty of them and a caller cannot bisect a hand-written one.
59pub fn load_theme_str(text: &str, base: Theme) -> Result<Theme, ThemeError> {
60    // `from_str`, not `str::parse`: the `FromStr` impl on `Value` parses a
61    // single TOML *value*, so a whole document is rejected as trailing
62    // content -- and the error says "unexpected content", which reads like a
63    // problem with the theme rather than with how it was parsed.
64    let doc: toml::Value = toml::from_str(text)
65        .map_err(|error: toml::de::Error| ThemeError::Syntax(error.to_string()))?;
66
67    let mut theme = base;
68    read_string(&doc, "name", &mut theme.name)?;
69    read_int(&doc, "corner_radius", &mut theme.corner_radius)?;
70    read_float(&doc, "noise_amplitude", &mut theme.noise_amplitude)?;
71    read_int(&doc, "shadow_extent", &mut theme.shadow_extent)?;
72    read_int(
73        &doc,
74        "animation_duration_ms",
75        &mut theme.animation_duration_ms,
76    )?;
77
78    if let Some(bar) = doc.get("title_bar") {
79        read_int(bar, "height", &mut theme.title_bar.height)?;
80        read_string(bar, "font", &mut theme.title_bar.font)?;
81        read_int(bar, "font_size", &mut theme.title_bar.font_size)?;
82    }
83
84    if let Some(colors) = doc.get("colors") {
85        let Colors {
86            panel_top,
87            panel_bottom,
88            rim_focused,
89            rim_blurred,
90            shadow,
91            title_text,
92            title_shadow,
93        } = &mut theme.colors;
94        read_color(colors, "panel_top", panel_top)?;
95        read_color(colors, "panel_bottom", panel_bottom)?;
96        read_color(colors, "rim_focused", rim_focused)?;
97        read_color(colors, "rim_blurred", rim_blurred)?;
98        read_color(colors, "shadow", shadow)?;
99        read_color(colors, "title_text", title_text)?;
100        read_color(colors, "title_shadow", title_shadow)?;
101    }
102
103    if let Some(buttons) = doc.get("buttons") {
104        let Buttons {
105            close,
106            minimize,
107            maximize,
108        } = &mut theme.buttons;
109        read_button(buttons, "close", close)?;
110        read_button(buttons, "minimize", minimize)?;
111        read_button(buttons, "maximize", maximize)?;
112    }
113
114    Ok(theme)
115}
116
117/// Load a theme from a file, filling anything absent from `base`.
118///
119/// # Errors
120///
121/// [`ThemeError::Io`] if the file cannot be read, and otherwise as
122/// [`load_theme_str`].
123pub fn load_theme_file(path: &str, base: Theme) -> Result<Theme, ThemeError> {
124    let text = std::fs::read_to_string(path).map_err(|source| ThemeError::Io {
125        path: path.to_owned(),
126        source,
127    })?;
128    load_theme_str(&text, base)
129}
130
131/// Read one string field, leaving the base value if it is absent.
132fn read_string(table: &toml::Value, field: &str, into: &mut String) -> Result<(), ThemeError> {
133    let Some(value) = table.get(field) else {
134        return Ok(());
135    };
136    let text = value.as_str().ok_or_else(|| ThemeError::WrongType {
137        field: field.to_owned(),
138        expected: "a string",
139    })?;
140    into.clear();
141    into.push_str(text);
142    Ok(())
143}
144
145/// Read one integer field.
146fn read_int(table: &toml::Value, field: &str, into: &mut i32) -> Result<(), ThemeError> {
147    let Some(value) = table.get(field) else {
148        return Ok(());
149    };
150    let wrong = || ThemeError::WrongType {
151        field: field.to_owned(),
152        expected: "an integer",
153    };
154    *into = i32::try_from(value.as_integer().ok_or_else(wrong)?).map_err(|_| wrong())?;
155    Ok(())
156}
157
158/// Read one float field.
159///
160/// An integer is accepted where a float is expected: `noise_amplitude = 0` is
161/// what a theme author writes for none, and TOML types it as an integer.
162fn read_float(table: &toml::Value, field: &str, into: &mut f64) -> Result<(), ThemeError> {
163    let Some(value) = table.get(field) else {
164        return Ok(());
165    };
166    *into = match value {
167        toml::Value::Float(float) => *float,
168        // A theme author writes `noise_amplitude = 0` for none, and TOML
169        // types that as an integer. Anything a theme carries is far inside
170        // what an f64 represents exactly, so the conversion is lossless here
171        // even though the types say it need not be in general.
172        toml::Value::Integer(int) => {
173            f64::from(i32::try_from(*int).map_err(|_| ThemeError::WrongType {
174                field: field.to_owned(),
175                expected: "a number a theme could use",
176            })?)
177        }
178        _ => {
179            return Err(ThemeError::WrongType {
180                field: field.to_owned(),
181                expected: "a number",
182            });
183        }
184    };
185    Ok(())
186}
187
188/// Read one colour field, reporting it by its own name.
189fn read_color(table: &toml::Value, field: &str, into: &mut Color) -> Result<(), ThemeError> {
190    read_color_as(table, field, field, into)
191}
192
193/// Read the colour at `key`, reporting it as `label`.
194///
195/// The two differ inside a nested table: a button's colour is keyed `fill`
196/// but is worth reporting as `buttons.close.fill`, because that is what the
197/// author has to find in the file. Looking it up by the qualified name
198/// instead finds nothing — which is not an error, since an absent field
199/// inherits, so the colour silently stays at the base value.
200fn read_color_as(
201    table: &toml::Value,
202    key: &str,
203    label: &str,
204    into: &mut Color,
205) -> Result<(), ThemeError> {
206    let Some(value) = table.get(key) else {
207        return Ok(());
208    };
209    let text = value.as_str().ok_or_else(|| ThemeError::WrongType {
210        field: label.to_owned(),
211        expected: "a #RRGGBB or #RRGGBBAA string",
212    })?;
213    *into = Color::from_hex(text).map_err(|source| ThemeError::BadColor {
214        field: label.to_owned(),
215        source,
216    })?;
217    Ok(())
218}
219
220/// Read one button's two colours.
221fn read_button(table: &toml::Value, field: &str, into: &mut Button) -> Result<(), ThemeError> {
222    let Some(button) = table.get(field) else {
223        return Ok(());
224    };
225    read_color_as(
226        button,
227        "fill",
228        &format!("buttons.{field}.fill"),
229        &mut into.fill,
230    )?;
231    read_color_as(
232        button,
233        "hover",
234        &format!("buttons.{field}.hover"),
235        &mut into.hover,
236    )
237}