| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 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 |
|
| 23 |
const DEFAULT_LIGHT: &str = "makenotwork"; |
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
|
| 33 |
const DEFAULT_DARK: &str = "carbonfox"; |
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
|
| 38 |
|
| 39 |
|
| 40 |
|
| 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 |
|
| 49 |
|
| 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 |
|
| 58 |
fn defaults() -> ThemeDefaults { |
| 59 |
ThemeDefaults::new(DEFAULT_LIGHT, DEFAULT_DARK) |
| 60 |
} |
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
fn available() -> Vec<ThemeMeta> { |
| 67 |
makeover::list_themes_from_dirs(&search_path()) |
| 68 |
} |
| 69 |
|
| 70 |
|
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
|
| 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 |
|
| 92 |
|
| 93 |
|
| 94 |
|
| 95 |
|
| 96 |
|
| 97 |
|
| 98 |
|
| 99 |
|
| 100 |
|
| 101 |
|
| 102 |
|
| 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 |
|
| 128 |
|
| 129 |
|
| 130 |
|
| 131 |
|
| 132 |
|
| 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 |
|
| 143 |
|
| 144 |
|
| 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 |
|
| 164 |
|
| 165 |
#[test] |
| 166 |
fn an_unset_key_follows_the_terminal() { |
| 167 |
assert_eq!(ThemeSelection::parse(None), ThemeSelection::Follow); |
| 168 |
} |
| 169 |
|
| 170 |
|
| 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 |
|
| 190 |
|
| 191 |
#[test] |
| 192 |
fn following_resolves_to_the_theme_matching_the_terminal() { |
| 193 |
let available = available(); |
| 194 |
if available.is_empty() { |
| 195 |
return; |
| 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 |
|
| 208 |
|
| 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 |
|
| 223 |
|
| 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 |
|
| 242 |
|
| 243 |
|
| 244 |
|
| 245 |
assert!(makeover::load_theme(&search_path(), "no-such-theme").is_err()); |
| 246 |
} |
| 247 |
} |
| 248 |
|