use crate::load::scan;
use crate::{
SemanticTokens, ThemeDefaults, ThemeDirs, ThemeId, ThemeMeta, ThemeSelection, Variant,
intent_css_declarations, intent_css_vars, resolve,
};
use std::collections::BTreeMap;
pub const THEME_ATTRIBUTE: &str = "data-theme";
pub fn keyed_intent_css_vars(id: &str, tokens: &SemanticTokens) -> String {
format!(
":root[{THEME_ATTRIBUTE}=\"{}\"] {{\n{}}}\n",
crate::css::string_body(id),
intent_css_declarations(tokens)
)
}
pub fn all_themes_css(dirs: &ThemeDirs, defaults: &ThemeDefaults) -> String {
let (seen, _) = scan(dirs);
let available: Vec<ThemeMeta> = seen.values().map(|scanned| scanned.meta.clone()).collect();
let resolved: BTreeMap<ThemeId, SemanticTokens> = seen
.into_iter()
.map(|(id, scanned)| (id, resolve(&scanned.colors())))
.collect();
let mut out = String::new();
let mut default_block = |variant: Variant, query: Option<&str>| {
let id = ThemeSelection::Follow.resolve(variant, defaults, &available);
let Some(tokens) = resolved.get(&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"));
}
for (id, tokens) in &resolved {
out.push('\n');
out.push_str(&keyed_intent_css_vars(id.as_str(), tokens));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixture::nord_toml;
use crate::fixture::shipped;
#[allow(unused_imports)]
use crate::{ThemeDirs, ThemeId, Token};
use crate::{list_themes_from_dirs, parse_theme_str};
use std::fs;
fn nord() -> ThemeId {
ThemeId::from_static("nord")
}
fn house() -> ThemeDefaults {
ThemeDefaults::new(
ThemeId::from_static("goingson"),
ThemeId::from_static("catppuccin-mocha"),
)
}
#[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 a_hostile_id_stays_inside_its_attribute_value() {
let tokens = resolve(&parse_theme_str(&nord(), nord_toml(), false).unwrap());
let keyed = keyed_intent_css_vars("x\"] body { color: red } :root[a=\"", &tokens);
assert!(
keyed.starts_with(":root[data-theme=\"x\\\"] body { color: red } :root[a=\\\"\"] {\n"),
"{keyed}"
);
}
#[test]
fn every_installed_theme_gets_a_block_and_they_are_in_id_order() {
let dirs = shipped();
let css = all_themes_css(&dirs, &house());
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)
.themes
.into_iter()
.map(|meta| meta.id.to_string())
.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(), &house());
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 = house();
assert!(!all_themes_css(&dirs, &plain).contains("prefers-contrast"));
let named = plain
.clone()
.high_contrast(ThemeId::from_static("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 dirs = shipped().custom(Some(custom.path().to_path_buf()));
let css = all_themes_css(&dirs, &house());
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"
);
}
}