| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
use crate::{ |
| 20 |
SemanticTokens, ThemeDefaults, ThemeSelection, Variant, intent_css_declarations, |
| 21 |
intent_css_vars, list_themes_from_dirs, load_semantic, |
| 22 |
}; |
| 23 |
use std::path::PathBuf; |
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
pub const THEME_ATTRIBUTE: &str = "data-theme"; |
| 31 |
|
| 32 |
|
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
|
| 38 |
|
| 39 |
pub fn keyed_intent_css_vars(id: &str, tokens: &SemanticTokens) -> String { |
| 40 |
format!( |
| 41 |
":root[{THEME_ATTRIBUTE}=\"{id}\"] {{\n{}}}\n", |
| 42 |
intent_css_declarations(tokens) |
| 43 |
) |
| 44 |
} |
| 45 |
|
| 46 |
|
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
|
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
pub fn all_themes_css(dirs: &[(PathBuf, bool)], defaults: &ThemeDefaults) -> String { |
| 69 |
let available = list_themes_from_dirs(dirs); |
| 70 |
let mut out = String::new(); |
| 71 |
|
| 72 |
let mut default_block = |variant: Variant, query: Option<&str>| { |
| 73 |
let id = ThemeSelection::Follow.resolve(variant, defaults, &available); |
| 74 |
let Ok(tokens) = load_semantic(dirs, &id) else { |
| 75 |
return; |
| 76 |
}; |
| 77 |
match query { |
| 78 |
None => out.push_str(&intent_css_vars(&tokens)), |
| 79 |
Some(query) => { |
| 80 |
out.push_str("\n@media ("); |
| 81 |
out.push_str(query); |
| 82 |
out.push_str(") {\n"); |
| 83 |
out.push_str(&intent_css_vars(&tokens)); |
| 84 |
out.push_str("}\n"); |
| 85 |
} |
| 86 |
} |
| 87 |
}; |
| 88 |
|
| 89 |
default_block(Variant::Light, None); |
| 90 |
default_block(Variant::Dark, Some("prefers-color-scheme: dark")); |
| 91 |
if defaults.names_high_contrast() { |
| 92 |
default_block(Variant::HighContrast, Some("prefers-contrast: more")); |
| 93 |
} |
| 94 |
|
| 95 |
let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect(); |
| 96 |
ids.sort_unstable(); |
| 97 |
for id in ids { |
| 98 |
if let Ok(tokens) = load_semantic(dirs, id) { |
| 99 |
out.push('\n'); |
| 100 |
out.push_str(&keyed_intent_css_vars(id, &tokens)); |
| 101 |
} |
| 102 |
} |
| 103 |
|
| 104 |
out |
| 105 |
} |
| 106 |
|
| 107 |
#[cfg(test)] |
| 108 |
mod tests { |
| 109 |
use super::*; |
| 110 |
use crate::fixture::nord_toml; |
| 111 |
use crate::{bundled_themes_dir, parse_theme_str, resolve}; |
| 112 |
use std::fs; |
| 113 |
|
| 114 |
|
| 115 |
|
| 116 |
|
| 117 |
fn shipped() -> Vec<(PathBuf, bool)> { |
| 118 |
vec![( |
| 119 |
bundled_themes_dir().expect("makeover ships its themes"), |
| 120 |
false, |
| 121 |
)] |
| 122 |
} |
| 123 |
|
| 124 |
#[test] |
| 125 |
fn a_keyed_block_carries_the_same_declarations_as_a_root_one() { |
| 126 |
let tokens = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap()); |
| 127 |
let keyed = keyed_intent_css_vars("nord", &tokens); |
| 128 |
assert!( |
| 129 |
keyed.starts_with(":root[data-theme=\"nord\"] {\n"), |
| 130 |
"{keyed}" |
| 131 |
); |
| 132 |
assert_eq!( |
| 133 |
keyed.replace(":root[data-theme=\"nord\"]", ":root"), |
| 134 |
intent_css_vars(&tokens), |
| 135 |
"the two emitters differ only in the selector" |
| 136 |
); |
| 137 |
} |
| 138 |
|
| 139 |
#[test] |
| 140 |
fn every_installed_theme_gets_a_block_and_they_are_in_id_order() { |
| 141 |
let dirs = shipped(); |
| 142 |
let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha")); |
| 143 |
|
| 144 |
let keys: Vec<&str> = css |
| 145 |
.match_indices(":root[data-theme=\"") |
| 146 |
.map(|(at, prefix)| { |
| 147 |
let rest = &css[at + prefix.len()..]; |
| 148 |
&rest[..rest.find('"').unwrap()] |
| 149 |
}) |
| 150 |
.collect(); |
| 151 |
|
| 152 |
let mut expected: Vec<String> = list_themes_from_dirs(&dirs) |
| 153 |
.into_iter() |
| 154 |
.map(|meta| meta.id) |
| 155 |
.collect(); |
| 156 |
expected.sort(); |
| 157 |
assert_eq!(keys, expected, "one block per theme, ordered by id"); |
| 158 |
assert!( |
| 159 |
keys.len() > 20, |
| 160 |
"the shipped set is the whole picker: {keys:?}" |
| 161 |
); |
| 162 |
} |
| 163 |
|
| 164 |
|
| 165 |
|
| 166 |
#[test] |
| 167 |
fn the_default_follows_the_system_and_a_pin_outranks_it() { |
| 168 |
let css = all_themes_css( |
| 169 |
&shipped(), |
| 170 |
&ThemeDefaults::new("goingson", "catppuccin-mocha"), |
| 171 |
); |
| 172 |
|
| 173 |
assert!(css.starts_with(":root {\n"), "the light default is first"); |
| 174 |
assert!(css.contains("@media (prefers-color-scheme: dark) {\n:root {\n")); |
| 175 |
|
| 176 |
|
| 177 |
|
| 178 |
|
| 179 |
let dark = css.find("prefers-color-scheme").unwrap(); |
| 180 |
let first_key = css.find(":root[data-theme=").unwrap(); |
| 181 |
assert!(dark < first_key, "defaults, then the keyed blocks"); |
| 182 |
} |
| 183 |
|
| 184 |
|
| 185 |
|
| 186 |
|
| 187 |
#[test] |
| 188 |
fn a_high_contrast_block_appears_only_when_one_was_named() { |
| 189 |
let dirs = shipped(); |
| 190 |
let plain = ThemeDefaults::new("goingson", "catppuccin-mocha"); |
| 191 |
assert!(!all_themes_css(&dirs, &plain).contains("prefers-contrast")); |
| 192 |
|
| 193 |
let named = plain.clone().high_contrast("high-contrast"); |
| 194 |
let css = all_themes_css(&dirs, &named); |
| 195 |
assert!( |
| 196 |
css.contains("@media (prefers-contrast: more) {\n:root {\n"), |
| 197 |
"{css}" |
| 198 |
); |
| 199 |
} |
| 200 |
|
| 201 |
|
| 202 |
|
| 203 |
#[test] |
| 204 |
fn an_unloadable_theme_is_skipped_rather_than_failing_the_sheet() { |
| 205 |
let custom = tempfile::tempdir().unwrap(); |
| 206 |
fs::write(custom.path().join("broken.toml"), "this is not = = toml").unwrap(); |
| 207 |
fs::write( |
| 208 |
custom.path().join("mine.toml"), |
| 209 |
"[meta]\nname = \"Mine\"\nvariant = \"dark\"\n[surface]\npage = \"#101010\"\n", |
| 210 |
) |
| 211 |
.unwrap(); |
| 212 |
|
| 213 |
let mut dirs = shipped(); |
| 214 |
dirs.push((custom.path().to_path_buf(), true)); |
| 215 |
let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha")); |
| 216 |
|
| 217 |
assert!( |
| 218 |
css.contains(":root[data-theme=\"mine\"] {"), |
| 219 |
"a custom theme is switchable too" |
| 220 |
); |
| 221 |
assert!(!css.contains("data-theme=\"broken\""), "{css}"); |
| 222 |
assert!( |
| 223 |
css.contains(":root[data-theme=\"nord\"] {"), |
| 224 |
"the rest of the sheet survives" |
| 225 |
); |
| 226 |
} |
| 227 |
} |
| 228 |
|