Skip to main content

max / makenotwork

9.8 KB · 248 lines History Blame Raw
1 //! Which theme magicmirror renders in, and where it loads from.
2 //!
3 //! No hex value is written anywhere in this crate. There is deliberately no
4 //! built-in fallback palette: a missing or malformed theme is an error the
5 //! operator sees, not something papered over by rendering in colors that exist
6 //! nowhere in the theme files.
7 //!
8 //! The shared app convention is wiki `makeover-app-convention`. magicmirror is
9 //! one of the store-less apps that note describes, with one difference worth
10 //! knowing: it already owns a config file, so `magicmirror.toml` *is* the
11 //! file-backed store and the convention's unprefixed `theme` key lives there.
12 //! There is no picker and nothing writes the key back, which is why this
13 //! module has no `remember`: magicmirror displays, and choosing a theme is an
14 //! edit to the same file that says what to display.
15
16 use std::path::PathBuf;
17
18 use anyhow::{Context, Result};
19 use makeover::{ThemeDefaults, ThemeDirs, ThemeMeta, ThemeSelection, Variant};
20 use makeover_tui::{Fidelity, Theme};
21
22 /// magicmirror's own light theme: the platform's titular skin.
23 const DEFAULT_LIGHT: &str = "makenotwork";
24
25 /// magicmirror's own dark theme.
26 ///
27 /// MNW authors no dark skin of its own, so this is the nearest thing in the
28 /// bundled set: near-neutral greys on the same axis as `makenotwork`'s
29 /// parchment, rather than a theme with a hue of its own to bring. It is a
30 /// fallback and not a pin — `ThemeSelection::resolve` reaches any installed
31 /// dark theme when this one is missing, and the operator names the theme they
32 /// want in `magicmirror.toml`.
33 const DEFAULT_DARK: &str = "carbonfox";
34
35 /// Theme search path: the operator's own themes win, then makeover's bundled
36 /// set.
37 ///
38 /// Two tiers and not three. There is no packaged `/usr/share` tier because
39 /// nothing packages magicmirror — it is an operator binary run out of a build,
40 /// so a system tier would be a directory that never exists.
41 fn search_path() -> Vec<(PathBuf, bool)> {
42 ThemeDirs::new()
43 .bundled(makeover::bundled_themes_dir())
44 .custom(config_home().map(|config| config.join("magicmirror").join("themes")))
45 .build()
46 }
47
48 /// `$XDG_CONFIG_HOME`, else `~/.config`, matching where magicmirror already
49 /// looks for `magicmirror.toml`.
50 fn config_home() -> Option<PathBuf> {
51 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
52 return Some(PathBuf::from(xdg));
53 }
54 std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
55 }
56
57 /// The themes magicmirror falls back to when nothing has been chosen.
58 fn defaults() -> ThemeDefaults {
59 ThemeDefaults::new(DEFAULT_LIGHT, DEFAULT_DARK)
60 }
61
62 /// Every theme magicmirror can render, from the same search path it loads from.
63 ///
64 /// makeover's own scan rather than a second one: a list here that disagreed
65 /// with [`load`] would resolve to an id that then failed to load.
66 fn available() -> Vec<ThemeMeta> {
67 makeover::list_themes_from_dirs(&search_path())
68 }
69
70 /// What magicmirror is being drawn on, as makeover's vocabulary.
71 ///
72 /// The terminal's answer to a `prefers-color-scheme` media query, and the only
73 /// one available without writing an OSC query and waiting for a reply before
74 /// the first frame. `COLORFGBG` carries the background as a color index; 0-6
75 /// and 8 are the dark ones. A terminal that says nothing reads as light, which
76 /// is the documented default.
77 fn ambient() -> Variant {
78 terminal_background().unwrap_or(Variant::Light)
79 }
80
81 fn terminal_background() -> Option<Variant> {
82 let value = std::env::var("COLORFGBG").ok()?;
83 let bg = value.rsplit(';').next()?.trim().parse::<u8>().ok()?;
84 Some(if bg <= 6 || bg == 8 {
85 Variant::Dark
86 } else {
87 Variant::Light
88 })
89 }
90
91 /// Load the theme a selection resolves to, as this terminal can draw it.
92 ///
93 /// Resolved by makeover against the ambient mode and what is actually
94 /// installed, so following the terminal reaches any dark theme the operator
95 /// dropped in rather than only the one this crate names, and a theme that has
96 /// since been deleted falls back instead of failing to load.
97 ///
98 /// Quantized through [`Theme::for_terminal`] rather than handed over as
99 /// 24-bit. Left alone, a terminal below truecolor approximates the colors
100 /// itself and its approximation collapses tones the theme keeps apart — which
101 /// on this surface means a `degraded` and a `failed` source that no longer
102 /// look different, on the one screen whose whole job is that difference.
103 pub(crate) fn load(selection: &ThemeSelection) -> Result<Theme> {
104 let dirs = search_path();
105 let id = selection.resolve(ambient(), &defaults(), &available());
106
107 let colors = makeover::load_theme(&dirs, &id)
108 .map_err(anyhow::Error::msg)
109 .with_context(|| {
110 let searched: Vec<String> = dirs
111 .iter()
112 .map(|(path, _)| path.display().to_string())
113 .collect();
114 format!("loading theme `{id}` (searched: {})", searched.join(", "))
115 })?;
116
117 Theme::from_theme(&colors)
118 .map(|theme| theme.for_terminal(Fidelity::detect()))
119 .map_err(|e| anyhow::anyhow!("{e}"))
120 .with_context(|| format!("theme `{id}` is incomplete"))
121 }
122
123 #[cfg(test)]
124 pub(crate) mod tests {
125 use super::*;
126
127 /// A fixed theme for the render tests.
128 ///
129 /// Parsed from makeover's embedded copy rather than loaded off the search
130 /// path, so the snapshot tests stay pure: they do not depend on which
131 /// directories exist on the machine running them, on `COLORFGBG`, or on
132 /// what the operator installed.
133 pub(crate) fn fixed() -> Theme {
134 let (_, source) = makeover::embedded_themes()
135 .find(|(id, _)| *id == DEFAULT_LIGHT)
136 .expect("makeover embeds magicmirror's default theme");
137 let colors = makeover::parse_theme_str(DEFAULT_LIGHT, source, false)
138 .expect("the embedded default theme parses");
139 Theme::from_theme(&colors).expect("the embedded default theme is complete")
140 }
141
142 // The operator's own themes must outrank the bundled ones. Asserted from
143 // this side because `ThemeDirs` is what makes the order not this file's to
144 // get backwards, and a hand-built vector here could still reverse it.
145 #[test]
146 fn the_operators_own_themes_outrank_the_bundled_ones() {
147 let dirs = search_path();
148 assert!(
149 dirs.iter().filter(|(_, is_custom)| *is_custom).count() <= 1,
150 "exactly one tier is the operator's: {dirs:?}",
151 );
152 if let (Some(custom), Some(bundled)) = (
153 dirs.iter().position(|(_, is_custom)| *is_custom),
154 dirs.iter().position(|(_, is_custom)| !*is_custom),
155 ) {
156 assert!(
157 custom > bundled,
158 "the operator's themes must come last so they win: {dirs:?}",
159 );
160 }
161 }
162
163 // Nothing in `magicmirror.toml` is Follow, not a pin on whatever the first run
164 // guessed.
165 #[test]
166 fn an_unset_key_follows_the_terminal() {
167 assert_eq!(ThemeSelection::parse(None), ThemeSelection::Follow);
168 }
169
170 // `COLORFGBG` carries the background as a color index; 0-6 and 8 are dark.
171 #[test]
172 fn the_terminal_background_reads_as_a_variant() {
173 for (raw, expect) in [
174 ("15;0", Variant::Dark),
175 ("0;15", Variant::Light),
176 ("15;8", Variant::Dark),
177 ("15;7", Variant::Light),
178 ] {
179 let dark = raw
180 .rsplit(';')
181 .next()
182 .and_then(|bg| bg.trim().parse::<u8>().ok())
183 .is_some_and(|bg| bg <= 6 || bg == 8);
184 let got = if dark { Variant::Dark } else { Variant::Light };
185 assert_eq!(got, expect, "COLORFGBG={raw}");
186 }
187 }
188
189 // Following resolves to the theme matching the terminal rather than to a
190 // fixed default, and both ids magicmirror names are ones makeover ships.
191 #[test]
192 fn following_resolves_to_the_theme_matching_the_terminal() {
193 let available = available();
194 if available.is_empty() {
195 return; // no theme directory on this machine; nothing to resolve against
196 }
197 assert_eq!(
198 ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available),
199 DEFAULT_DARK,
200 );
201 assert_eq!(
202 ThemeSelection::Follow.resolve(Variant::Light, &defaults(), &available),
203 DEFAULT_LIGHT,
204 );
205 }
206
207 // A pin wins over the ambient mode; that is the difference between a
208 // selection and a rendered id.
209 #[test]
210 fn a_pinned_theme_ignores_the_terminal() {
211 let available = available();
212 if available.is_empty() {
213 return;
214 }
215 let pinned = ThemeSelection::parse(Some("nord"));
216 assert_eq!(
217 pinned.resolve(Variant::Light, &defaults(), &available),
218 "nord"
219 );
220 }
221
222 // Both ids this crate ships against exist in makeover's set. A rename over
223 // there should fail here rather than at an operator's first launch.
224 #[test]
225 fn the_shipped_defaults_load_and_are_complete() {
226 if available().is_empty() {
227 return;
228 }
229 for id in [DEFAULT_LIGHT, DEFAULT_DARK] {
230 let colors = makeover::load_theme(&search_path(), id)
231 .unwrap_or_else(|e| panic!("default theme `{id}` failed to load: {e}"));
232 assert!(
233 Theme::from_theme(&colors).is_ok(),
234 "default theme `{id}` is incomplete",
235 );
236 }
237 }
238
239 #[test]
240 fn a_missing_theme_is_an_error_not_a_fallback() {
241 // A `Fixed` id that is not installed falls back through `resolve`, so
242 // the error path is the one where the resolved theme itself is
243 // unreadable. Asserted through `load_theme` directly, since `resolve`
244 // by design never hands back an id it did not find.
245 assert!(makeover::load_theme(&search_path(), "no-such-theme").is_err());
246 }
247 }
248