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    /// twenty-one 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 color 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 color writes three lines, not twenty-one. `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/// color is not `#RRGGBB` or `#RRGGBBAA` — each naming the field, since a
58/// theme file has twenty-one of them and a caller cannot bisect a hand-written
59/// one.
60///
61/// A section — `title_bar`, `colors`, `buttons` or one button within it —
62/// that is not a table is ignored rather than refused, as upstream ignores
63/// it: its fields are simply absent and inherit from `base`.
64pub fn load_theme_str(text: &str, base: Theme) -> Result<Theme, ThemeError> {
65    // `from_str`, not `str::parse`: the `FromStr` impl on `Value` parses a
66    // single TOML *value*, so a whole document is rejected as trailing
67    // content -- and the error says "unexpected content", which reads like a
68    // problem with the theme rather than with how it was parsed.
69    let doc: toml::Value = toml::from_str(text)
70        .map_err(|error: toml::de::Error| ThemeError::Syntax(error.to_string()))?;
71
72    let mut theme = base;
73    read_string(&doc, "name", &mut theme.name)?;
74    read_int(&doc, "corner_radius", &mut theme.corner_radius)?;
75    read_float(&doc, "noise_amplitude", &mut theme.noise_amplitude)?;
76    read_int(&doc, "shadow_extent", &mut theme.shadow_extent)?;
77    read_int(
78        &doc,
79        "animation_duration_ms",
80        &mut theme.animation_duration_ms,
81    )?;
82
83    if let Some(bar) = doc.get("title_bar") {
84        read_int(bar, "height", &mut theme.title_bar.height)?;
85        read_string(bar, "font", &mut theme.title_bar.font)?;
86        read_int(bar, "font_size", &mut theme.title_bar.font_size)?;
87    }
88
89    if let Some(colors) = doc.get("colors") {
90        let Colors {
91            panel_top,
92            panel_bottom,
93            rim_focused,
94            rim_blurred,
95            shadow,
96            title_text,
97            title_shadow,
98        } = &mut theme.colors;
99        read_color(colors, "panel_top", panel_top)?;
100        read_color(colors, "panel_bottom", panel_bottom)?;
101        read_color(colors, "rim_focused", rim_focused)?;
102        read_color(colors, "rim_blurred", rim_blurred)?;
103        read_color(colors, "shadow", shadow)?;
104        read_color(colors, "title_text", title_text)?;
105        read_color(colors, "title_shadow", title_shadow)?;
106    }
107
108    if let Some(buttons) = doc.get("buttons") {
109        let Buttons {
110            close,
111            minimize,
112            maximize,
113        } = &mut theme.buttons;
114        read_button(buttons, "close", close)?;
115        read_button(buttons, "minimize", minimize)?;
116        read_button(buttons, "maximize", maximize)?;
117    }
118
119    Ok(theme)
120}
121
122/// Load a theme from a file, filling anything absent from `base`.
123///
124/// # Errors
125///
126/// [`ThemeError::Io`] if the file cannot be read, and otherwise as
127/// [`load_theme_str`].
128pub fn load_theme_file(path: &str, base: Theme) -> Result<Theme, ThemeError> {
129    let text = std::fs::read_to_string(path).map_err(|source| ThemeError::Io {
130        path: path.to_owned(),
131        source,
132    })?;
133    load_theme_str(&text, base)
134}
135
136/// Read one string field, leaving the base value if it is absent.
137fn read_string(table: &toml::Value, field: &str, into: &mut String) -> Result<(), ThemeError> {
138    let Some(value) = table.get(field) else {
139        return Ok(());
140    };
141    let text = value.as_str().ok_or_else(|| ThemeError::WrongType {
142        field: field.to_owned(),
143        expected: "a string",
144    })?;
145    into.clear();
146    into.push_str(text);
147    Ok(())
148}
149
150/// Read one integer field.
151fn read_int(table: &toml::Value, field: &str, into: &mut i32) -> Result<(), ThemeError> {
152    let Some(value) = table.get(field) else {
153        return Ok(());
154    };
155    let wrong = || ThemeError::WrongType {
156        field: field.to_owned(),
157        expected: "an integer",
158    };
159    *into = i32::try_from(value.as_integer().ok_or_else(wrong)?).map_err(|_| wrong())?;
160    Ok(())
161}
162
163/// Read one float field.
164///
165/// An integer is accepted where a float is expected: `noise_amplitude = 0` is
166/// what a theme author writes for none, and TOML types it as an integer.
167fn read_float(table: &toml::Value, field: &str, into: &mut f64) -> Result<(), ThemeError> {
168    let Some(value) = table.get(field) else {
169        return Ok(());
170    };
171    *into = match value {
172        toml::Value::Float(float) => *float,
173        // A theme author writes `noise_amplitude = 0` for none, and TOML
174        // types that as an integer. Anything a theme carries is far inside
175        // what an f64 represents exactly, so the conversion is lossless here
176        // even though the types say it need not be in general.
177        toml::Value::Integer(int) => {
178            f64::from(i32::try_from(*int).map_err(|_| ThemeError::WrongType {
179                field: field.to_owned(),
180                expected: "a number a theme could use",
181            })?)
182        }
183        _ => {
184            return Err(ThemeError::WrongType {
185                field: field.to_owned(),
186                expected: "a number",
187            });
188        }
189    };
190    Ok(())
191}
192
193/// Read one color field, reporting it by its own name.
194fn read_color(table: &toml::Value, field: &str, into: &mut Color) -> Result<(), ThemeError> {
195    read_color_as(table, field, field, into)
196}
197
198/// Read the color at `key`, reporting it as `label`.
199///
200/// The two differ inside a nested table: a button's color is keyed `fill`
201/// but is worth reporting as `buttons.close.fill`, because that is what the
202/// author has to find in the file. Looking it up by the qualified name
203/// instead finds nothing — which is not an error, since an absent field
204/// inherits, so the color silently stays at the base value.
205fn read_color_as(
206    table: &toml::Value,
207    key: &str,
208    label: &str,
209    into: &mut Color,
210) -> Result<(), ThemeError> {
211    let Some(value) = table.get(key) else {
212        return Ok(());
213    };
214    let text = value.as_str().ok_or_else(|| ThemeError::WrongType {
215        field: label.to_owned(),
216        expected: "a #RRGGBB or #RRGGBBAA string",
217    })?;
218    *into = Color::from_hex(text).map_err(|source| ThemeError::BadColor {
219        field: label.to_owned(),
220        source,
221    })?;
222    Ok(())
223}
224
225/// Read one button's two colors.
226fn read_button(table: &toml::Value, field: &str, into: &mut Button) -> Result<(), ThemeError> {
227    let Some(button) = table.get(field) else {
228        return Ok(());
229    };
230    read_color_as(
231        button,
232        "fill",
233        &format!("buttons.{field}.fill"),
234        &mut into.fill,
235    )?;
236    read_color_as(
237        button,
238        "hover",
239        &format!("buttons.{field}.hover"),
240        &mut into.hover,
241    )
242}