use crate::{
SemanticTokens, ThemeDefaults, ThemeSelection, Variant, intent_css_declarations,
intent_css_vars, list_themes_from_dirs, load_semantic,
};
use std::path::PathBuf;
pub const THEME_ATTRIBUTE: &str = "data-theme";
pub fn keyed_intent_css_vars(id: &str, tokens: &SemanticTokens) -> String {
format!(
":root[{THEME_ATTRIBUTE}=\"{id}\"] {{\n{}}}\n",
intent_css_declarations(tokens)
)
}
pub fn all_themes_css(dirs: &[(PathBuf, bool)], defaults: &ThemeDefaults) -> String {
let available = list_themes_from_dirs(dirs);
let mut out = String::new();
let mut default_block = |variant: Variant, query: Option<&str>| {
let id = ThemeSelection::Follow.resolve(variant, defaults, &available);
let Ok(tokens) = load_semantic(dirs, &id) else {
return;
};
match query {
None => out.push_str(&intent_css_vars(&tokens)),
Some(query) => {
out.push_str("\n@media (");
out.push_str(query);
out.push_str(") {\n");
out.push_str(&intent_css_vars(&tokens));
out.push_str("}\n");
}
}
};
default_block(Variant::Light, None);
default_block(Variant::Dark, Some("prefers-color-scheme: dark"));
if defaults.names_high_contrast() {
default_block(Variant::HighContrast, Some("prefers-contrast: more"));
}
let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect();
ids.sort_unstable();
for id in ids {
if let Ok(tokens) = load_semantic(dirs, id) {
out.push('\n');
out.push_str(&keyed_intent_css_vars(id, &tokens));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixture::nord_toml;
use crate::{bundled_themes_dir, parse_theme_str, resolve};
use std::fs;
fn shipped() -> Vec<(PathBuf, bool)> {
vec![(
bundled_themes_dir().expect("makeover ships its themes"),
false,
)]
}
#[test]
fn a_keyed_block_carries_the_same_declarations_as_a_root_one() {
let tokens = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
let keyed = keyed_intent_css_vars("nord", &tokens);
assert!(
keyed.starts_with(":root[data-theme=\"nord\"] {\n"),
"{keyed}"
);
assert_eq!(
keyed.replace(":root[data-theme=\"nord\"]", ":root"),
intent_css_vars(&tokens),
"the two emitters differ only in the selector"
);
}
#[test]
fn every_installed_theme_gets_a_block_and_they_are_in_id_order() {
let dirs = shipped();
let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
let keys: Vec<&str> = css
.match_indices(":root[data-theme=\"")
.map(|(at, prefix)| {
let rest = &css[at + prefix.len()..];
&rest[..rest.find('"').unwrap()]
})
.collect();
let mut expected: Vec<String> = list_themes_from_dirs(&dirs)
.into_iter()
.map(|meta| meta.id)
.collect();
expected.sort();
assert_eq!(keys, expected, "one block per theme, ordered by id");
assert!(
keys.len() > 20,
"the shipped set is the whole picker: {keys:?}"
);
}
#[test]
fn the_default_follows_the_system_and_a_pin_outranks_it() {
let css = all_themes_css(
&shipped(),
&ThemeDefaults::new("goingson", "catppuccin-mocha"),
);
assert!(css.starts_with(":root {\n"), "the light default is first");
assert!(css.contains("@media (prefers-color-scheme: dark) {\n:root {\n"));
let dark = css.find("prefers-color-scheme").unwrap();
let first_key = css.find(":root[data-theme=").unwrap();
assert!(dark < first_key, "defaults, then the keyed blocks");
}
#[test]
fn a_high_contrast_block_appears_only_when_one_was_named() {
let dirs = shipped();
let plain = ThemeDefaults::new("goingson", "catppuccin-mocha");
assert!(!all_themes_css(&dirs, &plain).contains("prefers-contrast"));
let named = plain.clone().high_contrast("high-contrast");
let css = all_themes_css(&dirs, &named);
assert!(
css.contains("@media (prefers-contrast: more) {\n:root {\n"),
"{css}"
);
}
#[test]
fn an_unloadable_theme_is_skipped_rather_than_failing_the_sheet() {
let custom = tempfile::tempdir().unwrap();
fs::write(custom.path().join("broken.toml"), "this is not = = toml").unwrap();
fs::write(
custom.path().join("mine.toml"),
"[meta]\nname = \"Mine\"\nvariant = \"dark\"\n[surface]\npage = \"#101010\"\n",
)
.unwrap();
let mut dirs = shipped();
dirs.push((custom.path().to_path_buf(), true));
let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
assert!(
css.contains(":root[data-theme=\"mine\"] {"),
"a custom theme is switchable too"
);
assert!(!css.contains("data-theme=\"broken\""), "{css}");
assert!(
css.contains(":root[data-theme=\"nord\"] {"),
"the rest of the sheet survives"
);
}
}